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:
@@ -745,6 +745,84 @@ CREATE INDEX IF NOT EXISTS idx_halachot_tsv
|
||||
"""
|
||||
|
||||
|
||||
# ── V13: Missing precedents log ───────────────────────────────────
|
||||
# Track citations that the parties brought up but which are NOT yet in
|
||||
# the precedent_library. Created by the researcher (auto or chair)
|
||||
# whenever a citation can't be found in the corpus; closed by uploading
|
||||
# the actual decision via internal_decision_upload or
|
||||
# precedent_library_upload, at which point linked_case_law_id points to
|
||||
# the new case_law row and status flips to 'closed'.
|
||||
SCHEMA_V13_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS missing_precedents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
citation TEXT NOT NULL,
|
||||
case_name TEXT,
|
||||
cited_in_case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
|
||||
cited_in_document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
|
||||
cited_by_party TEXT CHECK (cited_by_party IN (
|
||||
'appellant', 'respondent', 'committee', 'permit_applicant', 'unknown'
|
||||
)),
|
||||
cited_by_party_name TEXT,
|
||||
legal_topic TEXT,
|
||||
legal_issue TEXT,
|
||||
claim_quote TEXT,
|
||||
status TEXT DEFAULT 'open' CHECK (status IN (
|
||||
'open', 'uploaded', 'closed', 'irrelevant'
|
||||
)),
|
||||
linked_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
|
||||
closed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_missing_precedents_case
|
||||
ON missing_precedents(cited_in_case_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_missing_precedents_status
|
||||
ON missing_precedents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_missing_precedents_citation
|
||||
ON missing_precedents(citation);
|
||||
"""
|
||||
|
||||
|
||||
# ── V14: Legal arguments (aggregated propositions) ────────────────
|
||||
# After ``claims_extractor`` extracts raw propositions (rows in ``claims``)
|
||||
# the LLM-driven aggregator groups them into ~6-12 distinct legal arguments
|
||||
# per party. ``legal_arguments`` holds the consolidated argument; the M:M
|
||||
# join table ``legal_argument_propositions`` links back to the source
|
||||
# propositions for traceability ("which raw claims feed this argument?").
|
||||
SCHEMA_V14_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS legal_arguments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
|
||||
party TEXT NOT NULL CHECK (party IN (
|
||||
'appellant', 'respondent', 'committee', 'permit_applicant', 'unknown'
|
||||
)),
|
||||
argument_index INTEGER NOT NULL,
|
||||
argument_title TEXT NOT NULL,
|
||||
argument_body TEXT NOT NULL,
|
||||
legal_topic TEXT,
|
||||
priority TEXT DEFAULT 'substantive' CHECK (priority IN (
|
||||
'threshold', 'substantive', 'procedural', 'relief'
|
||||
)),
|
||||
cited_precedents TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_legal_arguments_case
|
||||
ON legal_arguments(case_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party
|
||||
ON legal_arguments(case_id, party);
|
||||
|
||||
-- M:M back to ``claims`` (raw propositions).
|
||||
CREATE TABLE IF NOT EXISTS legal_argument_propositions (
|
||||
argument_id UUID NOT NULL REFERENCES legal_arguments(id) ON DELETE CASCADE,
|
||||
claim_id UUID NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (argument_id, claim_id)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(SCHEMA_SQL)
|
||||
@@ -760,7 +838,9 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
||||
await conn.execute(SCHEMA_V10_SQL)
|
||||
await conn.execute(SCHEMA_V11_SQL)
|
||||
await conn.execute(SCHEMA_V12_SQL)
|
||||
logger.info("Database schema initialized (v1-v12)")
|
||||
await conn.execute(SCHEMA_V13_SQL)
|
||||
await conn.execute(SCHEMA_V14_SQL)
|
||||
logger.info("Database schema initialized (v1-v14)")
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -782,7 +862,10 @@ async def create_case(
|
||||
hearing_date: date | None = None,
|
||||
notes: str = "",
|
||||
expected_outcome: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
# Default "" — DB CHECK constraint accepts empty, the upstream tool
|
||||
# (cases.case_create) is responsible for deriving the domain value
|
||||
# from the case_number prefix before calling here.
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
) -> dict:
|
||||
pool = await get_pool()
|
||||
@@ -3106,3 +3189,228 @@ async def search_precedent_library_hybrid(
|
||||
merged.append(d)
|
||||
merged.sort(key=lambda x: -x["score"])
|
||||
return merged[:limit]
|
||||
|
||||
|
||||
# ── Missing precedents (V13) ───────────────────────────────────────
|
||||
# Track citations from party briefs that aren't yet in the corpus.
|
||||
# Lifecycle: 'open' → researcher logs gap → chair uploads decision
|
||||
# → status='uploaded' (file ingested) → status='closed' (linked to
|
||||
# case_law row). 'irrelevant' = chair decided the citation isn't worth
|
||||
# adding to the library.
|
||||
|
||||
ALLOWED_MP_PARTIES = {
|
||||
"appellant", "respondent", "committee", "permit_applicant", "unknown",
|
||||
}
|
||||
ALLOWED_MP_STATUS = {"open", "uploaded", "closed", "irrelevant"}
|
||||
|
||||
|
||||
def _row_to_missing_precedent(row: asyncpg.Record) -> dict:
|
||||
d = dict(row)
|
||||
d["id"] = str(d["id"])
|
||||
if d.get("cited_in_case_id") is not None:
|
||||
d["cited_in_case_id"] = str(d["cited_in_case_id"])
|
||||
if d.get("cited_in_document_id") is not None:
|
||||
d["cited_in_document_id"] = str(d["cited_in_document_id"])
|
||||
if d.get("linked_case_law_id") is not None:
|
||||
d["linked_case_law_id"] = str(d["linked_case_law_id"])
|
||||
return d
|
||||
|
||||
|
||||
async def create_missing_precedent(
|
||||
citation: str,
|
||||
case_name: str | None = None,
|
||||
cited_in_case_id: UUID | None = None,
|
||||
cited_in_document_id: UUID | None = None,
|
||||
cited_by_party: str | None = None,
|
||||
cited_by_party_name: str | None = None,
|
||||
legal_topic: str | None = None,
|
||||
legal_issue: str | None = None,
|
||||
claim_quote: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new missing-precedent row (status='open' by default)."""
|
||||
if not citation.strip():
|
||||
raise ValueError("citation is required")
|
||||
if cited_by_party and cited_by_party not in ALLOWED_MP_PARTIES:
|
||||
raise ValueError(
|
||||
f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}"
|
||||
)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO missing_precedents (
|
||||
citation, case_name, cited_in_case_id, cited_in_document_id,
|
||||
cited_by_party, cited_by_party_name, legal_topic, legal_issue,
|
||||
claim_quote, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *""",
|
||||
citation.strip(), case_name, cited_in_case_id, cited_in_document_id,
|
||||
cited_by_party, cited_by_party_name, legal_topic, legal_issue,
|
||||
claim_quote, notes,
|
||||
)
|
||||
return _row_to_missing_precedent(row)
|
||||
|
||||
|
||||
async def list_missing_precedents(
|
||||
status: str | None = None,
|
||||
case_id: UUID | None = None,
|
||||
legal_topic: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""List missing precedents, joining the cited-in case_number for display."""
|
||||
pool = await get_pool()
|
||||
conditions: list[str] = []
|
||||
params: list = []
|
||||
idx = 1
|
||||
if status:
|
||||
conditions.append(f"mp.status = ${idx}")
|
||||
params.append(status)
|
||||
idx += 1
|
||||
if case_id:
|
||||
conditions.append(f"mp.cited_in_case_id = ${idx}")
|
||||
params.append(case_id)
|
||||
idx += 1
|
||||
if legal_topic:
|
||||
conditions.append(f"mp.legal_topic ILIKE ${idx}")
|
||||
params.append(f"%{legal_topic}%")
|
||||
idx += 1
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params.append(limit)
|
||||
params.append(offset)
|
||||
sql = f"""
|
||||
SELECT mp.*,
|
||||
c.case_number AS cited_in_case_number,
|
||||
cl.case_number AS linked_case_law_number,
|
||||
cl.case_name AS linked_case_law_name
|
||||
FROM missing_precedents mp
|
||||
LEFT JOIN cases c ON c.id = mp.cited_in_case_id
|
||||
LEFT JOIN case_law cl ON cl.id = mp.linked_case_law_id
|
||||
{where}
|
||||
ORDER BY
|
||||
CASE mp.status
|
||||
WHEN 'open' THEN 0
|
||||
WHEN 'uploaded' THEN 1
|
||||
WHEN 'closed' THEN 2
|
||||
WHEN 'irrelevant' THEN 3
|
||||
END,
|
||||
mp.created_at DESC
|
||||
LIMIT ${idx} OFFSET ${idx + 1}
|
||||
"""
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(sql, *params)
|
||||
return [_row_to_missing_precedent(r) for r in rows]
|
||||
|
||||
|
||||
async def get_missing_precedent(mp_id: UUID) -> dict | None:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT mp.*,
|
||||
c.case_number AS cited_in_case_number,
|
||||
cl.case_number AS linked_case_law_number,
|
||||
cl.case_name AS linked_case_law_name
|
||||
FROM missing_precedents mp
|
||||
LEFT JOIN cases c ON c.id = mp.cited_in_case_id
|
||||
LEFT JOIN case_law cl ON cl.id = mp.linked_case_law_id
|
||||
WHERE mp.id = $1
|
||||
""",
|
||||
mp_id,
|
||||
)
|
||||
return _row_to_missing_precedent(row) if row else None
|
||||
|
||||
|
||||
async def update_missing_precedent(mp_id: UUID, **fields) -> dict | None:
|
||||
"""Patch a missing-precedent row. Allowed fields: legal_topic,
|
||||
legal_issue, notes, cited_by_party, cited_by_party_name, case_name,
|
||||
status, linked_case_law_id, closed_at."""
|
||||
if not fields:
|
||||
return await get_missing_precedent(mp_id)
|
||||
allowed = {
|
||||
"legal_topic", "legal_issue", "notes", "cited_by_party",
|
||||
"cited_by_party_name", "case_name", "status", "linked_case_law_id",
|
||||
"closed_at", "claim_quote", "citation",
|
||||
}
|
||||
clean = {k: v for k, v in fields.items() if k in allowed}
|
||||
if not clean:
|
||||
return await get_missing_precedent(mp_id)
|
||||
if "status" in clean and clean["status"] not in ALLOWED_MP_STATUS:
|
||||
raise ValueError(
|
||||
f"status must be one of {sorted(ALLOWED_MP_STATUS)}"
|
||||
)
|
||||
if "cited_by_party" in clean and clean["cited_by_party"] and \
|
||||
clean["cited_by_party"] not in ALLOWED_MP_PARTIES:
|
||||
raise ValueError(
|
||||
f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}"
|
||||
)
|
||||
set_clauses = []
|
||||
values = []
|
||||
for i, (key, val) in enumerate(clean.items(), start=2):
|
||||
set_clauses.append(f"{key} = ${i}")
|
||||
values.append(val)
|
||||
set_clauses.append("updated_at = now()")
|
||||
sql = (
|
||||
f"UPDATE missing_precedents SET {', '.join(set_clauses)} "
|
||||
f"WHERE id = $1 RETURNING *"
|
||||
)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(sql, mp_id, *values)
|
||||
return _row_to_missing_precedent(row) if row else None
|
||||
|
||||
|
||||
async def close_missing_precedent(
|
||||
mp_id: UUID,
|
||||
linked_case_law_id: UUID | None = None,
|
||||
notes: str | None = None,
|
||||
status: str = "closed",
|
||||
) -> dict | None:
|
||||
"""Mark a missing-precedent row as closed (or 'uploaded'/'irrelevant')
|
||||
and link it to a case_law row if provided."""
|
||||
if status not in ALLOWED_MP_STATUS:
|
||||
raise ValueError(
|
||||
f"status must be one of {sorted(ALLOWED_MP_STATUS)}"
|
||||
)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
sets = ["status = $2", "closed_at = now()", "updated_at = now()"]
|
||||
params: list = [mp_id, status]
|
||||
idx = 3
|
||||
if linked_case_law_id is not None:
|
||||
sets.append(f"linked_case_law_id = ${idx}")
|
||||
params.append(linked_case_law_id)
|
||||
idx += 1
|
||||
if notes is not None:
|
||||
sets.append(f"notes = ${idx}")
|
||||
params.append(notes)
|
||||
idx += 1
|
||||
sql = (
|
||||
f"UPDATE missing_precedents SET {', '.join(sets)} "
|
||||
f"WHERE id = $1 RETURNING *"
|
||||
)
|
||||
row = await conn.fetchrow(sql, *params)
|
||||
return _row_to_missing_precedent(row) if row else None
|
||||
|
||||
|
||||
async def find_missing_precedent_by_citation(
|
||||
citation: str,
|
||||
case_id: UUID | None = None,
|
||||
) -> dict | None:
|
||||
"""Look up an existing row by citation string (exact match) and optionally
|
||||
cited-in case_id. Used to deduplicate auto-creation by the researcher."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if case_id is not None:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM missing_precedents "
|
||||
"WHERE citation = $1 AND cited_in_case_id = $2 LIMIT 1",
|
||||
citation.strip(), case_id,
|
||||
)
|
||||
else:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM missing_precedents WHERE citation = $1 LIMIT 1",
|
||||
citation.strip(),
|
||||
)
|
||||
return _row_to_missing_precedent(row) if row else None
|
||||
|
||||
Reference in New Issue
Block a user