Add pre-ruling interim draft (טיוטת ביניים) for appeals committee
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s

Lets the chair generate a partial decision DOCX before the discussion-and-
ruling block is decided. Same template, skill and DOCX styling as the final
decision (David, RTL, bookmarks) — only the block selection and order differ:
רקע (ו) → תכניות+היתרים (ט) → טענות (ז) → הליכים (ח). The opening (ה),
ruling (י), summary (יא), and signatures (יב) are omitted.

- New appraiser_facts table + CRUD + conflict detection in db.py (V5 schema).
  Conflict = same plan/permit identifier reported differently by 2+ appraisers.
- New appraiser_facts_extractor service: per-appraisal Claude extraction of
  plans + permits with raw quotes and page numbers.
- block-tet prompt extended with a permits sub-section sourced from the
  extracted facts, plus an explicit instruction to flag inter-appraiser
  conflicts in neutral wording without resolving them (deferred to block-yod).
- block-chet prompt extended with a post-hearing materials context sourced
  from documents.metadata.is_post_hearing.
- docx_exporter.export_decision now accepts mode='interim' which reorders
  the blocks per the chair's mental model and writes
  טיוטת-ביניים-v{N}.docx (versioned independently of regular drafts).
- 3 new MCP tools: extract_appraiser_facts, write_interim_draft,
  export_interim_draft. write_interim_draft auto-runs extraction if the
  appraiser_facts table is empty for the case.
This commit is contained in:
2026-04-18 13:28:04 +00:00
parent 2b40e02a65
commit c619c22a51
7 changed files with 731 additions and 17 deletions

View File

@@ -469,6 +469,34 @@ CREATE INDEX IF NOT EXISTS idx_case_law_level ON case_law(precedent_level);
"""
# ── Phase 5: Interim draft (appraiser facts + post-hearing flag) ───
SCHEMA_V5_SQL = """
-- appraiser_facts: תכניות והיתרים שצוינו ע"י כל שמאי בנפרד.
-- בשונה מ-claims (שהוא טענה משפטית), כאן מאוחסנת עובדה עניינית מתוך השומה.
-- שימוש ראשי: זיהוי סתירות בין שמאים על איזו תכנית או היתר חל בנכס.
CREATE TABLE IF NOT EXISTS appraiser_facts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
appraiser_name TEXT NOT NULL,
fact_type TEXT NOT NULL CHECK (fact_type IN ('plan', 'permit')),
identifier TEXT NOT NULL,
details JSONB NOT NULL DEFAULT '{}',
page_number INTEGER,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_case ON appraiser_facts(case_id, fact_type);
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_identifier ON appraiser_facts(case_id, identifier);
-- documents.metadata.is_post_hearing: flag for materials submitted after the hearing
-- (השלמות טיעון, הצעות פשרה). Used by block-chet to include them in the proceedings narrative.
-- No schema change needed — uses existing JSONB metadata column.
"""
async def init_schema() -> None:
pool = await get_pool()
async with pool.acquire() as conn:
@@ -477,7 +505,8 @@ async def init_schema() -> None:
await conn.execute(SCHEMA_V2_SQL)
await conn.execute(SCHEMA_V3_SQL)
await conn.execute(SCHEMA_V4_SQL)
logger.info("Database schema initialized (v1 + v2 + v3 + v4)")
await conn.execute(SCHEMA_V5_SQL)
logger.info("Database schema initialized (v1 + v2 + v3 + v4 + v5)")
# ── Case CRUD ───────────────────────────────────────────────────────
@@ -1293,3 +1322,112 @@ async def resolve_chair_feedback(
WHERE id = $1""",
feedback_id, applied_to,
)
# ── Appraiser facts (V5 — for interim drafts) ─────────────────────
async def replace_appraiser_facts(
case_id: UUID,
document_id: UUID,
facts: list[dict],
) -> int:
"""Replace all appraiser_facts for a given document.
Each fact dict: appraiser_name, fact_type ('plan'|'permit'),
identifier, details (dict), page_number (optional).
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
await conn.execute(
"DELETE FROM appraiser_facts WHERE document_id = $1", document_id,
)
for f in facts:
await conn.execute(
"""INSERT INTO appraiser_facts
(case_id, document_id, appraiser_name, fact_type,
identifier, details, page_number)
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
case_id, document_id,
f["appraiser_name"],
f["fact_type"],
f["identifier"],
json.dumps(f.get("details", {}), ensure_ascii=False),
f.get("page_number"),
)
return len(facts)
async def list_appraiser_facts(
case_id: UUID,
fact_type: str | None = None,
) -> list[dict]:
"""List appraiser_facts for a case, optionally filtered by fact_type."""
pool = await get_pool()
async with pool.acquire() as conn:
if fact_type:
rows = await conn.fetch(
"""SELECT * FROM appraiser_facts
WHERE case_id = $1 AND fact_type = $2
ORDER BY identifier, appraiser_name""",
case_id, fact_type,
)
else:
rows = await conn.fetch(
"""SELECT * FROM appraiser_facts
WHERE case_id = $1
ORDER BY fact_type, identifier, appraiser_name""",
case_id,
)
results = []
for r in rows:
d = dict(r)
d["id"] = str(d["id"])
d["case_id"] = str(d["case_id"])
d["document_id"] = str(d["document_id"])
if isinstance(d.get("details"), str):
d["details"] = json.loads(d["details"])
results.append(d)
return results
async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
"""Detect conflicts: identifiers cited by 2+ different appraisers in this case.
A conflict exists when the SAME identifier (e.g., "תמ"א 38") was reported
differently by two appraisers — different details, or one cited it and the
other did not. Returns list of conflict groups.
"""
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT identifier, fact_type,
json_agg(jsonb_build_object(
'appraiser_name', appraiser_name,
'details', details,
'page_number', page_number,
'document_id', document_id
) ORDER BY appraiser_name) AS entries,
COUNT(DISTINCT appraiser_name) AS n_appraisers
FROM appraiser_facts
WHERE case_id = $1
GROUP BY identifier, fact_type
HAVING COUNT(DISTINCT appraiser_name) > 1""",
case_id,
)
conflicts = []
for r in rows:
entries = r["entries"]
if isinstance(entries, str):
entries = json.loads(entries)
# Parse nested details if still strings
for e in entries:
if isinstance(e.get("details"), str):
e["details"] = json.loads(e["details"])
conflicts.append({
"identifier": r["identifier"],
"fact_type": r["fact_type"],
"n_appraisers": r["n_appraisers"],
"entries": entries,
})
return conflicts