feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled
Four parallel sub-agents closed the remaining critical gaps from the 26/05 Stage A/B sprint. Each block independently tested; aggregated here. ## #30/#31 finalizers (sub-agent A) * Auto-derive practice_area in case_create from case_number prefix (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197); default for CaseCreateRequest is now "" (the DB constraint catches any stray "appeals_committee"). * practice_area.py: derive_subtype now handles axis-B domain values (rishuy_uvniya/betterment_levy/compensation_197) without parsing the case number; new helper derive_domain_practice_area(). * Halacha re-extraction verified unnecessary — all 6 reclassified records already had is_binding=false and approved halachot. * Regression tests: 6 cases in tests/test_corpus_constraints.py covering practice_area enum, internal-committee chair/district, external-upload arar prefix, MCP guard. * UI: district input → Select dropdown (7 districts) in precedent-edit-sheet.tsx, preserving legacy free-text values. ## #37 בל"מ subtypes (sub-agent B) * 3 new appeal_subtypes: extension_request_{building_permit, betterment_levy,compensation}. APPEALS_COMMITTEE_SUBTYPES extended, SUBTYPES_BY_AREA mappings added. * New helpers: is_blam_subject(), is_blam_subtype(), derive_subtype_with_blam(case_number, subject, practice_area). case_create now uses it to auto-detect "בקשה להארכת מועד" subjects. * 3 methodology templates under docs/methodology/extension-request-*.md. * paperclip_client.py mapping updated for the 3 new subtypes (extension_request_building_permit→CMP, the other two→CMPA). * Frontend: bilingual "בל"מ" badge + filter dropdown on cases list + detail header; appeal-type-bars collapseBlam() merges בל"מ into its parent domain for aggregate bars. * Wizard auto-detects בל"מ from subject during case creation. * 3 Berlinger cases (1017/1018/1019-03-26) migrated to appeal_subtype=extension_request_building_permit via psql. ## #35 missing_precedents feature (sub-agent C) * Schema V13: missing_precedents table (citation, case_id, party, legal_topic, status, linked_case_law_id, claim_quote, ...) + FK constraints + 3 indexes. Applied via psql + idempotent migration. * 6 db.py service functions, 3 MCP tools, 6 FastAPI endpoints (POST/GET/PATCH/DELETE/upload — upload routes by citation prefix to ingest_internal_decision or ingest_precedent). * Next.js page /missing-precedents with 5 status tabs + filters + sidebar badge counter + detail drawer with metadata edit + smart upload form that switches fields per committee/court. * Bootstrap: 7 rows imported from the JSON file (3 citations × cases, all status=closed with linked_case_law_id). * legal-researcher.md: new §2ב.5 with missing_precedent_create usage + dedup semantics + tool grant. ## #36 legal_arguments aggregation (sub-agent D) * Schema V14: legal_arguments + legal_argument_propositions M:M. Applied via psql. * New service argument_aggregator.py with two functions — aggregate_claims_to_arguments() (Claude CLI / claude_session) and get_legal_arguments(). Graceful llm_unavailable handling when CLI is missing (containers). * 2 MCP tools + 2 API endpoints (POST .../aggregate-arguments as BackgroundTask, GET .../legal-arguments). * Frontend: shadcn Accordion + new legal-arguments-panel.tsx with hierarchical (party → priority badge → arguments) display, "טיעונים" tab on the case page, "חשב/חשב מחדש" buttons. * scripts/backfill_legal_arguments.py + SCRIPTS.md entry — dry-run found 8 candidate cases including 1017/1018/1019. ## Open follow-ups (intentionally deferred) * npm run api:types in web-ui (CLAUDE.md flow) — recommended before the next UI commit; not required for backend deployment. * Run backfill_legal_arguments.py --apply once the container picks up the new aggregator service. * webhook on missing-precedents upload-close to Paperclip (optional). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
412
web/app.py
412
web/app.py
@@ -1228,7 +1228,11 @@ class CaseCreateRequest(BaseModel):
|
||||
hearing_date: str = ""
|
||||
notes: str = ""
|
||||
expected_outcome: str = ""
|
||||
practice_area: str = "appeals_committee"
|
||||
# Empty default → cases_tools.case_create auto-derives the domain
|
||||
# practice_area from the case_number prefix (1xxx→rishuy_uvniya,
|
||||
# 8xxx→betterment_levy, 9xxx→compensation_197). Callers can still
|
||||
# send a domain value explicitly.
|
||||
practice_area: str = ""
|
||||
appeal_subtype: str = ""
|
||||
|
||||
|
||||
@@ -1267,8 +1271,10 @@ async def api_case_create(req: CaseCreateRequest):
|
||||
)
|
||||
parsed = json.loads(result)
|
||||
|
||||
# Auto-create Paperclip project for the new case
|
||||
appeal_type = req.appeal_subtype or "רישוי"
|
||||
# Auto-create Paperclip project for the new case. case_create may have
|
||||
# auto-derived appeal_subtype from the case-number prefix; prefer the
|
||||
# resolved value over the (possibly empty) request value.
|
||||
appeal_type = parsed.get("appeal_subtype") or req.appeal_subtype or "רישוי"
|
||||
try:
|
||||
pc_result = await pc_create_project(
|
||||
case_number=req.case_number,
|
||||
@@ -1744,6 +1750,77 @@ async def api_get_claims(case_number: str):
|
||||
return {"case_number": case_number, "claims": claims_by_party, "total": len(rows)}
|
||||
|
||||
|
||||
# ── Legal Arguments (aggregated claims) ────────────────────────────
|
||||
# The aggregator groups raw ``claims`` rows into ~6-12 distinct legal
|
||||
# arguments per party. The heavy lifting (LLM call) runs in the local
|
||||
# MCP server context where Claude CLI is available; here we expose
|
||||
# read + trigger endpoints. The trigger is a BackgroundTask only when
|
||||
# Claude CLI is actually present in the runtime (i.e. dev box) — inside
|
||||
# the FastAPI container it short-circuits with status="llm_unavailable".
|
||||
|
||||
@app.post("/api/cases/{case_number}/aggregate-arguments")
|
||||
async def api_aggregate_arguments(
|
||||
case_number: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
force: bool = False,
|
||||
):
|
||||
"""Aggregate raw claims into distinct legal arguments via Claude.
|
||||
|
||||
Runs as a BackgroundTask because the LLM pass can take 30-90 seconds.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
from legal_mcp.services import argument_aggregator
|
||||
result = await argument_aggregator.aggregate_claims_to_arguments(
|
||||
UUID(case["id"]), force=force,
|
||||
)
|
||||
logger.info(
|
||||
"aggregate_arguments[%s] finished: %s",
|
||||
case_number, result,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception(
|
||||
"aggregate_arguments[%s] failed: %s", case_number, e,
|
||||
)
|
||||
|
||||
background_tasks.add_task(_run)
|
||||
return {
|
||||
"status": "started",
|
||||
"case_number": case_number,
|
||||
"force": force,
|
||||
"message": "Aggregation started in background. Poll /legal-arguments for results.",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/legal-arguments")
|
||||
async def api_get_legal_arguments(case_number: str, party: str = ""):
|
||||
"""Return aggregated legal arguments for a case, grouped by party."""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
from legal_mcp.services import argument_aggregator
|
||||
args = await argument_aggregator.get_legal_arguments(
|
||||
UUID(case["id"]), party=party,
|
||||
)
|
||||
|
||||
# Group by party for the UI.
|
||||
by_party: dict[str, list[dict]] = {}
|
||||
for a in args:
|
||||
by_party.setdefault(a["party"], []).append(a)
|
||||
|
||||
return {
|
||||
"case_number": case_number,
|
||||
"total": len(args),
|
||||
"by_party": by_party,
|
||||
"arguments": args,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/direction")
|
||||
async def api_set_direction(case_number: str, req: DirectionRequest):
|
||||
"""Save the approved direction document for the discussion block."""
|
||||
@@ -4789,3 +4866,332 @@ async def halacha_update(halacha_id: str, req: HalachaUpdateRequest):
|
||||
if not row:
|
||||
raise HTTPException(404, "הלכה לא נמצאה")
|
||||
return row
|
||||
|
||||
|
||||
# ── Missing Precedents (TaskMaster #35) ────────────────────────────
|
||||
# Track citations from party briefs that aren't yet in the precedent
|
||||
# corpus. Researcher logs gaps; chair closes them by uploading the
|
||||
# actual decision via /api/precedent-library/upload or
|
||||
# /api/internal-decisions/upload, then links via the upload endpoint
|
||||
# here which delegates to one of those depending on the citation type.
|
||||
|
||||
|
||||
_ALLOWED_MP_PARTIES = {
|
||||
"appellant", "respondent", "committee", "permit_applicant", "unknown",
|
||||
}
|
||||
_ALLOWED_MP_STATUS = {"open", "uploaded", "closed", "irrelevant"}
|
||||
|
||||
|
||||
class MissingPrecedentCreate(BaseModel):
|
||||
citation: str
|
||||
case_number: str = "" # cited-in case
|
||||
cited_in_document_id: str | None = None
|
||||
cited_by_party: Literal[
|
||||
"appellant", "respondent", "committee", "permit_applicant", "unknown",
|
||||
] = "unknown"
|
||||
cited_by_party_name: str | None = None
|
||||
legal_topic: str | None = None
|
||||
legal_issue: str | None = None
|
||||
claim_quote: str | None = None
|
||||
case_name: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class MissingPrecedentPatch(BaseModel):
|
||||
legal_topic: str | None = None
|
||||
legal_issue: str | None = None
|
||||
notes: str | None = None
|
||||
cited_by_party: Literal[
|
||||
"appellant", "respondent", "committee", "permit_applicant", "unknown",
|
||||
] | None = None
|
||||
cited_by_party_name: str | None = None
|
||||
case_name: str | None = None
|
||||
status: Literal["open", "uploaded", "closed", "irrelevant"] | None = None
|
||||
citation: str | None = None
|
||||
claim_quote: str | None = None
|
||||
|
||||
|
||||
def _is_internal_committee_citation(citation: str) -> bool:
|
||||
"""Detect ועדת ערר citations — must go through internal_decision_upload
|
||||
so they get chair_name + district. The legacy library upload doesn't
|
||||
enforce those fields and the records end up un-searchable by chair."""
|
||||
norm = citation.strip()
|
||||
committee_prefixes = ("ערר ", "ערר(", "בל\"מ ", "בל\"מ(", "ARAR ")
|
||||
return any(norm.startswith(p) for p in committee_prefixes)
|
||||
|
||||
|
||||
@app.post("/api/missing-precedents")
|
||||
async def missing_precedent_create(req: MissingPrecedentCreate):
|
||||
"""Log a new missing precedent (status='open'). Dedupes by
|
||||
(citation, cited_in_case_id) — duplicate POST returns the existing row."""
|
||||
if not req.citation.strip():
|
||||
raise HTTPException(400, "citation חובה")
|
||||
|
||||
case_id: UUID | None = None
|
||||
if req.case_number.strip():
|
||||
c = await db.get_case_by_number(req.case_number.strip())
|
||||
if not c:
|
||||
raise HTTPException(404, f"תיק לא נמצא: {req.case_number}")
|
||||
case_id = UUID(c["id"])
|
||||
|
||||
doc_id: UUID | None = None
|
||||
if req.cited_in_document_id:
|
||||
try:
|
||||
doc_id = UUID(req.cited_in_document_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "cited_in_document_id לא תקין")
|
||||
|
||||
existing = await db.find_missing_precedent_by_citation(
|
||||
citation=req.citation.strip(),
|
||||
case_id=case_id,
|
||||
)
|
||||
if existing:
|
||||
return {**existing, "_duplicate": True}
|
||||
|
||||
row = await db.create_missing_precedent(
|
||||
citation=req.citation.strip(),
|
||||
case_name=req.case_name,
|
||||
cited_in_case_id=case_id,
|
||||
cited_in_document_id=doc_id,
|
||||
cited_by_party=req.cited_by_party,
|
||||
cited_by_party_name=req.cited_by_party_name,
|
||||
legal_topic=req.legal_topic,
|
||||
legal_issue=req.legal_issue,
|
||||
claim_quote=req.claim_quote,
|
||||
notes=req.notes,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
@app.get("/api/missing-precedents")
|
||||
async def missing_precedents_list(
|
||||
status: str = "",
|
||||
case_id: str = "",
|
||||
case_number: str = "",
|
||||
legal_topic: str = "",
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""List missing precedents, optionally filtered by status / case."""
|
||||
s = status.strip() or None
|
||||
if s and s not in _ALLOWED_MP_STATUS:
|
||||
raise HTTPException(400, f"status לא תקין: {status}")
|
||||
|
||||
case_uuid: UUID | None = None
|
||||
if case_id.strip():
|
||||
try:
|
||||
case_uuid = UUID(case_id.strip())
|
||||
except ValueError:
|
||||
raise HTTPException(400, "case_id לא תקין")
|
||||
elif case_number.strip():
|
||||
c = await db.get_case_by_number(case_number.strip())
|
||||
if not c:
|
||||
raise HTTPException(404, f"תיק לא נמצא: {case_number}")
|
||||
case_uuid = UUID(c["id"])
|
||||
|
||||
rows = await db.list_missing_precedents(
|
||||
status=s,
|
||||
case_id=case_uuid,
|
||||
legal_topic=legal_topic.strip() or None,
|
||||
limit=max(1, min(int(limit), 500)),
|
||||
offset=max(0, int(offset)),
|
||||
)
|
||||
# Counters useful for the sidebar badge.
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
counts = await conn.fetch(
|
||||
"SELECT status, COUNT(*) AS n FROM missing_precedents GROUP BY status"
|
||||
)
|
||||
by_status = {r["status"]: r["n"] for r in counts}
|
||||
return {
|
||||
"items": rows,
|
||||
"count": len(rows),
|
||||
"by_status": by_status,
|
||||
"total_open": by_status.get("open", 0),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/missing-precedents/{mp_id}")
|
||||
async def missing_precedent_get(mp_id: str):
|
||||
try:
|
||||
uid = UUID(mp_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "id לא תקין")
|
||||
row = await db.get_missing_precedent(uid)
|
||||
if not row:
|
||||
raise HTTPException(404, "רשומה לא נמצאה")
|
||||
return row
|
||||
|
||||
|
||||
@app.patch("/api/missing-precedents/{mp_id}")
|
||||
async def missing_precedent_update(mp_id: str, req: MissingPrecedentPatch):
|
||||
try:
|
||||
uid = UUID(mp_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "id לא תקין")
|
||||
fields = {k: v for k, v in req.model_dump(exclude_unset=True).items() if v is not None}
|
||||
if not fields:
|
||||
row = await db.get_missing_precedent(uid)
|
||||
if not row:
|
||||
raise HTTPException(404, "רשומה לא נמצאה")
|
||||
return row
|
||||
try:
|
||||
row = await db.update_missing_precedent(uid, **fields)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
if not row:
|
||||
raise HTTPException(404, "רשומה לא נמצאה")
|
||||
return row
|
||||
|
||||
|
||||
@app.delete("/api/missing-precedents/{mp_id}")
|
||||
async def missing_precedent_delete(mp_id: str):
|
||||
try:
|
||||
uid = UUID(mp_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "id לא תקין")
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM missing_precedents WHERE id = $1", uid,
|
||||
)
|
||||
deleted = int(result.split()[-1]) > 0
|
||||
if not deleted:
|
||||
raise HTTPException(404, "רשומה לא נמצאה")
|
||||
return {"deleted": True, "id": mp_id}
|
||||
|
||||
|
||||
@app.post("/api/missing-precedents/{mp_id}/upload")
|
||||
async def missing_precedent_upload(
|
||||
mp_id: str,
|
||||
file: UploadFile = File(...),
|
||||
case_number: str = Form(""), # for internal-committee path
|
||||
chair_name: str = Form(""),
|
||||
district: str = Form(""),
|
||||
case_name: str = Form(""),
|
||||
court: str = Form(""),
|
||||
decision_date: str = Form(""),
|
||||
practice_area: str = Form(""),
|
||||
appeal_subtype: str = Form(""),
|
||||
subject_tags: str = Form("[]"),
|
||||
is_binding: bool = Form(True),
|
||||
headnote: str = Form(""),
|
||||
summary: str = Form(""),
|
||||
precedent_level: str = Form(""),
|
||||
source_type: str = Form(""),
|
||||
):
|
||||
"""Upload the decision file behind a missing-precedent and link it.
|
||||
|
||||
Routes to ingest_internal_decision if the citation looks like a
|
||||
committee decision (ערר / בל"מ prefix), otherwise to ingest_precedent.
|
||||
Once the case_law row is created, the missing_precedents row is marked
|
||||
status='closed' with linked_case_law_id pointing to the new row.
|
||||
"""
|
||||
try:
|
||||
uid = UUID(mp_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "id לא תקין")
|
||||
mp = await db.get_missing_precedent(uid)
|
||||
if not mp:
|
||||
raise HTTPException(404, "רשומה לא נמצאה")
|
||||
if mp["status"] in {"closed", "uploaded"} and mp.get("linked_case_law_id"):
|
||||
raise HTTPException(409, "הרשומה כבר נסגרה — הסר קישור לפני העלאה חוזרת")
|
||||
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(400, f"סוג קובץ לא נתמך: {suffix}")
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
staged = UPLOAD_DIR / f"mp_{uuid4().hex[:8]}_{file.filename}"
|
||||
size = 0
|
||||
with staged.open("wb") as out:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_SIZE:
|
||||
staged.unlink(missing_ok=True)
|
||||
raise HTTPException(413, "קובץ גדול מדי")
|
||||
out.write(chunk)
|
||||
|
||||
try:
|
||||
tags = json.loads(subject_tags) if subject_tags else []
|
||||
if not isinstance(tags, list):
|
||||
tags = []
|
||||
except json.JSONDecodeError:
|
||||
tags = []
|
||||
|
||||
citation = mp["citation"]
|
||||
is_committee = _is_internal_committee_citation(citation)
|
||||
case_law_id: str | None = None
|
||||
closed: dict | None = None
|
||||
|
||||
try:
|
||||
if is_committee:
|
||||
if not chair_name.strip() or not district.strip():
|
||||
raise HTTPException(
|
||||
400,
|
||||
"החלטת ועדת ערר דורשת chair_name + district",
|
||||
)
|
||||
# case_number for the committee decision (not the cited-in case)
|
||||
committee_case_number = case_number.strip() or citation
|
||||
result = await int_decisions_service.ingest_internal_decision(
|
||||
case_number=committee_case_number,
|
||||
case_name=(case_name.strip() or mp.get("case_name") or "").strip(),
|
||||
court=court.strip(),
|
||||
decision_date=decision_date or None,
|
||||
chair_name=chair_name.strip(),
|
||||
district=district.strip(),
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype.strip(),
|
||||
subject_tags=tags,
|
||||
is_binding=is_binding,
|
||||
summary=summary.strip(),
|
||||
file_path=staged,
|
||||
)
|
||||
case_law_id = (
|
||||
result.get("case_law_id") if isinstance(result, dict) else None
|
||||
)
|
||||
else:
|
||||
if practice_area and practice_area not in _PRACTICE_AREAS:
|
||||
raise HTTPException(400, "practice_area לא תקין")
|
||||
if source_type and source_type not in _SOURCE_TYPES:
|
||||
raise HTTPException(400, "source_type לא תקין")
|
||||
result = await plib_service.ingest_precedent(
|
||||
file_path=staged,
|
||||
citation=citation,
|
||||
case_name=(case_name.strip() or mp.get("case_name") or "").strip(),
|
||||
court=court.strip(),
|
||||
decision_date=decision_date or None,
|
||||
source_type=source_type or "court_ruling",
|
||||
precedent_level=precedent_level,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype.strip(),
|
||||
subject_tags=tags,
|
||||
is_binding=is_binding,
|
||||
headnote=headnote.strip(),
|
||||
summary=summary.strip(),
|
||||
)
|
||||
case_law_id = (
|
||||
result.get("case_law_id") if isinstance(result, dict) else None
|
||||
)
|
||||
|
||||
if not case_law_id:
|
||||
raise HTTPException(500, "לא התקבל case_law_id מההעלאה")
|
||||
|
||||
try:
|
||||
closed = await db.close_missing_precedent(
|
||||
mp_id=uid,
|
||||
linked_case_law_id=UUID(case_law_id),
|
||||
notes=mp.get("notes"),
|
||||
status="closed",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("missing-precedent close failed")
|
||||
raise HTTPException(500, f"קישור הרשומה נכשל: {e}")
|
||||
finally:
|
||||
staged.unlink(missing_ok=True)
|
||||
|
||||
return {
|
||||
"missing_precedent": closed,
|
||||
"case_law_id": case_law_id,
|
||||
"route": "internal_committee" if is_committee else "external_upload",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user