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,166 @@
"""עוזר משפטי — CLI לניהול תהליך כתיבת החלטות.
Usage: python -m legal_mcp.cli <command>
"""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
def _run(coro):
"""Run async function."""
return asyncio.run(coro)
def _init():
"""Initialize DB."""
from legal_mcp.services.db import init_schema
_run(init_schema())
def cmd_status(case_number: str = ""):
"""סטטוס — תיק ספציפי או כללי."""
_init()
if case_number:
from legal_mcp.tools.workflow import workflow_status
print(_run(workflow_status(case_number)))
else:
from legal_mcp.tools.workflow import processing_status
print(_run(processing_status()))
def cmd_upload(case_number: str, file_path: str, doc_type: str = "auto", title: str = ""):
"""העלאת מסמך לתיק."""
_init()
from legal_mcp.tools.documents import document_upload
result = _run(document_upload(case_number, file_path, doc_type, title))
print(result)
def cmd_outcome(case_number: str, outcome: str, reasoning: str = ""):
"""הזנת תוצאה: rejected / accepted / partial."""
_init()
from legal_mcp.tools.workflow import set_outcome
result = _run(set_outcome(case_number, outcome, reasoning))
print(result)
def cmd_brainstorm(case_number: str):
"""סיעור מוחות — הצעת כיוונים לנימוק."""
_init()
from legal_mcp.tools.workflow import brainstorm_directions
result = _run(brainstorm_directions(case_number))
print(result)
def cmd_approve(case_number: str, direction: int = 0, notes: str = ""):
"""אישור כיוון."""
_init()
from legal_mcp.tools.workflow import approve_direction
result = _run(approve_direction(case_number, direction, notes))
print(result)
def cmd_write(case_number: str, block_id: str = "", instructions: str = ""):
"""כתיבת בלוק או כל הבלוקים."""
_init()
from legal_mcp.tools.drafting import write_block, write_all_blocks
if block_id:
result = _run(write_block(case_number, block_id, instructions))
else:
result = _run(write_all_blocks(case_number, instructions=instructions))
print(result)
def cmd_validate(case_number: str):
"""בדיקת QA."""
_init()
from legal_mcp.tools.drafting import validate_decision
result = _run(validate_decision(case_number))
print(result)
def cmd_export(case_number: str, output: str = ""):
"""ייצוא DOCX."""
_init()
from legal_mcp.tools.drafting import export_docx
result = _run(export_docx(case_number, output))
print(result)
def cmd_claims(case_number: str, role: str = ""):
"""שליפת טענות."""
_init()
from legal_mcp.tools.documents import get_claims
result = _run(get_claims(case_number, role))
print(result)
def cmd_metrics(case_number: str = ""):
"""מדדי הצלחה."""
_init()
from legal_mcp.tools.workflow import get_metrics
result = _run(get_metrics(case_number))
print(result)
def cmd_ingest_final(case_number: str, file_path: str):
"""קליטת גרסה סופית."""
_init()
from legal_mcp.tools.workflow import ingest_final_version
result = _run(ingest_final_version(case_number, file_path=file_path))
print(result)
COMMANDS = {
"status": (cmd_status, "סטטוס תיק או כללי", "[case_number]"),
"upload": (cmd_upload, "העלאת מסמך לתיק", "<case_number> <file_path> [doc_type] [title]"),
"outcome": (cmd_outcome, "הזנת תוצאה", "<case_number> <rejected|accepted|partial> [reasoning]"),
"brainstorm": (cmd_brainstorm, "סיעור מוחות", "<case_number>"),
"approve": (cmd_approve, "אישור כיוון", "<case_number> [direction_index] [notes]"),
"write": (cmd_write, "כתיבת בלוק/ים", "<case_number> [block_id] [instructions]"),
"validate": (cmd_validate, "בדיקת QA", "<case_number>"),
"export": (cmd_export, "ייצוא DOCX", "<case_number> [output_path]"),
"claims": (cmd_claims, "שליפת טענות", "<case_number> [party_role]"),
"metrics": (cmd_metrics, "מדדי הצלחה", "[case_number]"),
"ingest-final": (cmd_ingest_final, "קליטת גרסה סופית", "<case_number> <file_path>"),
}
def main():
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"):
print("עוזר משפטי — CLI\n")
print("שימוש: python -m legal_mcp.cli <command> [args]\n")
print("פקודות:")
for name, (_, desc, usage) in COMMANDS.items():
print(f" {name:15s} {desc:25s} {usage}")
print()
sys.exit(0)
cmd_name = sys.argv[1]
args = sys.argv[2:]
if cmd_name not in COMMANDS:
print(f"פקודה לא ידועה: {cmd_name}")
print(f"הפקודות הזמינות: {', '.join(COMMANDS.keys())}")
sys.exit(1)
func, _, _ = COMMANDS[cmd_name]
try:
func(*args)
except TypeError as e:
print(f"שגיאה בפרמטרים: {e}")
_, _, usage = COMMANDS[cmd_name]
print(f"שימוש: python -m legal_mcp.cli {cmd_name} {usage}")
sys.exit(1)
except Exception as e:
print(f"שגיאה: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,7 @@
"""Configuration loaded from central .env file."""
"""Configuration loaded from Infisical or central .env file.
Priority: Infisical → environment variables → .env file
"""
import os
from pathlib import Path
@@ -9,6 +12,23 @@ from dotenv import load_dotenv
dotenv_path = os.environ.get("DOTENV_PATH", str(Path.home() / ".env"))
load_dotenv(dotenv_path)
# Try loading from Infisical if configured
INFISICAL_TOKEN = os.environ.get("INFISICAL_TOKEN", "")
if INFISICAL_TOKEN:
try:
from infisical_sdk import InfisicalSDKClient
_client = InfisicalSDKClient(token=INFISICAL_TOKEN)
_secrets = _client.get_all_secrets(
environment=os.environ.get("INFISICAL_ENV", "production"),
project_id=os.environ.get("INFISICAL_PROJECT_ID", ""),
)
for s in _secrets:
os.environ.setdefault(s.secret_key, s.secret_value)
except ImportError:
pass # Infisical SDK not installed — use .env
except Exception:
pass # Infisical unreachable — fall back to .env
# PostgreSQL
POSTGRES_URL = os.environ.get(
"POSTGRES_URL",
@@ -38,3 +58,12 @@ TRAINING_DIR = DATA_DIR / "training"
# Chunking parameters
CHUNK_SIZE_TOKENS = 600
CHUNK_OVERLAP_TOKENS = 100
# External service allowlist — case materials may ONLY be sent to these domains
ALLOWED_EXTERNAL_SERVICES = {
"api.anthropic.com", # Claude API (text generation, OCR)
"api.voyageai.com", # Voyage AI (embeddings)
}
# Audit
AUDIT_ENABLED = os.environ.get("AUDIT_ENABLED", "true").lower() == "true"

View File

@@ -19,10 +19,16 @@ class CaseStatus(str, enum.Enum):
class DocType(str, enum.Enum):
APPEAL = "appeal" # כתב ערר
RESPONSE = "response" # תשובה
DECISION = "decision" # החלטה
REFERENCE = "reference" # מסמך עזר
EXHIBIT = "exhibit" # נספח
RESPONSE = "response" # תשובה / כתב תשובה
PROTOCOL = "protocol" # פרוטוקול דיון
PLAN = "plan" # תכנית (תב"ע)
PERMIT = "permit" # היתר בנייה
COURT_DECISION = "court_decision" # פסק דין / החלטת בית משפט
DECISION = "decision" # החלטת ועדה
APPRAISAL = "appraisal" # שומה / חוות דעת שמאית
OBJECTION = "objection" # התנגדות
EXHIBIT = "exhibit" # נספח / מסמך תומך
REFERENCE = "reference" # מסמך עזר אחר
class SectionType(str, enum.Enum):

View File

@@ -140,6 +140,36 @@ async def document_list(case_number: str) -> str:
return await documents.document_list(case_number)
# Claims extraction
@mcp.tool()
async def extract_claims(
case_number: str,
doc_title: str = "",
party_hint: str = "",
) -> str:
"""חילוץ טענות מכתב טענות בתיק. מחלץ טענות לפי צד ושומר ב-DB."""
return await documents.extract_claims(case_number, doc_title, party_hint)
@mcp.tool()
async def get_claims(
case_number: str,
party_role: str = "",
) -> str:
"""שליפת טענות שחולצו לתיק. party_role: appellant/respondent/committee/permit_applicant (ריק=הכל)."""
return await documents.get_claims(case_number, party_role)
# References
@mcp.tool()
async def extract_references(
case_number: str,
doc_title: str = "",
) -> str:
"""זיהוי תכניות, פסיקה וחקיקה מתוך מסמכי תיק. מזהה ומקשר להפניות קיימות ב-DB."""
return await documents.extract_references(case_number, doc_title)
# Search
@mcp.tool()
async def search_decisions(
@@ -193,12 +223,44 @@ async def get_decision_template(case_number: str) -> str:
return await drafting.get_decision_template(case_number)
@mcp.tool()
async def validate_decision(case_number: str) -> str:
"""בדיקת QA — 6 בדיקות איכות על ההחלטה. אם בדיקה קריטית נכשלת — ייצוא חסום."""
return await drafting.validate_decision(case_number)
@mcp.tool()
async def export_docx(case_number: str, output_path: str = "") -> str:
"""ייצוא החלטה לקובץ DOCX מעוצב — גופן David, RTL, כותרות, מספור סעיפים."""
return await drafting.export_docx(case_number, output_path)
@mcp.tool()
async def analyze_style() -> str:
"""ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה."""
return await drafting.analyze_style()
@mcp.tool()
async def write_block(
case_number: str,
block_id: str,
instructions: str = "",
) -> str:
"""כתיבת בלוק יחיד בהחלטה: block-alef עד block-yod-bet. שומר ב-DB."""
return await drafting.write_block(case_number, block_id, instructions)
@mcp.tool()
async def write_all_blocks(
case_number: str,
start_from: str = "block-alef",
instructions: str = "",
) -> str:
"""כתיבת כל הבלוקים בהחלטה, בלוק אחרי בלוק. שומר כל בלוק מיד."""
return await drafting.write_all_blocks(case_number, start_from, instructions)
# Workflow
@mcp.tool()
async def workflow_status(case_number: str) -> str:
@@ -206,12 +268,55 @@ async def workflow_status(case_number: str) -> str:
return await workflow.workflow_status(case_number)
@mcp.tool()
async def get_metrics(case_number: str = "") -> str:
"""מדדי הצלחה — KPIs לתיק ספציפי או דשבורד כולל. ריק = דשבורד."""
return await workflow.get_metrics(case_number)
@mcp.tool()
async def processing_status() -> str:
"""סטטוס כללי - מספר תיקים, מסמכים, chunks."""
return await workflow.processing_status()
# Outcome & Brainstorming
@mcp.tool()
async def set_outcome(
case_number: str,
outcome: str,
reasoning: str = "",
) -> str:
"""הזנת תוצאה לתיק: rejected (דחייה), accepted (קבלה), partial (קבלה חלקית). אם אין נימוק — מפעיל סיעור מוחות."""
return await workflow.set_outcome(case_number, outcome, reasoning)
@mcp.tool()
async def brainstorm_directions(case_number: str) -> str:
"""סיעור מוחות — הצגת טענות מרכזיות והצעת 2-3 כיוונים אפשריים לנימוק ההחלטה."""
return await workflow.brainstorm_directions(case_number)
@mcp.tool()
async def approve_direction(
case_number: str,
direction_index: int = 0,
additional_notes: str = "",
) -> str:
"""אישור כיוון — יוצר מסמך כיוון מאושר. חובה לפני כתיבת דיון (בלוק י)."""
return await workflow.approve_direction(case_number, direction_index, additional_notes)
@mcp.tool()
async def ingest_final_version(
case_number: str,
file_path: str = "",
final_text: str = "",
) -> str:
"""קליטת גרסה סופית שדפנה חתמה — השוואה לטיוטה וחילוץ לקחים לשיפור עתידי."""
return await workflow.ingest_final_version(case_number, file_path, final_text)
def main():
mcp.run(transport="stdio")

View File

@@ -0,0 +1,76 @@
"""Audit log — תיעוד כל פעולה במערכת.
כל פעולה מתועדת: מי, מתי, מה, על איזה תיק.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from uuid import UUID, uuid4
from legal_mcp.services import db
logger = logging.getLogger("audit")
async def log_action(
action: str,
case_id: UUID | None = None,
document_id: UUID | None = None,
details: dict | None = None,
user: str = "system",
) -> None:
"""רישום פעולה ב-audit log.
Args:
action: סוג הפעולה (upload, classify, extract_claims, set_outcome, write_block, export, etc.)
case_id: מזהה תיק (אם רלוונטי)
document_id: מזהה מסמך (אם רלוונטי)
details: פרטים נוספים
user: מזהה המשתמש
"""
pool = await db.get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""INSERT INTO audit_log (id, action, case_id, document_id, details, actor, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
uuid4(), action, case_id, document_id,
json.dumps(details or {}, ensure_ascii=False, default=str),
user, datetime.utcnow(),
)
logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user,
json.dumps(details or {}, ensure_ascii=False)[:200])
async def get_audit_log(
case_id: UUID | None = None,
action: str | None = None,
limit: int = 50,
) -> list[dict]:
"""שליפת audit log."""
pool = await db.get_pool()
conditions = []
params: list = []
idx = 1
if case_id:
conditions.append(f"case_id = ${idx}")
params.append(case_id)
idx += 1
if action:
conditions.append(f"action = ${idx}")
params.append(action)
idx += 1
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
params.append(limit)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"SELECT * FROM audit_log {where} ORDER BY created_at DESC LIMIT ${idx}",
*params,
)
return [dict(r) for r in rows]

View File

@@ -0,0 +1,573 @@
"""מנוע כתיבת בלוקים להחלטת ועדת ערר.
מייצר טקסט בפועל לכל בלוק (ה-יב) בהתבסס על:
- block-schema.md (פרמטרים, constraints, מבנה)
- SKILL.md (סגנון דפנה)
- חומרי המקור (מסמכים, טענות, פסיקה)
- מסמך כיוון (חובה לבלוק י)
בלוקים א-ד ויב = template-fill (ללא AI).
בלוקים ה-יא = AI generation עם Claude.
"""
from __future__ import annotations
import json
import logging
from datetime import date
from uuid import UUID
import anthropic
from legal_mcp import config
from legal_mcp.services import db, embeddings
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
# ── Block configuration ───────────────────────────────────────────
BLOCK_CONFIG = {
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 1024},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 4096},
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 4096},
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 2048},
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 2048},
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 8192},
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 2048},
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"},
}
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-20250514",
}
# ── Template blocks (א-ד, יב) ────────────────────────────────────
def write_block_alef(case: dict, decision: dict | None = None) -> str:
"""כותרת מוסדית."""
return f"""מדינת ישראל
ועדת ערר לתכנון ולבנייה — מחוז ירושלים
ערר מס' {case['case_number']}"""
def write_block_bet(case: dict, decision: dict | None = None) -> str:
"""הרכב הוועדה."""
panel = (decision or {}).get("panel_members", [])
members_text = ""
if panel:
for m in panel:
members_text += f"\n{m}"
return f"""בפני:
עו"ד דפנה תמיר, יו"ר{members_text}"""
def write_block_gimel(case: dict, decision: dict | None = None) -> str:
"""צדדים."""
appellants = "\n".join(case.get("appellants", ["(לא צוין)"]))
respondents = "\n".join(case.get("respondents", ["(לא צוין)"]))
return f"""{appellants}
נגד
{respondents}"""
def write_block_dalet(case: dict, decision: dict | None = None) -> str:
"""כותרת החלטה."""
return "החלטה"
def write_block_yod_bet(case: dict, decision: dict | None = None) -> str:
"""חתימות."""
today = date.today().strftime("%d.%m.%Y")
return f"""ניתנה היום, {today}, פה אחד.
דפנה תמיר, עו"ד
יו"ר ועדת הערר"""
TEMPLATE_WRITERS = {
"block-alef": write_block_alef,
"block-bet": write_block_bet,
"block-gimel": write_block_gimel,
"block-dalet": write_block_dalet,
"block-yod-bet": write_block_yod_bet,
}
# ── AI-generated blocks (ה-יא) ───────────────────────────────────
BLOCK_PROMPTS = {
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
## כללים:
- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..."
- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש
- 1-2 סעיפים בלבד
- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים
- מספור: 1.
## פרטי התיק:
{case_context}
## חומרי מקור:
{source_context}""",
"block-vav": """כתוב את בלוק הרקע העובדתי (בלוק ו, "פתח דבר") של החלטת ועדת ערר.
## כללים קריטיים:
- **רקע ניטרלי** — עובדות בלבד. אין ציטוטים ישירים מצדדים. אין מילות ערך/שיפוט ("חריג", "חטא", "בעייתי").
- סדר פנימי: מקרקעין → סביבה → היסטוריה תכנונית → מהות הבקשה → החלטת הוועדה → הגשת הערר
- סמן מיקומי תמונות: [📷 מיקום GIS], [📷 תשריט]
- ציטוט מפרוטוקול ועדה מקומית (אם יש) כ-blockquote
- מספור רציף מהבלוק הקודם
## פרטי התיק:
{case_context}
## חומרי מקור:
{source_context}""",
"block-zayin": """כתוב את בלוק טענות הצדדים (בלוק ז, "תמצית טענות הצדדים") של החלטת ועדת ערר.
## כללים:
- כל טענה בסעיף נפרד, גוף שלישי ("העורר טוען כי...")
- סדר קבוע: טענות העוררים → עמדת הוועדה המקומית → עמדת מבקשי ההיתר (אם יש)
- כותרת: "תמצית טענות הצדדים"
- נאמנות מוחלטת למקור — לא לשנות, לא לקצר ללא ציון
- אין ניתוח, אין מסקנות, אין הערכה
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
- מספור רציף
## טענות שחולצו:
{claims_context}
## פרטי התיק:
{case_context}""",
"block-chet": """כתוב את בלוק ההליכים (בלוק ח, "ההליכים בפני ועדת הערר") של החלטת ועדת ערר.
## כללים:
- תיעוד כרונולוגי: דיון → סיור → השלמות טיעון → החלטות ביניים
- תאריכים מדויקים
- תוכן כל השלמת טיעון בסעיף נפרד
- סמן תמונות מסיור: [📷 צילום מסיור]
- אין ניתוח או הערכה
- מספור רציף
## פרטי התיק:
{case_context}
## חומרי מקור:
{source_context}""",
"block-tet": """כתוב את בלוק התכניות החלות (בלוק ט) של החלטת ועדת ערר.
## כללים:
- ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות
- מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות
- אין ניתוח מעמיק (→ בלוק י), אין הכרעה בין פרשנויות
- מספור רציף
- בלוק אופציונלי — כתוב רק אם יש מורכבות תכנונית
## פרטי התיק:
{case_context}
## תכניות שזוהו:
{plans_context}
## חומרי מקור:
{source_context}""",
"block-yod": """כתוב את בלוק הדיון וההכרעה (בלוק י) של החלטת ועדת ערר.
## זהו הבלוק הקריטי ביותר — ליבת ההחלטה (ratio decidendi).
## מתודולוגיה — CREAC:
1. **C** (Conclusion) — פתח במסקנה: "לאחר שעיינו... מצאנו כי הערר [נדחה/מתקבל]"
2. **R** (Rule) — הצג את הכלל המשפטי הרלוונטי
3. **E** (Explanation) — צטט פסיקה שמסבירה את הכלל
4. **A** (Application) — יישם על העובדות הספציפיות
5. **C** (Conclusion) — מסקנת ביניים
## כללים קריטיים:
- **מסקנה בפתיחה** — לא בסוף
- **מענה לכל טענה** שהוצגה בבלוק ז
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
- ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
- מספור רציף
## כיוון מאושר (חובה):
{direction_context}
## מבנה לפי תוצאה:
{structure_guidance}
## טענות שצריך לענות עליהן:
{claims_context}
## חומרי מקור:
{source_context}
## פסיקה רלוונטית:
{precedents_context}
## סגנון דפנה:
{style_context}""",
"block-yod-alef": """כתוב את בלוק הסיכום (בלוק יא, "סוף דבר") של החלטת ועדת ערר.
## כללים:
- כותרת: "סוף דבר" או "סיכום"
- תוצאה ברורה: "הערר נדחה" / "הערר מתקבל" / "הערר מתקבל באופן חלקי"
- הוראות אופרטיביות חד-משמעיות
- אין חזרה על נימוקים — ההנמקה כבר בדיון
- מספור רציף
## מבנה לפי תוצאה:
- דחייה: "הערר נדחה" + תתי-סעיפים + פסקה חמה (רישוי בלבד)
- קבלה: "הערר מתקבל בכפוף ל..." + פרוזה
- קבלה חלקית: "הערר מתקבל באופן חלקי" + 2-3 הוראות אופרטיביות
## כיוון ותוצאה:
{direction_context}
## בלוקים קודמים (דיון):
{discussion_context}""",
}
# Discussion structure by outcome
STRUCTURE_GUIDANCE = {
"rejected": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.",
"accepted": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.",
"partial": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
}
async def write_block(
case_id: UUID,
block_id: str,
instructions: str = "",
) -> dict:
"""כתיבת בלוק יחיד בהחלטה.
Args:
case_id: מזהה התיק
block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...)
instructions: הנחיות נוספות
Returns:
dict עם content, word_count, block_id, generation_type
"""
if block_id not in BLOCK_CONFIG:
raise ValueError(f"Unknown block: {block_id}")
block_cfg = BLOCK_CONFIG[block_id]
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
decision = await db.get_decision_by_case(case_id)
# Template blocks
if block_id in TEMPLATE_WRITERS:
content = TEMPLATE_WRITERS[block_id](case, decision)
return _build_result(block_id, content, block_cfg)
# AI-generated blocks
prompt_template = BLOCK_PROMPTS.get(block_id)
if not prompt_template:
raise ValueError(f"No prompt template for {block_id}")
# Build context components
case_context = _build_case_context(case, decision)
source_context = await _build_source_context(case_id, block_id)
claims_context = await _build_claims_context(case_id)
direction_context = _build_direction_context(decision)
plans_context = await _build_plans_context(case_id)
precedents_context = await _build_precedents_context(case_id, block_id)
style_context = await _build_style_context()
discussion_context = await _build_previous_blocks_context(case_id, decision)
outcome = (decision or {}).get("outcome", "rejected")
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
# Format prompt
prompt = prompt_template.format(
case_context=case_context,
source_context=source_context,
claims_context=claims_context,
direction_context=direction_context,
plans_context=plans_context,
precedents_context=precedents_context,
style_context=style_context,
discussion_context=discussion_context,
structure_guidance=structure_guidance,
)
if instructions:
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
# Block י requires approved direction
if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {}
if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
# Call Claude
model_key = block_cfg["model"]
model = MODEL_MAP.get(model_key, MODEL_MAP["sonnet"])
temperature = block_cfg["temp"]
max_tokens = block_cfg.get("max_tokens", 4096)
client = _get_anthropic()
# For opus blocks, use extended thinking
kwargs: dict = {
"model": model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
}
if model_key == "opus" and temperature >= 0.3:
# Extended thinking for complex blocks
kwargs["temperature"] = 1 # Required for extended thinking
kwargs["thinking"] = {"type": "enabled", "budget_tokens": 16000}
else:
kwargs["temperature"] = temperature
message = client.messages.create(**kwargs)
# Extract text from response (skip thinking blocks)
content = ""
for block in message.content:
if block.type == "text":
content = block.text
break
return _build_result(block_id, content, block_cfg)
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
word_count = len(content.split())
return {
"block_id": block_id,
"block_index": block_cfg["index"],
"title": block_cfg["title"],
"content": content,
"word_count": word_count,
"generation_type": block_cfg["gen_type"],
"model_used": block_cfg["model"],
"temperature": block_cfg["temp"],
}
# ── Context builders ──────────────────────────────────────────────
def _build_case_context(case: dict, decision: dict | None) -> str:
outcome = (decision or {}).get("outcome", "")
outcome_heb = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, "")
return f"""- מספר תיק: {case['case_number']}
- כותרת: {case.get('title', '')}
- עוררים: {', '.join(case.get('appellants', []))}
- משיבים: {', '.join(case.get('respondents', []))}
- נושא: {case.get('subject', '')}
- כתובת: {case.get('property_address', '')}
- סוג ערר: {case.get('appeal_type', '')}
- תוצאה: {outcome_heb}"""
async def _build_source_context(case_id: UUID, block_id: str, max_chars: int = 15000) -> str:
"""Get relevant document excerpts for the block."""
docs = await db.list_documents(case_id)
context_parts = []
total = 0
for doc in docs:
if total >= max_chars:
break
text = await db.get_document_text(UUID(doc["id"]))
if text:
excerpt = text[:3000]
context_parts.append(f"--- {doc['title']} ({doc['doc_type']}) ---\n{excerpt}")
total += len(excerpt)
return "\n\n".join(context_parts) if context_parts else "(אין מסמכים)"
async def _build_claims_context(case_id: UUID) -> str:
claims = await db.get_claims(case_id)
if not claims:
return "(לא חולצו טענות)"
lines = []
current_role = ""
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
for c in claims:
if c["party_role"] != current_role:
current_role = c["party_role"]
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
lines.append(f"- {c['claim_text'][:300]}")
return "\n".join(lines)
def _build_direction_context(decision: dict | None) -> str:
if not decision:
return "(לא הוגדר כיוון)"
dir_doc = decision.get("direction_doc") or {}
if not dir_doc.get("approved"):
return "(כיוון לא אושר)"
parts = []
outcome_heb = dir_doc.get("outcome_hebrew", "")
if outcome_heb:
parts.append(f"תוצאה: {outcome_heb}")
reasoning = dir_doc.get("reasoning", "")
if reasoning:
parts.append(f"נימוק: {reasoning}")
direction = dir_doc.get("selected_direction")
if direction:
parts.append(f"כיוון נבחר: {direction.get('name', '')}")
for r in direction.get("reasoning", []):
parts.append(f" - {r}")
for p in direction.get("precedents", []):
parts.append(f" פסיקה: {p}")
notes = dir_doc.get("additional_notes", "")
if notes:
parts.append(f"הערות: {notes}")
return "\n".join(parts) if parts else "(אין מסמך כיוון)"
async def _build_plans_context(case_id: UUID) -> str:
"""Get plan references from document metadata."""
docs = await db.list_documents(case_id)
plans = set()
for doc in docs:
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
refs = metadata.get("references", {})
for p in refs.get("plans", []):
plans.add(p.get("plan_name", ""))
if plans:
return "\n".join(f"- {p}" for p in sorted(plans) if p)
return "(לא זוהו תכניות)"
async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
"""Search for similar precedent paragraphs."""
try:
case = await db.get_case(case_id)
subject = case.get("subject", "") if case else ""
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
query_emb = await embeddings.embed_query(query)
results = await db.search_similar(query_embedding=query_emb, limit=5)
# Filter out same case
results = [r for r in results if str(r.get("case_id")) != str(case_id)]
if results:
parts = []
for r in results[:3]:
parts.append(f"[{r.get('case_number', '?')}, {r.get('section_type', '')}] {r['content'][:400]}")
return "\n\n".join(parts)
except Exception as e:
logger.warning("Failed to fetch precedents: %s", e)
return "(אין תקדימים)"
async def _build_style_context() -> str:
patterns = await db.get_style_patterns()
if not patterns:
return "(אין דפוסי סגנון)"
lines = []
for p in patterns[:10]:
lines.append(f"- [{p['pattern_type']}] {p['pattern_text']}")
return "\n".join(lines)
async def _build_previous_blocks_context(case_id: UUID, decision: dict | None) -> str:
"""Get content of previously written blocks."""
if not decision:
return "(אין בלוקים קודמים)"
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT block_id, title, content, word_count
FROM decision_blocks
WHERE decision_id = $1 AND word_count > 0
ORDER BY block_index""",
UUID(decision["id"]),
)
if not rows:
return "(אין בלוקים קודמים)"
parts = []
for r in rows:
content = r["content"][:2000]
parts.append(f"### {r['title']} ({r['block_id']})\n{content}")
return "\n\n".join(parts)
# ── Store block ───────────────────────────────────────────────────
async def store_block(decision_id: UUID, block_result: dict) -> None:
"""שמירת בלוק ב-DB (upsert)."""
pool = await db.get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""INSERT INTO decision_blocks
(decision_id, block_id, block_index, title, content, word_count,
generation_type, model_used, temperature, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft')
ON CONFLICT (decision_id, block_id) DO UPDATE SET
content = EXCLUDED.content,
word_count = EXCLUDED.word_count,
generation_type = EXCLUDED.generation_type,
model_used = EXCLUDED.model_used,
temperature = EXCLUDED.temperature,
status = 'draft',
updated_at = now()""",
decision_id,
block_result["block_id"],
block_result["block_index"],
block_result["title"],
block_result["content"],
block_result["word_count"],
block_result["generation_type"],
block_result["model_used"],
block_result["temperature"],
)
async def write_and_store_block(
case_id: UUID,
block_id: str,
instructions: str = "",
) -> dict:
"""כתיבת בלוק ושמירה ב-DB."""
decision = await db.get_decision_by_case(case_id)
if not decision:
# Create decision if not exists
decision = await db.create_decision(case_id=case_id)
result = await write_block(case_id, block_id, instructions)
await store_block(UUID(decision["id"]), result)
return result

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,
}

View File

@@ -0,0 +1,260 @@
"""חילוץ טענות מכתבי טענות (ערר, תשובה) באמצעות Claude API.
שתי גישות:
1. extract_claims_with_ai — חילוץ עם Claude (לכתבי טענות קלט)
2. extract_claims_from_block — חילוץ regex (מבלוק ז של החלטות סופיות)
"""
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
EXTRACT_CLAIMS_PROMPT = """אתה מנתח מסמכים משפטיים בתחום תכנון ובניה. תפקידך לחלץ טענות מכתב טענות.
## כללים חשובים:
1. **נאמנות למקור** — כל טענה חייבת לשקף את מה שנכתב, לא לפרש או להוסיף.
2. **טענה = טיעון מובחן אחד** — אם פסקה מכילה 2 טיעונים שונים, פצל לשתי טענות.
3. **כל טענה חייבת להיות מובנת בפני עצמה** — גם בלי הקשר המסמך המלא.
4. **שמור על לשון הגוף שלישי** — גם אם המקור בגוף ראשון.
## סוג הצד (party_role):
- appellant — עורר/ת (מי שמגיש את הערר)
- respondent — משיב/ה (הצד שכנגד, לא הוועדה)
- committee — ועדה מקומית
- permit_applicant — מבקש/ת היתר
## פלט:
החזר JSON array בלבד:
[
{
"party_role": "appellant",
"claim_text": "הטענה בגוף שלישי, בעברית",
"topic": "נושא הטענה בקצרה (3-5 מילים)"
}
]
אם אין טענות — החזר [].
"""
async def extract_claims_with_ai(
text: str,
doc_type: str = "appeal",
party_hint: str = "",
) -> list[dict]:
"""חילוץ טענות מכתב טענות באמצעות Claude.
Args:
text: טקסט המסמך
doc_type: סוג המסמך (appeal/response)
party_hint: רמז לזהות הצד (אם ידוע)
Returns:
רשימת טענות עם party_role, claim_text, topic
"""
# For very long documents, truncate but try to keep complete paragraphs
max_chars = 25000
if len(text) > max_chars:
# Find a paragraph break near the limit
cutoff = text.rfind("\n\n", 0, max_chars)
if cutoff < max_chars // 2:
cutoff = max_chars
sample = text[:cutoff]
logger.info("Document truncated from %d to %d chars", len(text), len(sample))
else:
sample = text
context = f"סוג המסמך: {doc_type}"
if party_hint:
context += f"\nהצד המגיש: {party_hint}"
client = _get_anthropic()
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
messages=[
{
"role": "user",
"content": (
f"{EXTRACT_CLAIMS_PROMPT}\n\n"
f"{context}\n\n"
f"--- תחילת מסמך ---\n{sample}\n--- סוף מסמך ---"
),
}
],
)
raw = message.content[0].text.strip()
try:
# Extract JSON array from response
json_match = re.search(r"\[.*\]", raw, re.DOTALL)
if json_match:
claims = json.loads(json_match.group())
else:
claims = json.loads(raw)
except json.JSONDecodeError:
logger.warning("Failed to parse claims response: %s", raw[:200])
return []
if not isinstance(claims, list):
return []
# Add claim_index
for i, claim in enumerate(claims):
claim["claim_index"] = i
# Validate required fields
if "party_role" not in claim or "claim_text" not in claim:
continue
return [c for c in claims if "party_role" in c and "claim_text" in c]
# ── Regex-based extraction (from existing decisions) ──────────────
PARTY_PATTERNS = [
(r"טענות\s*העוררי[םן]|טענות\s*העורר\b|טענות\s*המבקש|טענות\s*המערער", "appellant"),
(r"עמדת\s*הוועדה\s*המקומית|עמדת\s*המשיבה|טענות\s*המשיבה|תגובת\s*המשיבה|הוועדה\s*המקומית$", "committee"),
(r"עמדת\s*המשיבי[םן]|עמדת\s*המשיב\b|טענות\s*המשיבי[םן]|טענות\s*המשיב\b", "respondent"),
(r"מבקשי\s*ההיתר|עמדת\s*מבקש|עמדת\s*היזם|מגישי\s*התכנית", "permit_applicant"),
(r"הבהרות\s*השמא|התייחסות\s*הצדדים", "appraiser"),
]
def _detect_party_role(line: str) -> str | None:
for pattern, role in PARTY_PATTERNS:
if re.search(pattern, line):
return role
return None
def extract_claims_from_block(text: str) -> list[dict]:
"""חילוץ טענות מבלוק ז של החלטה סופית (regex-based).
Replicates the logic from scripts/extract-claims.py for use as a service.
"""
lines = text.split("\n")
claims = []
current_role = "appellant"
current_claim_lines: list[str] = []
claim_index = 0
for line in lines:
stripped = line.strip()
if not stripped:
continue
role = _detect_party_role(stripped) if len(stripped.split()) <= 8 else None
if role:
if current_claim_lines:
claim_text = "\n".join(current_claim_lines).strip()
if len(claim_text) > 30:
claims.append({
"party_role": current_role,
"claim_text": claim_text,
"claim_index": claim_index,
})
claim_index += 1
current_claim_lines = []
current_role = role
continue
# Numbered sub-header starts new claim
if re.match(r"^\d+\.\s+\S.{3,40}$", stripped):
if current_claim_lines:
claim_text = "\n".join(current_claim_lines).strip()
if len(claim_text) > 30:
claims.append({
"party_role": current_role,
"claim_text": claim_text,
"claim_index": claim_index,
})
claim_index += 1
current_claim_lines = [stripped]
continue
# Each paragraph is a claim
if current_claim_lines:
claim_text = "\n".join(current_claim_lines).strip()
if len(claim_text) > 30:
claims.append({
"party_role": current_role,
"claim_text": claim_text,
"claim_index": claim_index,
})
claim_index += 1
current_claim_lines = [stripped]
# Last claim
if current_claim_lines:
claim_text = "\n".join(current_claim_lines).strip()
if len(claim_text) > 30:
claims.append({
"party_role": current_role,
"claim_text": claim_text,
"claim_index": claim_index,
})
return claims
async def extract_and_store_claims(
case_id: UUID,
document_id: UUID,
text: str,
doc_type: str = "appeal",
party_hint: str = "",
) -> dict:
"""חילוץ טענות ושמירה ב-DB.
Args:
case_id: מזהה התיק
document_id: מזהה המסמך
text: טקסט המסמך
doc_type: סוג (appeal/response)
party_hint: שם הצד המגיש
Returns:
סיכום: כמה טענות חולצו, לפי צד
"""
doc = await db.get_document(document_id)
source_name = doc["title"] if doc else str(document_id)
claims = await extract_claims_with_ai(text, doc_type, party_hint)
if not claims:
return {"status": "no_claims", "total": 0, "source": source_name}
stored = await db.store_claims(case_id, claims, source_document=source_name)
# Summarize by role
role_counts: dict[str, int] = {}
for c in claims:
role = c["party_role"]
role_counts[role] = role_counts.get(role, 0) + 1
return {
"status": "completed",
"total": stored,
"by_role": role_counts,
"source": source_name,
}

View File

@@ -0,0 +1,259 @@
"""סיווג אוטומטי של מסמכים וזיהוי צדדים.
שלוש פונקציות:
1. classify_document — סיווג סוג מסמך (ערר/תשובה/פרוטוקול/...)
2. identify_parties — זיהוי צדדים (עוררים, משיבים, ועדה, מבקשי היתר)
3. detect_appeal_type — זיהוי סוג ערר לפי מספר תיק
"""
from __future__ import annotations
import json
import logging
import re
import anthropic
from legal_mcp import config
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
# ── סיווג סוג מסמך ──────────────────────────────────────────────────
DOC_TYPES = {
"appeal": "כתב ערר",
"response": "תשובה / כתב תשובה",
"protocol": "פרוטוקול דיון",
"plan": "תכנית (תב\"ע)",
"permit": "היתר בנייה",
"court_decision": "פסק דין / החלטת בית משפט",
"decision": "החלטת ועדה",
"appraisal": "שומה / חוות דעת שמאית",
"objection": "התנגדות",
"exhibit": "נספח / מסמך תומך",
"reference": "מסמך עזר אחר",
}
CLASSIFY_PROMPT = """אתה מסווג מסמכים משפטיים בתחום תכנון ובניה.
קרא את תחילת המסמך וסווג אותו לאחד מהסוגים הבאים:
- appeal — כתב ערר (מוגש לוועדת ערר)
- response — כתב תשובה (תגובת הצד שכנגד או הוועדה המקומית)
- protocol — פרוטוקול דיון (רישום מדיון שהתקיים)
- plan — תכנית (תב"ע, תכנית מתאר, תכנית מפורטת)
- permit — היתר בנייה (או בקשה להיתר)
- court_decision — פסק דין או החלטה של בית משפט
- decision — החלטת ועדה מקומית או ועדת ערר
- appraisal — שומה או חוות דעת שמאית
- objection — התנגדות (לתכנית, להיתר)
- exhibit — נספח או מסמך תומך
- reference — מסמך עזר אחר
החזר JSON בלבד בפורמט:
{"doc_type": "...", "confidence": 0.0-1.0, "reasoning": "הסבר קצר"}
"""
PARTIES_PROMPT = """אתה מנתח מסמכים משפטיים בתחום תכנון ובניה.
קרא את המסמך וזהה את הצדדים המעורבים. חפש:
- עוררים (appellants) — מי שמגיש את הערר
- משיבים (respondents) — הצד שכנגד (לרוב ועדה מקומית, או מבקש היתר)
- ועדה מקומית (committee) — שם הוועדה המקומית
- מבקשי היתר (permit_applicants) — מי שביקש את ההיתר (אם שונה מהעוררים/משיבים)
החזר JSON בלבד בפורמט:
{
"appellants": ["שם1", "שם2"],
"respondents": ["שם1", "שם2"],
"committee": "שם הוועדה המקומית (אם מצוין)",
"permit_applicants": ["שם1"],
"confidence": 0.0-1.0
}
אם לא ניתן לזהות צד מסוים, החזר רשימה ריקה. אל תמציא שמות.
"""
async def classify_document(text: str) -> dict:
"""סיווג סוג מסמך על בסיס הטקסט.
Args:
text: טקסט המסמך (מספיק 3000 תווים ראשונים)
Returns:
dict עם doc_type, confidence, reasoning
"""
# Use first 3000 chars — usually enough for headers and intro
sample = text[:3000]
client = _get_anthropic()
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=256,
messages=[
{
"role": "user",
"content": f"{CLASSIFY_PROMPT}\n\n--- תחילת מסמך ---\n{sample}\n--- סוף דגימה ---",
}
],
)
raw = message.content[0].text.strip()
try:
# Extract JSON from response (handle markdown code blocks)
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 classification response: %s", raw)
return {"doc_type": "reference", "confidence": 0.0, "reasoning": "סיווג נכשל"}
# Validate doc_type
if result.get("doc_type") not in DOC_TYPES:
result["doc_type"] = "reference"
result["confidence"] = 0.0
return result
async def identify_parties(text: str) -> dict:
"""זיהוי צדדים מתוך טקסט מסמך.
Args:
text: טקסט המסמך (מספיק 5000 תווים ראשונים)
Returns:
dict עם appellants, respondents, committee, permit_applicants, confidence
"""
# Use first 5000 chars — parties usually in header/intro
sample = text[:5000]
client = _get_anthropic()
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=512,
messages=[
{
"role": "user",
"content": f"{PARTIES_PROMPT}\n\n--- תחילת מסמך ---\n{sample}\n--- סוף דגימה ---",
}
],
)
raw = message.content[0].text.strip()
try:
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 parties response: %s", raw)
return {
"appellants": [],
"respondents": [],
"committee": "",
"permit_applicants": [],
"confidence": 0.0,
}
# Normalize structure
return {
"appellants": result.get("appellants", []),
"respondents": result.get("respondents", []),
"committee": result.get("committee", ""),
"permit_applicants": result.get("permit_applicants", []),
"confidence": result.get("confidence", 0.0),
}
# ── זיהוי סוג ערר לפי מספר תיק ─────────────────────────────────────
APPEAL_TYPES = {
"licensing": "רישוי ובנייה", # 1xxx
"betterment": "היטל השבחה", # 8xxx
"compensation": "פיצויים (ס' 197)", # 9xxx
}
def detect_appeal_type(case_number: str) -> dict:
"""זיהוי סוג ערר לפי מספר תיק.
Convention:
1xxx = רישוי ובנייה
8xxx = היטל השבחה
9xxx = פיצויים (ס' 197)
Args:
case_number: מספר תיק (e.g. "1078-24", "8042-23", "9015-22")
Returns:
dict עם appeal_type, appeal_type_hebrew, confidence
"""
# Extract the numeric prefix before any dash/slash
match = re.match(r"(\d+)", case_number.strip())
if not match:
return {
"appeal_type": "",
"appeal_type_hebrew": "",
"confidence": 0.0,
}
num = int(match.group(1))
first_digit = str(num)[0] if num > 0 else ""
if first_digit == "1":
appeal_type = "licensing"
elif first_digit == "8":
appeal_type = "betterment"
elif first_digit == "9":
appeal_type = "compensation"
else:
return {
"appeal_type": "",
"appeal_type_hebrew": "",
"confidence": 0.5,
}
return {
"appeal_type": appeal_type,
"appeal_type_hebrew": APPEAL_TYPES[appeal_type],
"confidence": 1.0,
}
async def classify_and_identify(text: str, case_number: str = "") -> dict:
"""סיווג מלא: סוג מסמך + צדדים + סוג ערר.
Args:
text: טקסט המסמך
case_number: מספר תיק (אופציונלי, לזיהוי סוג ערר)
Returns:
dict עם classification, parties, appeal_type
"""
classification = await classify_document(text)
parties = await identify_parties(text)
appeal_type = detect_appeal_type(case_number) if case_number else {
"appeal_type": "",
"appeal_type_hebrew": "",
"confidence": 0.0,
}
return {
"classification": classification,
"parties": parties,
"appeal_type": appeal_type,
}

View File

@@ -131,6 +131,253 @@ CREATE INDEX IF NOT EXISTS idx_cases_number ON cases(case_number);
MIGRATIONS_SQL = """
ALTER TABLE cases ADD COLUMN IF NOT EXISTS expected_outcome TEXT DEFAULT '';
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
action TEXT NOT NULL,
case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
details JSONB DEFAULT '{}',
actor TEXT DEFAULT 'system',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_case ON audit_log(case_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
"""
# ── Phase 3: Workflow expansion ────────────────────────────────────
SCHEMA_V3_SQL = """
-- הרחבת decisions עם שדות חדשים
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS direction_doc JSONB DEFAULT NULL;
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS outcome_reasoning TEXT DEFAULT '';
-- הרחבת cases עם appeal_type (אם לא קיים)
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_type TEXT DEFAULT '';
-- טבלת qa_results
CREATE TABLE IF NOT EXISTS qa_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE,
case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
check_name TEXT NOT NULL,
passed BOOLEAN NOT NULL,
severity TEXT DEFAULT 'warning',
errors JSONB DEFAULT '[]',
details TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_qa_results_decision ON qa_results(decision_id);
CREATE INDEX IF NOT EXISTS idx_qa_results_case ON qa_results(case_id);
-- טבלת decision_definitions (אם לא קיימת)
CREATE TABLE IF NOT EXISTS decision_definitions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE,
term TEXT NOT NULL,
definition TEXT NOT NULL,
block_id TEXT DEFAULT 'block-he',
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_definitions_decision ON decision_definitions(decision_id);
-- טבלת appeal_type_rules (אם לא קיימת)
CREATE TABLE IF NOT EXISTS appeal_type_rules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
appeal_type TEXT NOT NULL,
rule_category TEXT NOT NULL,
rule_key TEXT NOT NULL,
rule_value JSONB NOT NULL,
description TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(appeal_type, rule_category, rule_key)
);
-- image_placeholders על decision_blocks
ALTER TABLE decision_blocks ADD COLUMN IF NOT EXISTS image_placeholders JSONB DEFAULT '[]';
"""
# ── Phase 2: Decision + Knowledge + RAG layers ────────────────────
SCHEMA_V2_SQL = """
-- ═══════════════════════════════════════════════════════════════════
-- Layer 2: Decision
-- ═══════════════════════════════════════════════════════════════════
-- decisions: מטאדטה של החלטה (גרסה אחת = רשומה אחת)
CREATE TABLE IF NOT EXISTS decisions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
version INTEGER DEFAULT 1,
status TEXT DEFAULT 'draft', -- draft/review/final/published
outcome TEXT DEFAULT '', -- rejected/accepted/partial
outcome_summary TEXT DEFAULT '', -- תמצית תוצאה (שורה אחת)
total_paragraphs INTEGER DEFAULT 0,
total_words INTEGER DEFAULT 0,
decision_date DATE,
author TEXT DEFAULT 'דפנה תמיר',
panel_members JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(case_id, version)
);
-- decision_blocks: 12 בלוקים לפי block-schema.md
CREATE TABLE IF NOT EXISTS decision_blocks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE,
block_id TEXT NOT NULL, -- block-alef, block-bet, ... block-yod-bet
block_index INTEGER NOT NULL, -- 1-12
title TEXT DEFAULT '', -- כותרת הבלוק (ריק לבלוקים ללא כותרת)
content TEXT DEFAULT '', -- תוכן מלא (markdown)
word_count INTEGER DEFAULT 0,
weight_percent NUMERIC(5,2) DEFAULT 0, -- משקל בפועל (%)
generation_type TEXT DEFAULT '', -- template-fill/reproduction/paraphrase/...
model_used TEXT DEFAULT '', -- sonnet/opus/script
temperature NUMERIC(3,2) DEFAULT 0,
status TEXT DEFAULT 'empty', -- empty/draft/review/final
notes TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(decision_id, block_id)
);
-- decision_paragraphs: סעיפים בודדים עם מעקב ציטוטים
CREATE TABLE IF NOT EXISTS decision_paragraphs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
block_id UUID REFERENCES decision_blocks(id) ON DELETE CASCADE,
paragraph_number INTEGER NOT NULL, -- מספור רציף בתוך ההחלטה
content TEXT NOT NULL,
word_count INTEGER DEFAULT 0,
citations JSONB DEFAULT '[]', -- [{case_law_id, text, type}]
cross_references JSONB DEFAULT '[]', -- הפניות לסעיפים אחרים ["סעיף 5 לעיל"]
created_at TIMESTAMPTZ DEFAULT now()
);
-- claims: טענות צדדים (בלוק ז)
CREATE TABLE IF NOT EXISTS claims (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
party_role TEXT NOT NULL, -- appellant/respondent/permit_applicant/committee
party_name TEXT DEFAULT '',
claim_text TEXT NOT NULL,
claim_index INTEGER DEFAULT 0, -- סדר הופעה
source_document TEXT DEFAULT '', -- מאיזה מסמך חולצה הטענה
addressed_in_paragraph INTEGER, -- באיזה סעיף בדיון נענתה
created_at TIMESTAMPTZ DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════════════
-- Layer 3: Legal Knowledge
-- ═══════════════════════════════════════════════════════════════════
-- case_law: פסיקה (תקדימים)
CREATE TABLE IF NOT EXISTS case_law (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_number TEXT UNIQUE NOT NULL, -- עע"מ 3975/22 או ערר 1011-03-25
case_name TEXT NOT NULL, -- שם קצר: "ב. קרן-נכסים"
court TEXT DEFAULT '', -- בג"ץ / עליון / מנהלי / ועדת ערר
date DATE,
subject_tags JSONB DEFAULT '[]', -- ["proprietary_claims", "parking"]
summary TEXT DEFAULT '', -- תמצית 2-3 משפטים
key_quote TEXT DEFAULT '', -- ציטוט מרכזי
full_text TEXT DEFAULT '', -- טקסט מלא אם זמין
source_url TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT now()
);
-- case_law_citations: קשרים בין פסיקה להחלטות שלנו
CREATE TABLE IF NOT EXISTS case_law_citations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE,
paragraph_id UUID REFERENCES decision_paragraphs(id) ON DELETE SET NULL,
citation_type TEXT DEFAULT 'support', -- support/distinguish/overrule/obiter
context_text TEXT DEFAULT '', -- ההקשר שבו צוטט
created_at TIMESTAMPTZ DEFAULT now()
);
-- statutory_provisions: חקיקה נפוצה
CREATE TABLE IF NOT EXISTS statutory_provisions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
statute_name TEXT NOT NULL, -- "חוק התכנון והבנייה"
section_number TEXT NOT NULL, -- "152(א)(2)"
section_title TEXT DEFAULT '', -- "זכות ערר"
full_text TEXT DEFAULT '', -- נוסח הסעיף
common_usage TEXT DEFAULT '', -- מתי משתמשים
subject_tags JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(statute_name, section_number)
);
-- transition_phrases: ביטויי מעבר של דפנה
CREATE TABLE IF NOT EXISTS transition_phrases (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
phrase TEXT UNIQUE NOT NULL, -- "ועל מנת לא לצאת בחסר"
usage_context TEXT DEFAULT '', -- מתי להשתמש
block_types JSONB DEFAULT '[]', -- באילו בלוקים: ["block-yod"]
frequency INTEGER DEFAULT 1, -- כמה פעמים ראינו
source_decision TEXT DEFAULT '', -- מאיזו החלטה
created_at TIMESTAMPTZ DEFAULT now()
);
-- lessons_learned: לקחים מהשוואת טיוטות לגרסאות סופיות
CREATE TABLE IF NOT EXISTS lessons_learned (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
lesson_title TEXT NOT NULL, -- "Discussion = continuous essay, no sub-headers"
lesson_text TEXT NOT NULL, -- תיאור מלא
category TEXT DEFAULT '', -- structure/style/content/process
applies_to JSONB DEFAULT '[]', -- ["block-yod", "all"]
source_case TEXT DEFAULT '', -- "הכט 1180-1181"
severity TEXT DEFAULT 'important', -- critical/important/nice-to-have
created_at TIMESTAMPTZ DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════════════
-- Layer 4: Extended RAG
-- ═══════════════════════════════════════════════════════════════════
-- paragraph_embeddings: embeddings של סעיפים בהחלטות
CREATE TABLE IF NOT EXISTS paragraph_embeddings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
paragraph_id UUID REFERENCES decision_paragraphs(id) ON DELETE CASCADE,
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now()
);
-- case_law_embeddings: embeddings של פסיקה
CREATE TABLE IF NOT EXISTS case_law_embeddings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
chunk_text TEXT NOT NULL,
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════════════
-- Indexes
-- ═══════════════════════════════════════════════════════════════════
CREATE INDEX IF NOT EXISTS idx_decisions_case ON decisions(case_id);
CREATE INDEX IF NOT EXISTS idx_decisions_status ON decisions(status);
CREATE INDEX IF NOT EXISTS idx_decision_blocks_decision ON decision_blocks(decision_id);
CREATE INDEX IF NOT EXISTS idx_decision_blocks_block_id ON decision_blocks(block_id);
CREATE INDEX IF NOT EXISTS idx_decision_paragraphs_block ON decision_paragraphs(block_id);
CREATE INDEX IF NOT EXISTS idx_claims_case ON claims(case_id);
CREATE INDEX IF NOT EXISTS idx_claims_role ON claims(party_role);
CREATE INDEX IF NOT EXISTS idx_case_law_subject ON case_law USING gin(subject_tags);
CREATE INDEX IF NOT EXISTS idx_case_law_citations_decision ON case_law_citations(decision_id);
CREATE INDEX IF NOT EXISTS idx_statutory_provisions_statute ON statutory_provisions(statute_name);
CREATE INDEX IF NOT EXISTS idx_transition_phrases_block ON transition_phrases USING gin(block_types);
CREATE INDEX IF NOT EXISTS idx_lessons_category ON lessons_learned(category);
CREATE INDEX IF NOT EXISTS idx_paragraph_embeddings_vec
ON paragraph_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);
CREATE INDEX IF NOT EXISTS idx_case_law_embeddings_vec
ON case_law_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);
"""
@@ -139,7 +386,9 @@ async def init_schema() -> None:
async with pool.acquire() as conn:
await conn.execute(SCHEMA_SQL)
await conn.execute(MIGRATIONS_SQL)
logger.info("Database schema initialized")
await conn.execute(SCHEMA_V2_SQL)
await conn.execute(SCHEMA_V3_SQL)
logger.info("Database schema initialized (v1 + v2 + v3)")
# ── Case CRUD ───────────────────────────────────────────────────────
@@ -307,6 +556,134 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
return d
# ── Claims ─────────────────────────────────────────────────────────
async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int:
"""Store extracted claims. Replaces existing claims from same source.
Each claim dict: party_role, claim_text, claim_index, party_name (optional)
"""
pool = await get_pool()
async with pool.acquire() as conn:
if source_document:
await conn.execute(
"DELETE FROM claims WHERE case_id = $1 AND source_document = $2",
case_id, source_document,
)
for claim in claims:
await conn.execute(
"""INSERT INTO claims (case_id, party_role, party_name, claim_text, claim_index, source_document)
VALUES ($1, $2, $3, $4, $5, $6)""",
case_id,
claim["party_role"],
claim.get("party_name", ""),
claim["claim_text"],
claim.get("claim_index", 0),
source_document,
)
return len(claims)
async def get_claims(case_id: UUID, party_role: str | None = None) -> list[dict]:
"""Get claims for a case, optionally filtered by party role."""
pool = await get_pool()
async with pool.acquire() as conn:
if party_role:
rows = await conn.fetch(
"SELECT * FROM claims WHERE case_id = $1 AND party_role = $2 ORDER BY claim_index",
case_id, party_role,
)
else:
rows = await conn.fetch(
"SELECT * FROM claims WHERE case_id = $1 ORDER BY party_role, claim_index",
case_id,
)
return [dict(r) for r in rows]
# ── Decisions ──────────────────────────────────────────────────────
async def create_decision(
case_id: UUID,
outcome: str = "",
outcome_summary: str = "",
outcome_reasoning: str = "",
direction_doc: dict | None = None,
) -> dict:
"""Create a decision record for a case."""
pool = await get_pool()
decision_id = uuid4()
async with pool.acquire() as conn:
# Check if a decision already exists for this case
existing = await conn.fetchrow(
"SELECT id, version FROM decisions WHERE case_id = $1 ORDER BY version DESC LIMIT 1",
case_id,
)
version = (existing["version"] + 1) if existing else 1
await conn.execute(
"""INSERT INTO decisions (id, case_id, version, outcome, outcome_summary,
outcome_reasoning, direction_doc)
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
decision_id, case_id, version, outcome, outcome_summary,
outcome_reasoning, json.dumps(direction_doc) if direction_doc else None,
)
return await get_decision(decision_id)
async def get_decision(decision_id: UUID) -> dict | None:
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT * FROM decisions WHERE id = $1", decision_id)
if not row:
return None
d = dict(row)
d["id"] = str(d["id"])
d["case_id"] = str(d["case_id"])
if isinstance(d.get("direction_doc"), str):
d["direction_doc"] = json.loads(d["direction_doc"])
if isinstance(d.get("panel_members"), str):
d["panel_members"] = json.loads(d["panel_members"])
return d
async def get_decision_by_case(case_id: UUID) -> dict | None:
"""Get the latest decision for a case."""
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM decisions WHERE case_id = $1 ORDER BY version DESC LIMIT 1",
case_id,
)
if not row:
return None
d = dict(row)
d["id"] = str(d["id"])
d["case_id"] = str(d["case_id"])
if isinstance(d.get("direction_doc"), str):
d["direction_doc"] = json.loads(d["direction_doc"])
if isinstance(d.get("panel_members"), str):
d["panel_members"] = json.loads(d["panel_members"])
return d
async def update_decision(decision_id: UUID, **fields) -> None:
if not fields:
return
pool = await get_pool()
set_clauses = []
values = []
for i, (key, val) in enumerate(fields.items(), start=2):
if key in ("direction_doc", "panel_members") and isinstance(val, (dict, list)):
val = json.dumps(val)
set_clauses.append(f"{key} = ${i}")
values.append(val)
set_clauses.append("updated_at = now()")
sql = f"UPDATE decisions SET {', '.join(set_clauses)} WHERE id = $1"
async with pool.acquire() as conn:
await conn.execute(sql, decision_id, *values)
# ── Chunks & Vectors ───────────────────────────────────────────────
async def store_chunks(
@@ -452,3 +829,104 @@ async def clear_style_patterns() -> None:
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM style_patterns")
# ── Semantic Search (V2 — decision blocks & case law) ─────────────
async def search_similar_paragraphs(
query_embedding: list[float],
limit: int = 10,
block_type: str | None = None,
) -> list[dict]:
"""Search decision paragraphs by semantic similarity."""
pool = await get_pool()
conditions = []
params: list = [query_embedding, limit]
param_idx = 3
if block_type:
conditions.append(f"db.block_id = ${param_idx}")
params.append(block_type)
param_idx += 1
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
sql = f"""
SELECT dp.content, dp.word_count, dp.paragraph_number,
db.block_id AS block_type, db.title AS block_title,
c.case_number, c.title AS case_title,
d.outcome, d.author,
1 - (pe.embedding <=> $1) AS score
FROM paragraph_embeddings pe
JOIN decision_paragraphs dp ON dp.id = pe.paragraph_id
JOIN decision_blocks db ON db.id = dp.block_id
JOIN decisions d ON d.id = db.decision_id
JOIN cases c ON c.id = d.case_id
{where}
ORDER BY pe.embedding <=> $1
LIMIT $2
"""
async with pool.acquire() as conn:
rows = await conn.fetch(sql, *params)
return [dict(r) for r in rows]
async def search_similar_case_law(
query_embedding: list[float],
limit: int = 5,
) -> list[dict]:
"""Search case law by semantic similarity."""
pool = await get_pool()
sql = """
SELECT cl.case_number, cl.case_name, cl.court, cl.summary,
cl.key_quote, cl.subject_tags,
cle.chunk_text,
1 - (cle.embedding <=> $1) AS score
FROM case_law_embeddings cle
JOIN case_law cl ON cl.id = cle.case_law_id
ORDER BY cle.embedding <=> $1
LIMIT $2
"""
async with pool.acquire() as conn:
rows = await conn.fetch(sql, query_embedding, limit)
results = []
for r in rows:
d = dict(r)
if isinstance(d.get("subject_tags"), str):
d["subject_tags"] = json.loads(d["subject_tags"])
results.append(d)
return results
async def search_precedents(
query_embedding: list[float],
limit: int = 10,
) -> list[dict]:
"""Combined search: paragraphs + case law, ranked by score."""
paragraphs = await search_similar_paragraphs(query_embedding, limit=limit)
case_law = await search_similar_case_law(query_embedding, limit=limit)
# Combine and sort by score
results = []
for p in paragraphs:
results.append({
"type": "decision_paragraph",
"score": float(p["score"]),
"case_number": p["case_number"],
"case_title": p["case_title"],
"block_type": p["block_type"],
"content": p["content"][:500],
"author": p["author"],
})
for c in case_law:
results.append({
"type": "case_law",
"score": float(c["score"]),
"case_number": c["case_number"],
"case_name": c["case_name"],
"court": c["court"],
"content": c["summary"],
})
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]

View File

@@ -0,0 +1,274 @@
"""ייצוא החלטת ועדת ערר ל-DOCX מעוצב.
דרישות: גופן David, RTL מלא, כותרות, מספור סעיפים רציף.
"""
from __future__ import annotations
import logging
import re
from datetime import date
from pathlib import Path
from uuid import UUID
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from legal_mcp import config
from legal_mcp.services import db
logger = logging.getLogger(__name__)
# ── Constants ─────────────────────────────────────────────────────
FONT_NAME = "David"
FONT_SIZE_BODY = Pt(12)
FONT_SIZE_TITLE = Pt(16)
FONT_SIZE_HEADING = Pt(14)
LINE_SPACING = 1.5
PAGE_MARGIN = Cm(2.5)
# ── RTL helpers ───────────────────────────────────────────────────
def _set_rtl_paragraph(paragraph) -> None:
"""Set paragraph-level RTL properties."""
pPr = paragraph._element.get_or_add_pPr()
bidi = OxmlElement("w:bidi")
bidi.set(qn("w:val"), "1")
pPr.append(bidi)
def _set_rtl_run(run) -> None:
"""Set run-level RTL properties."""
rPr = run._element.get_or_add_rPr()
rtl = OxmlElement("w:rtl")
rtl.set(qn("w:val"), "1")
rPr.append(rtl)
def _set_rtl_section(section) -> None:
"""Set section-level RTL (bidi)."""
sectPr = section._sectPr
bidi = OxmlElement("w:bidi")
bidi.set(qn("w:val"), "1")
sectPr.append(bidi)
def _add_paragraph(doc, text: str, style: str = "Normal",
bold: bool = False, font_size=None,
alignment=None, space_after: Pt | None = None) -> None:
"""Add an RTL paragraph with David font."""
para = doc.add_paragraph()
_set_rtl_paragraph(para)
if alignment:
para.alignment = alignment
else:
para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = para.add_run(text)
run.font.name = FONT_NAME
run.font.size = font_size or FONT_SIZE_BODY
run.bold = bold
_set_rtl_run(run)
# Line spacing
pf = para.paragraph_format
pf.line_spacing = LINE_SPACING
if space_after is not None:
pf.space_after = space_after
def _add_centered_paragraph(doc, text: str, bold: bool = True,
font_size=None) -> None:
"""Add centered RTL paragraph."""
_add_paragraph(doc, text, bold=bold, font_size=font_size,
alignment=WD_ALIGN_PARAGRAPH.CENTER)
def _add_blockquote(doc, text: str) -> None:
"""Add indented blockquote paragraph."""
para = doc.add_paragraph()
_set_rtl_paragraph(para)
para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
run = para.add_run(text)
run.font.name = FONT_NAME
run.font.size = Pt(11)
run.italic = True
_set_rtl_run(run)
pf = para.paragraph_format
pf.left_indent = Cm(1.5)
pf.right_indent = Cm(1.5)
pf.line_spacing = LINE_SPACING
def _add_image_placeholder(doc, description: str) -> None:
"""Add image placeholder box."""
_add_paragraph(doc, f"[{description}]",
alignment=WD_ALIGN_PARAGRAPH.CENTER,
font_size=Pt(10))
# ── Main export ───────────────────────────────────────────────────
async def export_decision(case_id: UUID, output_path: str | None = None) -> str:
"""ייצוא החלטה ל-DOCX.
Args:
case_id: מזהה התיק
output_path: נתיב לשמירה (אופציונלי)
Returns:
נתיב הקובץ שנוצר
"""
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
decision = await db.get_decision_by_case(case_id)
if not decision:
raise ValueError(f"No decision for case {case_id}")
# Get blocks
pool = await db.get_pool()
async with pool.acquire() as conn:
blocks = await conn.fetch(
"""SELECT block_id, block_index, title, content, word_count
FROM decision_blocks
WHERE decision_id = $1
ORDER BY block_index""",
UUID(decision["id"]),
)
if not blocks:
raise ValueError("No blocks in decision")
# Create document
doc = Document()
# Set page margins
for section in doc.sections:
section.top_margin = PAGE_MARGIN
section.bottom_margin = PAGE_MARGIN
section.left_margin = PAGE_MARGIN
section.right_margin = PAGE_MARGIN
_set_rtl_section(section)
# Write blocks
for block in blocks:
block_id = block["block_id"]
content = block["content"] or ""
if not content.strip():
continue
_write_block_to_docx(doc, block_id, block["title"], content)
# Determine output path
if not output_path:
case_dir = config.CASES_DIR / case["case_number"] / "output"
case_dir.mkdir(parents=True, exist_ok=True)
output_path = str(case_dir / f"החלטה-{case['case_number']}.docx")
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
doc.save(output_path)
logger.info("DOCX exported: %s", output_path)
return output_path
def _write_block_to_docx(doc, block_id: str, title: str, content: str) -> None:
"""Write a single block to the DOCX document."""
# Header blocks (א-ד)
if block_id == "block-alef":
for line in content.split("\n"):
if line.strip():
_add_centered_paragraph(doc, line.strip(), bold=True, font_size=FONT_SIZE_HEADING)
return
if block_id == "block-bet":
_add_paragraph(doc, "", space_after=Pt(6)) # spacer
for line in content.split("\n"):
if line.strip():
_add_centered_paragraph(doc, line.strip(), bold=False, font_size=FONT_SIZE_BODY)
return
if block_id == "block-gimel":
_add_paragraph(doc, "", space_after=Pt(6))
lines = content.split("\n")
for line in lines:
stripped = line.strip()
if not stripped:
continue
if stripped == "נגד":
_add_centered_paragraph(doc, "— נגד —", bold=True, font_size=FONT_SIZE_BODY)
else:
_add_centered_paragraph(doc, stripped, bold=False, font_size=FONT_SIZE_BODY)
return
if block_id == "block-dalet":
_add_paragraph(doc, "", space_after=Pt(12)) # spacer
_add_centered_paragraph(doc, "החלטה", bold=True, font_size=FONT_SIZE_TITLE)
_add_paragraph(doc, "", space_after=Pt(12))
return
if block_id == "block-yod-bet":
_add_paragraph(doc, "", space_after=Pt(24)) # spacer
for line in content.split("\n"):
if line.strip():
_add_centered_paragraph(doc, line.strip(), bold=False, font_size=FONT_SIZE_BODY)
return
# Content blocks (ה-יא) — parse paragraphs
paragraphs = content.split("\n")
for para_text in paragraphs:
stripped = para_text.strip()
if not stripped:
continue
# Section headings (e.g., "תמצית טענות הצדדים", "טענות העוררים")
if _is_section_heading(stripped):
_add_paragraph(doc, stripped, bold=True, font_size=FONT_SIZE_HEADING,
space_after=Pt(6))
continue
# Blockquotes (indented quotes from protocols/rulings)
if stripped.startswith('"') or stripped.startswith("״") or stripped.startswith(">"):
clean = stripped.lstrip(">").strip().strip('"').strip("״").strip('"')
_add_blockquote(doc, clean)
continue
# Image placeholders
if "📷" in stripped or stripped.startswith("[") and "תמונה" in stripped:
_add_image_placeholder(doc, stripped.strip("[]📷 "))
continue
# Regular numbered paragraph or plain text
_add_paragraph(doc, stripped)
def _is_section_heading(text: str) -> bool:
"""Detect section headings in decision text."""
heading_patterns = [
r"^תמצית\s+טענות",
r"^טענות\s+העוררי",
r"^עמדת\s+הוועדה",
r"^עמדת\s+מבקשי",
r"^ההליכים\s+בפני",
r"^דיון\s+והכרעה",
r"^סוף\s+דבר",
r"^סיכום",
r"^פתח\s+דבר",
r"^תכניות\s+חלות",
]
for pattern in heading_patterns:
if re.search(pattern, text):
return True
# Short bold-like lines (under 60 chars, not numbered)
if len(text) < 60 and not re.match(r"^\d+\.", text):
return False
return False

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", [])),
}

