Merge remote-tracking branch 'origin/main' into rebase-357
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

# Conflicts:
#	mcp-server/src/legal_mcp/services/db.py
This commit is contained in:
2026-06-30 12:09:49 +00:00
11 changed files with 236 additions and 58 deletions

View File

@@ -18,6 +18,43 @@ from legal_mcp.services import court_citation, halacha_quality, principles
logger = logging.getLogger(__name__)
# ── Primary-document classification (WS2 / task #200) ────────────────
# Canonical, single-source list of "primary" (מסמך עיקרי) doc_types — the
# substantive case documents the chair tracks for analysis (appeal, the
# replies/objections, the hearing protocol, the appraisal, and the
# committee decision). Every OTHER doc_type (plan, permit, court_decision,
# exhibit, reference…) is secondary. Chair-approved set (plan WS2 §1).
#
# G1/G2/INV-DM7: `is_primary` is DERIVED from `doc_type`, never an
# independently-written column. There is ONE source of truth: this tuple.
# The DB column `documents.is_primary` is a GENERATED ALWAYS … STORED column
# (V47) computed by Postgres from `doc_type`, so it can never drift; the
# Python helper below mirrors the same list for read-time derivation and for
# building the generated-column expression. No parallel write path.
PRIMARY_DOC_TYPES: tuple[str, ...] = (
"appeal", # כתב-ערר
"response", # תשובה / תגובה
"objection", # התנגדות
"protocol", # פרוטוקול-דיון
"appraisal", # שומה
"decision", # החלטת-ועדה
)
def is_primary_doc_type(doc_type: str | None) -> bool:
"""Whether a doc_type is a 'primary' (מסמך עיקרי) document. Single source
of truth = PRIMARY_DOC_TYPES; mirrors the V47 generated column."""
return doc_type in PRIMARY_DOC_TYPES
def _primary_doc_types_sql_array() -> str:
"""SQL ARRAY[...] literal of PRIMARY_DOC_TYPES for the generated column.
Values are fixed identifiers from this module (no user input) — safe to
inline; kept derived from the tuple so the list has ONE definition."""
quoted = ", ".join("'" + t.replace("'", "''") + "'" for t in PRIMARY_DOC_TYPES)
return f"ARRAY[{quoted}]::text[]"
_pool: asyncpg.Pool | None = None
_schema_ready: bool = False
_init_lock: asyncio.Lock = asyncio.Lock()
@@ -1782,7 +1819,23 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
"""
# ── V47: Protocol comparative analysis (WS4 / #203) ───────────────
# V47 (WS2 / task #200): "primary document" (מסמך עיקרי) concept.
# `documents.is_primary` is a GENERATED ALWAYS … STORED column derived purely
# from `doc_type` against the canonical PRIMARY_DOC_TYPES list (built from the
# single-source tuple via _primary_doc_types_sql_array). Because Postgres
# computes it, there is NO parallel write path and it can never drift from
# doc_type (G1/G2/INV-DM7 — same drift-free pattern as the GENERATED tsvectors,
# INV-DM3). Idempotent: ADD COLUMN IF NOT EXISTS; the generated expression is
# fixed, so re-running is a no-op. The partial index serves the
# "primary docs not yet analysed" queries that #201 builds on.
SCHEMA_V47_SQL = f"""
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_primary BOOLEAN
GENERATED ALWAYS AS (doc_type = ANY({_primary_doc_types_sql_array()})) STORED;
CREATE INDEX IF NOT EXISTS idx_documents_primary
ON documents(case_id) WHERE is_primary;
"""
# ── V48: Protocol comparative analysis (WS4 / #203) ───────────────
#
# protocol_analysis: case-knowledge derived from a hearing-protocol document by
# comparing the oral arguments raised at the hearing against the written
@@ -1798,7 +1851,7 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
# back to the canonical `cases` columns (panel via decisions, hearing_date on
# cases) — NOT duplicated here — and a copy of the raw extracted feed is kept on
# the analysis row for provenance only (G9).
SCHEMA_V47_SQL = """
SCHEMA_V48_SQL = """
CREATE TABLE IF NOT EXISTS protocol_analysis (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
@@ -1887,6 +1940,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V45_SQL)
await conn.execute(SCHEMA_V46_SQL)
await conn.execute(SCHEMA_V47_SQL)
await conn.execute(SCHEMA_V48_SQL)
async def init_schema() -> None:
@@ -2259,6 +2313,14 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
d["case_id"] = str(d["case_id"])
if isinstance(d.get("metadata"), str):
d["metadata"] = json.loads(d["metadata"])
# Primary/secondary classification (WS2 / #200). `is_primary` is the
# generated DB column (V47); derive it at read-time too so the field is
# always present even on rows fetched before the migration ran, and expose
# a human-facing `doc_category`. Single source of truth: PRIMARY_DOC_TYPES.
is_primary = bool(d["is_primary"]) if d.get("is_primary") is not None \
else is_primary_doc_type(d.get("doc_type"))
d["is_primary"] = is_primary
d["doc_category"] = "primary" if is_primary else "secondary"
return d
@@ -4003,7 +4065,7 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
return conflicts
# ── Protocol comparative analysis (V47 / WS4 #203) ────────────────
# ── Protocol comparative analysis (V48 / WS4 #203) ────────────────
async def replace_protocol_analysis(
case_id: UUID,