diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index b93c172..b10b04d 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -61,12 +61,13 @@ async def case_create( committee_type: str = "ועדה מקומית", hearing_date: str = "", notes: str = "", + expected_outcome: str = "", ) -> str: - """יצירת תיק ערר חדש.""" + """יצירת תיק ערר חדש. expected_outcome: rejection/partial_acceptance/full_acceptance/betterment_levy.""" return await cases.case_create( case_number, title, appellants, respondents, 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 = "", decision_date: str = "", tags: list[str] | None = None, + expected_outcome: str = "", ) -> str: - """עדכון פרטי תיק.""" + """עדכון פרטי תיק. expected_outcome: rejection/partial_acceptance/full_acceptance/betterment_levy.""" return await cases.case_update( case_number, status, title, subject, notes, - hearing_date, decision_date, tags, + hearing_date, decision_date, tags, expected_outcome, ) diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index b6ac6fb..721cde9 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -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: pool = await get_pool() async with pool.acquire() as conn: await conn.execute(SCHEMA_SQL) + await conn.execute(MIGRATIONS_SQL) logger.info("Database schema initialized") @@ -149,6 +155,7 @@ async def create_case( committee_type: str = "ועדה מקומית", hearing_date: date | None = None, notes: str = "", + expected_outcome: str = "", ) -> dict: pool = await get_pool() case_id = uuid4() @@ -156,13 +163,13 @@ async def create_case( await conn.execute( """INSERT INTO cases (id, case_number, title, appellants, respondents, subject, property_address, permit_number, committee_type, - hearing_date, notes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""", + hearing_date, notes, expected_outcome) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""", case_id, case_number, title, json.dumps(appellants or []), json.dumps(respondents or []), subject, property_address, permit_number, committee_type, - hearing_date, notes, + hearing_date, notes, expected_outcome, ) return await get_case(case_id) @@ -438,3 +445,10 @@ async def upsert_style_pattern( pattern_type, pattern_text, context, 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") diff --git a/mcp-server/src/legal_mcp/services/lessons.py b/mcp-server/src/legal_mcp/services/lessons.py new file mode 100644 index 0000000..e14b921 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/lessons.py @@ -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 + """## א. רקע עובדתי + + +[תיאור הרקע העובדתי של הערר] + +## ב. טענות העוררים + + +[סיכום טענות העוררים] + +## ג. טענות המשיבים + +[סיכום טענות המשיבים] + +## ד. דיון + + + + +[ניתוח משפטי — אסה רציפה] + +## ה. סיכום + + + +[סיכום בפורמט רשימה ממוספרת + סיום חם] + +--- +ניתנה היום, {date} +דפנה תמיר, יו"ר ועדת הערר +""", + + "partial_acceptance": _HEADER + """## א. רקע עובדתי + + +[תיאור הרקע העובדתי של הערר] + +## ב. טענות העוררים + + +[סיכום טענות העוררים] + +## ג. טענות המשיבים + +[סיכום טענות המשיבים] + +## ד. דיון + + + + +[ניתוח משפטי — אסה רציפה] + +## ה. סוף דבר + + + +[הוראות אופרטיביות בלבד: מה מתקבל, מה נדחה, תנאים] + +--- +ניתנה היום, {date} +דפנה תמיר, יו"ר ועדת הערר +""", + + "full_acceptance": _HEADER + """## א. רקע עובדתי + + +[תיאור הרקע העובדתי של הערר] + +## ב. טענות העוררים + + +[סיכום טענות העוררים] + +## ג. טענות המשיבים + +[סיכום טענות המשיבים] + +## ד. דיון + + + + +[ניתוח משפטי — אסה רציפה] + +## ה. סוף דבר + + + +[סוף דבר בפרוזה] + +--- +ניתנה היום, {date} +דפנה תמיר, יו"ר ועדת הערר +""", + + "betterment_levy": _HEADER + """## א. רקע עובדתי + + +[תיאור הרקע העובדתי של הערר] + +## ב. טענות העוררים + + +[סיכום טענות העוררים] + +## ג. טענות המשיבים + +[סיכום טענות המשיבים] + +## ד. דיון + + + +[ניתוח משפטי — אסה רציפה] + +## ה. סיכום + + + +[סיכום בפורמט רשימה ממוספרת, סיום יבש] + +--- +ניתנה היום, {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 "" diff --git a/mcp-server/src/legal_mcp/services/style_analyzer.py b/mcp-server/src/legal_mcp/services/style_analyzer.py index 2a3c43a..5c2ceb8 100644 --- a/mcp-server/src/legal_mcp/services/style_analyzer.py +++ b/mcp-server/src/legal_mcp/services/style_analyzer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging import re @@ -12,24 +13,31 @@ from legal_mcp.services import db logger = logging.getLogger(__name__) +# Token budget for Opus 1M context +MAX_INPUT_TOKENS = 900_000 +CHARS_PER_TOKEN = 4 # Hebrew text ratio ANALYSIS_PROMPT = """\ -אתה מנתח סגנון כתיבה משפטית. לפניך החלטות משפטיות שנכתבו על ידי אותה יושבת ראש של ועדת ערר. +אתה מנתח סגנון כתיבה משפטית. לפניך החלטות משפטיות מלאות שנכתבו על ידי אותה יושבת ראש של ועדת ערר. -נתח את ההחלטות וחלץ את דפוסי הכתיבה הבאים: +נתח את ההחלטות לעומק וחלץ את דפוסי הכתיבה הבאים: -1. **נוסחאות פתיחה** (opening_formula) - איך מתחילות ההחלטות +1. **נוסחאות פתיחה** (opening_formula) - איך מתחילות ההחלטות, מה המבנה של הפסקה הראשונה 2. **ביטויי מעבר** (transition) - ביטויים שמחברים בין חלקי ההחלטה -3. **סגנון ציטוט** (citation_style) - איך מצטטים חקיקה ופסיקה -4. **מבנה ניתוח** (analysis_structure) - איך בנוי הניתוח המשפטי -5. **נוסחאות סיום** (closing_formula) - איך מסתיימות ההחלטות -6. **ביטויים אופייניים** (characteristic_phrase) - ביטויים ייחודיים שחוזרים +3. **סגנון ציטוט** (citation_style) - איך מצטטים חקיקה, פסיקה, פרוטוקולים ומסמכים +4. **מבנה ניתוח** (analysis_structure) - איך בנוי הניתוח המשפטי, סדר הדיון בטענות +5. **נוסחאות סיום** (closing_formula) - איך מסתיימות ההחלטות, כולל הוצאות ותאריך +6. **ביטויים אופייניים** (characteristic_phrase) - ביטויים ייחודיים שחוזרים על פני ההחלטות +7. **זרימת טיעון** (argument_flow) - איך נבנה טיעון משפטי לאורך ההחלטה, מהצגת הבעיה דרך ניתוח ועד הכרעה +8. **התייחסות לראיות** (evidence_handling) - איך מתייחסת לראיות, מסמכים, חוות דעת ועדויות לכל דפוס, תן: - הטקסט המדויק של הדפוס - הקשר (באיזה חלק של ההחלטה הוא מופיע) - דוגמה מתוך הטקסט +חשוב: אתה רואה את ההחלטות המלאות. נצל את זה כדי לזהות דפוסים מכל חלקי ההחלטה - כולל אמצע הניתוח המשפטי, לא רק פתיחה וסיום. + החזר את התוצאות בפורמט הבא (JSON array): ```json [ @@ -46,6 +54,62 @@ ANALYSIS_PROMPT = """\ {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: """Analyze the style corpus and extract/update patterns. @@ -61,20 +125,34 @@ async def analyze_corpus() -> dict: if not rows: 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 = "" for row in rows: decisions_text += f"\n\n--- החלטה {row['decision_number'] or 'ללא מספר'} ---\n" - # Limit each decision to ~3000 chars to fit context - text = row["full_text"] - if len(text) > 3000: - text = text[:1500] + "\n...\n" + text[-1500:] - decisions_text += text + decisions_text += row["full_text"] - # Call Claude to analyze patterns client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) message = client.messages.create( - model="claude-sonnet-4-6", + model="claude-opus-4-6", max_tokens=16384, 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 - code_block = re.search(r"```(?:json)?\s*(\[[\s\S]*?\])\s*```", response_text) + +async def _analyze_multi_pass(rows) -> dict: + """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: - json_str = code_block.group(1) - else: - # Fallback: find the last JSON array (skip prose brackets) - all_arrays = list(re.finditer(r"\[[\s\S]*?\]", response_text)) - if not all_arrays: - return {"error": "Could not parse analysis results", "raw": response_text} - json_str = all_arrays[-1].group() + block_content = code_block.group(1).strip() + try: + result = json.loads(block_content) + if isinstance(result, list): + return result + except json.JSONDecodeError: + pass - try: - patterns = json.loads(json_str) - except json.JSONDecodeError as e: - return {"error": f"JSON parse error: {e}", "raw": response_text} + # Strategy 2: Find the outermost JSON array using bracket matching + start = response_text.find("[") + if start == -1: + 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 count = 0 @@ -116,6 +279,6 @@ async def analyze_corpus() -> dict: return { "patterns_found": count, - "decisions_analyzed": len(rows), + "decisions_analyzed": num_decisions, "pattern_types": list({p.get("type") for p in patterns}), } diff --git a/mcp-server/src/legal_mcp/tools/cases.py b/mcp-server/src/legal_mcp/tools/cases.py index 524934e..c43b993 100644 --- a/mcp-server/src/legal_mcp/tools/cases.py +++ b/mcp-server/src/legal_mcp/tools/cases.py @@ -22,6 +22,7 @@ async def case_create( committee_type: str = "ועדה מקומית", hearing_date: str = "", notes: str = "", + expected_outcome: str = "", ) -> str: """יצירת תיק ערר חדש. @@ -36,6 +37,7 @@ async def case_create( committee_type: סוג הוועדה (ברירת מחדל: ועדה מקומית) hearing_date: תאריך דיון (YYYY-MM-DD) notes: הערות + expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy) """ from datetime import date as date_type @@ -54,6 +56,7 @@ async def case_create( committee_type=committee_type, hearing_date=h_date, notes=notes, + expected_outcome=expected_outcome, ) # Initialize git repo for the case @@ -122,6 +125,7 @@ async def case_update( hearing_date: str = "", decision_date: str = "", tags: list[str] | None = None, + expected_outcome: str = "", ) -> str: """עדכון פרטי תיק. @@ -134,6 +138,7 @@ async def case_update( hearing_date: תאריך דיון (YYYY-MM-DD) decision_date: תאריך החלטה (YYYY-MM-DD) tags: תגיות + expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy) """ from datetime import date as date_type @@ -156,6 +161,8 @@ async def case_update( fields["decision_date"] = date_type.fromisoformat(decision_date) if tags is not None: fields["tags"] = tags + if expected_outcome: + fields["expected_outcome"] = expected_outcome updated = await db.update_case(UUID(case["id"]), **fields) diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index 3c20b4b..a939462 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -6,8 +6,21 @@ import json from uuid import UUID 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 = """# החלטה ## בפני: דפנה תמיר, יו"ר ועדת הערר מחוז ירושלים @@ -51,38 +64,106 @@ DECISION_TEMPLATE = """# החלטה async def get_style_guide() -> str: - """שליפת דפוסי הסגנון של דפנה - נוסחאות, ביטויים אופייניים ומבנה.""" + """שליפת דפוסי הסגנון של דפנה - נוסחאות, ביטויים אופייניים ומבנה, כולל לקחים מעשיים.""" 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" - 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" + + # Part 1: DB-sourced patterns (from analyze_style) + if patterns: + type_names = { + "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 @@ -104,6 +185,7 @@ async def draft_section( return f"תיק {case_number} לא נמצא." case_id = UUID(case["id"]) + expected_outcome = case.get("expected_outcome", "") # 1. Get relevant chunks from case documents section_query = { @@ -139,6 +221,7 @@ async def draft_section( "respondents": case["respondents"], "subject": case["subject"], "property_address": case["property_address"], + "expected_outcome": expected_outcome, }, "section": section, "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) async def get_decision_template(case_number: str) -> str: - """קבלת תבנית מבנית להחלטה מלאה עם פרטי התיק. + """קבלת תבנית מבנית להחלטה מלאה עם פרטי התיק, מותאמת לסוג התוצאה הצפויה. Args: case_number: מספר תיק הערר @@ -182,7 +291,9 @@ async def get_decision_template(case_number: str) -> str: if not case: return f"תיק {case_number} לא נמצא." - template = DECISION_TEMPLATE.format( + expected_outcome = case.get("expected_outcome", "") + + format_args = dict( case_number=case["case_number"], subject=case["subject"], 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"), ) - 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"\n" + f"\n" + f"\n\n" + ) + return header + template + else: + # Fallback to generic template + template = DECISION_TEMPLATE.format(**format_args) + if not expected_outcome: + template = ( + "\n" + f"\n\n" + ) + template + return template async def analyze_style() -> str: