feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
Six-phase upgrade of /training from a read-only dashboard into a full Style Studio for managing Daphna's style corpus. - Upload Sheet on /training: file → proofread preview → commit (no more CLI-only `upload-training` skill). - Rich corpus metadata: GET /api/training/corpus returns summary, outcome, key_principles, page_count, parties (regex), legal_citation, lessons_count. PATCH endpoint for chair edits. CorpusDetailDrawer with 4 tabs (details /content/lessons/patterns) replaces the bare table row. - LLM metadata enrichment: style_metadata_extractor + MCP tools (style_corpus_enrich, style_corpus_pending_enrichment) fill summary /outcome/key_principles via claude_session (free, host-side). - Per-decision lessons: new decision_lessons table + 4 REST endpoints + LessonsTab in drawer; hermes-curator now auto-posts findings as decision_lessons(source=curator). - Curator Portrait tab: prompt rendered with link to Gitea, recent curator findings, style_analyzer training prompts, propose-change form that writes proposals to data/curator-proposals/ for manual chair review (no auto-mutation of the agent file). - Style chat tab: SSE-streamed conversations with the style agent. New host-side pm2 service (legal-chat-service, port 8770) wraps claude CLI with stream-json + --resume continuation; FastAPI proxies via host.docker.internal. Zero API cost — uses chaim's claude.ai subscription. chat_conversations + chat_messages persist history. Architecture: keeps the existing rule that claude_session only runs on the host (not the container). The new legal-chat-service is the canonical bridge between the container and the local CLI for the chat feature; everything else (upload, metadata, lessons) stays within the container's existing capabilities. Audit script (scripts/audit_training_corpus.py) included for verifying which corpus rows still need enrichment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
647
web/app.py
647
web/app.py
@@ -12,6 +12,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date as date_type
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -945,32 +946,648 @@ async def training_corpus_delete(corpus_id: str):
|
||||
return result
|
||||
|
||||
|
||||
def _format_legal_citation(decision_number: str, decision_date: str) -> str:
|
||||
"""Compose the Israeli ועדת ערר citation string from corpus metadata.
|
||||
|
||||
Mirrors how decisions are referenced in Daphna's own writing — e.g.
|
||||
"ערר 1130-25 ועדת ערר ירושלים (26.4.2026)". Empty parts are dropped
|
||||
gracefully so partially populated rows still produce a readable label.
|
||||
"""
|
||||
if not decision_number:
|
||||
return ""
|
||||
parts = [f"ערר {decision_number}", "ועדת ערר ירושלים"]
|
||||
if decision_date:
|
||||
try:
|
||||
d = date_type.fromisoformat(decision_date)
|
||||
parts.append(f"({d.day}.{d.month}.{d.year})")
|
||||
except ValueError:
|
||||
pass
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
_PARTIES_PATTERNS = (
|
||||
# "העורר: X" or "העוררים: X". Captures up to a newline / end of stanza.
|
||||
re.compile(r"העורר(?:ים|ת)?[:\s]+([^\n]{3,120})"),
|
||||
re.compile(r"המבקש(?:ים|ת)?[:\s]+([^\n]{3,120})"),
|
||||
re.compile(r"בעניין[:\s]+([^\n]{3,120})"),
|
||||
)
|
||||
_RESPONDENT_PATTERNS = (
|
||||
re.compile(r"המשיב(?:ים|ה|ות)?[:\s]+([^\n]{3,120})"),
|
||||
re.compile(r"נגד\s*\n+\s*([^\n]{3,120})"),
|
||||
)
|
||||
|
||||
|
||||
def _extract_parties(text: str) -> dict[str, str]:
|
||||
"""Best-effort regex extraction of עורר/משיב from the first 5K of full_text.
|
||||
|
||||
We only scan the head of the document because the parties are always
|
||||
declared at the top in Israeli legal decisions. The result is a hint
|
||||
for display — never authoritative — so a miss returns an empty string
|
||||
rather than raising.
|
||||
"""
|
||||
head = (text or "")[:5000]
|
||||
appellant = respondent = ""
|
||||
for pat in _PARTIES_PATTERNS:
|
||||
m = pat.search(head)
|
||||
if m:
|
||||
appellant = m.group(1).strip(" .,-—")
|
||||
break
|
||||
for pat in _RESPONDENT_PATTERNS:
|
||||
m = pat.search(head)
|
||||
if m:
|
||||
respondent = m.group(1).strip(" .,-—")
|
||||
break
|
||||
return {"appellant": appellant, "respondent": respondent}
|
||||
|
||||
|
||||
@app.get("/api/training/corpus")
|
||||
async def training_corpus_list():
|
||||
"""List all decisions currently in the style corpus."""
|
||||
"""List all decisions currently in the style corpus, with enriched metadata.
|
||||
|
||||
Joins to ``documents`` via FK when available, falling back to the
|
||||
title-token match used in the chunking pipeline so legacy rows with
|
||||
``style_corpus.document_id IS NULL`` still resolve to their page_count
|
||||
and chunk counts.
|
||||
"""
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, decision_number, decision_date, subject_categories, "
|
||||
" length(full_text) as chars, created_at "
|
||||
"FROM style_corpus "
|
||||
"ORDER BY created_at DESC"
|
||||
"""
|
||||
SELECT sc.id,
|
||||
sc.decision_number,
|
||||
sc.decision_date,
|
||||
sc.subject_categories,
|
||||
length(sc.full_text) AS chars,
|
||||
substring(sc.full_text from 1 for 5000) AS head_text,
|
||||
sc.summary,
|
||||
sc.outcome,
|
||||
sc.key_principles,
|
||||
sc.appeal_subtype,
|
||||
sc.practice_area,
|
||||
sc.document_id,
|
||||
sc.created_at,
|
||||
d.page_count AS page_count,
|
||||
d.title AS doc_title
|
||||
FROM style_corpus sc
|
||||
LEFT JOIN documents d ON d.id = sc.document_id
|
||||
ORDER BY sc.created_at DESC
|
||||
"""
|
||||
)
|
||||
return [
|
||||
{
|
||||
lessons_counts = await db.count_decision_lessons_per_corpus()
|
||||
out = []
|
||||
for r in rows:
|
||||
cats = r["subject_categories"]
|
||||
if isinstance(cats, str):
|
||||
try:
|
||||
cats = json.loads(cats)
|
||||
except json.JSONDecodeError:
|
||||
cats = []
|
||||
kp = r["key_principles"]
|
||||
if isinstance(kp, str):
|
||||
try:
|
||||
kp = json.loads(kp)
|
||||
except json.JSONDecodeError:
|
||||
kp = []
|
||||
decision_date = str(r["decision_date"]) if r["decision_date"] else ""
|
||||
parties = _extract_parties(r["head_text"] or "")
|
||||
out.append({
|
||||
"id": str(r["id"]),
|
||||
"decision_number": r["decision_number"] or "",
|
||||
"decision_date": str(r["decision_date"]) if r["decision_date"] else "",
|
||||
"subject_categories": (
|
||||
json.loads(r["subject_categories"])
|
||||
if isinstance(r["subject_categories"], str)
|
||||
else r["subject_categories"] or []
|
||||
),
|
||||
"decision_date": decision_date,
|
||||
"subject_categories": cats or [],
|
||||
"chars": r["chars"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
||||
# ── enriched fields ──
|
||||
"summary": r["summary"] or "",
|
||||
"outcome": r["outcome"] or "",
|
||||
"key_principles": kp or [],
|
||||
"appeal_subtype": r["appeal_subtype"] or "",
|
||||
"practice_area": r["practice_area"] or "",
|
||||
"page_count": r["page_count"] or 0,
|
||||
"document_id": str(r["document_id"]) if r["document_id"] else None,
|
||||
"doc_title": r["doc_title"] or "",
|
||||
"parties": parties,
|
||||
"legal_citation": _format_legal_citation(r["decision_number"] or "", decision_date),
|
||||
"lessons_count": lessons_counts.get(str(r["id"]), 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Style-agent chat (delegated to legal-chat-service on host) ─────
|
||||
|
||||
|
||||
class ChatConversationCreate(BaseModel):
|
||||
title: str = "שיחה חדשה"
|
||||
style_corpus_id: str | None = None # optional — scope chat to a decision
|
||||
|
||||
|
||||
class ChatMessageRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _conv_to_json(row: dict) -> dict:
|
||||
"""Serialize a chat_conversations row for the API."""
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"title": row.get("title") or "",
|
||||
"style_corpus_id": str(row["style_corpus_id"]) if row.get("style_corpus_id") else None,
|
||||
"decision_number": row.get("decision_number") or "",
|
||||
"claude_session_id": row.get("claude_session_id"),
|
||||
"message_count": row.get("message_count", 0),
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else "",
|
||||
"last_message_at": row["last_message_at"].isoformat() if row.get("last_message_at") else "",
|
||||
}
|
||||
|
||||
|
||||
def _msg_to_json(row: dict) -> dict:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"role": row["role"],
|
||||
"content": row["content"],
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else "",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/training/chat/conversations")
|
||||
async def chat_create_conversation(body: ChatConversationCreate):
|
||||
"""Create a new style-agent chat conversation."""
|
||||
corpus_uuid: UUID | None = None
|
||||
if body.style_corpus_id:
|
||||
try:
|
||||
corpus_uuid = UUID(body.style_corpus_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid style_corpus_id")
|
||||
row = await db.create_chat_conversation(
|
||||
title=body.title.strip() or "שיחה חדשה",
|
||||
style_corpus_id=corpus_uuid,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(500, "failed to create conversation")
|
||||
return _conv_to_json(row)
|
||||
|
||||
|
||||
@app.get("/api/training/chat/conversations")
|
||||
async def chat_list_conversations(limit: int = 50):
|
||||
rows = await db.list_chat_conversations(limit=limit)
|
||||
return [_conv_to_json(r) for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/training/chat/conversations/{conv_id}")
|
||||
async def chat_get_conversation(conv_id: str):
|
||||
try:
|
||||
cid = UUID(conv_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid conv_id")
|
||||
conv = await db.get_chat_conversation(cid)
|
||||
if not conv:
|
||||
raise HTTPException(404, "conversation not found")
|
||||
messages = await db.list_chat_messages(cid)
|
||||
return {
|
||||
"conversation": _conv_to_json(conv),
|
||||
"messages": [_msg_to_json(m) for m in messages],
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/training/chat/conversations/{conv_id}")
|
||||
async def chat_delete_conversation(conv_id: str):
|
||||
try:
|
||||
cid = UUID(conv_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid conv_id")
|
||||
result = await db.delete_chat_conversation(cid)
|
||||
if not result.get("deleted"):
|
||||
raise HTTPException(404, "conversation not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/training/chat/conversations/{conv_id}/messages")
|
||||
async def chat_send_message(conv_id: str, body: ChatMessageRequest):
|
||||
"""Send a user message; stream the assistant response as SSE.
|
||||
|
||||
Proxies through ``web.chat_proxy.stream_chat_message`` to the
|
||||
legal-chat-service running on the host.
|
||||
"""
|
||||
try:
|
||||
cid = UUID(conv_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid conv_id")
|
||||
text = (body.content or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "content is required")
|
||||
from web import chat_proxy
|
||||
return await chat_proxy.stream_chat_message(cid, text)
|
||||
|
||||
|
||||
@app.get("/api/training/chat/health")
|
||||
async def chat_health():
|
||||
"""Probe legal-chat-service liveness from inside the container.
|
||||
|
||||
Useful when the UI wants to gracefully degrade ("שירות הצ'אט אינו
|
||||
זמין") instead of letting messages fail mid-stream.
|
||||
"""
|
||||
from web import chat_proxy
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
r = await client.get(f"{chat_proxy.CHAT_SERVICE_URL}/health")
|
||||
return {"reachable": r.status_code == 200, "status": r.status_code,
|
||||
"url": chat_proxy.CHAT_SERVICE_URL}
|
||||
except Exception as e:
|
||||
return {"reachable": False, "error": str(e),
|
||||
"url": chat_proxy.CHAT_SERVICE_URL}
|
||||
|
||||
|
||||
# ── Curator portrait — read prompt + stats + accept proposals ──────
|
||||
|
||||
|
||||
# The curator agent's prompt is symlinked into Paperclip, but the source
|
||||
# lives in the legal-ai repo. Resolve via env so the container (where the
|
||||
# agent file is mounted from a different path) and the host both work.
|
||||
_AGENTS_DIR = Path(os.environ.get(
|
||||
"AGENTS_DIR",
|
||||
str(Path(__file__).resolve().parent.parent / ".claude" / "agents"),
|
||||
))
|
||||
_CURATOR_PROPOSALS_DIR = Path(os.environ.get(
|
||||
"CURATOR_PROPOSALS_DIR",
|
||||
str(Path(__file__).resolve().parent.parent / "data" / "curator-proposals"),
|
||||
))
|
||||
_GITEA_REPO_BASE = os.environ.get(
|
||||
"GITEA_REPO_BASE",
|
||||
"https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/training/curator/prompt")
|
||||
async def get_curator_prompt():
|
||||
"""Return the hermes-curator agent's prompt (read-only) + Gitea source URL.
|
||||
|
||||
The file is the canonical source of how the curator analyzes Daphna's
|
||||
final decisions. Changes go through git/Gitea, not the UI — the UI just
|
||||
surfaces it for transparency.
|
||||
"""
|
||||
path = _AGENTS_DIR / "hermes-curator.md"
|
||||
if not path.exists():
|
||||
raise HTTPException(404, f"curator prompt not found at {path}")
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
stat = path.stat()
|
||||
except OSError as e:
|
||||
raise HTTPException(500, f"failed to read curator prompt: {e}")
|
||||
gitea_url = (
|
||||
f"{_GITEA_REPO_BASE}/src/branch/main/.claude/agents/hermes-curator.md"
|
||||
)
|
||||
return {
|
||||
"content": content,
|
||||
"filename": path.name,
|
||||
"bytes": stat.st_size,
|
||||
"last_modified": stat.st_mtime,
|
||||
"gitea_url": gitea_url,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/training/curator/style-analyzer-prompt")
|
||||
async def get_style_analyzer_prompt():
|
||||
"""Return the system prompt that style_analyzer.py uses to extract patterns.
|
||||
|
||||
Surfaces the *training-time* prompt (Claude Opus 1M context) so the
|
||||
chair can compare it against the curator's post-export prompt. Both
|
||||
are shown side-by-side in the curator-portrait tab.
|
||||
"""
|
||||
# Embedded as a string so we don't need to import the service module
|
||||
# here (which would pull in claude_session + db). The prompt is the
|
||||
# one defined in mcp-server/src/legal_mcp/services/style_analyzer.py.
|
||||
try:
|
||||
from legal_mcp.services import style_analyzer
|
||||
return {
|
||||
"analysis_prompt": style_analyzer.ANALYSIS_PROMPT,
|
||||
"single_decision_prompt": style_analyzer.SINGLE_DECISION_PROMPT,
|
||||
"synthesis_prompt": style_analyzer.SYNTHESIS_PROMPT,
|
||||
"max_input_tokens": style_analyzer.MAX_INPUT_TOKENS,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"failed to load style_analyzer prompt: {e}")
|
||||
|
||||
|
||||
@app.get("/api/training/curator/stats")
|
||||
async def get_curator_stats():
|
||||
"""Cheap aggregate stats over decision_lessons + style_corpus.
|
||||
|
||||
Used by the Curator-Portrait tab to show "10 curator findings across 24
|
||||
decisions". We deliberately keep this server-side and aggregate so the
|
||||
UI can render a single card without fanning out N queries.
|
||||
"""
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
total_lessons = await conn.fetchval(
|
||||
"SELECT count(*) FROM decision_lessons WHERE source = 'curator'"
|
||||
)
|
||||
decisions_with_findings = await conn.fetchval(
|
||||
"SELECT count(DISTINCT style_corpus_id) FROM decision_lessons "
|
||||
"WHERE source = 'curator'"
|
||||
)
|
||||
total_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus")
|
||||
applied = await conn.fetchval(
|
||||
"SELECT count(*) FROM decision_lessons "
|
||||
"WHERE source = 'curator' AND applied_to_skill = true"
|
||||
)
|
||||
# Last 10 curator findings — newest first
|
||||
recent_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT dl.id, dl.lesson_text, dl.category, dl.applied_to_skill,
|
||||
dl.created_at,
|
||||
sc.decision_number, sc.decision_date
|
||||
FROM decision_lessons dl
|
||||
JOIN style_corpus sc ON sc.id = dl.style_corpus_id
|
||||
WHERE dl.source = 'curator'
|
||||
ORDER BY dl.created_at DESC
|
||||
LIMIT 10
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"total_findings": total_lessons or 0,
|
||||
"decisions_with_findings": decisions_with_findings or 0,
|
||||
"decisions_total": total_corpus or 0,
|
||||
"findings_applied": applied or 0,
|
||||
"recent_findings": [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"lesson_text": r["lesson_text"],
|
||||
"category": r["category"],
|
||||
"applied_to_skill": bool(r["applied_to_skill"]),
|
||||
"decision_number": r["decision_number"] or "",
|
||||
"decision_date": str(r["decision_date"]) if r["decision_date"] else "",
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else "",
|
||||
}
|
||||
for r in recent_rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class CuratorProposal(BaseModel):
|
||||
title: str
|
||||
proposed_change: str # markdown — what to change in the prompt
|
||||
rationale: str # markdown — why
|
||||
|
||||
|
||||
@app.post("/api/training/curator/proposals")
|
||||
async def create_curator_proposal(body: CuratorProposal):
|
||||
"""Save a proposed change to the curator prompt as a file on disk.
|
||||
|
||||
No automatic commit, no overwrite — the chair (chaim) reviews the
|
||||
file manually and applies it through git. This is intentional: the
|
||||
prompt is too load-bearing to mutate from a web UI.
|
||||
"""
|
||||
title = (body.title or "").strip()
|
||||
if not title:
|
||||
raise HTTPException(400, "title is required")
|
||||
if not body.proposed_change.strip():
|
||||
raise HTTPException(400, "proposed_change is required")
|
||||
|
||||
_CURATOR_PROPOSALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Slug-ish filename — strip anything that isn't a Hebrew letter, ASCII
|
||||
# letter, digit, hyphen, or underscore. Hebrew letters are explicitly
|
||||
# allowed because most proposals will be in Hebrew.
|
||||
slug = re.sub(r"[^\w-\-]+", "-", title)[:60].strip("-_") or "proposal"
|
||||
today = date_type.today().isoformat()
|
||||
fname = f"{today}-{slug}.md"
|
||||
path = _CURATOR_PROPOSALS_DIR / fname
|
||||
|
||||
# If a proposal with the same slug already exists today, append a
|
||||
# numeric suffix so we don't silently overwrite.
|
||||
idx = 2
|
||||
while path.exists():
|
||||
path = _CURATOR_PROPOSALS_DIR / f"{today}-{slug}-{idx}.md"
|
||||
idx += 1
|
||||
|
||||
md = (
|
||||
f"# הצעת שינוי לפרומפט hermes-curator\n\n"
|
||||
f"- **תאריך:** {today}\n"
|
||||
f"- **כותרת:** {title}\n\n"
|
||||
f"## שינוי מוצע\n\n{body.proposed_change.strip()}\n\n"
|
||||
f"## נימוק\n\n{body.rationale.strip() or '(לא ניתן)'}\n"
|
||||
)
|
||||
try:
|
||||
path.write_text(md, encoding="utf-8")
|
||||
except OSError as e:
|
||||
raise HTTPException(500, f"failed to write proposal: {e}")
|
||||
return {
|
||||
"saved": True,
|
||||
"filename": path.name,
|
||||
"path": str(path),
|
||||
"bytes": len(md.encode("utf-8")),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/training/curator/proposals")
|
||||
async def list_curator_proposals():
|
||||
"""List proposed-change files in data/curator-proposals/, newest first."""
|
||||
if not _CURATOR_PROPOSALS_DIR.exists():
|
||||
return []
|
||||
items = []
|
||||
for p in sorted(_CURATOR_PROPOSALS_DIR.iterdir(),
|
||||
key=lambda f: f.stat().st_mtime, reverse=True):
|
||||
if not p.is_file() or p.suffix.lower() != ".md":
|
||||
continue
|
||||
stat = p.stat()
|
||||
items.append({
|
||||
"filename": p.name,
|
||||
"bytes": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
# ── Per-decision lessons (decision_lessons table) ──────────────────
|
||||
|
||||
|
||||
class LessonCreate(BaseModel):
|
||||
lesson_text: str
|
||||
category: str = "general"
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class LessonPatch(BaseModel):
|
||||
lesson_text: str | None = None
|
||||
category: str | None = None
|
||||
applied_to_skill: bool | None = None
|
||||
|
||||
|
||||
_LESSON_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"}
|
||||
_LESSON_SOURCES = {"manual", "curator", "chair", "style_analyzer"}
|
||||
|
||||
|
||||
def _lesson_to_json(row: dict) -> dict:
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"style_corpus_id": str(row["style_corpus_id"]),
|
||||
"lesson_text": row["lesson_text"],
|
||||
"category": row["category"],
|
||||
"source": row["source"],
|
||||
"applied_to_skill": bool(row["applied_to_skill"]),
|
||||
"created_by": row.get("created_by", ""),
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else "",
|
||||
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else "",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/training/corpus/{corpus_id}/lessons")
|
||||
async def list_corpus_lessons(corpus_id: str):
|
||||
try:
|
||||
cid = UUID(corpus_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid corpus_id")
|
||||
rows = await db.list_decision_lessons(cid)
|
||||
return [_lesson_to_json(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/api/training/corpus/{corpus_id}/lessons")
|
||||
async def add_corpus_lesson(corpus_id: str, body: LessonCreate):
|
||||
try:
|
||||
cid = UUID(corpus_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid corpus_id")
|
||||
text = (body.lesson_text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "lesson_text is required")
|
||||
if body.category not in _LESSON_CATEGORIES:
|
||||
raise HTTPException(400, f"invalid category; allowed: {sorted(_LESSON_CATEGORIES)}")
|
||||
if body.source not in _LESSON_SOURCES:
|
||||
raise HTTPException(400, f"invalid source; allowed: {sorted(_LESSON_SOURCES)}")
|
||||
row = await db.add_decision_lesson(
|
||||
cid, lesson_text=text, category=body.category, source=body.source,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(500, "failed to insert lesson")
|
||||
return _lesson_to_json(row)
|
||||
|
||||
|
||||
@app.patch("/api/training/lessons/{lesson_id}")
|
||||
async def patch_corpus_lesson(lesson_id: str, body: LessonPatch):
|
||||
try:
|
||||
lid = UUID(lesson_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid lesson_id")
|
||||
if body.category is not None and body.category not in _LESSON_CATEGORIES:
|
||||
raise HTTPException(400, f"invalid category; allowed: {sorted(_LESSON_CATEGORIES)}")
|
||||
result = await db.update_decision_lesson(
|
||||
lid,
|
||||
lesson_text=body.lesson_text,
|
||||
category=body.category,
|
||||
applied_to_skill=body.applied_to_skill,
|
||||
)
|
||||
if not result.get("updated"):
|
||||
if result.get("reason") == "not found":
|
||||
raise HTTPException(404, "lesson not found")
|
||||
return result # "nothing to update" — 200 with reason
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/training/lessons/{lesson_id}")
|
||||
async def delete_corpus_lesson(lesson_id: str):
|
||||
try:
|
||||
lid = UUID(lesson_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid lesson_id")
|
||||
result = await db.delete_decision_lesson(lid)
|
||||
if not result.get("deleted"):
|
||||
raise HTTPException(404, "lesson not found")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/training/corpus/{corpus_id}/full-text")
|
||||
async def training_corpus_full_text(corpus_id: str):
|
||||
"""Return the proofread full_text for a single corpus row.
|
||||
|
||||
Kept out of the list endpoint because full_text is large (50K-650K chars
|
||||
per decision) and the table view only needs counts. The drawer fetches
|
||||
it on demand when the chair opens the "content" tab.
|
||||
"""
|
||||
try:
|
||||
cid = UUID(corpus_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid corpus_id")
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT decision_number, full_text FROM style_corpus WHERE id = $1",
|
||||
cid,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "corpus row not found")
|
||||
return {
|
||||
"id": corpus_id,
|
||||
"decision_number": row["decision_number"] or "",
|
||||
"full_text": row["full_text"] or "",
|
||||
}
|
||||
|
||||
|
||||
class TrainingCorpusPatch(BaseModel):
|
||||
"""Editable metadata fields on a style_corpus row.
|
||||
|
||||
full_text is intentionally NOT editable — the corpus is write-once.
|
||||
For corrections, re-upload the decision via /api/training/upload.
|
||||
"""
|
||||
decision_number: str | None = None
|
||||
decision_date: str | None = None # ISO YYYY-MM-DD, or "" to clear
|
||||
subject_categories: list[str] | None = None
|
||||
summary: str | None = None
|
||||
outcome: str | None = None
|
||||
key_principles: list[str] | None = None
|
||||
appeal_subtype: str | None = None
|
||||
practice_area: str | None = None
|
||||
|
||||
|
||||
@app.patch("/api/training/corpus/{corpus_id}")
|
||||
async def training_corpus_patch(corpus_id: str, patch: TrainingCorpusPatch):
|
||||
"""Update metadata fields on a corpus row. Only provided fields are touched."""
|
||||
try:
|
||||
cid = UUID(corpus_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid corpus_id")
|
||||
|
||||
fields = patch.model_dump(exclude_none=True)
|
||||
if not fields:
|
||||
return {"updated": False, "reason": "no fields to update"}
|
||||
|
||||
# Coerce decision_date "" → SQL NULL, otherwise parse as DATE.
|
||||
if "decision_date" in fields:
|
||||
v = fields["decision_date"]
|
||||
if v == "":
|
||||
fields["decision_date"] = None
|
||||
else:
|
||||
try:
|
||||
fields["decision_date"] = date_type.fromisoformat(v)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, f"invalid decision_date: {e}")
|
||||
|
||||
# subject_categories + key_principles are JSONB columns.
|
||||
if "subject_categories" in fields:
|
||||
fields["subject_categories"] = json.dumps(fields["subject_categories"])
|
||||
if "key_principles" in fields:
|
||||
fields["key_principles"] = json.dumps(fields["key_principles"])
|
||||
|
||||
# Build a positional UPDATE — asyncpg doesn't support named parameters.
|
||||
cols = list(fields.keys())
|
||||
set_clause = ", ".join(f"{c} = ${i + 2}" for i, c in enumerate(cols))
|
||||
values = [fields[c] for c in cols]
|
||||
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.fetchrow(
|
||||
f"UPDATE style_corpus SET {set_clause} "
|
||||
f"WHERE id = $1 "
|
||||
f"RETURNING id, decision_number, decision_date, summary, outcome",
|
||||
cid, *values,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(404, "corpus row not found")
|
||||
return {
|
||||
"updated": True,
|
||||
"id": str(result["id"]),
|
||||
"decision_number": result["decision_number"] or "",
|
||||
"decision_date": str(result["decision_date"]) if result["decision_date"] else "",
|
||||
"summary_len": len(result["summary"] or ""),
|
||||
"outcome_len": len(result["outcome"] or ""),
|
||||
}
|
||||
|
||||
|
||||
# Headers that defeat proxy buffering for SSE streams. `X-Accel-Buffering: no`
|
||||
|
||||
Reference in New Issue
Block a user