Add outcome-aware drafting, lessons system, and improved style analysis

- Add expected_outcome field to cases (rejection/partial/full/betterment_levy)
- New lessons.py module with golden ratios, templates, and drafting guidance per outcome type
- Style analyzer now uses Opus with full decision text (no truncation), with multi-pass fallback for large corpora
- Drafting tool provides outcome-specific templates, section guidance, and ratio comments
- Improved JSON extraction with bracket-matching fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 18:58:42 +00:00
parent 6f515dc2cb
commit 39089dcef5
6 changed files with 726 additions and 71 deletions

View File

@@ -61,12 +61,13 @@ async def case_create(
committee_type: str = "ועדה מקומית", committee_type: str = "ועדה מקומית",
hearing_date: str = "", hearing_date: str = "",
notes: str = "", notes: str = "",
expected_outcome: str = "",
) -> str: ) -> str:
"""יצירת תיק ערר חדש.""" """יצירת תיק ערר חדש. expected_outcome: rejection/partial_acceptance/full_acceptance/betterment_levy."""
return await cases.case_create( return await cases.case_create(
case_number, title, appellants, respondents, case_number, title, appellants, respondents,
subject, property_address, permit_number, committee_type, subject, property_address, permit_number, committee_type,
hearing_date, notes, hearing_date, notes, expected_outcome,
) )
@@ -92,11 +93,12 @@ async def case_update(
hearing_date: str = "", hearing_date: str = "",
decision_date: str = "", decision_date: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
expected_outcome: str = "",
) -> str: ) -> str:
"""עדכון פרטי תיק.""" """עדכון פרטי תיק. expected_outcome: rejection/partial_acceptance/full_acceptance/betterment_levy."""
return await cases.case_update( return await cases.case_update(
case_number, status, title, subject, notes, case_number, status, title, subject, notes,
hearing_date, decision_date, tags, hearing_date, decision_date, tags, expected_outcome,
) )

View File

