Add pre-ruling interim draft (טיוטת ביניים) for appeals committee
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user