Files
legal-ai/mcp-server/src/legal_mcp/services/learning_loop.py
Chaim 0d995483ce feat(style-acq T4+T5): פנקס-התאמה draft↔final + דיסטילציה אוטומטית דרך ה-curator
סוגר את לולאת-הלמידה (INV-LRN4): כל החלטה נסגרת מול הסופי, וכל סופי
מנותח מול הטיוטה. מזין את הטבלאות ש-T15 כבר קורא מהן.

T5 — פנקס-התאמה:
- SCHEMA_V26: טבלת draft_final_pairs (snapshot draft + final + diff + analysis + status).
- db: create/update/list_draft_final_pairs.
- mark-final (app.py): תופס snapshot של הטיוטה (decision_blocks) ברגע החתימה,
  לפני שאפשר לדרוס אותו, ופותח שורת-פנקס (status=final_received).

T4 — דיסטילציה אוטומטית:
- learning_loop.process_final_version: משתמש ב-snapshot (לא בבלוקים שאולי השתנו),
  מסווג style_method↔substance, שומר הצעה ב-pair (status=analyzed).
  **הוסר ה-auto-upsert של style_patterns** — ביטל את ה-bug שדרס את שער-היו"ר
  וזיהם סגנון במהות (INV-LRN1 + INV-LRN5).
- LESSONS_PROMPT: הפרדת style_method↔substance מפורשת + לקח מופשט בלבד.
- curator wake + hermes-curator.md: מריץ ingest_final_version ראשון; מציע רק
  style_method שלא תועד; substance→מסלול precedent.

INV-LRN1 (שער-יו"ר, אין auto-commit) · INV-LRN4 (ניגוד-אמת) · INV-LRN5 (טוהר).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:20:57 +00:00

174 lines
6.5 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.
"""לולאת למידה — השוואת טיוטה לגרסה סופית וחילוץ לקחים.
שלב 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", [])),
}