View File

@@ -0,0 +1,165 @@
"""מדדי הצלחה (KPIs) לתהליך כתיבת החלטות.
מדדים:
1. אחוז שינוי — השוואת טיוטה לגרסה סופית (יעד: <10%)
2. אפס הזיות — ספירת הפניות לא מבוססות
3. מענה לכל טענה — כיסוי טענות בדיון
4. משקלות בטווח — עמידה ביחסי הזהב
5. רקע ניטרלי — ללא מילות שיפוט
6. זמן עיבוד — מקליטה עד טיוטה
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from uuid import UUID
from legal_mcp.services import db
logger = logging.getLogger(__name__)
async def get_case_metrics(case_id: UUID) -> dict:
"""חישוב מדדים לתיק בודד."""
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
decision = await db.get_decision_by_case(case_id)
pool = await db.get_pool()
metrics = {
"case_number": case["case_number"],
"title": case.get("title", ""),
"status": case.get("status", ""),
}
# 1. Change percentage (if final version exists)
if decision and decision.get("status") == "final":
async with pool.acquire() as conn:
# Get draft word count
draft_words = await conn.fetchval(
"SELECT SUM(word_count) FROM decision_blocks WHERE decision_id = $1",
UUID(decision["id"]),
)
metrics["draft_words"] = draft_words or 0
# Change percent is stored during learning loop
metrics["change_percent"] = None # populated from learning_loop results
else:
metrics["draft_words"] = 0
metrics["change_percent"] = None
# 2. QA results
async with pool.acquire() as conn:
qa_rows = await conn.fetch(
"SELECT check_name, passed, severity, errors FROM qa_results WHERE case_id = $1",
case_id,
)
if qa_rows:
qa_results = {}
for row in qa_rows:
errors = json.loads(row["errors"]) if isinstance(row["errors"], str) else row["errors"]
qa_results[row["check_name"]] = {
"passed": row["passed"],
"severity": row["severity"],
"error_count": len(errors) if errors else 0,
}
metrics["qa"] = qa_results
metrics["qa_passed"] = all(r["passed"] for r in qa_results.values())
metrics["qa_critical_failures"] = sum(
1 for r in qa_results.values()
if not r["passed"] and r["severity"] == "critical"
)
else:
metrics["qa"] = None
metrics["qa_passed"] = None
# 3. Claims coverage
claims = await db.get_claims(case_id)
metrics["total_claims"] = len(claims)
# 4. Documents
docs = await db.list_documents(case_id)
metrics["total_documents"] = len(docs)
# 5. Processing time
if docs and decision:
first_doc_time = min(
d.get("created_at", datetime.max) for d in docs
if d.get("created_at")
)
decision_time = decision.get("created_at")
if first_doc_time and decision_time:
delta = decision_time - first_doc_time
metrics["processing_hours"] = round(delta.total_seconds() / 3600, 1)
else:
metrics["processing_hours"] = None
else:
metrics["processing_hours"] = None
return metrics
async def get_dashboard() -> dict:
"""דשבורד כולל — סיכום מדדים על כל התיקים."""
pool = await db.get_pool()
async with pool.acquire() as conn:
# Case counts by status
status_rows = await conn.fetch(
"SELECT status, COUNT(*) as cnt FROM cases GROUP BY status ORDER BY cnt DESC"
)
cases_by_status = {r["status"]: r["cnt"] for r in status_rows}
# Total counts
total_cases = await conn.fetchval("SELECT COUNT(*) FROM cases")
total_docs = await conn.fetchval("SELECT COUNT(*) FROM documents")
total_claims = await conn.fetchval("SELECT COUNT(*) FROM claims")
total_chunks = await conn.fetchval("SELECT COUNT(*) FROM document_chunks")
total_decisions = await conn.fetchval("SELECT COUNT(*) FROM decisions")
total_corpus = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
total_patterns = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
total_case_law = await conn.fetchval("SELECT COUNT(*) FROM case_law")
# QA summary
qa_total = await conn.fetchval("SELECT COUNT(DISTINCT case_id) FROM qa_results")
qa_passed = await conn.fetchval(
"""SELECT COUNT(DISTINCT case_id) FROM qa_results
WHERE case_id NOT IN (
SELECT case_id FROM qa_results WHERE passed = false AND severity = 'critical'
)"""
)
# Final decisions
final_count = await conn.fetchval(
"SELECT COUNT(*) FROM decisions WHERE status = 'final'"
)
# Average words per decision
avg_words = await conn.fetchval(
"SELECT AVG(total_words) FROM decisions WHERE total_words > 0"
)
return {
"summary": {
"total_cases": total_cases,
"total_documents": total_docs,
"total_claims": total_claims,
"total_chunks": total_chunks,
"total_decisions": total_decisions,
"final_decisions": final_count,
"style_corpus": total_corpus,
"style_patterns": total_patterns,
"case_law_entries": total_case_law,
},
"cases_by_status": cases_by_status,
"qa": {
"cases_validated": qa_total,
"cases_passed": qa_passed,
"pass_rate": round(qa_passed / qa_total * 100, 1) if qa_total else None,
},
"avg_decision_words": round(avg_words) if avg_words else None,
}

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from uuid import UUID
from legal_mcp.services import chunker, db, embeddings, extractor
from legal_mcp.services import chunker, classifier, db, embeddings, extractor, references_extractor
logger = logging.getLogger(__name__)
@@ -37,6 +37,26 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
page_count=page_count,
)
# Step 1.5: Classify document and identify parties
logger.info("Classifying document")
case_number = ""
if case_id:
case = await db.get_case(case_id)
if case:
case_number = case.get("case_number", "")
classification_result = await classifier.classify_and_identify(text, case_number)
await db.update_document(
document_id,
metadata=classification_result,
)
logger.info(
"Classification: %s (confidence: %.2f), parties found: %d appellants, %d respondents",
classification_result["classification"].get("doc_type", "?"),
classification_result["classification"].get("confidence", 0),
len(classification_result["parties"].get("appellants", [])),
len(classification_result["parties"].get("respondents", [])),
)
# Step 2: Chunk
logger.info("Chunking document (%d chars)", len(text))
chunks = chunker.chunk_document(text)
@@ -63,6 +83,18 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
]
stored = await db.store_chunks(document_id, case_id, chunk_dicts)
# Step 5: Extract references (plans, case law, legislation)
logger.info("Extracting legal references")
refs_result = await references_extractor.extract_and_link_references(
document_id, case_id, text,
)
logger.info(
"References found: %d plans, %d case law (%d linked), %d legislation",
refs_result["plans"], refs_result["case_law"],
refs_result["case_law_linked"], refs_result["legislation"],
)
await db.update_document(document_id, extraction_status="completed")
logger.info("Document processed: %d chunks stored", stored)
@@ -71,6 +103,12 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict:
"chunks": stored,
"pages": page_count,
"text_length": len(text),
"classification": classification_result,
"references": {
"plans": refs_result["plans"],
"case_law": refs_result["case_law"],
"legislation": refs_result["legislation"],
},
}
except Exception as e:

View File

@@ -0,0 +1,309 @@
"""בקרת איכות וולידציה של החלטות לפני ייצוא.
6 בדיקות:
1. neutral_background — רקע ניטרלי (ללא מילות שיפוט/ציטוטים מצדדים)
2. claims_coverage — כל טענה מבלוק ז נענתה בבלוק י
3. weight_compliance — משקלות בלוקים בטווח הנכון
4. structural_integrity — כל בלוקי חובה קיימים
5. no_duplication — אין כפילויות בין רקע לדיון
6. sequential_numbering — מספור רציף
אם בדיקה קריטית נכשלת → חוסם ייצוא.
"""
from __future__ import annotations
import json
import logging
import re
from uuid import UUID
from legal_mcp.services import db
logger = logging.getLogger(__name__)
# ── Value/judgment words forbidden in background ──────────────────
VALUE_WORDS = [
"חריג", "חטא", "בעייתי", "מזעזע", "שערורייתי", "מגוחך",
"נפשע", "פגום", "חמור", "מקומם", "בלתי סביר", "מופרז",
"מגונה", "פסול", "נלוז", "מטריד", "שגוי", "מוטעה",
]
QUOTE_INDICATORS = [
r"לטענת\s+(העוררי|המשיב|מבקשי)",
r"לדברי\s+(העוררי|המשיב|מבקשי)",
r"העורר\s+טוען",
r"המשיבה\s+טוענת",
r"לשיטת\s+(העוררי|המשיב)",
]
# ── Weight ranges ─────────────────────────────────────────────────
WEIGHT_RANGES = {
"licensing": {
"block-he": (0.5, 5), "block-vav": (3, 40), "block-zayin": (13, 40),
"block-chet": (0, 15), "block-tet": (0, 15),
"block-yod": (30, 75), "block-yod-alef": (1, 10),
},
"betterment": {
"block-he": (0, 5), "block-vav": (2, 20), "block-zayin": (15, 40),
"block-chet": (0, 25), "block-tet": (0, 15),
"block-yod": (25, 75), "block-yod-alef": (1, 10),
},
"compensation": {
"block-he": (0, 5), "block-vav": (2, 20), "block-zayin": (15, 40),
"block-chet": (0, 25), "block-tet": (0, 15),
"block-yod": (25, 75), "block-yod-alef": (1, 10),
},
}
# ── Individual checks ─────────────────────────────────────────────
def check_neutral_background(blocks: list[dict]) -> dict:
"""בדיקת ניטרליות בלוק הרקע (ו)."""
vav = next((b for b in blocks if b["block_id"] == "block-vav"), None)
if not vav or not vav.get("content"):
return {"name": "neutral_background", "passed": True, "errors": [], "severity": "critical"}
errors = []
lines = vav["content"].split("\n")
for i, line in enumerate(lines):
for word in VALUE_WORDS:
if word in line:
errors.append(f"מילת שיפוט (שורה {i+1}): \"{word}\"")
for pattern in QUOTE_INDICATORS:
if re.search(pattern, line):
errors.append(f"ציטוט מצד (שורה {i+1}): \"{line[:60]}...\"")
return {
"name": "neutral_background",
"passed": len(errors) == 0,
"errors": errors,
"severity": "warning",
}
def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
"""בדיקה שכל טענה מבלוק ז נענתה בבלוק י."""
yod = next((b for b in blocks if b["block_id"] == "block-yod"), None)
if not yod or not yod.get("content"):
return {"name": "claims_coverage", "passed": False,
"errors": ["בלוק דיון (י) ריק"], "severity": "critical"}
if not claims:
return {"name": "claims_coverage", "passed": True, "errors": [], "severity": "critical"}
yod_text = yod["content"].lower()
errors = []
for claim in claims:
claim_text = claim.get("claim_text", "")
# Extract key phrases (3+ word sequences) from claim
words = claim_text.split()
key_phrases = []
for j in range(0, len(words) - 2):
phrase = " ".join(words[j:j+3])
if len(phrase) > 8:
key_phrases.append(phrase.lower())
# Check if any key phrase appears in discussion
found = any(phrase in yod_text for phrase in key_phrases[:5])
if not found:
short = claim_text[:80]
errors.append(f"טענה לא נענתה: \"{short}...\"")
return {
"name": "claims_coverage",
"passed": len(errors) == 0,
"errors": errors,
"severity": "critical",
}
def check_weight_compliance(blocks: list[dict], appeal_type: str) -> dict:
"""בדיקת משקלות בלוקים בטווח."""
ranges = WEIGHT_RANGES.get(appeal_type, WEIGHT_RANGES["licensing"])
total_words = sum(b.get("word_count", 0) for b in blocks)
if total_words == 0:
return {"name": "weight_compliance", "passed": False,
"errors": ["אין תוכן בהחלטה"], "severity": "critical"}
errors = []
for block in blocks:
bid = block["block_id"]
wc = block.get("word_count", 0)
if bid in ranges and wc > 0:
weight = wc / total_words * 100
low, high = ranges[bid]
if weight < low:
errors.append(f"{block.get('title', bid)}: {weight:.1f}% (מינימום: {low}%)")
elif weight > high:
errors.append(f"{block.get('title', bid)}: {weight:.1f}% (מקסימום: {high}%)")
return {
"name": "weight_compliance",
"passed": len(errors) == 0,
"errors": errors,
"severity": "warning",
}
def check_structural_integrity(blocks: list[dict]) -> dict:
"""בדיקת מבנה — כל בלוקי חובה קיימים."""
required = {"block-he", "block-zayin", "block-yod", "block-yod-alef"}
present = {b["block_id"] for b in blocks if b.get("word_count", 0) > 0}
missing = required - present
errors = []
if missing:
block_names = {"block-he": "פתיחה (ה)", "block-zayin": "טענות (ז)",
"block-yod": "דיון (י)", "block-yod-alef": "סיכום (יא)"}
for m in missing:
errors.append(f"בלוק חובה חסר: {block_names.get(m, m)}")
# Check discussion is the heaviest content block
content_blocks = [b for b in blocks if b["block_id"] not in
("block-alef", "block-bet", "block-gimel", "block-dalet", "block-yod-bet")]
if content_blocks:
heaviest = max(content_blocks, key=lambda x: x.get("word_count", 0))
if heaviest["block_id"] != "block-yod":
errors.append(f"בלוק הדיון אינו הגדול ביותר — {heaviest.get('title', '')} גדול יותר")
return {
"name": "structural_integrity",
"passed": len(errors) == 0,
"errors": errors,
"severity": "critical",
}
def check_no_duplication(blocks: list[dict]) -> dict:
"""בדיקת כפילויות בין רקע לדיון."""
vav = next((b for b in blocks if b["block_id"] == "block-vav"), None)
yod = next((b for b in blocks if b["block_id"] == "block-yod"), None)
if not vav or not yod:
return {"name": "no_duplication", "passed": True, "errors": [], "severity": "warning"}
vav_text = vav.get("content", "")
yod_text = yod.get("content", "")
errors = []
# Find sentences from background repeated verbatim in discussion
sentences = [s.strip() for s in re.split(r'[.!?]', vav_text) if len(s.strip()) > 30]
for sent in sentences:
if sent in yod_text:
errors.append(f"כפילות: \"{sent[:60]}...\"")
return {
"name": "no_duplication",
"passed": len(errors) == 0,
"errors": errors,
"severity": "warning",
}
def check_sequential_numbering(blocks: list[dict]) -> dict:
"""בדיקת מספור רציף בין הבלוקים."""
errors = []
all_numbers = []
for block in blocks:
content = block.get("content", "")
# Find numbered paragraphs (e.g., "1.", "2.", "15.")
numbers = re.findall(r"^(\d+)\.", content, re.MULTILINE)
all_numbers.extend(int(n) for n in numbers)
if all_numbers:
# Check for gaps
sorted_nums = sorted(set(all_numbers))
for i in range(1, len(sorted_nums)):
if sorted_nums[i] - sorted_nums[i-1] > 1:
errors.append(f"פער במספור: {sorted_nums[i-1]}{sorted_nums[i]}")
# Check starts at 1
if sorted_nums and sorted_nums[0] != 1:
errors.append(f"מספור מתחיל מ-{sorted_nums[0]} במקום 1")
return {
"name": "sequential_numbering",
"passed": len(errors) == 0,
"errors": errors,
"severity": "warning",
}
# ── Main validation ───────────────────────────────────────────────
async def validate_decision(case_id: UUID) -> dict:
"""הרצת כל בדיקות QA על החלטה.
Returns:
dict עם passed (bool), results (list), critical_failures (int)
"""
case = await db.get_case(case_id)
if not case:
raise ValueError(f"Case {case_id} not found")
decision = await db.get_decision_by_case(case_id)
if not decision:
raise ValueError(f"No decision for case {case_id}")
# Get blocks
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT block_id, block_index, title, content, word_count
FROM decision_blocks WHERE decision_id = $1
ORDER BY block_index""",
UUID(decision["id"]),
)
blocks = [dict(r) for r in rows]
# Get claims
claims = await db.get_claims(case_id)
# Determine appeal type
appeal_type = case.get("appeal_type", "licensing")
# Run all checks
results = [
check_neutral_background(blocks),
check_claims_coverage(blocks, claims),
check_weight_compliance(blocks, appeal_type),
check_structural_integrity(blocks),
check_no_duplication(blocks),
check_sequential_numbering(blocks),
]
critical_failures = sum(1 for r in results if not r["passed"] and r["severity"] == "critical")
all_passed = all(r["passed"] for r in results)
# Store results in qa_results table
async with pool.acquire() as conn:
# Clear previous results
await conn.execute(
"DELETE FROM qa_results WHERE case_id = $1",
case_id,
)
for result in results:
await conn.execute(
"""INSERT INTO qa_results
(decision_id, case_id, check_name, passed, severity, errors, details)
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
UUID(decision["id"]), case_id,
result["name"], result["passed"], result["severity"],
json.dumps(result["errors"], ensure_ascii=False),
"",
)
return {
"passed": all_passed,
"critical_failures": critical_failures,
"export_blocked": critical_failures > 0,
"results": results,
"total_checks": len(results),
"passed_checks": sum(1 for r in results if r["passed"]),
}

View File

@@ -0,0 +1,200 @@
"""זיהוי תכניות, פסיקה וחקיקה במסמכים משפטיים.
שלוש קטגוריות:
1. תכניות (תב"ע, תמ"א, תכניות מקומיות/מחוזיות)
2. פסיקה (עע"מ, בג"ץ, ע"א, עררים)
3. חקיקה (חוק התכנון, חוק מיסוי מקרקעין, וכו')
"""
from __future__ import annotations
import json
import logging
import re
from uuid import UUID
from legal_mcp.services import db
logger = logging.getLogger(__name__)
# ── Regex patterns ────────────────────────────────────────────────────
# Plans (תכניות)
PLAN_PATTERNS = [
# תמ"א with number
re.compile(r'תמ"א\s*[\-]?\s*(\d+)(?:\s*[\-/]\s*(\S+))?'),
# תכנית מתאר with identifiers
re.compile(r'תכנית\s+(?:מתאר\s+)?(?:ארצית|מחוזית|מקומית)?\s*(?:מס[\'"]?\s*)?(\d[\d/\-\.]+\S*)'),
# תב"ע with identifiers
re.compile(r'תב"ע\s*(?:מס[\'"]?\s*)?(\S+)'),
# Specific plan number patterns (e.g., 62/3, ירושלים 12345)
re.compile(r'תכנית\s+(\S+\s*\d[\d/\-\.]+\S*)'),
]
# Case law (פסיקה)
CASE_LAW_PATTERNS = [
# Court types: עע"מ, בג"ץ, ע"א, בר"ם, עת"מ, עמ"נ, ע"ע, רע"א, דנ"א
re.compile(r'(עע"מ|בג"ץ|ע"א|בר"ם|עת"מ|עמ"נ|ע"ע|רע"א|דנ"א|בש"א)\s*(\d[\d/\-]+)'),
# ערר with district
re.compile(r'ערר\s*\(?\s*(מרכז|ירושלים|חי\'?פה?|ת"א|תל[- ]אביב|דרום|צפון)\s*\)?\s*(\d[\d/\-]+)'),
# ערר without district
re.compile(r'ערר\s+(\d{3,5}[\-/]\d{2,4})'),
]
# Legislation (חקיקה)
LEGISLATION_PATTERNS = [
re.compile(r'(חוק\s+התכנון\s+והבני[יה]ה)\s*[,،]?\s*(?:תשכ"ה[- ])?(?:1965)?(?:\s*[,،]\s*ס(?:עיף|\'\')\s*(\d+\S*))?'),
re.compile(r'(חוק\s+מיסוי\s+מקרקעין)\s*(?:\(שבח\s+ורכישה\))?\s*[,،]?\s*(?:תשכ"ג[- ])?(?:1963)?(?:\s*[,،]\s*ס(?:עיף|\'\')\s*(\d+\S*))?'),
re.compile(r'(תקנות\s+התכנון\s+והבני[יה]ה)\s*\(([^)]+)\)'),
re.compile(r'(חוק\s+ההתיישנות)\s*(?:תשי"ח[- ])?(?:1958)?'),
re.compile(r'ס(?:עיף|\'\')\s*(\d+\S*)\s*(?:ל|של)?(חוק\s+\S+(?:\s+\S+){0,3})'),
]
def extract_plans(text: str) -> list[dict]:
"""זיהוי תכניות במסמך."""
plans = []
seen = set()
for pattern in PLAN_PATTERNS:
for match in pattern.finditer(text):
full = match.group(0).strip()
if full in seen or len(full) < 4:
continue
seen.add(full)
start = max(0, match.start() - 60)
end = min(len(text), match.end() + 100)
context = text[start:end].replace("\n", " ").strip()
plans.append({
"plan_name": full,
"context": context,
})
return plans
def extract_case_law(text: str) -> list[dict]:
"""זיהוי פסיקה מצוטטת במסמך."""
citations = []
seen = set()
for pattern in CASE_LAW_PATTERNS:
for match in pattern.finditer(text):
full = match.group(0).strip()
if full in seen:
continue
seen.add(full)
start = max(0, match.start() - 50)
end = min(len(text), match.end() + 100)
context = text[start:end].replace("\n", " ").strip()
# Try to extract case name from context
case_name = ""
name_match = re.search(r'(?:עניין|פרשת|נ[\'"]?\s+)\s*(\S+(?:\s+\S+)?)', context)
if name_match:
case_name = name_match.group(1).strip()
citations.append({
"citation_text": full,
"case_name": case_name,
"context": context,
})
return citations
def extract_legislation(text: str) -> list[dict]:
"""זיהוי חקיקה מצוטטת במסמך."""
legislation = []
seen = set()
for pattern in LEGISLATION_PATTERNS:
for match in pattern.finditer(text):
full = match.group(0).strip()
if full in seen or len(full) < 5:
continue
seen.add(full)
start = max(0, match.start() - 40)
end = min(len(text), match.end() + 80)
context = text[start:end].replace("\n", " ").strip()
legislation.append({
"statute_text": full,
"context": context,
})
return legislation
def extract_all_references(text: str) -> dict:
"""זיהוי כל ההפניות במסמך: תכניות, פסיקה, חקיקה."""
return {
"plans": extract_plans(text),
"case_law": extract_case_law(text),
"legislation": extract_legislation(text),
}
async def extract_and_link_references(
document_id: UUID,
case_id: UUID,
text: str,
) -> dict:
"""זיהוי הפניות ושמירה ב-DB.
מזהה פסיקה וחקיקה, ומנסה לקשר לרשומות קיימות ב-case_law.
"""
refs = extract_all_references(text)
# Try to match case_law citations to existing DB records
pool = await db.get_pool()
linked = 0
async with pool.acquire() as conn:
# Get existing case_law for matching
case_laws = await conn.fetch("SELECT id, case_number, case_name FROM case_law")
case_law_map = {}
for cl in case_laws:
case_law_map[cl["case_number"]] = cl["id"]
parts = cl["case_number"].split()
if len(parts) > 1:
case_law_map[parts[-1]] = cl["id"]
for cit in refs["case_law"]:
case_law_id = None
for key, cl_id in case_law_map.items():
if key in cit["citation_text"] or cit["citation_text"] in key:
case_law_id = cl_id
break
cit["matched_in_db"] = case_law_id is not None
if case_law_id:
linked += 1
# Store references in document metadata
doc = await db.get_document(document_id)
if doc:
existing_metadata = doc.get("metadata") or {}
if isinstance(existing_metadata, str):
existing_metadata = json.loads(existing_metadata)
existing_metadata["references"] = {
"plans": [{"plan_name": p["plan_name"]} for p in refs["plans"]],
"case_law": [
{"citation": c["citation_text"], "case_name": c.get("case_name", ""), "in_db": c.get("matched_in_db", False)}
for c in refs["case_law"]
],
"legislation": [{"statute": l["statute_text"]} for l in refs["legislation"]],
}
await db.update_document(document_id, metadata=existing_metadata)
return {
"plans": len(refs["plans"]),
"case_law": len(refs["case_law"]),
"case_law_linked": linked,
"legislation": len(refs["legislation"]),
"details": refs,
}

View File

@@ -15,7 +15,7 @@ from legal_mcp.services import db, processor
async def document_upload(
case_number: str,
file_path: str,
doc_type: str = "appeal",
doc_type: str = "auto",
title: str = "",
) -> str:
"""העלאה ועיבוד מסמך לתיק ערר. מחלץ טקסט, יוצר chunks ו-embeddings.
@@ -23,7 +23,7 @@ async def document_upload(
Args:
case_number: מספר תיק הערר
file_path: נתיב מלא לקובץ (PDF, DOCX, RTF, TXT)
doc_type: סוג מסמך (appeal=כתב ערר, response=תשובה, decision=החלטה, reference=מסמך עזר, exhibit=נספח)
doc_type: סוג מסמך (auto=סיווג אוטומטי, appeal=כתב ערר, response=תשובה, protocol=פרוטוקול, plan=תכנית, permit=היתר, court_decision=פסק דין, decision=החלטת ועדה, appraisal=שומה, objection=התנגדות, exhibit=נספח, reference=מסמך עזר)
title: שם המסמך (אם ריק, ייקח משם הקובץ)
"""
case = await db.get_case_by_number(case_number)
@@ -44,17 +44,29 @@ async def document_upload(
dest = case_dir / source.name
shutil.copy2(str(source), str(dest))
# For auto classification, start with "reference" — will be updated after processing
initial_doc_type = doc_type if doc_type != "auto" else "reference"
# Create document record
doc = await db.create_document(
case_id=case_id,
doc_type=doc_type,
doc_type=initial_doc_type,
title=title,
file_path=str(dest),
)
# Process document (extract → chunk → embed → store)
# Process document (extract → classify → chunk → embed → store)
result = await processor.process_document(UUID(doc["id"]), case_id)
# If auto-classification, update doc_type from classification result
actual_doc_type = initial_doc_type
if doc_type == "auto" and result.get("classification"):
classified_type = result["classification"].get("classification", {}).get("doc_type", "")
if classified_type:
actual_doc_type = classified_type
await db.update_document(UUID(doc["id"]), doc_type=classified_type)
doc["doc_type"] = classified_type
# Git commit
repo_dir = config.CASES_DIR / case_number
if repo_dir.exists():
@@ -62,10 +74,16 @@ async def document_upload(
doc_type_hebrew = {
"appeal": "כתב ערר",
"response": "תשובה",
"protocol": "פרוטוקול",
"plan": "תכנית",
"permit": "היתר",
"court_decision": "פסק דין",
"decision": "החלטה",
"reference": "מסמך עזר",
"appraisal": "שומה",
"objection": "התנגדות",
"exhibit": "נספח",
}.get(doc_type, doc_type)
"reference": "מסמך עזר",
}.get(actual_doc_type, actual_doc_type)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
cwd=repo_dir,
@@ -216,3 +234,135 @@ async def document_list(case_number: str) -> str:
return f"אין מסמכים בתיק {case_number}."
return json.dumps(docs, default=str, ensure_ascii=False, indent=2)
async def extract_references(
case_number: str,
doc_title: str = "",
) -> str:
"""זיהוי תכניות, פסיקה וחקיקה מתוך מסמכי תיק.
Args:
case_number: מספר תיק הערר
doc_title: שם מסמך ספציפי (אם ריק, מזהה בכל המסמכים)
"""
from legal_mcp.services import references_extractor
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
docs = await db.list_documents(case_id)
if not docs:
return f"אין מסמכים בתיק {case_number}."
if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
results = []
for doc in docs:
text = await db.get_document_text(UUID(doc["id"]))
if not text:
continue
refs = await references_extractor.extract_and_link_references(
UUID(doc["id"]), case_id, text,
)
results.append({
"document": doc["title"],
"plans": refs["plans"],
"case_law": refs["case_law"],
"case_law_linked": refs["case_law_linked"],
"legislation": refs["legislation"],
})
return json.dumps(results, default=str, ensure_ascii=False, indent=2)
async def extract_claims(
case_number: str,
doc_title: str = "",
party_hint: str = "",
) -> str:
"""חילוץ טענות מכתב טענות בתיק ושמירה ב-DB.
Args:
case_number: מספר תיק הערר
doc_title: שם מסמך ספציפי (אם ריק, מחלץ מכל כתבי הטענות)
party_hint: שם הצד המגיש (אם ידוע)
"""
from legal_mcp.services import claims_extractor
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
docs = await db.list_documents(case_id)
if not docs:
return f"אין מסמכים בתיק {case_number}."
# Filter to claims documents (appeal, response) or specific doc
if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
else:
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
if not docs:
return "לא נמצאו כתבי טענות בתיק."
results = []
for doc in docs:
text = await db.get_document_text(UUID(doc["id"]))
if not text:
continue
result = await claims_extractor.extract_and_store_claims(
case_id=case_id,
document_id=UUID(doc["id"]),
text=text,
doc_type=doc["doc_type"],
party_hint=party_hint,
)
results.append(result)
return json.dumps(results, default=str, ensure_ascii=False, indent=2)
async def get_claims(case_number: str, party_role: str = "") -> str:
"""שליפת טענות שחולצו לתיק.
Args:
case_number: מספר תיק הערר
party_role: סינון לפי צד (appellant/respondent/committee/permit_applicant). ריק = הכל.
"""
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
claims = await db.get_claims(
UUID(case["id"]),
party_role=party_role if party_role else None,
)
if not claims:
return f"אין טענות בתיק {case_number}."
# Format for display
role_hebrew = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
"appraiser": "שמאי",
}
formatted = []
for c in claims:
formatted.append({
"party": role_hebrew.get(c["party_role"], c["party_role"]),
"claim": c["claim_text"],
"source": c.get("source_document", ""),
})
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2)

View File

@@ -332,9 +332,144 @@ async def get_decision_template(case_number: str) -> str:
return template
async def validate_decision(case_number: str) -> str:
"""בדיקת QA אוטומטית על ההחלטה — 6 בדיקות. אם נכשלת בדיקה קריטית — ייצוא חסום.
Args:
case_number: מספר תיק הערר
"""
from legal_mcp.services import qa_validator
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
try:
result = await qa_validator.validate_decision(case_id)
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
async def export_docx(case_number: str, output_path: str = "") -> str:
"""ייצוא החלטה לקובץ DOCX מעוצב — גופן David, RTL, כותרות, מספור סעיפים.
Args:
case_number: מספר תיק הערר
output_path: נתיב לשמירה (אופציונלי — ברירת מחדל: תיקיית התיק)
"""
from legal_mcp.services import docx_exporter
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
try:
path = await docx_exporter.export_decision(case_id, output_path or None)
return json.dumps({
"status": "completed",
"path": path,
"message": f"DOCX נוצר: {path}",
}, ensure_ascii=False, indent=2)
except ValueError as e:
return json.dumps({
"status": "error",
"message": str(e),
}, ensure_ascii=False, indent=2)
async def analyze_style() -> str:
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם."""
from legal_mcp.services.style_analyzer import analyze_corpus
result = await analyze_corpus()
return json.dumps(result, ensure_ascii=False, indent=2)
async def write_block(
case_number: str,
block_id: str,
instructions: str = "",
) -> str:
"""כתיבת בלוק יחיד בהחלטה. כותב ושומר ב-DB.
Args:
case_number: מספר תיק הערר
block_id: מזהה הבלוק: block-alef, block-bet, block-gimel, block-dalet, block-he, block-vav, block-zayin, block-chet, block-tet, block-yod, block-yod-alef, block-yod-bet
instructions: הנחיות נוספות לניסוח
"""
from legal_mcp.services import block_writer
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
try:
result = await block_writer.write_and_store_block(case_id, block_id, instructions)
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e:
return str(e)
async def write_all_blocks(
case_number: str,
start_from: str = "block-alef",
instructions: str = "",
) -> str:
"""כתיבת כל הבלוקים בהחלטה, בלוק אחרי בלוק. שומר כל בלוק מיד אחרי כתיבה.
Args:
case_number: מספר תיק הערר
start_from: מאיזה בלוק להתחיל (ברירת מחדל: block-alef)
instructions: הנחיות כלליות
"""
from legal_mcp.services import block_writer
from legal_mcp.services.block_writer import BLOCK_CONFIG
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
# Determine start index
start_idx = BLOCK_CONFIG.get(start_from, {}).get("index", 1)
results = []
block_order = sorted(BLOCK_CONFIG.items(), key=lambda x: x[1]["index"])
for bid, cfg in block_order:
if cfg["index"] < start_idx:
continue
try:
result = await block_writer.write_and_store_block(case_id, bid, instructions)
results.append({
"block_id": bid,
"title": result["title"],
"word_count": result["word_count"],
"status": "completed",
})
except ValueError as e:
results.append({
"block_id": bid,
"title": cfg["title"],
"status": "error",
"error": str(e),
})
# Stop on critical error (e.g., missing direction for block-yod)
if "כיוון מאושר" in str(e):
break
total_words = sum(r.get("word_count", 0) for r in results)
return json.dumps({
"blocks": results,
"total_words": total_words,
"completed": sum(1 for r in results if r["status"] == "completed"),
}, default=str, ensure_ascii=False, indent=2)

View File

@@ -1,4 +1,4 @@
"""MCP tools for workflow status tracking."""
"""MCP tools for workflow: status, outcome, brainstorming, direction."""
from __future__ import annotations
@@ -95,6 +95,25 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
return steps
async def get_metrics(case_number: str = "") -> str:
"""מדדי הצלחה — KPIs לתיק ספציפי או דשבורד כולל.
Args:
case_number: מספר תיק (אם ריק — דשבורד כולל)
"""
from legal_mcp.services import metrics
if case_number:
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
result = await metrics.get_case_metrics(UUID(case["id"]))
else:
result = await metrics.get_dashboard()
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
async def processing_status() -> str:
"""סטטוס כללי - מספר תיקים, מסמכים ממתינים לעיבוד."""
pool = await db.get_pool()
@@ -116,3 +135,186 @@ async def processing_status() -> str:
"style_corpus_entries": corpus_count,
"style_patterns": pattern_count,
}, ensure_ascii=False, indent=2)
# ── Outcome & Brainstorming ───────────────────────────────────────
async def set_outcome(
case_number: str,
outcome: str,
reasoning: str = "",
) -> str:
"""הזנת תוצאה לתיק ערר. יוצר רשומת החלטה ומפעיל סיעור מוחות אם אין נימוק.
Args:
case_number: מספר תיק הערר
outcome: תוצאה — rejected (דחייה), accepted (קבלה), partial (קבלה חלקית)
reasoning: נימוק (אופציונלי). אם ריק — מפעיל סיעור מוחות.
"""
from legal_mcp.services import brainstorm
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
valid_outcomes = ("rejected", "accepted", "partial")
if outcome not in valid_outcomes:
return f"תוצאה לא תקינה. אפשרויות: {', '.join(valid_outcomes)}"
case_id = UUID(case["id"])
# Create or update decision
existing = await db.get_decision_by_case(case_id)
if existing:
await db.update_decision(
UUID(existing["id"]),
outcome=outcome,
outcome_summary=reasoning[:200] if reasoning else "",
outcome_reasoning=reasoning,
)
decision = await db.get_decision(UUID(existing["id"]))
else:
decision = await db.create_decision(
case_id=case_id,
outcome=outcome,
outcome_summary=reasoning[:200] if reasoning else "",
outcome_reasoning=reasoning,
)
# Update case status
await db.update_case(case_id, status="in_progress", expected_outcome=outcome)
outcome_hebrew = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, outcome)
result = {
"decision_id": decision["id"],
"outcome": outcome,
"outcome_hebrew": outcome_hebrew,
"reasoning": reasoning,
"has_reasoning": bool(reasoning),
}
if not reasoning:
result["message"] = "לא סופק נימוק. הפעל /brainstorm כדי לקבל כיוונים אפשריים."
result["next_step"] = "brainstorm"
else:
result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה."
result["next_step"] = "draft"
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
async def brainstorm_directions(
case_number: str,
) -> str:
"""סיעור מוחות — הצגת טענות מרכזיות והצעת 2-3 כיוונים אפשריים לנימוק.
Args:
case_number: מספר תיק הערר
"""
from legal_mcp.services import brainstorm
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
# Get existing decision for outcome
decision = await db.get_decision_by_case(case_id)
if not decision:
return "לא הוזנה תוצאה לתיק. הפעל set_outcome קודם."
outcome = decision.get("outcome", "")
reasoning = decision.get("outcome_reasoning", "")
directions = await brainstorm.generate_directions(case_id, outcome, reasoning)
# Save brainstorm results to decision
await db.update_decision(
UUID(decision["id"]),
direction_doc={"brainstorm": directions, "approved": False},
)
return json.dumps(directions, default=str, ensure_ascii=False, indent=2)
async def approve_direction(
case_number: str,
direction_index: int = 0,
additional_notes: str = "",
) -> str:
"""אישור כיוון — יוצר מסמך כיוון מאושר. לא ניתן להתחיל כתיבת דיון בלי כיוון מאושר.
Args:
case_number: מספר תיק הערר
direction_index: מספר הכיוון שנבחר (0 = ראשון)
additional_notes: הערות נוספות
"""
from legal_mcp.services import brainstorm
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
decision = await db.get_decision_by_case(case_id)
if not decision:
return "לא הוזנה תוצאה לתיק."
direction_data = decision.get("direction_doc") or {}
brainstorm_result = direction_data.get("brainstorm", {})
if not brainstorm_result.get("directions"):
return "לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם."
direction_doc = brainstorm.build_direction_doc(
outcome=decision.get("outcome", ""),
reasoning=decision.get("outcome_reasoning", ""),
directions_result=brainstorm_result,
selected_direction=direction_index,
additional_notes=additional_notes,
)
await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc)
return json.dumps({
"status": "approved",
"message": "כיוון אושר. ניתן להתחיל כתיבת טיוטה.",
"direction": direction_doc,
}, default=str, ensure_ascii=False, indent=2)
async def ingest_final_version(
case_number: str,
file_path: str = "",
final_text: str = "",
) -> str:
"""קליטת גרסה סופית (שדפנה חתמה). משווה לטיוטה ומחלצת לקחים.
Args:
case_number: מספר תיק הערר
file_path: נתיב לקובץ הגרסה הסופית (PDF/DOCX)
final_text: טקסט ישיר (אם אין קובץ)
"""
from legal_mcp.services import learning_loop
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
# Extract text from file if provided
if file_path and not final_text:
from legal_mcp.services import extractor
final_text, _ = await extractor.extract_text(file_path)
if not final_text:
return "לא סופק טקסט — יש לספק file_path או final_text."
try:
result = await learning_loop.process_final_version(case_id, final_text)
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)

56
scripts/backup-db.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
# גיבוי יומי אוטומטי למסד הנתונים PostgreSQL
# Usage: ./backup-db.sh [backup_dir]
# Cron: 0 2 * * * /home/chaim/legal-ai/scripts/backup-db.sh
set -euo pipefail
BACKUP_DIR="${1:-/home/chaim/legal-ai/data/backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/legal_ai_${TIMESTAMP}.sql.gz"
# Load DB credentials from .env
source "${HOME}/.env" 2>/dev/null || true
DB_HOST="${POSTGRES_HOST:-127.0.0.1}"
DB_PORT="${POSTGRES_PORT:-5433}"
DB_NAME="${POSTGRES_DB:-legal_ai}"
DB_USER="${POSTGRES_USER:-legal_ai}"
# Create backup directory
mkdir -p "${BACKUP_DIR}"
echo "[$(date)] Starting backup: ${BACKUP_FILE}"
# Dump and compress
PGPASSWORD="${POSTGRES_PASSWORD:-}" pg_dump \
-h "${DB_HOST}" \
-p "${DB_PORT}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
--format=custom \
--compress=9 \
-f "${BACKUP_FILE%.gz}"
gzip "${BACKUP_FILE%.gz}" 2>/dev/null || true
echo "[$(date)] Backup completed: ${BACKUP_FILE}"
# Verify backup
if [ -f "${BACKUP_FILE}" ]; then
SIZE=$(du -h "${BACKUP_FILE}" | cut -f1)
echo "[$(date)] Backup size: ${SIZE}"
else
# pg_dump --format=custom already compresses
BACKUP_FILE="${BACKUP_FILE%.gz}"
SIZE=$(du -h "${BACKUP_FILE}" | cut -f1)
echo "[$(date)] Backup size: ${SIZE} (custom format)"
fi
# Cleanup: keep last 30 days
find "${BACKUP_DIR}" -name "legal_ai_*.sql*" -mtime +30 -delete 2>/dev/null
echo "[$(date)] Old backups cleaned (>30 days)"
# Count remaining backups
COUNT=$(find "${BACKUP_DIR}" -name "legal_ai_*" | wc -l)
echo "[$(date)] Total backups: ${COUNT}"

