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,206 @@
"""סיעור מוחות לגיבוש כיוון ההחלטה.
שלב 4א באיפיון המוצר:
1. הצגת טענות מרכזיות
2. הצעת 2-3 כיוונים אפשריים לנימוק
3. שיח אינטראקטיבי עד לכיוון מוסכם
4. יצירת מסמך כיוון
"""
from __future__ import annotations
import json
import logging
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
BRAINSTORM_PROMPT = """אתה יועץ משפטי מומחה בתכנון ובניה. תפקידך לסייע בגיבוש כיוון להחלטת ועדת ערר.
## הנחיות:
1. **הצג את הטענות המרכזיות** מכל הצדדים — 3-5 טענות עיקריות
2. **הצע 2-3 כיוונים אפשריים** לנימוק ההחלטה. כל כיוון כולל:
- שם הכיוון (שורה אחת)
- נימוקים מרכזיים (2-3 נקודות)
- פסיקה רלוונטית (אם יש)
- חוזקות וחולשות
3. **אל תמליץ** על כיוון אחד — הצג אותם באופן ניטרלי
4. **סוג הערר** משפיע על הטון: רישוי = חם יחסית, השבחה/פיצויים = קר ומקצועי
## תוצאה שנקבעה: {outcome_hebrew}
{reasoning_context}
## פלט:
החזר JSON בפורמט:
{{
"key_claims": [
{{"party": "עוררים/משיבים", "claim": "טענה מרכזית", "strength": "חזקה/בינונית/חלשה"}}
],
"directions": [
{{
"name": "שם הכיוון",
"reasoning": ["נימוק 1", "נימוק 2"],
"precedents": ["פסיקה רלוונטית"],
"strengths": ["חוזקה"],
"weaknesses": ["חולשה"]
}}
],
"recommended_order": "סדר מומלץ של נימוקים בדיון"
}}
"""
OUTCOME_HEBREW = {
"rejected": "דחייה",
"accepted": "קבלה",
"partial": "קבלה חלקית",
}
APPEAL_TYPE_TONE = {
"licensing": "טון חם יחסית — יש הקשר תכנוני רחב ואלמנטים אנושיים",
"betterment": "טון קר ומקצועי — יבש, ללא רגשות",
"compensation": "טון קר ומקצועי — דומה להיטל השבחה",
}
async def generate_directions(
case_id: UUID,
outcome: str,
reasoning: str = "",
) -> dict:
"""סיעור מוחות — הצגת טענות מרכזיות והצעת כיוונים.
Args:
case_id: מזהה התיק
outcome: תוצאה (rejected/accepted/partial)
reasoning: נימוק ראשוני (אם יש)
Returns:
dict עם key_claims, directions, recommended_order
"""
# Gather context
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
claims = await db.get_claims(case_id)
docs = await db.list_documents(case_id)
# Build claims summary
claims_text = ""
if claims:
for c in claims:
role_heb = {"appellant": "עוררים", "respondent": "משיבים",
"committee": "ועדה מקומית", "permit_applicant": "מבקשי היתר"}.get(c["party_role"], c["party_role"])
claims_text += f"- [{role_heb}] {c['claim_text'][:200]}\n"
# Get document excerpts for context
doc_context = ""
for doc in docs[:5]:
text = await db.get_document_text(UUID(doc["id"]))
if text:
doc_context += f"\n--- {doc['title']} ({doc['doc_type']}) ---\n{text[:3000]}\n"
# Determine appeal type tone
appeal_type = case.get("appeal_type", "")
tone_hint = APPEAL_TYPE_TONE.get(appeal_type, "")
outcome_hebrew = OUTCOME_HEBREW.get(outcome, outcome)
reasoning_context = f"נימוק ראשוני: {reasoning}" if reasoning else "לא סופק נימוק — יש להציע כיוונים."
prompt = BRAINSTORM_PROMPT.format(
outcome_hebrew=outcome_hebrew,
reasoning_context=reasoning_context,
)
if tone_hint:
prompt += f"\n\n## טון: {tone_hint}"
user_content = f"""{prompt}
## פרטי התיק:
- מספר: {case['case_number']}
- נושא: {case.get('subject', '')}
- עוררים: {', '.join(case.get('appellants', []))}
- משיבים: {', '.join(case.get('respondents', []))}
## טענות שחולצו:
{claims_text or '(לא חולצו טענות עדיין)'}
## חומרי המקור:
{doc_context or '(אין מסמכים בתיק)'}
"""
client = _get_anthropic()
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
messages=[{"role": "user", "content": user_content}],
)
raw = message.content[0].text.strip()
try:
import re
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
if json_match:
result = json.loads(json_match.group())
else:
result = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Failed to parse brainstorm response: %s", raw[:300])
return {
"key_claims": [],
"directions": [],
"recommended_order": "",
"raw_response": raw,
}
return result
def build_direction_doc(
outcome: str,
reasoning: str,
directions_result: dict,
selected_direction: int | None = None,
additional_notes: str = "",
) -> dict:
"""בניית מסמך כיוון מתוצאות סיעור המוחות.
Args:
outcome: תוצאה שנקבעה
reasoning: נימוק
directions_result: תוצאות מ-generate_directions
selected_direction: אינדקס הכיוון שנבחר (0-based)
additional_notes: הערות נוספות מהמשתמש
"""
direction = None
if selected_direction is not None and directions_result.get("directions"):
directions = directions_result["directions"]
if 0 <= selected_direction < len(directions):
direction = directions[selected_direction]
return {
"outcome": outcome,
"outcome_hebrew": OUTCOME_HEBREW.get(outcome, outcome),
"reasoning": reasoning,
"selected_direction": direction,
"key_claims": directions_result.get("key_claims", []),
"recommended_order": directions_result.get("recommended_order", ""),
"additional_notes": additional_notes,
"approved": True,
}