oliver-sales-ops-platform/backend/app/api/matching.py
DJP 06110d6d71 fix: NV review round 1 — pipeline blockers + UX feedback + hidden bug hardening
Phase 1 (pipeline blockers):
- Stage 6 normalizer: move Claude call to BackgroundTasks (fixes 504 at proxy);
  frontend polls useStageArtifacts(6) + useClientAssets every 2s with a
  2-min soft cap and a "Normalizing… ~30s" banner.
- Stage 8 ratecard: raise ValueError when no Stage-7 matches selected so
  the user gets a clear 400 instead of silent "0 lines built" success.
- Stage 4 Q&A pack: visible amber empty-state callout when no clarifications.
- Stage 1 intake: green CTA banner after metadata lands telling the user
  to scroll down and click Complete Stage 1.
- APP_PUBLIC_URL: log a warning at startup if empty / not fully-qualified
  so approval-email links don't ship as broken relative URLs.

Phase 2 (reviewer UX):
- Remove "Operating model" select from intake form (sales lead doesn't
  know the solution yet); default model_type='current_oplus'.
- Inline-edit pencil for opportunity name in OpportunityView header.
- Stage 3 TROWLS sliders default to 0/10 (was 5/10 — anchored everyone
  to "average" and the reviewer could save without engaging).
- Trim APPROVAL_ROLES to ['commercial', 'solution'] (was 5 roles).
- Stage 6 confirm dialog only fires on re-run, not first run.
- "+ Add manually" → "+ Add deliverable manually" with helper text.
- Asset normalizer prompt + post-hoc stop-list filter excluding internal
  pitch artefacts (pitch decks, response decks, win-themes, etc.) that
  were appearing as job routes in Stage 7.

Phase 3 (hardening):
- with_for_update() row locks on stage_machine.complete_stage and
  approvals.submit_decision so double-clicks can't double-advance.
- 30s idempotency window on Stage 7 matching kick-off.

Deferred (next round): paste-link upload, single-use approval tokens,
FK indexes migration, datetime.utcnow → now(timezone.utc) sweep,
notes-owner schema change, file-extraction "unsearchable" UI badge.

