Files
legal-ai/mcp-server/src/legal_mcp/services/block_writer.py
Chaim be9fa9e712 Add decision-writing methodology based on FJC, Garner, Posner sources
"בית ספר להחלטות" Phase 2 — the system now has formal analytical
methodology for building quasi-judicial decisions, separate from
Dafna's writing style (SKILL.md) and content checklists.

What was done:
- Downloaded 5 authoritative sources (~341K words): FJC Judicial
  Writing Manual (1991+2020), Garner Legal Writing in Plain English,
  Posner How Judges Think, Scalia/Garner Making Your Case
- Extracted principles from all sources into intermediate docs
- Synthesized into docs/decision-methodology.md (3,400 words,
  12 sections, 10 guiding principles)
- Integrated methodology into block-yod prompt via {methodology_guidance}
- Restructured legal-writer agent workflow to follow analytical stages
- Made "answer all claims" flexible (bundle/skip via chair_directions)
- Added methodology compliance check (#7) to legal-qa agent
- Updated all knowledge files (CLAUDE.md, SKILL.md, lessons, corpus)

Three-layer architecture:
1. Methodology (decision-methodology.md) — universal, how to think
2. Content checklists (lessons.py) — specific per appeal subtype
3. Style (SKILL.md) — Dafna's personal writing patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:29:16 +00:00

857 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""מנוע כתיבת בלוקים להחלטת ועדת ערר.
מייצר טקסט בפועל לכל בלוק (ה-יב) בהתבסס על:
- block-schema.md (פרמטרים, constraints, מבנה)
- SKILL.md (סגנון דפנה)
- חומרי המקור (מסמכים, טענות, פסיקה)
- מסמך כיוון (חובה לבלוק י)
בלוקים א-ד ויב = template-fill (ללא AI).
בלוקים ה-יא = AI generation עם Claude.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import date
from uuid import UUID
from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session
from legal_mcp.services.lessons import get_content_checklist, get_methodology_summary
logger = logging.getLogger(__name__)
# ── Block configuration ───────────────────────────────────────────
# Output token limits per Anthropic docs (April 2026):
# Opus 4.6: up to 128K output tokens
# Sonnet 4.6: up to 64K output tokens
# Streaming required when max_tokens > 21,333
BLOCK_CONFIG = {
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 4096},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 16384},
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 16384},
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 8192},
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 16384},
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 16384},
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 8192},
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"},
}
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-20250514",
}
# ── Template blocks (א-ד, יב) ────────────────────────────────────
def write_block_alef(case: dict, decision: dict | None = None) -> str:
"""כותרת מוסדית."""
return f"""מדינת ישראל
ועדת ערר לתכנון ולבנייה — מחוז ירושלים
ערר מס' {case['case_number']}"""
def write_block_bet(case: dict, decision: dict | None = None) -> str:
"""הרכב הוועדה."""
panel = (decision or {}).get("panel_members", [])
members_text = ""
if panel:
for m in panel:
members_text += f"\n{m}"
return f"""בפני:
עו"ד דפנה תמיר, יו"ר{members_text}"""
def write_block_gimel(case: dict, decision: dict | None = None) -> str:
"""צדדים."""
appellants = "\n".join(case.get("appellants", ["(לא צוין)"]))
respondents = "\n".join(case.get("respondents", ["(לא צוין)"]))
return f"""{appellants}
נגד
{respondents}"""
def write_block_dalet(case: dict, decision: dict | None = None) -> str:
"""כותרת החלטה."""
return "החלטה"
def write_block_yod_bet(case: dict, decision: dict | None = None) -> str:
"""חתימות."""
today = date.today().strftime("%d.%m.%Y")
return f"""ניתנה היום, {today}, פה אחד.
דפנה תמיר, עו"ד
יו"ר ועדת הערר"""
TEMPLATE_WRITERS = {
"block-alef": write_block_alef,
"block-bet": write_block_bet,
"block-gimel": write_block_gimel,
"block-dalet": write_block_dalet,
"block-yod-bet": write_block_yod_bet,
}
# ── AI-generated blocks (ה-יא) ───────────────────────────────────
BLOCK_PROMPTS = {
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
## כללים:
- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..."
- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש
- 1-2 סעיפים בלבד
- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים
- מספור: 1.
## פרטי התיק:
{case_context}
## חומרי מקור:
{source_context}""",
"block-vav": """כתוב את בלוק הרקע העובדתי (בלוק ו, "פתח דבר") של החלטת ועדת ערר.
## כללים קריטיים:
- **רקע ניטרלי** — עובדות בלבד. אין ציטוטים ישירים מצדדים. אין מילות ערך/שיפוט ("חריג", "חטא", "בעייתי").
- סדר פנימי: מקרקעין → סביבה → היסטוריה תכנונית → מהות הבקשה → החלטת הוועדה → הגשת הערר
- סמן מיקומי תמונות: [📷 מיקום GIS], [📷 תשריט]
- ציטוט מפרוטוקול ועדה מקומית (אם יש) כ-blockquote
- מספור רציף מהבלוק הקודם
## פרטי התיק:
{case_context}
## חומרי מקור:
{source_context}""",
"block-zayin": """כתוב את בלוק טענות הצדדים (בלוק ז, "תמצית טענות הצדדים") של החלטת ועדת ערר.
## כללים קריטיים:
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
- **מבנה קבוע עם 3 חלקים:**
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
- כותרת: "תמצית טענות הצדדים"
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
- מספור רציף
- **יעד אורך: 800-1500 מילים**
## טענות שחולצו (קבץ טענות דומות לנושאים):
{claims_context}
## פרטי התיק:
{case_context}""",
"block-chet": """כתוב את בלוק ההליכים (בלוק ח, "ההליכים בפני ועדת הערר") של החלטת ועדת ערר.
## כללים:
- תיעוד כרונולוגי: דיון → סיור → השלמות טיעון → החלטות ביניים
- תאריכים מדויקים
- תוכן כל השלמת טיעון בסעיף נפרד
- סמן תמונות מסיור: [📷 צילום מסיור]
- אין ניתוח או הערכה
- מספור רציף
## פרטי התיק:
{case_context}
## חומרי מקור:
{source_context}""",
"block-tet": """כתוב את בלוק התכניות החלות (בלוק ט) של החלטת ועדת ערר.
## כללים:
- ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות
- מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות
- אין ניתוח מעמיק (→ בלוק י), אין הכרעה בין פרשנויות
- מספור רציף
- בלוק אופציונלי — כתוב רק אם יש מורכבות תכנונית
## פרטי התיק:
{case_context}
## תכניות שזוהו:
{plans_context}
## חומרי מקור:
{source_context}""",
"block-yod": """כתוב את בלוק הדיון וההכרעה (בלוק י) של החלטת ועדת ערר.
## זהו הבלוק הקריטי ביותר — ליבת ההחלטה (ratio decidendi).
## אורך נדרש: **2,000-4,000 מילים לפחות**. זהו הבלוק הארוך ביותר בהחלטה (35-50%).
{methodology_guidance}
{content_checklist}
## כללים נוספים:
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
- **מספור רציף** — המשך מספור מהבלוק הקודם
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
## כיוון מאושר (חובה):
{direction_context}
## מבנה לפי תוצאה:
{structure_guidance}
## טענות:
{claims_context}
## חומרי מקור:
{source_context}
## פסיקה רלוונטית (צטט מכאן ומהידע הכללי שלך):
{precedents_context}
## סגנון דפנה:
{style_context}""",
"block-yod-alef": """כתוב את בלוק הסיכום (בלוק יא, "סוף דבר") של החלטת ועדת ערר.
## כללים:
- כותרת: "סוף דבר" או "סיכום"
- תוצאה ברורה: "הערר נדחה" / "הערר מתקבל" / "הערר מתקבל באופן חלקי"
- הוראות אופרטיביות חד-משמעיות
- אין חזרה על נימוקים — ההנמקה כבר בדיון
- מספור רציף
## מבנה לפי תוצאה:
- דחייה: "הערר נדחה" + תתי-סעיפים + פסקה חמה (רישוי בלבד)
- קבלה: "הערר מתקבל בכפוף ל..." + פרוזה
- קבלה חלקית: "הערר מתקבל באופן חלקי" + 2-3 הוראות אופרטיביות
## כיוון ותוצאה:
{direction_context}
## בלוקים קודמים (דיון):
{discussion_context}""",
}
# Discussion structure by outcome
STRUCTURE_GUIDANCE = {
"rejected": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.",
"accepted": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.",
"partial": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
}
async def write_block(
case_id: UUID,
block_id: str,
instructions: str = "",
) -> dict:
"""כתיבת בלוק יחיד בהחלטה.
Args:
case_id: מזהה התיק
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
instructions: הנחיות נוספות
Returns:
dict עם content, word_count, block_id, generation_type
"""
if block_id not in BLOCK_CONFIG:
raise ValueError(f"Unknown block: {block_id}")
block_cfg = BLOCK_CONFIG[block_id]
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
decision = await db.get_decision_by_case(case_id)
# Template blocks
if block_id in TEMPLATE_WRITERS:
content = TEMPLATE_WRITERS[block_id](case, decision)
return _build_result(block_id, content, block_cfg)
# AI-generated blocks
prompt_template = BLOCK_PROMPTS.get(block_id)
if not prompt_template:
raise ValueError(f"No prompt template for {block_id}")
# Build context components
case_context = _build_case_context(case, decision)
source_context = await _build_source_context(case_id, block_id)
claims_context = await _build_claims_context(case_id)
direction_context = _build_direction_context(decision)
plans_context = await _build_plans_context(case_id)
precedents_context = await _build_precedents_context(case_id, block_id)
style_context = await _build_style_context()
discussion_context = await _build_previous_blocks_context(case_id, decision)
outcome = (decision or {}).get("outcome", "rejected")
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
# Content checklist — tells block-yod WHAT topics to cover
content_checklist = ""
methodology_guidance = ""
if block_id == "block-yod":
content_checklist = get_content_checklist(
appeal_type=case.get("appeal_type", ""),
subject=case.get("subject", ""),
subject_categories=case.get("subject_categories", []),
)
# Methodology guidance — tells block-yod HOW to reason (universal, not case-specific)
methodology_guidance = get_methodology_summary()
# Format prompt — per Anthropic long-context best practices:
# Place source documents FIRST (top of prompt), instructions LAST.
# "Queries at the end can improve response quality by up to 30%"
formatted_prompt = prompt_template.format(
case_context=case_context,
source_context=source_context,
claims_context=claims_context,
direction_context=direction_context,
plans_context=plans_context,
precedents_context=precedents_context,
style_context=style_context,
discussion_context=discussion_context,
structure_guidance=structure_guidance,
content_checklist=content_checklist,
methodology_guidance=methodology_guidance,
)
# Restructure: sources first, then instructions
prompt = (
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
f"{source_context}\n\n"
f"---\n\n"
f"{formatted_prompt}"
)
if instructions:
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
# Block י requires approved direction
if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {}
if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
# Call Claude via Claude Code session (no API)
model_key = block_cfg["model"]
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
content = claude_session.query(prompt, timeout=timeout)
return _build_result(block_id, content, block_cfg)
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
word_count = len(content.split())
return {
"block_id": block_id,
"block_index": block_cfg["index"],
"title": block_cfg["title"],
"content": content,
"word_count": word_count,
"generation_type": block_cfg["gen_type"],
"model_used": block_cfg["model"],
"temperature": block_cfg["temp"],
}
# ── Context builders ──────────────────────────────────────────────
def _build_case_context(case: dict, decision: dict | None) -> str:
outcome = (decision or {}).get("outcome", "")
outcome_heb = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, "")
return f"""- מספר תיק: {case['case_number']}
- כותרת: {case.get('title', '')}
- עוררים: {', '.join(case.get('appellants', []))}
- משיבים: {', '.join(case.get('respondents', []))}
- נושא: {case.get('subject', '')}
- כתובת: {case.get('property_address', '')}
- סוג ערר: {case.get('appeal_type', '')}
- תוצאה: {outcome_heb}"""
async def _build_source_context(case_id: UUID, block_id: str) -> str:
"""Get full document texts for the block.
Per Anthropic best practices: send full source documents, not truncated excerpts.
Place documents at the TOP of the prompt (before instructions) for 30% better recall.
For grounding: instruct Claude to cite word-for-word from these documents.
"""
docs = await db.list_documents(case_id)
context_parts = []
for doc in docs:
text = await db.get_document_text(UUID(doc["id"]))
if text:
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
return "\n\n".join(context_parts) if context_parts else "(אין מסמכים)"
async def _build_claims_context(case_id: UUID) -> str:
claims = await db.get_claims(case_id)
if not claims:
return "(לא חולצו טענות)"
# Filter out claims from block-zayin (decision summary) — use only
# claims extracted from original pleadings (appeal, response, etc.)
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
if not source_claims:
# Fallback to all claims if no source claims exist
source_claims = claims
lines = []
current_role = ""
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
claim_num = 0
for c in source_claims:
if c["party_role"] != current_role:
current_role = c["party_role"]
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
claim_num += 1
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
return "\n".join(lines)
def _build_direction_context(decision: dict | None) -> str:
if not decision:
return "(לא הוגדר כיוון)"
dir_doc = decision.get("direction_doc") or {}
if not dir_doc.get("approved"):
return "(כיוון לא אושר)"
parts = []
outcome_heb = dir_doc.get("outcome_hebrew", "")
if outcome_heb:
parts.append(f"תוצאה: {outcome_heb}")
reasoning = dir_doc.get("reasoning", "")
if reasoning:
parts.append(f"נימוק: {reasoning}")
direction = dir_doc.get("selected_direction")
if direction:
parts.append(f"כיוון נבחר: {direction.get('name', '')}")
for r in direction.get("reasoning", []):
parts.append(f" - {r}")
for p in direction.get("precedents", []):
parts.append(f" פסיקה: {p}")
notes = dir_doc.get("additional_notes", "")
if notes:
parts.append(f"הערות: {notes}")
return "\n".join(parts) if parts else "(אין מסמך כיוון)"
async def _build_plans_context(case_id: UUID) -> str:
"""Get plan references from document metadata."""
docs = await db.list_documents(case_id)
plans = set()
for doc in docs:
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
refs = metadata.get("references", {})
for p in refs.get("plans", []):
plans.add(p.get("plan_name", ""))
if plans:
return "\n".join(f"- {p}" for p in sorted(plans) if p)
return "(לא זוהו תכניות)"
async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
"""Search for similar precedent paragraphs from other decisions and case law."""
parts = []
try:
case = await db.get_case(case_id)
case_number = case.get("case_number", "") if case else ""
subject = case.get("subject", "") if case else ""
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
query_emb = await embeddings.embed_query(query)
# Search 1: paragraph_embeddings (from other decisions by Dafna)
para_results = await db.search_similar_paragraphs(
query_embedding=query_emb, limit=10, block_type="block-yod",
)
# Filter out same case
para_results = [r for r in para_results if r.get("case_number", "") != case_number]
for r in para_results[:4]:
parts.append(
f"[החלטת {r.get('case_number', '?')}{r.get('case_title', '')}, "
f"בלוק {r.get('block_type', '')}]\n{r['content'][:500]}"
)
# Search 2: case_law_embeddings (precedent case law)
pool = await db.get_pool()
async with pool.acquire() as conn:
caselaw_rows = await conn.fetch(
"""SELECT cl.case_number, cl.case_name, cl.court, cl.summary, cl.key_quote,
1 - (cle.embedding <=> $1) AS score
FROM case_law_embeddings cle
JOIN case_law cl ON cl.id = cle.case_law_id
ORDER BY cle.embedding <=> $1
LIMIT 5""",
query_emb,
)
for r in caselaw_rows[:3]:
text = r["key_quote"] or r["summary"] or ""
if text:
parts.append(
f"[פסיקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
f"score={r['score']:.3f}\n{text[:400]}"
)
except Exception as e:
logger.warning("Failed to fetch precedents: %s", e)
return "\n\n".join(parts) if parts else "(אין תקדימים)"
async def _build_style_context() -> str:
"""Build comprehensive style guide from DB patterns + SKILL.md rules.
Per Anthropic: explicit style instructions reduce generic output.
"""
lines = []
# Core style rules (from SKILL.md analysis)
lines.append("""## כללי סגנון דפנה תמיר — חובה:
### טון:
- ערר רישוי (1xxx): חם יחסית, עם אלמנטים אנושיים
- ערר השבחה (8xxx): קר, יבש, מקצועי
- גוף ראשון רבים: "אנו סבורים", "מצאנו כי", "לדעתנו"
- ישיר ובהיר — לא אקדמי ולא מסורבל
### ביטויים ייחודיים (חובה להשתמש):
- "לפנינו..." (פתיחה)
- "כידוע..." (הצגת עקרון ידוע)
- "ברי כי..." / "ודוק..." (הדגשה)
- "אין בידנו לקבל" (דחיית טענה)
- "בטענה זו מצאנו טעם" (קבלת טענה)
- "יחד עם זאת" (מעבר לאיזון)
- "למעלה מן הצורך" / "נבקש שלא לצאת בחסר" (הרחבה)
- "הדברים מתחדדים שעה ש..." (חידוד)
- "מחד... מאידך... על כן..." (איזון לפני הכרעה)
- "לאור כל האמור לעיל" (סיכום)
- "ניתנה פה אחד היום" (סיום)
### מבנה דיון:
- אסה רציפה ללא כותרות משנה (חריג: נושאים נפרדים לחלוטין)
- מסקנה בפתיחה, לא בסוף
- מעברים טקסטואליים, לא כותרות
- ניטרול טענות חלשות לפני ניתוח מעמיק
- ציטוטי פסיקה כבלוקים מוגדלים
### טענות צדדים:
- עוררים: "העוררים טוענים כי...", "לטענתם...", "עוד ציינו כי..."
- ועדה: "הוועדה המקומית הציגה/הבהירה/הוסיפה כי..."
- מבקשי היתר: "מבקשי ההיתר דוחים מכל וכל...", "לטענתם...", "מבקשי ההיתר מציינים כי..."
""")
# DB patterns (actual examples from Dafna's decisions)
patterns = await db.get_style_patterns()
if patterns:
lines.append("### דפוסים שחולצו מהחלטות קודמות:")
grouped: dict[str, list] = {}
for p in patterns:
grouped.setdefault(p["pattern_type"], []).append(p)
type_names = {
"opening_formula": "פתיחה",
"transition": "מעברים",
"characteristic_phrase": "ביטויים אופייניים",
"closing_formula": "סיום",
"citation_style": "ציטוט",
}
for ptype in ["characteristic_phrase", "transition", "opening_formula", "closing_formula"]:
items = grouped.get(ptype, [])
if items:
lines.append(f"\n**{type_names.get(ptype, ptype)}:**")
for item in items[:8]:
lines.append(f"- {item['pattern_text']}")
return "\n".join(lines)
async def _build_previous_blocks_context(case_id: UUID, decision: dict | None) -> str:
"""Get content of previously written blocks."""
if not decision:
return "(אין בלוקים קודמים)"
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT block_id, title, content, word_count
FROM decision_blocks
WHERE decision_id = $1 AND word_count > 0
ORDER BY block_index""",
UUID(decision["id"]),
)
if not rows:
return "(אין בלוקים קודמים)"
parts = []
for r in rows:
content = r["content"][:2000]
parts.append(f"### {r['title']} ({r['block_id']})\n{content}")
return "\n\n".join(parts)
# ── Context-only mode (for Claude Code to write) ─────────────────
async def get_block_context(case_id: UUID, block_id: str, instructions: str = "") -> dict:
"""Return full context package for a block WITHOUT calling Claude API.
Claude Code (or any external writer) uses this context to write the block,
then saves it via save_block_content.
"""
if block_id not in BLOCK_CONFIG:
raise ValueError(f"Unknown block: {block_id}")
block_cfg = BLOCK_CONFIG[block_id]
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
decision = await db.get_decision_by_case(case_id)
# Template blocks — return content directly
if block_id in TEMPLATE_WRITERS:
content = TEMPLATE_WRITERS[block_id](case, decision)
return {
"block_id": block_id,
"title": block_cfg["title"],
"mode": "template",
"content": content,
}
# Build all context components
prompt_template = BLOCK_PROMPTS.get(block_id, "")
case_context = _build_case_context(case, decision)
source_context = await _build_source_context(case_id, block_id)
claims_context = await _build_claims_context(case_id)
direction_context = _build_direction_context(decision)
plans_context = await _build_plans_context(case_id)
precedents_context = await _build_precedents_context(case_id, block_id)
style_context = await _build_style_context()
discussion_context = await _build_previous_blocks_context(case_id, decision)
outcome = (decision or {}).get("outcome", "rejected")
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
# Content checklist + methodology for block-yod
content_checklist = ""
methodology_guidance = ""
if block_id == "block-yod":
content_checklist = get_content_checklist(
appeal_type=case.get("appeal_type", ""),
subject=case.get("subject", ""),
subject_categories=case.get("subject_categories", []),
)
methodology_guidance = get_methodology_summary()
formatted_prompt = prompt_template.format(
case_context=case_context,
source_context=source_context,
claims_context=claims_context,
direction_context=direction_context,
plans_context=plans_context,
precedents_context=precedents_context,
style_context=style_context,
discussion_context=discussion_context,
structure_guidance=structure_guidance,
content_checklist=content_checklist,
methodology_guidance=methodology_guidance,
)
if instructions:
formatted_prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
# Block י requires approved direction
if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {}
if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר.")
return {
"block_id": block_id,
"title": block_cfg["title"],
"mode": "context",
"prompt": formatted_prompt,
"source_documents": source_context,
"claims": claims_context,
"direction": direction_context,
"precedents": precedents_context,
"style_guide": style_context,
"previous_blocks": discussion_context,
}
async def save_block_content(case_id: UUID, block_id: str, content: str) -> dict:
"""Save block content written by Claude Code (or any external writer).
Saves to DB and also writes/updates the draft file on disk.
"""
if block_id not in BLOCK_CONFIG:
raise ValueError(f"Unknown block: {block_id}")
block_cfg = BLOCK_CONFIG[block_id]
decision = await db.get_decision_by_case(case_id)
if not decision:
decision = await db.create_decision(case_id=case_id)
result = _build_result(block_id, content, block_cfg)
result["generation_type"] = "claude-code"
result["model_used"] = "claude-code"
await store_block(UUID(decision["id"]), result)
# Also write/update the draft file on disk
await _update_draft_file(case_id, UUID(decision["id"]))
return result
async def _update_draft_file(case_id: UUID, decision_id: UUID) -> None:
"""Rebuild drafts/decision.md from all blocks in DB."""
from pathlib import Path
case = await db.get_case(case_id)
if not case:
return
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()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT content FROM decision_blocks WHERE decision_id = $1 AND content != '' ORDER BY block_index",
decision_id,
)
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")
logger.info("Draft file updated: %s (%d blocks)", draft_path, len(rows))
# ── Renumbering ───────────────────────────────────────────────────
async def renumber_all_blocks(decision_id: UUID) -> dict:
"""מספור רציף מחדש של כל הבלוקים בהחלטה.
עובר על כל הבלוקים לפי סדר, ומחליף את כל המספורים
(1. 2. 3. או **1.** **2.**) לרצף אחד רציף.
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT block_id, block_index, content, word_count
FROM decision_blocks WHERE decision_id = $1
ORDER BY block_index""",
decision_id,
)
current_num = 1
updated = 0
# Blocks that shouldn't be numbered
skip_blocks = {"block-alef", "block-bet", "block-gimel", "block-dalet", "block-yod-bet"}
for row in rows:
if row["block_id"] in skip_blocks or not row["content"]:
continue
content = row["content"]
# Replace numbered paragraphs: "N." or "**N.**" or "**N.**" at line start
def replace_num(match):
nonlocal current_num
prefix = match.group(1) or "" # bold markers
suffix = match.group(3) or "" # bold markers
result = f"{prefix}{current_num}{suffix}"
current_num += 1
return result
new_content = re.sub(
r'^(\*\*)?(\d+)(\.?\*?\*?\.)',
replace_num,
content,
flags=re.MULTILINE,
)
if new_content != content:
async with pool.acquire() as conn:
await conn.execute(
"UPDATE decision_blocks SET content = $1, updated_at = now() WHERE decision_id = $2 AND block_id = $3",
new_content, decision_id, row["block_id"],
)
updated += 1
return {"total_paragraphs": current_num - 1, "blocks_updated": updated}
# ── Store block ───────────────────────────────────────────────────
async def store_block(decision_id: UUID, block_result: dict) -> None:
"""שמירת בלוק ב-DB (upsert)."""
pool = await db.get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""INSERT INTO decision_blocks
(decision_id, block_id, block_index, title, content, word_count,
generation_type, model_used, temperature, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft')
ON CONFLICT (decision_id, block_id) DO UPDATE SET
content = EXCLUDED.content,
word_count = EXCLUDED.word_count,
generation_type = EXCLUDED.generation_type,
model_used = EXCLUDED.model_used,
temperature = EXCLUDED.temperature,
status = 'draft',
updated_at = now()""",
decision_id,
block_result["block_id"],
block_result["block_index"],
block_result["title"],
block_result["content"],
block_result["word_count"],
block_result["generation_type"],
block_result["model_used"],
block_result["temperature"],
)
async def write_and_store_block(
case_id: UUID,
block_id: str,
instructions: str = "",
) -> dict:
"""כתיבת בלוק ושמירה ב-DB."""
decision = await db.get_decision_by_case(case_id)
if not decision:
# Create decision if not exists
decision = await db.create_decision(case_id=case_id)
result = await write_block(case_id, block_id, instructions)
await store_block(UUID(decision["id"]), result)
return result