All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
אודיט #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>
188 lines
7.3 KiB
Python
188 lines
7.3 KiB
Python
"""לולאת למידה — השוואת טיוטה לגרסה סופית וחילוץ לקחים.
|
||
|
||
שלב 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", [])),
|
||
}
|