fix(precedents): מראה-מקום never blank — seed at upload + inline Gemini enrichment #318

Merged
chaim merged 1 commits from worktree-citation-autofill-root into main 2026-06-20 15:30:40 +00:00
3 changed files with 54 additions and 8 deletions
Showing only changes of commit b0bcdbeeef - Show all commits

View File

@@ -4334,12 +4334,19 @@ async def create_external_case_law(
precedent_level: str = "",
is_binding: bool = True,
document_id: UUID | None = None,
citation_formatted: str = "",
) -> dict:
"""Insert a chair-uploaded external precedent into case_law.
If a row with this ``case_number`` already exists with
source_kind='cited_only' (auto-discovered), promote it to
source_kind='external_upload' and fill in the missing fields.
``citation_formatted`` seeds the מראה-מקום from the chair's typed upload-form
value so the field is NEVER blank between upload and metadata extraction (the
inline enrichment later UPGRADES it to the canonical derived form via
``apply_to_record(force_citation=True)``). On a cited_only→external promotion
an existing non-empty value (a prior chair edit) is preserved.
"""
# INV-DM7: an appeals-committee source is persuasive even when uploaded via
# the external path — coerce to non-binding so it matches the committee
@@ -4364,11 +4371,12 @@ async def create_external_case_law(
summary, key_quote, full_text, source_url,
source_kind, document_id, extraction_status,
halacha_extraction_status, practice_area, appeal_subtype,
headnote, source_type, precedent_level, is_binding, content_hash
headnote, source_type, precedent_level, is_binding, content_hash,
citation_formatted
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
'external_upload', $10, 'processing', 'pending',
$11, $12, $13, $14, $15, $16, $17
$11, $12, $13, $14, $15, $16, $17, $18
)
ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee'
DO UPDATE SET
@@ -4390,14 +4398,16 @@ async def create_external_case_law(
source_kind = 'external_upload',
extraction_status = 'processing',
halacha_extraction_status = 'pending',
content_hash = EXCLUDED.content_hash
content_hash = EXCLUDED.content_hash,
citation_formatted = COALESCE(
NULLIF(case_law.citation_formatted, ''), EXCLUDED.citation_formatted)
RETURNING *
""",
case_number, case_name, court, decision_date, tags_json,
summary, key_quote, full_text, source_url,
document_id, practice_area, appeal_subtype, headnote,
source_type, precedent_level, is_binding,
_content_hash(full_text),
_content_hash(full_text), (citation_formatted or "").strip(),
)
return _row_to_case_law(row)

View File

@@ -68,7 +68,11 @@ def _external_staging_subdir(inputs: dict) -> str:
async def _create_external_record(**kw) -> dict:
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number)."""
"""Adapter: maps canonical inputs (citation) to create_external_case_law(case_number).
The chair's typed citation seeds ``citation_formatted`` so the מראה-מקום is never
blank before metadata extraction; the inline enrichment upgrades it to the
canonical form (see ``ingest_precedent``)."""
return await db.create_external_case_law(
case_number=kw["citation"].strip(),
case_name=kw["case_name"],
@@ -84,6 +88,7 @@ async def _create_external_record(**kw) -> dict:
precedent_level=kw.get("precedent_level", ""),
is_binding=kw.get("is_binding", True),
document_id=kw.get("document_id"),
citation_formatted=kw["citation"].strip(),
)
@@ -126,10 +131,28 @@ async def ingest_precedent(
"appeal_subtype": appeal_subtype, "subject_tags": subject_tags,
"is_binding": is_binding, "headnote": headnote, "summary": summary,
}
return await ingest.ingest_document(
result = await ingest.ingest_document(
_EXTERNAL_SPEC, inputs=inputs, file_path=file_path,
document_id=document_id, progress=progress,
)
# Inline metadata enrichment (Gemini, in-container — gemini_session is direct
# REST, no local CLI). UPGRADES the seeded provisional citation_formatted to the
# canonical derived form (parties + reporter + date) so the מראה-מקום is correct
# the moment the chair opens /precedents/[id] — not deferred to the local drainer.
# force_citation=True overwrites the seed only; chair edits aren't reachable yet
# (row just created). Best-effort: on no key / API failure the seed remains and
# the queued metadata drain (request_metadata_extraction, already set) is the
# fallback. Halacha extraction is NOT touched here — it stays local (claude_session).
cid = result.get("case_law_id") if isinstance(result, dict) else None
if cid:
try:
from legal_mcp.services import precedent_metadata_extractor as _pme
r = await _pme.extract_and_apply(UUID(str(cid)), force_citation=True)
if r.get("status") == "completed":
await db.set_case_law_metadata_status(UUID(str(cid)), "completed")
except Exception as e: # noqa: BLE001 — enrichment is best-effort; drainer is fallback
logger.warning("inline metadata enrichment failed for %s (drainer will retry): %s", cid, e)
return result
async def reextract_halachot(

View File

@@ -260,6 +260,7 @@ async def apply_to_record(
case_law_id: UUID | str,
suggested: dict,
overwrite_case_number: bool = False,
force_citation: bool = False,
) -> dict:
"""Merge suggested metadata into the case_law row, filling ONLY empty fields.
@@ -274,6 +275,13 @@ async def apply_to_record(
overwrite_case_number: when True, update case_number from case_number_clean
even if the field already has a value (used for one-time migration enrichment).
force_citation: when True, (re)assemble citation_formatted even if the field
is non-empty — used by the at-upload inline enrichment to UPGRADE the seeded
provisional citation (the raw chair input) to the canonical derived form. The
write still happens only when the deterministic assembly SUCCEEDS (a missing
component → no write → the seed is preserved). The drainer keeps the default
(False) so a chair's manual edit in /precedents/[id] is never clobbered.
"""
if isinstance(case_law_id, str):
case_law_id = UUID(case_law_id)
@@ -482,7 +490,7 @@ async def apply_to_record(
# source_type/district/proceeding_type/parties). Only fill when empty so chair
# edits in /precedents/[id] are preserved; abstains (no write) when a component
# is missing.
if not (record.get("citation_formatted") or "").strip():
if force_citation or not (record.get("citation_formatted") or "").strip():
eff = {**record, **fields_to_update}
eff_parties = (
fields_to_update.get("parties") or record.get("parties") or ""
@@ -505,6 +513,7 @@ async def apply_to_record(
async def extract_and_apply(
case_law_id: UUID | str,
overwrite_case_number: bool = False,
force_citation: bool = False,
) -> dict:
"""Convenience wrapper: extract → merge into row → return summary."""
suggested = await extract_metadata(case_law_id)
@@ -523,7 +532,11 @@ async def extract_and_apply(
"status": "extraction_failed" if has_text else "no_metadata",
"fields": [],
}
result = await apply_to_record(case_law_id, suggested, overwrite_case_number=overwrite_case_number)
result = await apply_to_record(
case_law_id, suggested,
overwrite_case_number=overwrite_case_number,
force_citation=force_citation,
)
if result["updated"]:
await db.recompute_searchable(case_law_id)
return {