oliver-sales-ops-platform/backend/app/api/assets.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

173 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 2040s, 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}