diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 725dabb..63e4a7b 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -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) diff --git a/mcp-server/src/legal_mcp/services/precedent_library.py b/mcp-server/src/legal_mcp/services/precedent_library.py index 8fca14a..7fb2639 100644 --- a/mcp-server/src/legal_mcp/services/precedent_library.py +++ b/mcp-server/src/legal_mcp/services/precedent_library.py @@ -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( diff --git a/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py b/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py index aa2bfae..2bdf42f 100644 --- a/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py +++ b/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py @@ -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 {