@@ -129,10 +129,16 @@ CREATE INDEX IF NOT EXISTS idx_cases_number ON cases(case_number);
""" """
MIGRATIONS_SQL = """
ALTER TABLE cases ADD COLUMN IF NOT EXISTS expected_outcome TEXT DEFAULT '';
"""
async def init_schema() -> None: async def init_schema() -> None:
pool = await get_pool() pool = await get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
await conn.execute(SCHEMA_SQL) await conn.execute(SCHEMA_SQL)
await conn.execute(MIGRATIONS_SQL)
logger.info("Database schema initialized") logger.info("Database schema initialized")
@@ -149,6 +155,7 @@ async def create_case(
committee_type: str = "ועדה מקומית", committee_type: str = "ועדה מקומית",
hearing_date: date | None = None, hearing_date: date | None = None,
notes: str = "", notes: str = "",
expected_outcome: str = "",
) -> dict: ) -> dict:
pool = await get_pool() pool = await get_pool()
case_id = uuid4() case_id = uuid4()
@@ -156,13 +163,13 @@ async def create_case(
await conn.execute( await conn.execute(
"""INSERT INTO cases (id, case_number, title, appellants, respondents, """INSERT INTO cases (id, case_number, title, appellants, respondents,
subject, property_address, permit_number, committee_type, subject, property_address, permit_number, committee_type,
hearing_date, notes) hearing_date, notes, expected_outcome)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""", VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""",
case_id, case_number, title, case_id, case_number, title,
json.dumps(appellants or []), json.dumps(appellants or []),
json.dumps(respondents or []), json.dumps(respondents or []),
subject, property_address, permit_number, committee_type, subject, property_address, permit_number, committee_type,
hearing_date, notes, hearing_date, notes, expected_outcome,
) )
return await get_case(case_id) return await get_case(case_id)
@@ -438,3 +445,10 @@ async def upsert_style_pattern(
pattern_type, pattern_text, context, pattern_type, pattern_text, context,
json.dumps(examples or []), json.dumps(examples or []),
) )
async def clear_style_patterns() -> None:
"""Delete all existing style patterns (used before re-analysis)."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM style_patterns")

View File

@@ -0,0 +1,331 @@
"""Lessons learned from comparing AI drafts to Dafna Tamir's final decisions.
Source: /data/uploads/לקחים-לעדכון-שרת-כתיבת-החלטות.md
Based on analysis of: Hecht 1180-1181 (rejection) and Beit HaKerem 1126/25+1141/25 (partial acceptance).
"""
from __future__ import annotations
# ── Valid outcome values ────────────────────────────────────────────
VALID_OUTCOMES = ("rejection", "partial_acceptance", "full_acceptance", "betterment_levy")
# ── Golden Ratios (section % of total) ─────────────────────────────
GOLDEN_RATIOS: dict[str, dict[str, tuple[int, int]]] = {
"rejection": {"background": (15, 25), "claims": (30, 40), "discussion": (37, 50), "summary": (2, 9)},
"full_acceptance": {"background": (30, 40), "claims": (20, 30), "discussion": (35, 45), "summary": (3, 5)},
"partial_acceptance": {"background": (25, 35), "claims": (25, 30), "discussion": (40, 47), "summary": (2, 3)},
"betterment_levy": {"background": (6, 18), "claims": (13, 25), "discussion": (32, 48), "summary": (3, 4)},
}
# ── Paragraph length guidance (word counts) ────────────────────────
PARAGRAPH_LENGTHS = {
"claims": (40, 60),
"discussion_regular": (40, 80),
"discussion_with_citation": (200, 600),
"discussion_average": (80, 120),
}
# ── Transition phrases ─────────────────────────────────────────────
TRANSITION_PHRASES = [
# From Hecht (rejection)
{"phrase": "ועל מנת לא לצאת בחסר", "context": "פתיחת obiter dicta", "outcome": None},
{"phrase": "נציין כי טענות אלו נטענו בלשון רפה", "context": "הכרה בטענות חלשות", "outcome": None},
{"phrase": "עינינו הרואות", "context": "סיכום אחרי ציטוט ארוך", "outcome": None},
{"phrase": "נוסיף.", "context": "מעבר חד (מילה אחת)", "outcome": None},
{"phrase": "אם כך, לעת הזו", "context": "מסקנה מציטוטים", "outcome": None},
{"phrase": "למיטב הבנתנו", "context": "עמדה זהירה", "outcome": None},
{"phrase": "נשלים ונציין", "context": "נקודה אחרונה לפני סיכום", "outcome": None},
# From Beit HaKerem (partial acceptance)
{"phrase": "הדברים משליכים על שיקול הדעת ב...", "context": "קישור ממצא למסקנה", "outcome": "partial_acceptance"},
{"phrase": "רוצה לומר כי", "context": "הסבר חלופי", "outcome": None},
{"phrase": "נוצר מצב בו", "context": "הצגת בעיה", "outcome": None},
{"phrase": "לכך נוסיף כי", "context": "הוספת נדבך", "outcome": None},
{"phrase": "יש אולי להצר על כך ש...", "context": "ביקורת עדינה", "outcome": None},
{"phrase": "עם ההבנה לטענה זו של העוררים, אין בידנו לקבלה", "context": "acknowledge-reject מרוכך", "outcome": None},
]
# ── Opening strategies by outcome ──────────────────────────────────
OPENING_STRATEGIES = {
"rejection": {
"style": "broad_contextual",
"paragraphs": (5, 8),
"description": "פתיחה רחבה — הקשר תכנוני כללי, רקע לפני צלילה לטענות",
},
"full_acceptance": {
"style": "direct_conclusion",
"paragraphs": (1, 2),
"description": "פתיחה ישירה — ישר למסקנה, תמציתית",
},
"partial_acceptance": {
"style": "tension_mapping",
"paragraphs": (3, 6),
"description": (
"מיפוי מתחים — 1-2 פסקאות על ערך התכנון, "
"אחר כך 'בערר דנן עולות שאלות כיצד והאם...' "
"עם רשימת 4-6 נקודות מתח בבולטים, "
"ואז 'כל הנקודות לעיל עומדות לפנינו...' → מעבר לניתוח"
),
},
"betterment_levy": {
"style": "direct_with_disclaimer",
"paragraphs": (1, 3),
"description": "פתיחה ישירה עם מסקנה + 'על מנת לא לצאת בחסר'",
},
}
# ── Summary strategies by outcome ──────────────────────────────────
SUMMARY_STRATEGIES = {
"rejection": {
"heading": "סיכום",
"format": "numbered_hebrew_with_warm_closing",
"description": "אותיות עבריות (א-ו) עם פירוט נימוקים + פסקת סיום חמה",
},
"full_acceptance": {
"heading": "סוף דבר",
"format": "prose_paragraphs",
"description": "פרוזה (3-5 פסקאות), ללא פסקה חמה",
},
"partial_acceptance": {
"heading": "סוף דבר",
"format": "ultra_minimal_operative",
"description": (
"אולטרה-מינימלי: 2-3 הוראות אופרטיביות בלבד. "
"אפס חזרה על נימוקים. אפס הוצאות. אפס סיום חם. "
"כל ההנמקה כבר בדיון — הסיכום = רק מה מתקבל, מה נדחה, ותנאים"
),
},
"betterment_levy": {
"heading": "סיכום",
"format": "numbered_hebrew_dry",
"description": "אותיות עבריות, סיום יבש ללא פסקה חמה",
},
}
# ── Discussion structure rules ─────────────────────────────────────
DISCUSSION_RULES: dict[str, list[str]] = {
"universal": [
"פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.",
"חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).",
"טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.",
],
"rejection": [
"מבנה עיגולים קונצנטריים: שכבות הגנה — סף (ס' 152) → מריט → obiter dicta.",
"שאלת הסף (ס' 152) = כלי אסטרטגי, לא חובה. משתמשים בה כשהיא חזקה.",
],
"partial_acceptance": [
"מבנה: מיפוי מתחים → ניתוח נושא-נושא → הוראות אופרטיביות.",
"שאלת הסף (ס' 152) — בדרך כלל מדלגים. כשיש שאלות מהותיות חזקות (חניה, שימור, קווי בניין), דפנה מעדיפה דיון בגוף העניין.",
"דפוס 'בית בודד': כשתמ\"א 38 חלה על בית בודד, אינטרס החיזוק מוחלש → שיקול דעת זהיר יותר.",
"דפוס 'תכנית אב כמגן': כשקיימת תכנית אב → לצטט אותה → ההיתר 'משתלב עם ראיה כללית'.",
],
"full_acceptance": [
"מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.",
],
"betterment_levy": [
"מבנה ישיר עם מסקנה מוקדמת + 'על מנת לא לצאת בחסר' לנקודות נוספות.",
],
}
# ── Citation technique ─────────────────────────────────────────────
CITATION_GUIDANCE = (
"העדפה לציטוט דרך 'החלטה מרכזת' — החלטה אחת שכבר ריכזה את הפסיקה הרלוונטית. "
"דפוס: 'נפנה לניתוח המקיף שערכה ועדת הערר במסגרת ערר [שם]...' → בלוק ציטוט 200-500 מילים → 'אם כך, לעת הזו...'. "
"גמישות: כשיש שאלות משפטיות מרובות או חדשניות, כל נושא עשוי לדרוש תקדים נפרד. "
"בנושאי חניה/תשתיות — צלילה לעומק: ציטוט ישיר של הוראות תכנית (400+ מילים עם ניתוח שזור)."
)
# ── Decision templates by outcome ──────────────────────────────────
_HEADER = """# החלטה
## בפני: דפנה תמיר, יו"ר ועדת הערר מחוז ירושלים
**ערר מספר:** {case_number}
**נושא:** {subject}
**העוררים:** {appellants}
**המשיבים:** {respondents}
**כתובת הנכס:** {property_address}
---
"""
DECISION_TEMPLATES: dict[str, str] = {
"rejection": _HEADER + """## א. רקע עובדתי
<!-- {ratios_background} -->
[תיאור הרקע העובדתי של הערר]
## ב. טענות העוררים
<!-- {ratios_claims} -->
[סיכום טענות העוררים]
## ג. טענות המשיבים
[סיכום טענות המשיבים]
## ד. דיון
<!-- אסה רציפה, ללא כותרות משנה. מבנה עיגולים קונצנטריים: סף → מריט → obiter -->
<!-- פתיחה רחבה: 5-8 פסקאות הקשר תכנוני -->
<!-- {ratios_discussion} -->
[ניתוח משפטי — אסה רציפה]
## ה. סיכום
<!-- אותיות עבריות (א-ו) + פסקת סיום חמה -->
<!-- {ratios_summary} -->
[סיכום בפורמט רשימה ממוספרת + סיום חם]
---
ניתנה היום, {date}
דפנה תמיר, יו"ר ועדת הערר
""",
"partial_acceptance": _HEADER + """## א. רקע עובדתי
<!-- {ratios_background} -->
[תיאור הרקע העובדתי של הערר]
## ב. טענות העוררים
<!-- {ratios_claims} -->
[סיכום טענות העוררים]
## ג. טענות המשיבים
[סיכום טענות המשיבים]
## ד. דיון
<!-- אסה רציפה, ללא כותרות משנה. מבנה: מיפוי מתחים → ניתוח נושא-נושא -->
<!-- פתיחת מיפוי מתחים: 1-2 פסקאות על ערך התכנון, רשימת מתחים, מעבר לניתוח -->
<!-- {ratios_discussion} -->
[ניתוח משפטי — אסה רציפה]
## ה. סוף דבר
<!-- אולטרה-מינימלי: 2-3 הוראות אופרטיביות בלבד. אפס נימוקים. אפס הוצאות. -->
<!-- {ratios_summary} -->
[הוראות אופרטיביות בלבד: מה מתקבל, מה נדחה, תנאים]
---
ניתנה היום, {date}
דפנה תמיר, יו"ר ועדת הערר
""",
"full_acceptance": _HEADER + """## א. רקע עובדתי
<!-- {ratios_background} -->
[תיאור הרקע העובדתי של הערר]
## ב. טענות העוררים
<!-- {ratios_claims} -->
[סיכום טענות העוררים]
## ג. טענות המשיבים
[סיכום טענות המשיבים]
## ד. דיון
<!-- אסה רציפה, ללא כותרות משנה. מבנה ישיר -->
<!-- פתיחה ישירה: 1-2 פסקאות, ישר למסקנה -->
<!-- {ratios_discussion} -->
[ניתוח משפטי — אסה רציפה]
## ה. סוף דבר
<!-- פרוזה: 3-5 פסקאות, ללא פסקה חמה -->
<!-- {ratios_summary} -->
[סוף דבר בפרוזה]
---
ניתנה היום, {date}
דפנה תמיר, יו"ר ועדת הערר
""",
"betterment_levy": _HEADER + """## א. רקע עובדתי
<!-- {ratios_background} -->
[תיאור הרקע העובדתי של הערר]
## ב. טענות העוררים
<!-- {ratios_claims} -->
[סיכום טענות העוררים]
## ג. טענות המשיבים
[סיכום טענות המשיבים]
## ד. דיון
<!-- אסה רציפה, ללא כותרות משנה. מבנה ישיר עם מסקנה מוקדמת -->
<!-- {ratios_discussion} -->
[ניתוח משפטי — אסה רציפה]
## ה. סיכום
<!-- אותיות עבריות, סיום יבש -->
<!-- {ratios_summary} -->
[סיכום בפורמט רשימה ממוספרת, סיום יבש]
---
ניתנה היום, {date}
דפנה תמיר, יו"ר ועדת הערר
""",
}
# ── Helper function ────────────────────────────────────────────────
def get_lessons_for_outcome(outcome: str) -> dict:
"""Assemble all relevant lessons for a given expected outcome."""
if outcome not in VALID_OUTCOMES:
return {"error": f"outcome must be one of: {', '.join(VALID_OUTCOMES)}"}
ratios = GOLDEN_RATIOS[outcome]
rules = DISCUSSION_RULES.get("universal", []) + DISCUSSION_RULES.get(outcome, [])
# Filter transition phrases: universal + outcome-specific
phrases = [
p for p in TRANSITION_PHRASES
if p["outcome"] is None or p["outcome"] == outcome
]
return {
"outcome": outcome,
"golden_ratios": {
k: f"{v[0]}-{v[1]}%" for k, v in ratios.items()
},
"opening_strategy": OPENING_STRATEGIES[outcome],
"summary_strategy": SUMMARY_STRATEGIES[outcome],
"discussion_rules": rules,
"citation_guidance": CITATION_GUIDANCE,
"transition_phrases": [
{"phrase": p["phrase"], "context": p["context"]}
for p in phrases
],
"paragraph_lengths": {
k: f"{v[0]}-{v[1]} מילים" for k, v in PARAGRAPH_LENGTHS.items()
},
}
def format_ratios_comment(outcome: str, section: str) -> str:
"""Format golden ratio as an HTML comment for templates."""
ratios = GOLDEN_RATIOS.get(outcome, {})
if section in ratios:
lo, hi = ratios[section]
return f"יעד: {lo}-{hi}% מסך ההחלטה"
return ""

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import re import re
@@ -12,24 +13,31 @@ from legal_mcp.services import db
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Token budget for Opus 1M context
MAX_INPUT_TOKENS = 900_000
CHARS_PER_TOKEN = 4 # Hebrew text ratio
ANALYSIS_PROMPT = """\ ANALYSIS_PROMPT = """\
אתה מנתח סגנון כתיבה משפטית. לפניך החלטות משפטיות שנכתבו על ידי אותה יושבת ראש של ועדת ערר. אתה מנתח סגנון כתיבה משפטית. לפניך החלטות משפטיות מלאות שנכתבו על ידי אותה יושבת ראש של ועדת ערר.
נתח את ההחלטות וחלץ את דפוסי הכתיבה הבאים: נתח את ההחלטות לעומק וחלץ את דפוסי הכתיבה הבאים:
1. **נוסחאות פתיחה** (opening_formula) - איך מתחילות ההחלטות 1. **נוסחאות פתיחה** (opening_formula) - איך מתחילות ההחלטות, מה המבנה של הפסקה הראשונה
2. **ביטויי מעבר** (transition) - ביטויים שמחברים בין חלקי ההחלטה 2. **ביטויי מעבר** (transition) - ביטויים שמחברים בין חלקי ההחלטה
3. **סגנון ציטוט** (citation_style) - איך מצטטים חקיקה ופסיקה 3. **סגנון ציטוט** (citation_style) - איך מצטטים חקיקה, פסיקה, פרוטוקולים ומסמכים
4. **מבנה ניתוח** (analysis_structure) - איך בנוי הניתוח המשפטי 4. **מבנה ניתוח** (analysis_structure) - איך בנוי הניתוח המשפטי, סדר הדיון בטענות
5. **נוסחאות סיום** (closing_formula) - איך מסתיימות ההחלטות 5. **נוסחאות סיום** (closing_formula) - איך מסתיימות ההחלטות, כולל הוצאות ותאריך
6. **ביטויים אופייניים** (characteristic_phrase) - ביטויים ייחודיים שחוזרים 6. **ביטויים אופייניים** (characteristic_phrase) - ביטויים ייחודיים שחוזרים על פני ההחלטות
7. **זרימת טיעון** (argument_flow) - איך נבנה טיעון משפטי לאורך ההחלטה, מהצגת הבעיה דרך ניתוח ועד הכרעה
8. **התייחסות לראיות** (evidence_handling) - איך מתייחסת לראיות, מסמכים, חוות דעת ועדויות
לכל דפוס, תן: לכל דפוס, תן:
- הטקסט המדויק של הדפוס - הטקסט המדויק של הדפוס
- הקשר (באיזה חלק של ההחלטה הוא מופיע) - הקשר (באיזה חלק של ההחלטה הוא מופיע)
- דוגמה מתוך הטקסט - דוגמה מתוך הטקסט
חשוב: אתה רואה את ההחלטות המלאות. נצל את זה כדי לזהות דפוסים מכל חלקי ההחלטה - כולל אמצע הניתוח המשפטי, לא רק פתיחה וסיום.
החזר את התוצאות בפורמט הבא (JSON array): החזר את התוצאות בפורמט הבא (JSON array):
```json ```json
[ [
@@ -46,6 +54,62 @@ ANALYSIS_PROMPT = """\
{decisions} {decisions}
""" """
SINGLE_DECISION_PROMPT = """\
אתה מנתח סגנון כתיבה משפטית. לפניך החלטה משפטית מלאה שנכתבה על ידי יושבת ראש של ועדת ערר.
חלץ את כל דפוסי הכתיבה מההחלטה הזו, כולל:
1. נוסחאות פתיחה (opening_formula)
2. ביטויי מעבר (transition)
3. סגנון ציטוט (citation_style)
4. מבנה ניתוח (analysis_structure)
5. נוסחאות סיום (closing_formula)
6. ביטויים אופייניים (characteristic_phrase)
7. זרימת טיעון (argument_flow)
8. התייחסות לראיות (evidence_handling)
לכל דפוס, תן: הטקסט המדויק, הקשר, ודוגמה מתוך הטקסט.
החזר JSON array בפורמט:
```json
[
{{
"type": "opening_formula",
"text": "...",
"context": "...",
"example": "..."
}}
]
```
ההחלטה:
{decision}
"""
SYNTHESIS_PROMPT = """\
לפניך דפוסי כתיבה שחולצו מ-{num_decisions} החלטות משפטיות של אותה יושבת ראש ועדת ערר.
משימתך:
1. איחוד דפוסים כפולים או דומים
2. זיהוי דפוסים שחוזרים על פני מספר החלטות (ציין תדירות)
3. הבחנה בין דפוסים אופייניים באמת לבין ניסוחים חד-פעמיים
4. שמירה על המבנה: type, text, context, example
החזר JSON array מאוחד של הדפוסים המשמעותיים ביותר:
```json
[
{{
"type": "opening_formula",
"text": "...",
"context": "...",
"example": "..."
}}
]
```
הדפוסים שחולצו:
{patterns}
"""
async def analyze_corpus() -> dict: async def analyze_corpus() -> dict:
"""Analyze the style corpus and extract/update patterns. """Analyze the style corpus and extract/update patterns.
@@ -61,20 +125,34 @@ async def analyze_corpus() -> dict:
if not rows: if not rows:
return {"error": "אין החלטות בקורפוס. העלה החלטות קודמות תחילה."} return {"error": "אין החלטות בקורפוס. העלה החלטות קודמות תחילה."}
# Prepare text for analysis # Clear old patterns before re-analysis
await db.clear_style_patterns()
# Calculate token budget
total_chars = sum(len(row["full_text"]) for row in rows)
estimated_tokens = total_chars // CHARS_PER_TOKEN
logger.info(
"Style analysis: %d decisions, %d chars, ~%d tokens",
len(rows), total_chars, estimated_tokens,
)
if estimated_tokens < MAX_INPUT_TOKENS:
return await _analyze_single_pass(rows)
else:
return await _analyze_multi_pass(rows)
async def _analyze_single_pass(rows) -> dict:
"""Send all decisions in a single API call."""
decisions_text = "" decisions_text = ""
for row in rows: for row in rows:
decisions_text += f"\n\n--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n" decisions_text += f"\n\n--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n"
# Limit each decision to ~3000 chars to fit context decisions_text += row["full_text"]
text = row["full_text"]
if len(text) > 3000:
text = text[:1500] + "\n...\n" + text[-1500:]
decisions_text += text
# Call Claude to analyze patterns
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
message = client.messages.create( message = client.messages.create(
model="claude-sonnet-4-6", model="claude-opus-4-6",
max_tokens=16384, max_tokens=16384,
messages=[ messages=[
{ {
@@ -84,24 +162,109 @@ async def analyze_corpus() -> dict:
], ],
) )
response_text = message.content[0].text return await _parse_and_store_patterns(message.content[0].text, len(rows))
# Extract JSON from response - prefer code-block fenced JSON
import json async def _analyze_multi_pass(rows) -> dict:
code_block = re.search(r"```(?:json)?\s*(\[[\s\S]*?\])\s*```", response_text) """Analyze each decision individually, then synthesize patterns."""
client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
all_patterns = []
# Pass 1: Analyze each decision individually
for row in rows:
decision_text = f"--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n"
decision_text += row["full_text"]
message = client.messages.create(
model="claude-opus-4-6",
max_tokens=8192,
messages=[
{
"role": "user",
"content": SINGLE_DECISION_PROMPT.format(decision=decision_text),
}
],
)
patterns = _extract_json(message.content[0].text)
if patterns:
all_patterns.extend(patterns)
if not all_patterns:
return {"error": "לא הצלחתי לחלץ דפוסים מההחלטות"}
# Pass 2: Synthesize across all decisions
message = client.messages.create(
model="claude-opus-4-6",
max_tokens=16384,
messages=[
{
"role": "user",
"content": SYNTHESIS_PROMPT.format(
num_decisions=len(rows),
patterns=json.dumps(all_patterns, ensure_ascii=False, indent=2),
),
}
],
)
return await _parse_and_store_patterns(message.content[0].text, len(rows))
def _extract_json(response_text: str) -> list | None:
"""Extract JSON array from Claude's response text."""
# Strategy 1: Extract content between code fences, then parse
code_block = re.search(r"```(?:json)?\s*([\s\S]*?)```", response_text)
if code_block: if code_block:
json_str = code_block.group(1) block_content = code_block.group(1).strip()
else: try:
# Fallback: find the last JSON array (skip prose brackets) result = json.loads(block_content)
all_arrays = list(re.finditer(r"\[[\s\S]*?\]", response_text)) if isinstance(result, list):
if not all_arrays: return result
return {"error": "Could not parse analysis results", "raw": response_text} except json.JSONDecodeError:
json_str = all_arrays[-1].group() pass
try: # Strategy 2: Find the outermost JSON array using bracket matching
patterns = json.loads(json_str) start = response_text.find("[")
except json.JSONDecodeError as e: if start == -1:
return {"error": f"JSON parse error: {e}", "raw": response_text} return None
depth = 0
in_string = False
escape_next = False
for i in range(start, len(response_text)):
c = response_text[i]
if escape_next:
escape_next = False
continue
if c == "\\":
escape_next = True
continue
if c == '"':
in_string = not in_string
continue
if in_string:
continue
if c == "[":
depth += 1
elif c == "]":
depth -= 1
if depth == 0:
try:
return json.loads(response_text[start:i + 1])
except json.JSONDecodeError as e:
logger.warning("JSON parse error: %s", e)
return None
return None
async def _parse_and_store_patterns(response_text: str, num_decisions: int) -> dict:
"""Parse Claude's response and store patterns in the database."""
patterns = _extract_json(response_text)
if patterns is None:
return {"error": "Could not parse analysis results", "raw": response_text}
# Store patterns # Store patterns
count = 0 count = 0
@@ -116,6 +279,6 @@ async def analyze_corpus() -> dict:
return { return {
"patterns_found": count, "patterns_found": count,
"decisions_analyzed": len(rows), "decisions_analyzed": num_decisions,
"pattern_types": list({p.get("type") for p in patterns}), "pattern_types": list({p.get("type") for p in patterns}),
} }

View File

@@ -22,6 +22,7 @@ async def case_create(
committee_type: str = "ועדה מקומית", committee_type: str = "ועדה מקומית",
hearing_date: str = "", hearing_date: str = "",
notes: str = "", notes: str = "",
expected_outcome: str = "",
) -> str: ) -> str:
"""יצירת תיק ערר חדש. """יצירת תיק ערר חדש.
@@ -36,6 +37,7 @@ async def case_create(
committee_type: סוג הוועדה (ברירת מחדל: ועדה מקומית) committee_type: סוג הוועדה (ברירת מחדל: ועדה מקומית)
hearing_date: תאריך דיון (YYYY-MM-DD) hearing_date: תאריך דיון (YYYY-MM-DD)
notes: הערות notes: הערות
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
""" """
from datetime import date as date_type from datetime import date as date_type
@@ -54,6 +56,7 @@ async def case_create(
committee_type=committee_type, committee_type=committee_type,
hearing_date=h_date, hearing_date=h_date,
notes=notes, notes=notes,
expected_outcome=expected_outcome,
) )
# Initialize git repo for the case # Initialize git repo for the case
@@ -122,6 +125,7 @@ async def case_update(
hearing_date: str = "", hearing_date: str = "",
decision_date: str = "", decision_date: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
expected_outcome: str = "",
) -> str: ) -> str:
"""עדכון פרטי תיק. """עדכון פרטי תיק.
@@ -134,6 +138,7 @@ async def case_update(
hearing_date: תאריך דיון (YYYY-MM-DD) hearing_date: תאריך דיון (YYYY-MM-DD)
decision_date: תאריך החלטה (YYYY-MM-DD) decision_date: תאריך החלטה (YYYY-MM-DD)
tags: תגיות tags: תגיות
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
""" """
from datetime import date as date_type from datetime import date as date_type
@@ -156,6 +161,8 @@ async def case_update(
fields["decision_date"] = date_type.fromisoformat(decision_date) fields["decision_date"] = date_type.fromisoformat(decision_date)
if tags is not None: if tags is not None:
fields["tags"] = tags fields["tags"] = tags
if expected_outcome:
fields["expected_outcome"] = expected_outcome
updated = await db.update_case(UUID(case["id"]), **fields) updated = await db.update_case(UUID(case["id"]), **fields)

View File

@@ -6,8 +6,21 @@ import json
from uuid import UUID from uuid import UUID
from legal_mcp.services import db, embeddings from legal_mcp.services import db, embeddings
from legal_mcp.services.lessons import (
CITATION_GUIDANCE,
DECISION_TEMPLATES,
DISCUSSION_RULES,
GOLDEN_RATIOS,
OPENING_STRATEGIES,
PARAGRAPH_LENGTHS,
SUMMARY_STRATEGIES,
TRANSITION_PHRASES,
VALID_OUTCOMES,
format_ratios_comment,
get_lessons_for_outcome,
)
# Fallback template for cases without expected_outcome
DECISION_TEMPLATE = """# החלטה DECISION_TEMPLATE = """# החלטה
## בפני: דפנה תמיר, יו"ר ועדת הערר מחוז ירושלים ## בפני: דפנה תמיר, יו"ר ועדת הערר מחוז ירושלים
@@ -51,38 +64,106 @@ DECISION_TEMPLATE = """# החלטה
async def get_style_guide() -> str: async def get_style_guide() -> str:
"""שליפת דפוסי הסגנון של דפנה - נוסחאות, ביטויים אופייניים ומבנה.""" """שליפת דפוסי הסגנון של דפנה - נוסחאות, ביטויים אופייניים ומבנה, כולל לקחים מעשיים."""
patterns = await db.get_style_patterns() patterns = await db.get_style_patterns()
if not patterns:
return "לא נמצאו דפוסי סגנון. יש להעלות החלטות קודמות ולהריץ ניתוח סגנון (/style-report)."
grouped: dict[str, list] = {}
for p in patterns:
pt = p["pattern_type"]
if pt not in grouped:
grouped[pt] = []
grouped[pt].append({
"text": p["pattern_text"],
"context": p["context"],
"frequency": p["frequency"],
})
type_names = {
"opening_formula": "נוסחאות פתיחה",
"transition": "ביטויי מעבר",
"citation_style": "סגנון ציטוט",
"analysis_structure": "מבנה ניתוח",
"closing_formula": "נוסחאות סיום",
"characteristic_phrase": "ביטויים אופייניים",
}
result = "# מדריך סגנון - דפנה תמיר\n\n" result = "# מדריך סגנון - דפנה תמיר\n\n"
for ptype, items in grouped.items():
result += f"## {type_names.get(ptype, ptype)}\n\n" # Part 1: DB-sourced patterns (from analyze_style)
for item in items: if patterns:
result += f"- **{item['text']}** ({item['context']}, תדירות: {item['frequency']})\n" type_names = {
result += "\n" "opening_formula": "נוסחאות פתיחה",
"transition": "ביטויי מעבר",
"citation_style": "סגנון ציטוט",
"analysis_structure": "מבנה ניתוח",
"closing_formula": "נוסחאות סיום",
"characteristic_phrase": "ביטויים אופייניים",
"argument_flow": "זרימת טיעון",
"evidence_handling": "התייחסות לראיות",
}
grouped: dict[str, list] = {}
for p in patterns:
pt = p["pattern_type"]
if pt not in grouped:
grouped[pt] = []
grouped[pt].append({
"text": p["pattern_text"],
"context": p["context"],
"frequency": p["frequency"],
})
for ptype, items in grouped.items():
result += f"## {type_names.get(ptype, ptype)}\n\n"
for item in items:
result += f"- **{item['text']}** ({item['context']}, תדירות: {item['frequency']})\n"
result += "\n"
else:
result += "_לא נמצאו דפוסים מקורפוס. יש להעלות החלטות ולהריץ /style-report._\n\n"
# Part 2: Lessons-based guidance
result += "---\n\n# לקחים מעשיים\n\n"
# Universal discussion rules
result += "## כללי דיון אוניברסליים\n\n"
for rule in DISCUSSION_RULES["universal"]:
result += f"- {rule}\n"
result += "\n"
# Citation technique
result += "## טכניקת ציטוט\n\n"
result += f"{CITATION_GUIDANCE}\n\n"
# Transition phrases
result += "## ביטויי מעבר (מהשוואת טיוטות)\n\n"
for p in TRANSITION_PHRASES:
ctx = p["context"]
outcome_note = f" [בעיקר ב-{p['outcome']}]" if p["outcome"] else ""
result += f"- **{p['phrase']}** — {ctx}{outcome_note}\n"
result += "\n"
# Paragraph lengths
result += "## אורכי פסקאות מומלצים\n\n"
result += "| סוג | מילים |\n|-----|------|\n"
labels = {
"claims": "טענות",
"discussion_regular": "דיון רגיל",
"discussion_with_citation": "דיון + ציטוט",
"discussion_average": "ממוצע דיון",
}
for key, (lo, hi) in PARAGRAPH_LENGTHS.items():
result += f"| {labels.get(key, key)} | {lo}-{hi} |\n"
result += "\n"
# Golden ratios
result += "## יחסי הזהב (אחוזי סעיפים מסך ההחלטה)\n\n"
result += "| סוג ערר | רקע | טענות | דיון | סיכום |\n"
result += "|---------|------|-------|------|-------|\n"
outcome_labels = {
"rejection": "רישוי נדחה",
"full_acceptance": "רישוי מתקבל",
"partial_acceptance": "רישוי קבלה חלקית",
"betterment_levy": "היטל השבחה",
}
for outcome in VALID_OUTCOMES:
r = GOLDEN_RATIOS[outcome]
result += (
f"| {outcome_labels[outcome]} "
f"| {r['background'][0]}-{r['background'][1]}% "
f"| {r['claims'][0]}-{r['claims'][1]}% "
f"| {r['discussion'][0]}-{r['discussion'][1]}% "
f"| {r['summary'][0]}-{r['summary'][1]}% |\n"
)
result += "\n"
# Opening and summary strategies
result += "## אסטרטגיות פתיחה וסיכום לפי תוצאה\n\n"
for outcome in VALID_OUTCOMES:
opening = OPENING_STRATEGIES[outcome]
summary = SUMMARY_STRATEGIES[outcome]
result += f"### {outcome_labels[outcome]}\n"
result += f"- **פתיחה:** {opening['description']} ({opening['paragraphs'][0]}-{opening['paragraphs'][1]} פסקאות)\n"
result += f"- **סיכום ({summary['heading']}):** {summary['description']}\n\n"
return result return result
@@ -104,6 +185,7 @@ async def draft_section(
return f"תיק {case_number} לא נמצא." return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"]) case_id = UUID(case["id"])
expected_outcome = case.get("expected_outcome", "")
# 1. Get relevant chunks from case documents # 1. Get relevant chunks from case documents
section_query = { section_query = {
@@ -139,6 +221,7 @@ async def draft_section(
"respondents": case["respondents"], "respondents": case["respondents"],
"subject": case["subject"], "subject": case["subject"],
"property_address": case["property_address"], "property_address": case["property_address"],
"expected_outcome": expected_outcome,
}, },
"section": section, "section": section,
"instructions": instructions, "instructions": instructions,
@@ -167,11 +250,37 @@ async def draft_section(
], ],
} }
# 4. Add outcome-aware drafting guidance
if expected_outcome and expected_outcome in VALID_OUTCOMES:
lessons = get_lessons_for_outcome(expected_outcome)
guidance: dict = {
"outcome": expected_outcome,
"golden_ratios": lessons["golden_ratios"],
"citation_guidance": lessons["citation_guidance"],
}
# Section-specific guidance
if section == "legal_analysis":
guidance["discussion_rules"] = lessons["discussion_rules"]
guidance["opening_strategy"] = lessons["opening_strategy"]
guidance["transition_phrases"] = lessons["transition_phrases"]
guidance["paragraph_lengths"] = lessons["paragraph_lengths"]
elif section in ("conclusion", "ruling"):
guidance["summary_strategy"] = lessons["summary_strategy"]
elif section == "facts":
guidance["target_ratio"] = lessons["golden_ratios"].get("background", "")
guidance["paragraph_lengths"] = {"claims": lessons["paragraph_lengths"].get("claims", "")}
elif section in ("appellant_claims", "respondent_claims"):
guidance["target_ratio"] = lessons["golden_ratios"].get("claims", "")
guidance["paragraph_lengths"] = {"claims": lessons["paragraph_lengths"].get("claims", "")}
context["drafting_guidance"] = guidance
return json.dumps(context, ensure_ascii=False, indent=2) return json.dumps(context, ensure_ascii=False, indent=2)
async def get_decision_template(case_number: str) -> str: async def get_decision_template(case_number: str) -> str:
"""קבלת תבנית מבנית להחלטה מלאה עם פרטי התיק. """קבלת תבנית מבנית להחלטה מלאה עם פרטי התיק, מותאמת לסוג התוצאה הצפויה.
Args: Args:
case_number: מספר תיק הערר case_number: מספר תיק הערר
@@ -182,7 +291,9 @@ async def get_decision_template(case_number: str) -> str:
if not case: if not case:
return f"תיק {case_number} לא נמצא." return f"תיק {case_number} לא נמצא."
template = DECISION_TEMPLATE.format( expected_outcome = case.get("expected_outcome", "")
format_args = dict(
case_number=case["case_number"], case_number=case["case_number"],
subject=case["subject"], subject=case["subject"],
appellants=", ".join(case.get("appellants", [])), appellants=", ".join(case.get("appellants", [])),
@@ -191,7 +302,34 @@ async def get_decision_template(case_number: str) -> str:
date=date.today().strftime("%d.%m.%Y"), date=date.today().strftime("%d.%m.%Y"),
) )
return template # Use outcome-specific template if available
if expected_outcome and expected_outcome in DECISION_TEMPLATES:
# Add ratio comments
format_args["ratios_background"] = format_ratios_comment(expected_outcome, "background")
format_args["ratios_claims"] = format_ratios_comment(expected_outcome, "claims")
format_args["ratios_discussion"] = format_ratios_comment(expected_outcome, "discussion")
format_args["ratios_summary"] = format_ratios_comment(expected_outcome, "summary")
template = DECISION_TEMPLATES[expected_outcome].format(**format_args)
# Add guidance header
opening = OPENING_STRATEGIES[expected_outcome]
summary = SUMMARY_STRATEGIES[expected_outcome]
header = (
f"<!-- תבנית מותאמת ל: {expected_outcome} -->\n"
f"<!-- פתיחת דיון: {opening['description']} -->\n"
f"<!-- סיכום: {summary['description']} -->\n\n"
)
return header + template
else:
# Fallback to generic template
template = DECISION_TEMPLATE.format(**format_args)
if not expected_outcome:
template = (
"<!-- לא הוגדרה תוצאה צפויה. הגדר expected_outcome בתיק לקבלת תבנית מותאמת. -->\n"
f"<!-- ערכים אפשריים: {', '.join(VALID_OUTCOMES)} -->\n\n"
) + template
return template
async def analyze_style() -> str: async def analyze_style() -> str: