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>
163 lines
6.3 KiB
Python
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)
|