"""לולאת למידה — השוואת טיוטה לגרסה סופית וחילוץ לקחים. שלב 7 באיפיון: 1. קליטת גרסה סופית (שדפנה חתמה) 2. השוואת טיוטה לסופית — זיהוי שינויים 3. חילוץ לקחים: ביטויים חדשים, דפוסים שהשתנו, שגיאות חוזרות 4. עדכון מודל הסגנון """ from __future__ import annotations import logging from uuid import UUID from legal_mcp import config from legal_mcp.config import parse_llm_json from legal_mcp.services import db, claude_session logger = logging.getLogger(__name__) def compute_diff_stats(draft_text: str, final_text: str) -> dict: """חישוב סטטיסטיקות השוואה בין טיוטה לסופית.""" draft_words = draft_text.split() final_words = final_text.split() draft_len = len(draft_words) final_len = len(final_words) # Simple word-level diff (not a full diff algorithm, but good enough for stats) draft_set = set(draft_words) final_set = set(final_words) common = draft_set & final_set added = final_set - draft_set removed = draft_set - final_set # Estimate change percentage if draft_len == 0: change_pct = 100.0 else: change_pct = (len(added) + len(removed)) / max(draft_len, final_len) * 100 return { "draft_words": draft_len, "final_words": final_len, "change_percent": round(change_pct, 1), "words_added": len(added), "words_removed": len(removed), "words_common": len(common), } LESSONS_PROMPT = """אתה מנתח את הפער בין טיוטה (AI) לגרסה סופית שדפנה תמיר חתמה, כדי ללמוד **איך דפנה כותבת ומנתחת** — לא את ההלכה הספציפית. ## הבחנה קריטית (INV-LRN5 — טוהר-הקול): לכל שינוי קבע `domain`: - **style_method** — *איך* דפנה כותבת/חושבת: ניסוח, קצב, מבנה, תנועות-הנמקה, ביטויי-מעבר, טון, סדר-טיפול. **זה מה שלומדים** (ניתן להכללה לכל תיק). - **substance** — תוכן ספציפי-לתיק: הלכה, עובדה, תקדים, מספר. **לא לומדים** (לא ניתן לגרור לתיק אחר). ## משימה: 1. זהה שינויים מהותיים (לא הקלדה/פורמט/מספור-אוטומטי). 2. לכל שינוי: `type` (expression_change / structure_change / content_addition / content_removal / tone_change / reasoning_move / error_fix) + `domain` (style_method / substance). 3. הסק לקח **מופשט** (על השיטה/הקול, לא על התוכן) — רק עבור style_method. ## פלט JSON: { "changes": [ {"type": "...", "domain": "style_method|substance", "block": "block-yod", "description": "...", "draft_text": "...", "final_text": "...", "lesson": "לקח מופשט (style_method בלבד)"} ], "new_expressions": ["ביטוי-מעבר/נוסחה חדשים (style_method בלבד — לא הלכות)"], "overall_assessment": "1-2 משפטים" } """ async def analyze_changes(draft_text: str, final_text: str) -> dict: """ניתוח שינויים בין טיוטה לגרסה סופית עם Claude.""" # Truncate for context window max_chars = 15000 draft_sample = draft_text[:max_chars] final_sample = final_text[:max_chars] prompt = f"""{LESSONS_PROMPT} --- טיוטה --- {draft_sample} --- גרסה סופית --- {final_sample} """ result = await claude_session.query_json(prompt) if result is None: logger.warning("Failed to parse lessons response") return {"changes": [], "new_expressions": [], "overall_assessment": ""} return result async def process_final_version( case_id: UUID, final_text: str, ) -> dict: """קליטת גרסה סופית, השוואה לטיוטה, חילוץ לקחים. Args: case_id: מזהה התיק final_text: טקסט הגרסה הסופית Returns: dict עם diff stats, changes, lessons """ decision = await db.get_decision_by_case(case_id) if not decision: raise ValueError(f"No decision for case {case_id}") # Prefer the immutable snapshot captured at mark-final (T5/INV-LRN4); fall back # to the live blocks (which may have been edited after sign-off). pool = await db.get_pool() pair_id = None draft_text = "" async with pool.acquire() as conn: pair = await conn.fetchrow( """SELECT id, draft_text FROM draft_final_pairs WHERE case_id = $1 AND status = 'final_received' ORDER BY created_at DESC LIMIT 1""", case_id, ) if pair: pair_id = pair["id"] draft_text = pair["draft_text"] or "" if not draft_text: rows = await conn.fetch( """SELECT content FROM decision_blocks WHERE decision_id = $1 AND word_count > 0 ORDER BY block_index""", UUID(decision["id"]), ) draft_text = "\n\n".join(r["content"] for r in rows if r["content"]) if not draft_text: raise ValueError("No draft content to compare") # Compute stats (pure) + AI distillation (style/method vs substance) diff_stats = compute_diff_stats(draft_text, final_text) analysis = await analyze_changes(draft_text, final_text) # INV-LRN1: do NOT auto-commit learnings into writer-consumed channels. # The distillation is a PROPOSAL stored on the pair; the chair/curator approves # it (→ decision_lessons / appeal_type_rules, surfaced by T15) via the gate. # (Previously this auto-upserted every new_expression as a style_pattern — # that both bypassed the gate and contaminated style with substance. Removed.) if pair_id is not None: await db.update_draft_final_pair( UUID(str(pair_id)), final_text=final_text, diff_stats=diff_stats, analysis=analysis, status="analyzed", ) # Update decision + case status await db.update_decision(UUID(decision["id"]), status="final") case = await db.get_case(case_id) if case: await db.update_case(case_id, status="final") return { "diff_stats": diff_stats, "analysis": analysis, "pair_id": str(pair_id) if pair_id else None, "lessons_count": len(analysis.get("changes", [])), "new_expressions": len(analysis.get("new_expressions", [])), }