69
scripts/restore-db.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# שחזור מסד נתונים מגיבוי
# Usage: ./restore-db.sh <backup_file>
set -euo pipefail
if [ $# -lt 1 ]; then
echo "שימוש: ./restore-db.sh <backup_file>"
echo ""
echo "גיבויים זמינים:"
ls -lhrt /home/chaim/legal-ai/data/backups/legal_ai_* 2>/dev/null || echo " (אין גיבויים)"
exit 1
fi
BACKUP_FILE="$1"
if [ ! -f "${BACKUP_FILE}" ]; then
echo "קובץ לא נמצא: ${BACKUP_FILE}"
exit 1
fi
# Load DB credentials
source "${HOME}/.env" 2>/dev/null || true
DB_HOST="${POSTGRES_HOST:-127.0.0.1}"
DB_PORT="${POSTGRES_PORT:-5433}"
DB_NAME="${POSTGRES_DB:-legal_ai}"
DB_USER="${POSTGRES_USER:-legal_ai}"
echo "⚠️ שחזור מגיבוי: ${BACKUP_FILE}"
echo " מסד נתונים: ${DB_NAME}@${DB_HOST}:${DB_PORT}"
echo ""
read -p "האם להמשיך? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "בוטל."
exit 0
fi
# Determine file format
if [[ "${BACKUP_FILE}" == *.gz ]]; then
echo "[$(date)] Decompressing..."
gunzip -k "${BACKUP_FILE}"
RESTORE_FILE="${BACKUP_FILE%.gz}"
else
RESTORE_FILE="${BACKUP_FILE}"
fi
echo "[$(date)] Restoring..."
PGPASSWORD="${POSTGRES_PASSWORD:-}" pg_restore \
-h "${DB_HOST}" \
-p "${DB_PORT}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
--clean \
--if-exists \
--no-owner \
"${RESTORE_FILE}" 2>&1 || true
echo "[$(date)] ✅ שחזור הושלם"
# Verify
PGPASSWORD="${POSTGRES_PASSWORD:-}" psql \
-h "${DB_HOST}" \
-p "${DB_PORT}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
-c "SELECT COUNT(*) as cases FROM cases; SELECT COUNT(*) as documents FROM documents;" 2>/dev/null || true