Add full decision writing pipeline: classify, extract, brainstorm, write, QA, export

New services (11 files):
- classifier.py: auto doc-type classification + party identification (Claude Haiku)
- claims_extractor.py: claim extraction from pleadings (Claude Sonnet + regex)
- references_extractor.py: plan/case-law/legislation detection (regex)
- brainstorm.py: direction generation with 2-3 options (Claude Sonnet)
- block_writer.py: 12-block decision writer (template + Claude Sonnet/Opus)
- docx_exporter.py: DOCX export with David font, RTL, headings
- qa_validator.py: 6 QA checks with export blocking on critical failure
- learning_loop.py: draft vs final comparison + lesson extraction
- metrics.py: KPIs dashboard per case and global
- audit.py: action audit log
- cli.py: standalone CLI with 11 commands

Updated pipeline: extract → classify → chunk → embed → store → extract_references
New MCP tools: 29 total (was 16)
New DB tables: audit_log, decisions CRUD, claims CRUD
Config: Infisical support, external service allowlist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 10:21:47 +00:00
parent df7cc4f5a5
commit d9e5ef0f46
21 changed files with 3957 additions and 14 deletions

View File

@@ -0,0 +1,187 @@
"""לולאת למידה — השוואת טיוטה לגרסה סופית וחילוץ לקחים.
שלב 7 באיפיון:
1. קליטת גרסה סופית (שדפנה חתמה)
2. השוואת טיוטה לסופית — זיהוי שינויים
3. חילוץ לקחים: ביטויים חדשים, דפוסים שהשתנו, שגיאות חוזרות
4. עדכון מודל הסגנון
"""
from __future__ import annotations
import json
import logging
import re
from uuid import UUID
import anthropic
from legal_mcp import config
from legal_mcp.services import db
logger = logging.getLogger(__name__)
_anthropic_client: anthropic.Anthropic | None = None
def _get_anthropic() -> anthropic.Anthropic:
global _anthropic_client
if _anthropic_client is None:
_anthropic_client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
return _anthropic_client
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) וגרסה סופית (שעברה עריכת דפנה).
## משימה:
1. זהה את השינויים המהותיים (לא הקלדה/פורמט)
2. סווג כל שינוי:
- expression_change — ביטוי שהוחלף (הצע כלקח לעתיד)
- structure_change — שינוי מבני (סדר, חלוקה)
- content_addition — תוכן שנוסף (מה חסר?)
- content_removal — תוכן שהוסר (מה מיותר?)
- tone_change — שינוי טון (רשמי יותר/פחות)
- error_fix — תיקון שגיאה עובדתית/משפטית
3. הסק לקחים שניתן להפעיל בהחלטות עתידיות
## פלט JSON:
{
"changes": [
{"type": "...", "description": "תיאור השינוי", "draft_text": "...", "final_text": "...", "lesson": "לקח לעתיד"}
],
"new_expressions": ["ביטוי חדש שדפנה הוסיפה"],
"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]
client = _get_anthropic()
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
messages=[{
"role": "user",
"content": f"""{LESSONS_PROMPT}
--- טיוטה ---
{draft_sample}
--- גרסה סופית ---
{final_sample}
""",
}],
)
raw = message.content[0].text.strip()
try:
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
if json_match:
return json.loads(json_match.group())
return json.loads(raw)
except json.JSONDecodeError:
logger.warning("Failed to parse lessons response")
return {"changes": [], "new_expressions": [], "overall_assessment": raw[:200]}
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}")
# Get draft text (combine all blocks)
pool = await db.get_pool()
async with pool.acquire() as conn:
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
diff_stats = compute_diff_stats(draft_text, final_text)
# Analyze changes with AI
analysis = await analyze_changes(draft_text, final_text)
# Store new expressions as style patterns
for expr in analysis.get("new_expressions", []):
if expr and len(expr) > 3:
await db.upsert_style_pattern(
pattern_type="characteristic_phrase",
pattern_text=expr,
context="למד מגרסה סופית",
)
# Update decision status
await db.update_decision(
UUID(decision["id"]),
status="final",
)
# Update case status
case = await db.get_case(case_id)
if case:
await db.update_case(case_id, status="final")
return {
"diff_stats": diff_stats,
"analysis": analysis,
"lessons_count": len(analysis.get("changes", [])),
"new_expressions": len(analysis.get("new_expressions", [])),
}