Stage 10: persist efficiency profile as stage artifact

Stage 10 was previously implicit — the per-discipline override sliders in
Stage 11 fed straight into team-shape calculations without leaving an
audit trail. This adds:
- POST /opportunities/{id}/efficiency-profile — saves the active
  scenario / blanket pct / discipline overrides / tools applied / notes
  as a stage_artifact (type='efficiency_profile').
- GET /opportunities/{id}/efficiency-profile — returns the most recent.

The payload shape is loose by design ({scenario, blanket_pct,
discipline_overrides, tools_applied, notes}) so the Stage 11 UI can
evolve without a migration. The artifact is the audit trail; the live
calculation still runs on the query params passed to GET /team-shape.

Smoke-tested against opp #2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-27 14:21:35 -04:00
parent 7c5ab130ed
commit 2eb0422e40

View file

@ -519,6 +519,51 @@ async def post_support_docs(opportunity_id: int, db: AsyncSession = Depends(get_
return await _run_simple_agent(db, opportunity_id, run_support_docs, 13, "support_docs")
@router.post("/{opportunity_id}/efficiency-profile")
async def save_efficiency_profile(
opportunity_id: int,
payload: dict,
db: AsyncSession = Depends(get_db),
):
"""Stage 10 — persist the efficiency profile (scenario + per-discipline
overrides + tools applied + notes) as a stage_artifact so the
team-shape inputs are audited alongside the rest of the pipeline.
Payload is intentionally loose: {scenario, blanket_pct,
discipline_overrides: {discipline: pct}, tools_applied: [str], notes}.
"""
opp = await _get_opp(db, opportunity_id)
artifact = StageArtifact(
opportunity_id=opp.id,
stage_number=10,
artifact_type="efficiency_profile",
content_json=payload,
)
db.add(artifact)
await db.commit()
await db.refresh(artifact)
return {"artifact_id": artifact.id, "saved_at": artifact.created_at, "content": payload}
@router.get("/{opportunity_id}/efficiency-profile")
async def get_efficiency_profile(opportunity_id: int, db: AsyncSession = Depends(get_db)):
await _get_opp(db, opportunity_id)
result = await db.execute(
select(StageArtifact)
.where(
StageArtifact.opportunity_id == opportunity_id,
StageArtifact.stage_number == 10,
StageArtifact.artifact_type == "efficiency_profile",
)
.order_by(StageArtifact.created_at.desc())
.limit(1)
)
artifact = result.scalar_one_or_none()
if artifact is None:
return None
return {"artifact_id": artifact.id, "saved_at": artifact.created_at, "content": artifact.content_json}
@router.get("/{opportunity_id}/qualification", response_model=QualificationScorecardOut | None)
async def get_qualification(opportunity_id: int, db: AsyncSession = Depends(get_db)):
"""Return the most recent qualification scorecard, or null if none saved yet."""