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:
@@ -270,6 +270,40 @@ UPDATE document_chunks dc
|
||||
WHERE dc.document_id = d.id AND dc.practice_area IS NULL;
|
||||
"""
|
||||
|
||||
# ── Phase 5: case_precedents (user-attached legal quotes) ──────────
|
||||
|
||||
SCHEMA_V5_SQL = """
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- case_precedents: legal support the chair attaches to a case / section
|
||||
-- during the compose phase. Self-contained — quote + citation are
|
||||
-- stored inline, with an optional FK to an archived PDF in documents.
|
||||
-- Not linked to case_law (which has UNIQUE(case_number)) to keep the
|
||||
-- citation as free-text. A backfill pass into case_law is a future
|
||||
-- follow-up once the UI stabilizes.
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS case_precedents (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
|
||||
section_id TEXT, -- NULL = case-level
|
||||
-- else "threshold_1" / "issue_3"
|
||||
quote TEXT NOT NULL,
|
||||
citation TEXT NOT NULL, -- free-text "מראה מקום"
|
||||
chair_note TEXT DEFAULT '',
|
||||
pdf_document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
|
||||
practice_area TEXT, -- denormalized from cases
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_case
|
||||
ON case_precedents(case_id, section_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_library
|
||||
ON case_precedents(citation);
|
||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_area
|
||||
ON case_precedents(practice_area);
|
||||
"""
|
||||
|
||||
# ── Phase 2: Decision + Knowledge + RAG layers ────────────────────
|
||||
|
||||
SCHEMA_V2_SQL = """
|
||||
@@ -459,7 +493,8 @@ async def init_schema() -> None:
|
||||
await conn.execute(SCHEMA_V2_SQL)
|
||||
await conn.execute(SCHEMA_V3_SQL)
|
||||
await conn.execute(SCHEMA_V4_SQL)
|
||||
logger.info("Database schema initialized (v1 + v2 + v3 + v4)")
|
||||
await conn.execute(SCHEMA_V5_SQL)
|
||||
logger.info("Database schema initialized (v1 + v2 + v3 + v4 + v5)")
|
||||
|
||||
|
||||
# ── Case CRUD ───────────────────────────────────────────────────────
|
||||
@@ -680,6 +715,113 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
# ── case_precedents CRUD ───────────────────────────────────────────
|
||||
|
||||
def _row_to_precedent(row: asyncpg.Record) -> dict:
|
||||
d = dict(row)
|
||||
for k in ("id", "case_id"):
|
||||
if d.get(k) is not None:
|
||||
d[k] = str(d[k])
|
||||
if d.get("pdf_document_id") is not None:
|
||||
d["pdf_document_id"] = str(d["pdf_document_id"])
|
||||
for ts in ("created_at", "updated_at"):
|
||||
if d.get(ts) is not None:
|
||||
d[ts] = d[ts].isoformat()
|
||||
return d
|
||||
|
||||
|
||||
async def create_case_precedent(
|
||||
case_id: UUID,
|
||||
quote: str,
|
||||
citation: str,
|
||||
section_id: str | None = None,
|
||||
chair_note: str = "",
|
||||
pdf_document_id: UUID | None = None,
|
||||
practice_area: str | None = None,
|
||||
) -> dict:
|
||||
"""Insert a new attached precedent. practice_area is inherited from
|
||||
the parent case when not explicitly supplied, so the cross-case
|
||||
library search can filter without a JOIN."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if practice_area is None:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT practice_area FROM cases WHERE id = $1", case_id
|
||||
)
|
||||
practice_area = row["practice_area"] if row else None
|
||||
inserted = await conn.fetchrow(
|
||||
"""INSERT INTO case_precedents
|
||||
(case_id, section_id, quote, citation, chair_note,
|
||||
pdf_document_id, practice_area)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *""",
|
||||
case_id, section_id, quote, citation, chair_note,
|
||||
pdf_document_id, practice_area,
|
||||
)
|
||||
return _row_to_precedent(inserted)
|
||||
|
||||
|
||||
async def list_case_precedents(case_id: UUID) -> list[dict]:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM case_precedents WHERE case_id = $1 "
|
||||
"ORDER BY section_id NULLS FIRST, created_at",
|
||||
case_id,
|
||||
)
|
||||
return [_row_to_precedent(r) for r in rows]
|
||||
|
||||
|
||||
async def delete_case_precedent(precedent_id: UUID) -> bool:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM case_precedents WHERE id = $1", precedent_id
|
||||
)
|
||||
return result.endswith(" 1")
|
||||
|
||||
|
||||
async def search_precedent_library(
|
||||
query: str, practice_area: str = "", limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Cross-case typeahead for the citation field. Returns one row per
|
||||
distinct citation so the user sees each precedent once even if they
|
||||
previously attached it to multiple cases/sections. No embeddings —
|
||||
simple ILIKE is fine at this scale."""
|
||||
pool = await get_pool()
|
||||
pattern = f"%{query}%"
|
||||
async with pool.acquire() as conn:
|
||||
if practice_area:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT DISTINCT ON (citation)
|
||||
id, citation, quote, chair_note, practice_area, created_at
|
||||
FROM case_precedents
|
||||
WHERE practice_area = $1
|
||||
AND (citation ILIKE $2 OR quote ILIKE $2)
|
||||
ORDER BY citation, created_at DESC
|
||||
LIMIT $3""",
|
||||
practice_area, pattern, limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT DISTINCT ON (citation)
|
||||
id, citation, quote, chair_note, practice_area, created_at
|
||||
FROM case_precedents
|
||||
WHERE citation ILIKE $1 OR quote ILIKE $1
|
||||
ORDER BY citation, created_at DESC
|
||||
LIMIT $2""",
|
||||
pattern, limit,
|
||||
)
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["id"] = str(d["id"])
|
||||
if d.get("created_at"):
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# ── Claims ─────────────────────────────────────────────────────────
|
||||
|
||||
async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int:
|
||||
|
||||
Reference in New Issue
Block a user