Files
legal-ai/mcp-server/src/legal_mcp/services/learning_loop.py
Chaim 94a4c3600e
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
fix(learning): process_final_version מאחסן דיסטילציה גם כשאין pair (create-or-update, INV-LRN4)
אודיט #122 חשף ש-process_final_version מחשב diff+analysis אך משליך אותם כשאין
draft_final_pair במצב final_received — קרה ל-5 תיקים סופיים היסטוריים שקדמו למנגנון
ה-snapshot ב-mark-final (pair ראשון 2026-06-06), ולכל קריאת ingest_final_version ישירה.
התוצאה: הפרת INV-LRN4 בפועל (סופי שלא הושווה/נשמר).

התיקון: create-or-update — כשאין pair, פותחים אחד מ-decision_blocks החיים (status→analyzed)
כך שהדיסטילציה נשמרת כ-הצעה ברשם. לתיקים חדשים אין שינוי-התנהגות (תמיד יש pair
מ-mark-final → רק ה-update רץ). זה keystone שמאפשר backfill (#125.2) דרך הפייפליין הקיים.

caveat מתועד בלוג: לתיק היסטורי ה-draft = blocks נוכחיים (אולי נערכו אחרי-חתימה),
לא snapshot-אמיתי.

Invariants:
- INV-LRN4 (מקיים) — כל סופי מקבל pair ומנותח; אין סופי "פתוח".
- INV-LRN1/G10 (נשמר) — הדיסטילציה נשמרת כ-הצעה (analyzed) בלבד; שער ה-promote הידני
  לקיפול ל-appeal_type_rules לא נעקף.
- G2 (מקיים) — אותו פנקס draft_final_pairs, לא מסלול מקביל.
- G1 (מקיים) — נרמול במקור (הרשם) במקום תיקון-בקריאה.

ref: data/audit/learning-loop-activity-20260611.md · TaskMaster legal-ai #122/#125.1

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

188 lines
7.3 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, tools="") # no tool_use → no error_max_turns
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.)
#
# create-or-update (INV-LRN4): normally mark-final already opened a
# 'final_received' pair, so we just advance it. For a case whose final
# pre-dates the mark-final snapshot mechanism (historical backfill) or a direct
# ingest_final_version call, no pair exists — open one now from the live blocks
# so the distillation is actually persisted instead of silently discarded.
# Caveat: the captured draft is the CURRENT blocks (possibly edited after
# sign-off), not a true mark-final snapshot.
if pair_id is None:
pair_id = await db.create_draft_final_pair(case_id, draft_text, "")
logger.info(
"process_final_version: no 'final_received' pair for case %s — opened one "
"from live blocks (backfill path; draft may post-date sign-off)",
case_id,
)
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", [])),
}