Win/loss analysis
Generate hierarchy-scoped win/loss reports from recorded deal outcomes, extract recurring themes and competitors, and surface battlecard suggestions.
Win/loss analysis
Win/loss turns per-deal outcome capture into narrative reports a sales leader
actually reads — recurring themes, competitor mentions, buyer decision criteria,
and the pipeline stage where lost deals dropped off. Reports are scoped to the
caller's management hierarchy so a rep never sees a peer's deals through the
report. The API is under /v1/win-loss and is gated by the analytics module.
Live
Backed by the win_loss_reports and win_loss_report_deals tables plus the
per-deal win_loss_records capture. Generation, theme extraction, and
hierarchy scoping are all real code (core.win_loss).
What a report is
POST /v1/win-loss/generate (admin/owner only) pulls a bounded set of deal
outcomes and produces one win_loss_reports row. Inputs:
period_start/period_end— bounded againstwin_loss_records.recorded_at.segment_filter— an optional dict supportingsegment(substring match on notes),owner_emails, andmin_value/max_valuebounds on deal value.
Generation is idempotent: the unique index over
(tenant_id, period_start, period_end, segment_filter) means re-running the same
triple updates the existing row in place rather than spawning a duplicate. Each
re-run recomputes the theme block from scratch, since the underlying deal set can
grow between runs.
Theme extraction
core.win_loss.theme_extractor produces the four blocks stamped onto
win_loss_reports.ai_themes_json:
themes— recurring short noun phrases across dealscompetitors— competitor mentions with frequency and sharedecision_criteria— buyer-stated criteria recurring in notesdrop_off_stages— for lost deals, the stage where the deal was lost
Extraction has two paths. When the tenant has an AI provider configured, it
routes through the tenant-scoped AIClient (credential + ledger + budget +
kill-switch + PII redaction) with a strict-JSON prompt. When no key is present —
CI, dev, or a tenant that hasn't connected a provider — it falls through to a
deterministic keyword-bucketing heuristic tagged heuristic-v1, so the detail
page always renders something concrete.
Hierarchy scoping
Report contents are constrained by core.visibility.hierarchy —
specifically owner_email_filter_clause. The report header is a tenant-scoped
artifact, but the deals inside it are filtered to the viewer's subtree. Default-
deny applies: a viewer with no subordinates and no admin role sees a report
header only when they personally recorded at least one of its deals. The same
filter is applied on both the list and the drill-down, so a rep can't reach peer
deals through GET /v1/win-loss/reports/{id}.
Endpoints
| Method & path | Purpose |
|---|---|
GET /v1/win-loss | Per-deal feed for the caller |
POST /v1/win-loss/generate | Generate a report (admin/owner) |
GET /v1/win-loss/reports | Paginated report list, hierarchy-filtered |
GET /v1/win-loss/reports/{id} | Full detail with scoped deal drill-down |
Battlecards
When a competitor recurs across reports, core.win_loss.battlecards.suggest_battlecards
aggregates the competitor and decision-criteria blocks and emits deterministic
suggestion records — "Competitor X appears in 5 of the last 8 reports, price
cited each time." This is the statistical signal to go build a battlecard, not
a generated talk track; suggestions are sorted by descending total mentions and
filtered by minimum-mention/minimum-report thresholds so a single stray mention
doesn't create noise.
Battlecard authoring is a signal, not a full editor
The current surface emits suggestions and thresholds them. A full battlecard authoring tool with LLM-enriched talk tracks is planned; the suggestion shape on disk is stable so that enrichment can land without a migration.