Migration 0005 adds three new tables (and the matchconfidence enum):
client_assets, matches, ratecard_lines. All FKed to opportunities with
ondelete=CASCADE so re-running a stage cleanly wipes the downstream
artifacts that depended on it.
Stage 6 — Asset Normalizer:
- services/asset_normalizer.py runs Claude (full uploaded text + a hint
from the Stage 2 diagnosis) and submits a structured deliverables
list via tool_use.
- api/assets.py exposes CRUD on ClientAsset rows + POST .../normalize
to (re-)run the agent. Re-running wipes existing assets, which
cascades to matches and ratecard_lines automatically.
- Smoke-tested against the Versuni brief: 7 normalized assets seeded.
Stage 7 — AI matching:
- services/ai_matching.py is the V1 engine ported and slimmed for V2:
full GMAL catalog sent per call (~3k–20k tokens depending on whether
AI-enhanced descriptions are populated), Claude returns up to 3
candidates, top match auto-selected when score >= 0.8, alternatives
kept only when within 5% of the top score.
- BackgroundTasks runs the agent off-request so the frontend can poll
GET /matches for progress. Per-call AI cost rolls onto the opportunity.
- api/matching.py: POST /match (kick off), GET /matches, PUT
/matches/{id}/select (toggle the chosen one — auto-deselects siblings
so there's exactly one selection per asset).
- Smoke-tested: 15 matches across 7 assets, GMAL323 et al picked with
reasonable scores.
Stage 8 — Ratecard:
- services/ratecard_builder.py is the V1 builder, with the V1 hours×
volume bug-fix already baked in: total_hours stores per-1-asset
hours; volume sits on the row; aggregators multiply at read time.
- api/ratecard.py: POST /ratecard/build, GET /ratecard returns a
RatecardSummary whose total_hours is correctly computed as
sum(per_asset × volume).
- Smoke-tested: 24 lines, 5,620.5 total project hours, base × volume
displayed separately.
Tests added by the parallel test agent (commit was queued during build):
- test_qualification.py (10): TROWLS save/get, threshold boundaries
(50%/60%), Pydantic 0–10 range guards, missing-dimension 422,
qualification_score stamped on opportunity, newest-scorecard wins,
404 paths.
- test_qa_pack.py (5): Excel + Word downloads (real PK\\x03\\x04
signature, openpyxl-parseable, priority-sort verified), filename
safety against /, ?, ", 404 paths.
Suite: 73 collected, 70 passed, 3 skipped (real-Anthropic), 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
236 lines
8.3 KiB
Python
236 lines
8.3 KiB
Python
"""Stage 3 — TROWLS qualification scorecard coverage.
|
|
|
|
Exercises POST /opportunities/{id}/qualification and GET counterpart against
|
|
the live backend. The scorecard is six dimensions (T R O W L S), each 0-10.
|
|
Total/percentage and recommendation thresholds are computed server-side:
|
|
>= 60% -> "proceed"
|
|
>= 50% -> "slt_review"
|
|
< 50% -> "no_go"
|
|
The percentage is also stamped onto Opportunity.qualification_score.
|
|
|
|
The fixtures `client` (httpx.AsyncClient) and `opportunity` (creates +
|
|
yields + deletes with cascade) come from conftest.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _scores(t: int, r: int, o: int, w: int, l: int, s: int) -> dict:
|
|
"""Build a TROWLSScores payload."""
|
|
return {
|
|
"timing": t,
|
|
"relationship": r,
|
|
"opportunity_size": o,
|
|
"what_we_know": w,
|
|
"location": l,
|
|
"sector": s,
|
|
}
|
|
|
|
|
|
def _save_payload(scores: dict, notes: dict | None = None, overall: str | None = None) -> dict:
|
|
body: dict = {"scores": scores}
|
|
if notes is not None:
|
|
body["notes"] = notes
|
|
if overall is not None:
|
|
body["overall_notes"] = overall
|
|
return body
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# POST /qualification — happy paths and threshold boundaries
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
async def test_save_mid_range_scorecard_proceed(
|
|
client: httpx.AsyncClient, opportunity
|
|
):
|
|
"""Mid-range scores 7,8,6,7,9,7 → total=44, pct=73, recommendation=proceed.
|
|
|
|
Also: opportunity.qualification_score is stamped to the same pct (73)."""
|
|
opp_id = opportunity["id"]
|
|
payload = _save_payload(
|
|
scores=_scores(7, 8, 6, 7, 9, 7),
|
|
overall="Looks promising — good fit on sector.",
|
|
)
|
|
r = await client.post(f"/opportunities/{opp_id}/qualification", json=payload)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["total_score"] == 44
|
|
assert body["score_pct"] == 73
|
|
assert body["recommendation"] == "proceed"
|
|
assert isinstance(body["artifact_id"], int)
|
|
assert body["artifact_id"] > 0
|
|
assert body["overall_notes"] == "Looks promising — good fit on sector."
|
|
# Echoed scores are preserved
|
|
assert body["scores"]["timing"] == 7
|
|
assert body["scores"]["sector"] == 7
|
|
|
|
# Opportunity has been stamped with the pct.
|
|
r = await client.get(f"/opportunities/{opp_id}")
|
|
assert r.status_code == 200
|
|
assert r.json()["qualification_score"] == 73
|
|
|
|
|
|
async def test_save_all_fives_slt_review(client: httpx.AsyncClient, opportunity):
|
|
"""All 5s → total=30, pct=50, recommendation='slt_review' (>=50 boundary)."""
|
|
opp_id = opportunity["id"]
|
|
r = await client.post(
|
|
f"/opportunities/{opp_id}/qualification",
|
|
json=_save_payload(_scores(5, 5, 5, 5, 5, 5)),
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["total_score"] == 30
|
|
assert body["score_pct"] == 50
|
|
assert body["recommendation"] == "slt_review"
|
|
|
|
|
|
async def test_save_all_fours_no_go(client: httpx.AsyncClient, opportunity):
|
|
"""All 4s → total=24, pct=40, recommendation='no_go' (<50)."""
|
|
opp_id = opportunity["id"]
|
|
r = await client.post(
|
|
f"/opportunities/{opp_id}/qualification",
|
|
json=_save_payload(_scores(4, 4, 4, 4, 4, 4)),
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["total_score"] == 24
|
|
assert body["score_pct"] == 40
|
|
assert body["recommendation"] == "no_go"
|
|
|
|
|
|
async def test_save_all_sixes_proceed_boundary(client: httpx.AsyncClient, opportunity):
|
|
"""All 6s → total=36, pct=60, recommendation='proceed' (>=60 boundary)."""
|
|
opp_id = opportunity["id"]
|
|
r = await client.post(
|
|
f"/opportunities/{opp_id}/qualification",
|
|
json=_save_payload(_scores(6, 6, 6, 6, 6, 6)),
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["total_score"] == 36
|
|
assert body["score_pct"] == 60
|
|
assert body["recommendation"] == "proceed"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# POST /qualification — Pydantic validation (422)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
async def test_save_score_above_range_422(client: httpx.AsyncClient, opportunity):
|
|
"""A dimension >10 must be rejected by Pydantic (Field(ge=0, le=10))."""
|
|
opp_id = opportunity["id"]
|
|
bad = _scores(11, 5, 5, 5, 5, 5) # timing = 11 → invalid
|
|
r = await client.post(
|
|
f"/opportunities/{opp_id}/qualification",
|
|
json=_save_payload(bad),
|
|
)
|
|
assert r.status_code == 422, r.text
|
|
|
|
|
|
async def test_save_score_below_range_422(client: httpx.AsyncClient, opportunity):
|
|
"""A dimension <0 must be rejected (relationship = -1)."""
|
|
opp_id = opportunity["id"]
|
|
bad = _scores(5, -1, 5, 5, 5, 5)
|
|
r = await client.post(
|
|
f"/opportunities/{opp_id}/qualification",
|
|
json=_save_payload(bad),
|
|
)
|
|
assert r.status_code == 422, r.text
|
|
|
|
|
|
async def test_save_missing_dimension_422(client: httpx.AsyncClient, opportunity):
|
|
"""All six TROWLS dimensions are required — omitting one is 422."""
|
|
opp_id = opportunity["id"]
|
|
incomplete = {
|
|
"timing": 5,
|
|
"relationship": 5,
|
|
"opportunity_size": 5,
|
|
"what_we_know": 5,
|
|
"location": 5,
|
|
# 'sector' is missing
|
|
}
|
|
r = await client.post(
|
|
f"/opportunities/{opp_id}/qualification",
|
|
json={"scores": incomplete},
|
|
)
|
|
assert r.status_code == 422, r.text
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# GET /qualification
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
async def test_get_qualification_unsaved_returns_null(
|
|
client: httpx.AsyncClient, opportunity
|
|
):
|
|
"""A fresh opportunity has no scorecard → returns JSON null."""
|
|
opp_id = opportunity["id"]
|
|
r = await client.get(f"/opportunities/{opp_id}/qualification")
|
|
assert r.status_code == 200, r.text
|
|
assert r.json() is None
|
|
|
|
|
|
async def test_get_qualification_returns_most_recent(
|
|
client: httpx.AsyncClient, opportunity
|
|
):
|
|
"""Round-trip POST → GET returns the same shape with the same artifact_id;
|
|
saving again returns the NEWER scorecard from GET (most recent)."""
|
|
opp_id = opportunity["id"]
|
|
|
|
# First save
|
|
first = _save_payload(_scores(7, 7, 7, 7, 7, 7), overall="first")
|
|
r = await client.post(f"/opportunities/{opp_id}/qualification", json=first)
|
|
assert r.status_code == 200, r.text
|
|
first_body = r.json()
|
|
first_artifact = first_body["artifact_id"]
|
|
|
|
r = await client.get(f"/opportunities/{opp_id}/qualification")
|
|
assert r.status_code == 200, r.text
|
|
got = r.json()
|
|
assert got is not None
|
|
assert got["artifact_id"] == first_artifact
|
|
assert got["overall_notes"] == "first"
|
|
assert got["total_score"] == first_body["total_score"]
|
|
assert got["score_pct"] == first_body["score_pct"]
|
|
assert got["recommendation"] == first_body["recommendation"]
|
|
|
|
# Second save — different scores → different recommendation, NEW artifact_id
|
|
second = _save_payload(_scores(4, 4, 4, 4, 4, 4), overall="second (no go)")
|
|
r = await client.post(f"/opportunities/{opp_id}/qualification", json=second)
|
|
assert r.status_code == 200, r.text
|
|
second_body = r.json()
|
|
assert second_body["artifact_id"] != first_artifact
|
|
assert second_body["recommendation"] == "no_go"
|
|
|
|
# GET should now return the newer one
|
|
r = await client.get(f"/opportunities/{opp_id}/qualification")
|
|
assert r.status_code == 200, r.text
|
|
latest = r.json()
|
|
assert latest["artifact_id"] == second_body["artifact_id"]
|
|
assert latest["overall_notes"] == "second (no go)"
|
|
assert latest["total_score"] == 24
|
|
assert latest["score_pct"] == 40
|
|
assert latest["recommendation"] == "no_go"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# 404
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
async def test_save_qualification_unknown_opportunity_404(client: httpx.AsyncClient):
|
|
r = await client.post(
|
|
"/opportunities/9999999/qualification",
|
|
json=_save_payload(_scores(5, 5, 5, 5, 5, 5)),
|
|
)
|
|
assert r.status_code == 404, r.text
|