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:
187
mcp-server/src/legal_mcp/services/learning_loop.py
Normal file
187
mcp-server/src/legal_mcp/services/learning_loop.py
Normal 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", [])),
|
||||
}
|
||||
Reference in New Issue
Block a user