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>
173 lines
5.9 KiB
Python
173 lines
5.9 KiB
Python
"""Stage 6 — client asset CRUD + normalize agent."""
|
||
|
||
import logging
|
||
|
||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database import async_session, get_db
|
||
from app.models.file import OpportunityFile
|
||
from app.models.opportunity import Opportunity
|
||
from app.models.asset import ClientAsset
|
||
from app.models.stage import StageArtifact
|
||
from app.schemas.asset import (
|
||
ClientAssetCreate,
|
||
ClientAssetOut,
|
||
ClientAssetUpdate,
|
||
)
|
||
from app.services.asset_normalizer import run_normalize
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
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
|
||
|
||
|
||
@router.get("/{opportunity_id}/assets", response_model=list[ClientAssetOut])
|
||
async def list_assets(opportunity_id: int, db: AsyncSession = Depends(get_db)):
|
||
await _get_opp(db, opportunity_id)
|
||
result = await db.execute(
|
||
select(ClientAsset)
|
||
.where(ClientAsset.opportunity_id == opportunity_id)
|
||
.order_by(ClientAsset.sort_order, ClientAsset.id)
|
||
)
|
||
return list(result.scalars().all())
|
||
|
||
|
||
@router.post("/{opportunity_id}/assets", response_model=ClientAssetOut)
|
||
async def create_asset(
|
||
opportunity_id: int,
|
||
payload: ClientAssetCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _get_opp(db, opportunity_id)
|
||
# next sort_order = max+1
|
||
next_order_result = await db.execute(
|
||
select(ClientAsset).where(ClientAsset.opportunity_id == opportunity_id)
|
||
)
|
||
existing = list(next_order_result.scalars().all())
|
||
next_order = (max((a.sort_order or 0) for a in existing) + 1) if existing else 0
|
||
|
||
ca = ClientAsset(
|
||
opportunity_id=opportunity_id,
|
||
raw_name=payload.raw_name[:500],
|
||
raw_description=payload.raw_description,
|
||
client_tier=payload.client_tier,
|
||
volume=payload.volume,
|
||
sort_order=next_order,
|
||
)
|
||
db.add(ca)
|
||
await db.commit()
|
||
await db.refresh(ca)
|
||
return ca
|
||
|
||
|
||
@router.put("/{opportunity_id}/assets/{asset_id}", response_model=ClientAssetOut)
|
||
async def update_asset(
|
||
opportunity_id: int,
|
||
asset_id: int,
|
||
payload: ClientAssetUpdate,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
await _get_opp(db, opportunity_id)
|
||
result = await db.execute(
|
||
select(ClientAsset).where(
|
||
ClientAsset.id == asset_id,
|
||
ClientAsset.opportunity_id == opportunity_id,
|
||
)
|
||
)
|
||
ca = result.scalar_one_or_none()
|
||
if ca is None:
|
||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||
|
||
data = payload.model_dump(exclude_unset=True)
|
||
if "raw_name" in data and data["raw_name"]:
|
||
data["raw_name"] = data["raw_name"][:500]
|
||
for field, value in data.items():
|
||
setattr(ca, field, value)
|
||
|
||
await db.commit()
|
||
await db.refresh(ca)
|
||
return ca
|
||
|
||
|
||
@router.delete("/{opportunity_id}/assets/{asset_id}")
|
||
async def delete_asset(opportunity_id: int, asset_id: int, db: AsyncSession = Depends(get_db)):
|
||
await _get_opp(db, opportunity_id)
|
||
result = await db.execute(
|
||
select(ClientAsset).where(
|
||
ClientAsset.id == asset_id,
|
||
ClientAsset.opportunity_id == opportunity_id,
|
||
)
|
||
)
|
||
ca = result.scalar_one_or_none()
|
||
if ca is None:
|
||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||
await db.delete(ca)
|
||
await db.commit()
|
||
return {"detail": f"Asset {asset_id} deleted"}
|
||
|
||
|
||
async def _run_normalize_in_bg(opportunity_id: int) -> None:
|
||
"""Background task: open a fresh session, run the normalizer.
|
||
|
||
Mirrors the pattern used by Stage 7 matching (`_run_match_in_bg`). The
|
||
Claude call inside `run_normalize` regularly takes 20–40s, which exceeds
|
||
most proxy/load-balancer idle timeouts (Apache default 30s), so we run
|
||
it out-of-band rather than blocking the request.
|
||
"""
|
||
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 normalize: opportunity {opportunity_id} not found")
|
||
return
|
||
try:
|
||
await run_normalize(db, opp)
|
||
await db.commit()
|
||
except Exception as e:
|
||
logger.error(f"BG normalize failed for {opportunity_id}: {e}", exc_info=True)
|
||
await db.rollback()
|
||
|
||
|
||
@router.post("/{opportunity_id}/assets/normalize", status_code=202)
|
||
async def run_normalizer(
|
||
opportunity_id: int,
|
||
background_tasks: BackgroundTasks,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""Kick off the Stage 6 Asset Normalizer in the background.
|
||
|
||
Returns 202 immediately. The frontend polls
|
||
`GET /opportunities/{id}/stages/6/artifacts` and `GET /assets` to detect
|
||
completion (a new `normalized_assets` artifact appears, then the
|
||
`client_assets` list refreshes). Re-running wipes existing ClientAssets
|
||
(cascade to matches + ratecard).
|
||
"""
|
||
opp = await _get_opp(db, opportunity_id)
|
||
|
||
# Cheap guardrail: at least one file with extractable text must exist —
|
||
# otherwise the bg task will fail silently and the user sees nothing.
|
||
file_check = await db.execute(
|
||
select(OpportunityFile.id)
|
||
.where(
|
||
OpportunityFile.opportunity_id == opp.id,
|
||
OpportunityFile.text_char_count > 0,
|
||
)
|
||
.limit(1)
|
||
)
|
||
if file_check.scalar_one_or_none() is None:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="No files with extractable text — upload an RFP/brief at Stage 1 first.",
|
||
)
|
||
|
||
background_tasks.add_task(_run_normalize_in_bg, opportunity_id)
|
||
return {"detail": "Normalize started", "opportunity_id": opportunity_id}
|