feat(Day 49): /v1/doctrines/classify endpoint for topic-dossier UX
Browse filesNew POST endpoint that takes a free-text query and returns top-k
matching doctrines with metadata (name_he, anchor_case, anchor_year,
statute, domain, score, pending_lawyer_review flag).
Powers the frontend topic-dossier panel that appears inline with
lawyer-ask results — user sees related doctrines without navigating
away. Uses the existing classify_doctrines() + load_doctrine_catalog()
internals; no new business logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- tau_rag/api/fastapi_app.py +48 -0
tau_rag/api/fastapi_app.py
CHANGED
|
@@ -22537,6 +22537,54 @@ def admin_funnel(request: Request, hours: int = 24): # type: ignore
|
|
| 22537 |
# `anchor_case`, `n_leading_cases` — not the full schema (elements,
|
| 22538 |
# exceptions, keywords stay internal).
|
| 22539 |
# ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22540 |
@app.get("/v1/doctrines/catalog")
|
| 22541 |
def doctrines_catalog_public(): # type: ignore
|
| 22542 |
"""Public read-only summary of the curated doctrine catalog."""
|
|
|
|
| 22537 |
# `anchor_case`, `n_leading_cases` — not the full schema (elements,
|
| 22538 |
# exceptions, keywords stay internal).
|
| 22539 |
# ──────────────────────────────────────────────────────────────────────
|
| 22540 |
+
class _DoctrineClassifyRequest(BaseModel): # type: ignore
|
| 22541 |
+
text: str
|
| 22542 |
+
k: int = 3
|
| 22543 |
+
min_score: float = 1.0
|
| 22544 |
+
|
| 22545 |
+
|
| 22546 |
+
@app.post("/v1/doctrines/classify")
|
| 22547 |
+
def doctrines_classify(body: _DoctrineClassifyRequest): # type: ignore
|
| 22548 |
+
"""Day 49 — classify a free-text query against the doctrine catalog.
|
| 22549 |
+
|
| 22550 |
+
Returns the top-k matching doctrines with their metadata so the
|
| 22551 |
+
frontend can render a "related doctrines" panel inline with the
|
| 22552 |
+
lawyer-ask result (topic-dossier UX).
|
| 22553 |
+
"""
|
| 22554 |
+
if not (body.text or "").strip():
|
| 22555 |
+
return {"ok": True, "matches": [], "n_total": 0}
|
| 22556 |
+
try:
|
| 22557 |
+
from ..intelligence.doctrine_classifier import (
|
| 22558 |
+
classify_doctrines, load_doctrine_catalog,
|
| 22559 |
+
)
|
| 22560 |
+
cat = load_doctrine_catalog()
|
| 22561 |
+
idx = {d["id"]: d for d in cat.get("doctrines", [])}
|
| 22562 |
+
matches = classify_doctrines(body.text, k=body.k,
|
| 22563 |
+
min_score=body.min_score)
|
| 22564 |
+
out = []
|
| 22565 |
+
for m in matches:
|
| 22566 |
+
doc = idx.get(m.doctrine_id, {})
|
| 22567 |
+
leading = (doc.get("leading_cases") or [])[:1]
|
| 22568 |
+
anchor_case = leading[0].get("citation") if leading else None
|
| 22569 |
+
anchor_year = leading[0].get("year") if leading else None
|
| 22570 |
+
out.append({
|
| 22571 |
+
"id": m.doctrine_id,
|
| 22572 |
+
"name_he": m.name_he,
|
| 22573 |
+
"domain": m.domain,
|
| 22574 |
+
"score": round(m.score, 2),
|
| 22575 |
+
"anchor_case": anchor_case,
|
| 22576 |
+
"anchor_year": anchor_year,
|
| 22577 |
+
"statute": ((doc.get("statute_refs") or [{}])[0]
|
| 22578 |
+
.get("law") if doc.get("statute_refs") else None),
|
| 22579 |
+
"pending_review": bool(doc.get("_pending_lawyer_review")),
|
| 22580 |
+
})
|
| 22581 |
+
return {"ok": True, "matches": out, "n_total": len(out)}
|
| 22582 |
+
except Exception as e:
|
| 22583 |
+
return JSONResponse(status_code=500, content={
|
| 22584 |
+
"ok": False, "error": f"{type(e).__name__}: {e}"
|
| 22585 |
+
})
|
| 22586 |
+
|
| 22587 |
+
|
| 22588 |
@app.get("/v1/doctrines/catalog")
|
| 22589 |
def doctrines_catalog_public(): # type: ignore
|
| 22590 |
"""Public read-only summary of the curated doctrine catalog."""
|