Add case_precedents: attached legal support for the compose phase
New self-contained table + MCP tools + FastAPI endpoints for letting
the chair attach external case-law quotes (quote + citation מראה מקום,
optional chair note, optional archived PDF) to either a specific
threshold_claim / issue or the case as a whole.
Data model
- case_precedents (SCHEMA_V5_SQL) — case_id, section_id NULL/
"threshold_N"/"issue_N", quote, citation (free-text), chair_note,
pdf_document_id FK to documents, denormalized practice_area for
cross-case library filtering.
- Deliberately NOT linked to the existing case_law table — that one
has UNIQUE(case_number) which would force parsing the free-text
citation into a structured key. A backfill pass into case_law is
a later follow-up once the UI stabilizes.
- db.py gains 4 helpers: create_case_precedent, list_case_precedents,
delete_case_precedent, search_precedent_library. The last uses
DISTINCT ON (citation) for the cross-case typeahead so each
precedent appears once even if reused across many cases.
MCP tools (legal_mcp/tools/precedents.py)
- precedent_attach, precedent_list, precedent_remove,
precedent_search_library — registered in server.py.
FastAPI (web/app.py)
- POST /api/cases/{n}/precedents — create, with PrecedentCreateRequest
- POST /api/cases/{n}/precedents/upload-pdf — one-shot PDF upload to
a dedicated documents/precedents/ subdirectory, creates a
documents row with doc_type="precedent_archive" and no text
extraction (archive only)
- GET /api/cases/{n}/precedents — list
- DELETE /api/precedents/{id} — uses path param since precedent_id
is a UUID (slash-safe, unlike case numbers)
- GET /api/precedents/search?q=...&practice_area=... — library
typeahead
Block-writer integration into _build_precedents_context is a deferred
follow-up — Phase 1 surfaces the feature in the compose UI only.
Plan: ~/.claude/plans/woolly-cooking-graham.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
116
web/app.py
116
web/app.py
@@ -29,7 +29,7 @@ import asyncpg
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, processor, proofreader, research_md
|
||||
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools
|
||||
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools, precedents as precedents_tools
|
||||
|
||||
# Import integration clients (same directory)
|
||||
_web_dir = Path(__file__).resolve().parent
|
||||
@@ -1643,6 +1643,120 @@ async def api_research_chair_position(case_number: str, req: ChairPositionReques
|
||||
raise HTTPException(500, f"שגיאה בשמירה: {e}")
|
||||
|
||||
|
||||
# ── Precedents API — attached case-law quotes for the compose phase ──
|
||||
|
||||
|
||||
class PrecedentCreateRequest(BaseModel):
|
||||
quote: str
|
||||
citation: str
|
||||
section_id: str = "" # empty = case-level / general discussion
|
||||
chair_note: str = ""
|
||||
pdf_document_id: str = "" # UUID string, empty = no PDF
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/precedents")
|
||||
async def api_precedent_attach(case_number: str, req: PrecedentCreateRequest):
|
||||
"""Attach a legal precedent (quote + citation) to a case, optionally
|
||||
scoped to a specific threshold_claim / issue section. Cross-case
|
||||
library reuse happens at the search endpoint — this one always
|
||||
inserts a new row."""
|
||||
if req.section_id and not re.match(r"^(threshold|issue)_\d+$", req.section_id):
|
||||
raise HTTPException(400, "section_id לא תקין")
|
||||
if not req.quote.strip() or not req.citation.strip():
|
||||
raise HTTPException(400, "quote ו-citation חובה")
|
||||
|
||||
result = await precedents_tools.precedent_attach(
|
||||
case_number=case_number,
|
||||
quote=req.quote,
|
||||
citation=req.citation,
|
||||
section_id=req.section_id,
|
||||
chair_note=req.chair_note,
|
||||
pdf_document_id=req.pdf_document_id,
|
||||
)
|
||||
data = json.loads(result)
|
||||
if data.get("error"):
|
||||
raise HTTPException(404, data["error"])
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/precedents/upload-pdf")
|
||||
async def api_precedent_upload_pdf(
|
||||
case_number: str,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""One-shot PDF upload for a precedent attachment. Stores the file
|
||||
on disk alongside other case documents and creates a `documents`
|
||||
row with doc_type='precedent_archive'. Returns {document_id} so the
|
||||
frontend can pass it into POST /precedents. No SSE / background
|
||||
processing — archive only, no text extraction."""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "No filename provided")
|
||||
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in {".pdf", ".docx", ".doc"}:
|
||||
raise HTTPException(400, f"סוג קובץ לא נתמך לפסיקה: {ext}")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(400, f"קובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB")
|
||||
|
||||
# Save under a dedicated precedents/ subdirectory so they don't mix
|
||||
# with extracted originals.
|
||||
case_dir = config.find_case_dir(case_number) / "documents" / "precedents"
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = re.sub(r"[^\w\u0590-\u05FF\s.\-()]", "", Path(file.filename).stem).strip()
|
||||
dest = case_dir / f"{safe_name or 'precedent'}{ext}"
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
dest = case_dir / f"{safe_name or 'precedent'}-{counter}{ext}"
|
||||
counter += 1
|
||||
dest.write_bytes(content)
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
doc = await db.create_document(
|
||||
case_id=case_id,
|
||||
doc_type="precedent_archive",
|
||||
title=safe_name or "precedent",
|
||||
file_path=str(dest),
|
||||
)
|
||||
return {"document_id": doc["id"], "filename": dest.name}
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/precedents")
|
||||
async def api_precedent_list(case_number: str):
|
||||
"""List all precedents attached to a case, grouped client-side by section_id."""
|
||||
result = await precedents_tools.precedent_list(case_number)
|
||||
data = json.loads(result)
|
||||
if isinstance(data, dict) and data.get("error"):
|
||||
raise HTTPException(404, data["error"])
|
||||
return data
|
||||
|
||||
|
||||
@app.delete("/api/precedents/{precedent_id}")
|
||||
async def api_precedent_delete(precedent_id: str):
|
||||
"""Delete a precedent attachment. The archived PDF (if any) stays
|
||||
in the documents table — orphaned references nullify via FK
|
||||
ON DELETE SET NULL — so we keep the audit trail of the file."""
|
||||
result = await precedents_tools.precedent_remove(precedent_id)
|
||||
data = json.loads(result)
|
||||
if data.get("error"):
|
||||
raise HTTPException(400, data["error"])
|
||||
if not data.get("deleted"):
|
||||
raise HTTPException(404, "לא נמצא")
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/precedents/search")
|
||||
async def api_precedent_search(q: str, practice_area: str = "", limit: int = 10):
|
||||
"""Cross-case library typeahead. Returns one row per distinct citation."""
|
||||
result = await precedents_tools.precedent_search_library(q, practice_area, limit)
|
||||
return json.loads(result)
|
||||
|
||||
|
||||
# ── Exports API — drafts, versions, download, upload, mark-final ──
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user