Source: REVIEW-SESSIONS/Sales Op Platform Feedback NV_ 060526.xlsx
(rows R6-R48, 25+ feedback items mapped to specific stages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:30:48 -04:00

163 lines
6.3 KiB
Python

"""Stage 7 — match client assets to GMAL catalog."""
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, async_session
from app.models.opportunity import Opportunity
from app.models.gmal import GmalAsset
from app.models.asset import ClientAsset, Match, MatchConfidence
from app.schemas.asset import MatchOut, MatchSelectRequest
from app.services.ai_matching import match_opportunity_assets
router = APIRouter()
logger = logging.getLogger(__name__)
# Idempotency window — second matching call within this many seconds returns
# 409 instead of spawning a duplicate background task that would race on the
# same client_assets rows.
MATCH_IDEMPOTENCY_SECONDS = 30
async def _get_opp(db: AsyncSession, opportunity_id: int) -> Opportunity:
result = await db.execute(select(Opportunity).where(Opportunity.id == opportunity_id))
opp = result.scalar_one_or_none()
if opp is None:
raise HTTPException(status_code=404, detail=f"Opportunity {opportunity_id} not found")
return opp
def _match_to_out(m: Match, gmal: GmalAsset | None) -> MatchOut:
return MatchOut(
id=m.id,
client_asset_id=m.client_asset_id,
gmal_asset_id=m.gmal_asset_id,
gmal_id=gmal.gmal_id if gmal else None,
gmal_name=(gmal.asset_name if gmal else None),
gmal_unique_name=(gmal.unique_name if gmal else None),
confidence=m.confidence.value,
confidence_score=float(m.confidence_score) if m.confidence_score else None,
ai_reasoning=m.ai_reasoning,
caveat_text=m.caveat_text,
is_selected=m.is_selected,
rank=m.rank,
created_at=m.created_at,
)
async def _run_match_in_bg(opportunity_id: int) -> None:
"""Background task: open a fresh session, run the match agent."""
async with async_session() as db:
opp_result = await db.execute(select(Opportunity).where(Opportunity.id == opportunity_id))
opp = opp_result.scalar_one_or_none()
if opp is None:
logger.warning(f"BG match: opportunity {opportunity_id} not found")
return
try:
await match_opportunity_assets(db, opp)
await db.commit()
except Exception as e:
logger.error(f"BG match failed for {opportunity_id}: {e}", exc_info=True)
await db.rollback()
@router.post("/{opportunity_id}/match")
async def kick_off_match(
opportunity_id: int,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Kick off the matching agent in the background. Frontend polls
GET /matches for results."""
opp = await _get_opp(db, opportunity_id)
asset_result = await db.execute(
select(ClientAsset).where(ClientAsset.opportunity_id == opp.id).limit(1)
)
if asset_result.scalar_one_or_none() is None:
raise HTTPException(status_code=400, detail="No client assets — run Stage 6 (normalize) first.")
# Idempotency: a Match row created in the last MATCH_IDEMPOTENCY_SECONDS
# for this opportunity means the agent ran (or is running) very recently.
# Returning 409 stops the user double-clicking and racing two bg tasks
# against the same data. Match.created_at is stored as a naive UTC
# datetime (see app/models/asset.py), so the cutoff is naive too.
cutoff = datetime.utcnow() - timedelta(seconds=MATCH_IDEMPOTENCY_SECONDS)
recent_match_result = await db.execute(
select(Match)
.join(ClientAsset, Match.client_asset_id == ClientAsset.id)
.where(
ClientAsset.opportunity_id == opp.id,
Match.created_at >= cutoff,
)
.limit(1)
)
if recent_match_result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail=f"Matching already ran within the last {MATCH_IDEMPOTENCY_SECONDS}s. Wait a moment, then refresh — results should appear shortly.",
)
background_tasks.add_task(_run_match_in_bg, opportunity_id)
return {"detail": "Matching started", "opportunity_id": opportunity_id}
@router.get("/{opportunity_id}/matches", response_model=list[MatchOut])
async def list_matches(opportunity_id: int, db: AsyncSession = Depends(get_db)):
await _get_opp(db, opportunity_id)
result = await db.execute(
select(Match, GmalAsset, ClientAsset)
.join(GmalAsset, Match.gmal_asset_id == GmalAsset.id)
.join(ClientAsset, Match.client_asset_id == ClientAsset.id)
.where(ClientAsset.opportunity_id == opportunity_id)
.order_by(ClientAsset.sort_order, Match.rank)
)
return [_match_to_out(m, g) for m, g, _ in result.all()]
@router.put("/{opportunity_id}/matches/{match_id}/select", response_model=MatchOut)
async def toggle_match_selection(
opportunity_id: int,
match_id: int,
payload: MatchSelectRequest,
db: AsyncSession = Depends(get_db),
):
"""Mark a match as the chosen one for its client asset.
When `is_selected=True` and another match for the same client_asset is
currently selected, deselect it first (one selection per asset).
"""
await _get_opp(db, opportunity_id)
result = await db.execute(
select(Match, ClientAsset)
.join(ClientAsset, Match.client_asset_id == ClientAsset.id)
.where(Match.id == match_id, ClientAsset.opportunity_id == opportunity_id)
)
row = result.first()
if row is None:
raise HTTPException(status_code=404, detail=f"Match {match_id} not found")
match, ca = row
if payload.is_selected:
# Deselect siblings
siblings_result = await db.execute(
select(Match).where(
Match.client_asset_id == match.client_asset_id,
Match.id != match.id,
Match.is_selected == True,
)
)
for s in siblings_result.scalars().all():
s.is_selected = False
match.is_selected = payload.is_selected
await db.commit()
await db.refresh(match)
# Reload GMAL for the response
gmal_result = await db.execute(select(GmalAsset).where(GmalAsset.id == match.gmal_asset_id))
gmal = gmal_result.scalar_one_or_none()
return _match_to_out(match, gmal)