Merge pull request 'fix(#88+#87): סנכרון DB↔file אוטומטי + claims_coverage מבחין כתב-ערר מתכתובת' (#96) from worktree-style-acquisition-mvp into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m25s
This commit was merged in pull request #96.
This commit is contained in:
@@ -463,6 +463,7 @@ The draft's biggest structural error was adding the "נבאר" doctrinal paragra
|
|||||||
- **Problem:** legal-writer updates `decision_blocks` in the DB, but legal-qa reads from `drafts/decision.md` on disk. In CMPA-62 the writer reported updating block headers in DB but the file did not re-sync, causing QA-2 to fail on exactly the same issue twice.
|
- **Problem:** legal-writer updates `decision_blocks` in the DB, but legal-qa reads from `drafts/decision.md` on disk. In CMPA-62 the writer reported updating block headers in DB but the file did not re-sync, causing QA-2 to fail on exactly the same issue twice.
|
||||||
- **Lesson:** Single source of truth is mandatory — either the writer must write to BOTH the DB and the decision.md file in one atomic step, or there must be an automatic `regenerate-draft` hook that runs after every block update so the file always reflects the latest DB state. Two unsynchronized sources will keep producing the same false-fail loop.
|
- **Lesson:** Single source of truth is mandatory — either the writer must write to BOTH the DB and the decision.md file in one atomic step, or there must be an automatic `regenerate-draft` hook that runs after every block update so the file always reflects the latest DB state. Two unsynchronized sources will keep producing the same false-fail loop.
|
||||||
- **Owner:** Infrastructure task — not a writer/QA prompt fix.
|
- **Owner:** Infrastructure task — not a writer/QA prompt fix.
|
||||||
|
- **✅ RESOLVED (GAP-88, 2026-06-06):** `block_writer._update_draft_file` is now an automatic regenerate hook called from `store_block` (every persist) **and** `renumber_all_blocks` — so `drafts/decision.md` always reflects `decision_blocks`. legal-qa already validates against the DB; both sides are now identical.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1088,37 +1088,39 @@ async def save_block_content(case_id: UUID, block_id: str, content: str) -> dict
|
|||||||
result["generation_type"] = "claude-code"
|
result["generation_type"] = "claude-code"
|
||||||
result["model_used"] = "claude-code"
|
result["model_used"] = "claude-code"
|
||||||
|
|
||||||
await store_block(UUID(decision["id"]), result)
|
await store_block(UUID(decision["id"]), result) # store_block syncs the file (#35)
|
||||||
await db.mark_blocks_stale(case_id, False)
|
await db.mark_blocks_stale(case_id, False)
|
||||||
|
|
||||||
# Also write/update the draft file on disk
|
|
||||||
await _update_draft_file(case_id, UUID(decision["id"]))
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _update_draft_file(case_id: UUID, decision_id: UUID) -> None:
|
async def _update_draft_file(decision_id: UUID) -> None:
|
||||||
"""Rebuild drafts/decision.md from all blocks in DB."""
|
"""Rebuild drafts/decision.md from all blocks in DB — the single
|
||||||
from pathlib import Path
|
regenerate-draft hook (lessons #35 / GAP-88). Called after EVERY
|
||||||
|
decision_blocks mutation (store_block, renumber) so the on-disk file never
|
||||||
case = await db.get_case(case_id)
|
drifts from the DB. legal-qa validates against the DB; export and the chair
|
||||||
if not case:
|
read the file — keeping them identical kills the "QA fails twice on the same
|
||||||
return
|
already-fixed issue" loop (CMPA-62). Resolves case from decision_id so no
|
||||||
|
caller has to thread case_id through."""
|
||||||
case_dir = config.find_case_dir(case["case_number"])
|
|
||||||
draft_dir = case_dir / "drafts"
|
|
||||||
draft_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
pool = await db.get_pool()
|
pool = await db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
|
case_row = await conn.fetchrow(
|
||||||
|
"SELECT c.case_number FROM decisions d JOIN cases c ON c.id = d.case_id "
|
||||||
|
"WHERE d.id = $1",
|
||||||
|
decision_id,
|
||||||
|
)
|
||||||
|
if not case_row:
|
||||||
|
return
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT content FROM decision_blocks WHERE decision_id = $1 AND content != '' ORDER BY block_index",
|
"SELECT content FROM decision_blocks WHERE decision_id = $1 AND content != '' ORDER BY block_index",
|
||||||
decision_id,
|
decision_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
draft_dir = config.find_case_dir(case_row["case_number"]) / "drafts"
|
||||||
|
draft_dir.mkdir(parents=True, exist_ok=True)
|
||||||
draft_path = draft_dir / "decision.md"
|
draft_path = draft_dir / "decision.md"
|
||||||
draft_path.write_text("\n\n".join(row["content"] for row in rows if row["content"]), encoding="utf-8")
|
draft_path.write_text("\n\n".join(row["content"] for row in rows if row["content"]), encoding="utf-8")
|
||||||
logger.info("Draft file updated: %s (%d blocks)", draft_path, len(rows))
|
logger.info("Draft file synced: %s (%d blocks)", draft_path, len(rows))
|
||||||
|
|
||||||
|
|
||||||
# ── Renumbering ───────────────────────────────────────────────────
|
# ── Renumbering ───────────────────────────────────────────────────
|
||||||
@@ -1172,6 +1174,11 @@ async def renumber_all_blocks(decision_id: UUID) -> dict:
|
|||||||
)
|
)
|
||||||
updated += 1
|
updated += 1
|
||||||
|
|
||||||
|
# #35 — renumber mutates content via raw UPDATE (bypasses store_block), so
|
||||||
|
# sync the draft file here too, otherwise the file keeps stale numbering.
|
||||||
|
if updated:
|
||||||
|
await _update_draft_file(decision_id)
|
||||||
|
|
||||||
return {"total_paragraphs": current_num - 1, "blocks_updated": updated}
|
return {"total_paragraphs": current_num - 1, "blocks_updated": updated}
|
||||||
|
|
||||||
|
|
||||||
@@ -1204,6 +1211,9 @@ async def store_block(decision_id: UUID, block_result: dict) -> None:
|
|||||||
block_result["model_used"],
|
block_result["model_used"],
|
||||||
block_result["temperature"],
|
block_result["temperature"],
|
||||||
)
|
)
|
||||||
|
# #35 — regenerate the on-disk draft on every persist so DB and file stay
|
||||||
|
# identical (legal-qa reads DB; export/chair read the file).
|
||||||
|
await _update_draft_file(decision_id)
|
||||||
|
|
||||||
|
|
||||||
async def write_and_store_block(
|
async def write_and_store_block(
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ CLAIMS_CHECK_PROMPT = """אתה בודק איכות החלטות משפטיות.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
|
async def check_claims_coverage(blocks: list[dict], claims: list[dict], outcome: str = "") -> dict:
|
||||||
"""בדיקה סמנטית (Claude) שכל טענה נענתה בדיון."""
|
"""בדיקה סמנטית (Claude) שכל טענה נענתה בדיון."""
|
||||||
yod = next((b for b in blocks if b["block_id"] == "block-yod"), None)
|
yod = next((b for b in blocks if b["block_id"] == "block-yod"), None)
|
||||||
if not yod or not yod.get("content"):
|
if not yod or not yod.get("content"):
|
||||||
@@ -114,16 +114,26 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
|
|||||||
if not claims:
|
if not claims:
|
||||||
return {"name": "claims_coverage", "passed": True, "errors": [], "severity": "critical"}
|
return {"name": "claims_coverage", "passed": True, "errors": [], "severity": "critical"}
|
||||||
|
|
||||||
# Filter: only APPELLANT claims from original pleadings.
|
# #87/GAP-87 — only the appellant's claims from the APPEAL PLEADING itself
|
||||||
# Committee/permit_applicant claims are defensive positions, not claims
|
# must be addressed. claim_type: 'claim'=כתב ערר (mandatory), 'response'=כתב
|
||||||
# that need to be "addressed" in the discussion.
|
# תשובה, 'reply'=תגובה/השלמת-טיעון/תכתובת (supplementary correspondence — NOT
|
||||||
|
# a standalone duty to answer, especially on full acceptance). Counting reply/
|
||||||
|
# correspondence claims as "unanswered" produced false QA fails (1033-25).
|
||||||
source_claims = [
|
source_claims = [
|
||||||
c for c in claims
|
c for c in claims
|
||||||
if c.get("source_document", "") != "block-zayin"
|
if c.get("source_document", "") != "block-zayin"
|
||||||
and c.get("party_role") in ("appellant", "respondent")
|
and c.get("claim_type") == "claim"
|
||||||
|
and c.get("party_role") == "appellant"
|
||||||
]
|
]
|
||||||
if not source_claims:
|
if not source_claims:
|
||||||
# Fallback: all non-block-zayin claims
|
# Fallback: appellant/respondent pleadings, excluding supplementary replies.
|
||||||
|
source_claims = [
|
||||||
|
c for c in claims
|
||||||
|
if c.get("source_document", "") != "block-zayin"
|
||||||
|
and c.get("claim_type") != "reply"
|
||||||
|
and c.get("party_role") in ("appellant", "respondent")
|
||||||
|
]
|
||||||
|
if not source_claims:
|
||||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
|
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
|
||||||
if not source_claims:
|
if not source_claims:
|
||||||
source_claims = claims
|
source_claims = claims
|
||||||
@@ -165,9 +175,14 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
|
|||||||
total = len(source_claims)
|
total = len(source_claims)
|
||||||
covered = len(addressed) + len(partial)
|
covered = len(addressed) + len(partial)
|
||||||
|
|
||||||
|
# On full acceptance the appellant prevailed in full — not every sub-claim
|
||||||
|
# needs individual treatment (the chair noted this for correspondence claims,
|
||||||
|
# 1033-25). Relax the missing-tolerance accordingly.
|
||||||
|
allowed_missing_ratio = 0.4 if outcome == "full_acceptance" else 0.2
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"name": "claims_coverage",
|
"name": "claims_coverage",
|
||||||
"passed": len(missing) <= total * 0.2, # Allow up to 20% missing
|
"passed": len(missing) <= total * allowed_missing_ratio,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"severity": "critical",
|
"severity": "critical",
|
||||||
"details": f"{covered}/{total} טענות נענו ({covered/total*100:.0f}%), {len(partial)} חלקית, {len(missing)} חסרות",
|
"details": f"{covered}/{total} טענות נענו ({covered/total*100:.0f}%), {len(partial)} חלקית, {len(missing)} חסרות",
|
||||||
@@ -361,8 +376,10 @@ async def validate_decision(case_id: UUID) -> dict:
|
|||||||
# Get claims
|
# Get claims
|
||||||
claims = await db.get_claims(case_id)
|
claims = await db.get_claims(case_id)
|
||||||
|
|
||||||
# Determine appeal type
|
# Determine appeal type + outcome (outcome relaxes claims coverage on full acceptance — #87)
|
||||||
appeal_type = case.get("appeal_type", "licensing")
|
appeal_type = case.get("appeal_type", "licensing")
|
||||||
|
from legal_mcp.services.lessons import canonical_outcome
|
||||||
|
outcome = canonical_outcome(decision.get("outcome", "") or "")
|
||||||
|
|
||||||
# Run all checks
|
# Run all checks
|
||||||
# Run sync checks
|
# Run sync checks
|
||||||
@@ -370,7 +387,7 @@ async def validate_decision(case_id: UUID) -> dict:
|
|||||||
check_neutral_background(blocks),
|
check_neutral_background(blocks),
|
||||||
]
|
]
|
||||||
# Async check: claims coverage with Claude
|
# Async check: claims coverage with Claude
|
||||||
results.append(await check_claims_coverage(blocks, claims))
|
results.append(await check_claims_coverage(blocks, claims, outcome))
|
||||||
# More sync checks
|
# More sync checks
|
||||||
results.extend([
|
results.extend([
|
||||||
check_weight_compliance(blocks, appeal_type),
|
check_weight_compliance(blocks, appeal_type),
|
||||||
|
|||||||
Reference in New Issue
Block a user