fix(corpus): committee decisions are persuasive, never binding (INV-DM7)
ועדת-ערר decisions (source_kind=internal_committee / source_type=
appeals_committee) are persuasive authority — they do not bind another
committee, nor the committee itself. The stored is_binding column wrongly
defaulted to True across the FastAPI form + service/db layers, so 46 of 92
committee rows were marked binding and got the BINDING halacha-extraction
prompt. Authority is structural for this source (INV-DM7) — normalize at the
source (G1), not trust the input.
Changes:
- db.create_internal_committee_decision: coerce is_binding=False (structural)
- db.create_external_case_law: coerce False when source_type=appeals_committee
- db.update_case_law: coerce False when a patch relabels source_type=
appeals_committee (Gemini reclassification path)
- internal_decisions.migrate_from_external_corpus: set is_binding=FALSE on
the external→internal reclassification UPDATE
- service + FastAPI-form defaults: True → False for the internal path
- SCHEMA_V42: backfill legacy committee rows (is_binding True→False) +
CHECK constraint case_law_committee_not_binding_check so a binding
committee row can never be written again ("so it doesn't recur")
- spec: X8-field-provenance aligned to INV-DM7
Verified against live DB in a rollback transaction: 46 rows flip to 0;
court_ruling (239) + cited_only (31) unaffected; binding-committee INSERT
rejected by the constraint.
Invariants: INV-DM7 (authority ⊥ rule-type) · G1 (normalize at source) ·
G2 (single source of truth, no parallel path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,14 +35,14 @@
|
|||||||
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
|
| `case_number` (citation) | CHAIR (חובה) | מפתח idempotency |
|
||||||
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
|
| `full_text`, `extraction_status`, `source_kind` | DETERMINISTIC | — |
|
||||||
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
|
| `case_name`, `court`, `date`, `headnote`, `summary`, `key_quote`, `subject_tags`, `appeal_subtype`, `precedent_level`, `source_type`, `citation_formatted` | CHAIR או OPUS | Opus ממלא רק אם ריק |
|
||||||
| `is_binding` | CHAIR (default true) | קובע prompt-הלכה |
|
| `is_binding` | CHAIR (default true) **אך** DETERMINISTIC-false ל-`source_type=appeals_committee` | קובע prompt-הלכה. סמכות מבנית ([INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-ועדה הוא תמיד persuasive — נכפה ל-false ב-`create_external_case_law` ונאכף ע"י `case_law_committee_not_binding_check` |
|
||||||
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
|
| chunks (`content`/`section_type`/`page_number`) | DETERMINISTIC | — |
|
||||||
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
|
| `embedding` (chunks) | Voyage (לא-LLM-reasoning) | ⚠ לא-GENERATED ([gap-audit GAP-09](gap-audit.md)) |
|
||||||
| כל `halachot` | OPUS | נכנס pending_review |
|
| כל `halachot` | OPUS | נכנס pending_review |
|
||||||
|
|
||||||
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
|
### 2ב. החלטה פנימית (`case_law`, source_kind=`internal_committee`)
|
||||||
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
|
כמו 2א, ובנוסף: `case_number` **חובה**; `chair_name`/`district`/`proceeding_type` — CHAIR או OPUS או DERIVED;
|
||||||
`source_type` = `appeals_committee` (DETERMINISTIC קבוע). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
`source_type` = `appeals_committee` (DETERMINISTIC קבוע); `is_binding` = `false` (DETERMINISTIC קבוע — נכפה ב-`create_internal_committee_decision`, [INV-DM7](02-data-model.md#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד): החלטת-ועדה אינה מחייבת ועדה אחרת ולא את עצמה — תמיד persuasive). placeholder `"(טרם חולץ)"` מסומן ל-chair_name/district
|
||||||
ריקים ומטופל כריק ע"י ה-extractor.
|
ריקים ומטופל כריק ע"י ה-extractor.
|
||||||
|
|
||||||
### 2ג. מסמך-תיק (`documents`)
|
### 2ג. מסמך-תיק (`documents`)
|
||||||
|
|||||||
@@ -1669,6 +1669,27 @@ CREATE INDEX IF NOT EXISTS idx_hcc_canonical
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_V42_SQL = """
|
||||||
|
-- INV-DM7: authority (binding/persuasive) is STRUCTURAL for committee sources.
|
||||||
|
-- An appeals-committee decision (source_kind='internal_committee', or any row
|
||||||
|
-- with source_type='appeals_committee') is persuasive, NEVER binding — a
|
||||||
|
-- committee's ruling does not bind another committee, nor itself. is_binding
|
||||||
|
-- stays a stored column (it selects the halacha-extraction prompt), but for
|
||||||
|
-- committee sources it is a faithful cache of the derived value, not a chair
|
||||||
|
-- guess. Mirrors 02-data-model.md §INV-DM7 / X8-field-provenance.md.
|
||||||
|
-- Step 1 — normalize legacy rows at the source (G1), idempotent.
|
||||||
|
UPDATE case_law SET is_binding = FALSE
|
||||||
|
WHERE is_binding IS TRUE
|
||||||
|
AND (source_kind = 'internal_committee' OR source_type = 'appeals_committee');
|
||||||
|
-- Step 2 — constrain so a binding committee row can never be written again.
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE case_law ADD CONSTRAINT case_law_committee_not_binding_check
|
||||||
|
CHECK (NOT ((source_kind = 'internal_committee'
|
||||||
|
OR source_type = 'appeals_committee') AND is_binding));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||||
@@ -1686,7 +1707,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await _apply_schema_ddl(conn)
|
await _apply_schema_ddl(conn)
|
||||||
finally:
|
finally:
|
||||||
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
|
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
|
||||||
logger.info("Database schema initialized (v1-v41)")
|
logger.info("Database schema initialized (v1-v42)")
|
||||||
|
|
||||||
|
|
||||||
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||||
@@ -1732,6 +1753,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V39_SQL)
|
await conn.execute(SCHEMA_V39_SQL)
|
||||||
await conn.execute(SCHEMA_V40_SQL)
|
await conn.execute(SCHEMA_V40_SQL)
|
||||||
await conn.execute(SCHEMA_V41_SQL)
|
await conn.execute(SCHEMA_V41_SQL)
|
||||||
|
await conn.execute(SCHEMA_V42_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -4249,6 +4271,11 @@ async def create_external_case_law(
|
|||||||
source_kind='cited_only' (auto-discovered), promote it to
|
source_kind='cited_only' (auto-discovered), promote it to
|
||||||
source_kind='external_upload' and fill in the missing fields.
|
source_kind='external_upload' and fill in the missing fields.
|
||||||
"""
|
"""
|
||||||
|
# INV-DM7: an appeals-committee source is persuasive even when uploaded via
|
||||||
|
# the external path — coerce to non-binding so it matches the committee
|
||||||
|
# invariant and case_law_committee_not_binding_check (G1).
|
||||||
|
if source_type == "appeals_committee":
|
||||||
|
is_binding = False
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -4317,7 +4344,7 @@ async def create_internal_committee_decision(
|
|||||||
appeal_subtype: str = "",
|
appeal_subtype: str = "",
|
||||||
subject_tags: list[str] | None = None,
|
subject_tags: list[str] | None = None,
|
||||||
summary: str = "",
|
summary: str = "",
|
||||||
is_binding: bool = True,
|
is_binding: bool = False,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
proceeding_type: str = "ערר",
|
proceeding_type: str = "ערר",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -4327,6 +4354,10 @@ async def create_internal_committee_decision(
|
|||||||
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
|
exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
|
||||||
filed against an existing appeal with the same number).
|
filed against an existing appeal with the same number).
|
||||||
"""
|
"""
|
||||||
|
# INV-DM7: authority is structural for this source — an appeals-committee
|
||||||
|
# decision is persuasive, never binding. Coerce regardless of caller so the
|
||||||
|
# value can never violate case_law_committee_not_binding_check (G1).
|
||||||
|
is_binding = False
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
case_number = _canonical_case_number(case_number)
|
case_number = _canonical_case_number(case_number)
|
||||||
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
tags_json = json.dumps(subject_tags or [], ensure_ascii=False)
|
||||||
@@ -4470,6 +4501,12 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
|
|||||||
"proceeding_type", "citation_formatted", "parties",
|
"proceeding_type", "citation_formatted", "parties",
|
||||||
}
|
}
|
||||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
|
# INV-DM7: reclassifying a row to an appeals-committee source makes it
|
||||||
|
# persuasive — coerce is_binding in the same patch so the UPDATE can't
|
||||||
|
# violate case_law_committee_not_binding_check (e.g. the Gemini metadata
|
||||||
|
# extractor relabels an external court_ruling as appeals_committee).
|
||||||
|
if updates.get("source_type") == "appeals_committee":
|
||||||
|
updates["is_binding"] = False
|
||||||
if not updates:
|
if not updates:
|
||||||
return await get_case_law(case_law_id)
|
return await get_case_law(case_law_id)
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async def _create_internal_record(**kw) -> dict:
|
|||||||
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
appeal_subtype=(kw.get("appeal_subtype") or "").strip(),
|
||||||
subject_tags=list(kw.get("subject_tags") or []),
|
subject_tags=list(kw.get("subject_tags") or []),
|
||||||
summary=(kw.get("summary") or "").strip(),
|
summary=(kw.get("summary") or "").strip(),
|
||||||
is_binding=kw.get("is_binding", True),
|
is_binding=kw.get("is_binding", False), # INV-DM7: committee = persuasive
|
||||||
document_id=kw.get("document_id"),
|
document_id=kw.get("document_id"),
|
||||||
proceeding_type=kw.get("proceeding_type") or "ערר",
|
proceeding_type=kw.get("proceeding_type") or "ערר",
|
||||||
)
|
)
|
||||||
@@ -108,7 +108,7 @@ async def ingest_internal_decision(
|
|||||||
appeal_subtype: str = "",
|
appeal_subtype: str = "",
|
||||||
subject_tags: list[str] | None = None,
|
subject_tags: list[str] | None = None,
|
||||||
summary: str = "",
|
summary: str = "",
|
||||||
is_binding: bool = True,
|
is_binding: bool = False, # INV-DM7: committee sources are persuasive
|
||||||
file_path: str | Path | None = None,
|
file_path: str | Path | None = None,
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
document_id: UUID | None = None,
|
document_id: UUID | None = None,
|
||||||
@@ -229,6 +229,7 @@ async def migrate_from_external_corpus(dry_run: bool = False) -> dict:
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""UPDATE case_law
|
"""UPDATE case_law
|
||||||
SET source_kind = 'internal_committee',
|
SET source_kind = 'internal_committee',
|
||||||
|
is_binding = FALSE, -- INV-DM7: committee = persuasive
|
||||||
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
district = CASE WHEN $2 <> '' THEN $2 ELSE district END
|
||||||
WHERE id = $1""",
|
WHERE id = $1""",
|
||||||
row["id"], district,
|
row["id"], district,
|
||||||
|
|||||||
@@ -7141,7 +7141,7 @@ async def internal_decisions_upload(
|
|||||||
practice_area: str = Form(""),
|
practice_area: str = Form(""),
|
||||||
appeal_subtype: str = Form(""),
|
appeal_subtype: str = Form(""),
|
||||||
subject_tags: str = Form("[]"),
|
subject_tags: str = Form("[]"),
|
||||||
is_binding: bool = Form(True),
|
is_binding: bool = Form(False), # INV-DM7: committee = persuasive (coerced at db too)
|
||||||
summary: str = Form(""),
|
summary: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Upload a planning appeals-committee decision to the internal corpus.
|
"""Upload a planning appeals-committee decision to the internal corpus.
|
||||||
|
|||||||
Reference in New Issue
Block a user