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:
166
mcp-server/src/legal_mcp/cli.py
Normal file
166
mcp-server/src/legal_mcp/cli.py
Normal 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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
76
mcp-server/src/legal_mcp/services/audit.py
Normal file
76
mcp-server/src/legal_mcp/services/audit.py
Normal 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]
|
||||
573
mcp-server/src/legal_mcp/services/block_writer.py
Normal file
573
mcp-server/src/legal_mcp/services/block_writer.py
Normal 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
|
||||
206
mcp-server/src/legal_mcp/services/brainstorm.py
Normal file
206
mcp-server/src/legal_mcp/services/brainstorm.py
Normal 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,
|
||||
}
|
||||
260
mcp-server/src/legal_mcp/services/claims_extractor.py
Normal file
260
mcp-server/src/legal_mcp/services/claims_extractor.py
Normal 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,
|
||||
}
|
||||
259
mcp-server/src/legal_mcp/services/classifier.py
Normal file
259
mcp-server/src/legal_mcp/services/classifier.py
Normal 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,
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
274
mcp-server/src/legal_mcp/services/docx_exporter.py
Normal file
274
mcp-server/src/legal_mcp/services/docx_exporter.py
Normal 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
|
||||
187
mcp-server/src/legal_mcp/services/learning_loop.py
Normal file
187
mcp-server/src/legal_mcp/services/learning_loop.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""לולאת למידה — השוואת טיוטה לגרסה סופית וחילוץ לקחים.
|
||||
|
||||
שלב 7 באיפיון:
|
||||
1. קליטת גרסה סופית (שדפנה חתמה)
|
||||
2. השוואת טיוטה לסופית — זיהוי שינויים
|
||||
3. חילוץ לקחים: ביטויים חדשים, דפוסים שהשתנו, שגיאות חוזרות
|
||||
4. עדכון מודל הסגנון
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
import anthropic
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_anthropic_client: anthropic.Anthropic | None = None
|
||||
|
||||
|
||||
def _get_anthropic() -> anthropic.Anthropic:
|
||||
global _anthropic_client
|
||||
if _anthropic_client is None:
|
||||
_anthropic_client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY)
|
||||
return _anthropic_client
|
||||
|
||||
|
||||
def compute_diff_stats(draft_text: str, final_text: str) -> dict:
|
||||
"""חישוב סטטיסטיקות השוואה בין טיוטה לסופית."""
|
||||
draft_words = draft_text.split()
|
||||
final_words = final_text.split()
|
||||
|
||||
draft_len = len(draft_words)
|
||||
final_len = len(final_words)
|
||||
|
||||
# Simple word-level diff (not a full diff algorithm, but good enough for stats)
|
||||
draft_set = set(draft_words)
|
||||
final_set = set(final_words)
|
||||
|
||||
common = draft_set & final_set
|
||||
added = final_set - draft_set
|
||||
removed = draft_set - final_set
|
||||
|
||||
# Estimate change percentage
|
||||
if draft_len == 0:
|
||||
change_pct = 100.0
|
||||
else:
|
||||
change_pct = (len(added) + len(removed)) / max(draft_len, final_len) * 100
|
||||
|
||||
return {
|
||||
"draft_words": draft_len,
|
||||
"final_words": final_len,
|
||||
"change_percent": round(change_pct, 1),
|
||||
"words_added": len(added),
|
||||
"words_removed": len(removed),
|
||||
"words_common": len(common),
|
||||
}
|
||||
|
||||
|
||||
LESSONS_PROMPT = """אתה מנתח שינויים בהחלטות משפטיות. קיבלת טיוטה (שנוצרה ע"י AI) וגרסה סופית (שעברה עריכת דפנה).
|
||||
|
||||
## משימה:
|
||||
1. זהה את השינויים המהותיים (לא הקלדה/פורמט)
|
||||
2. סווג כל שינוי:
|
||||
- expression_change — ביטוי שהוחלף (הצע כלקח לעתיד)
|
||||
- structure_change — שינוי מבני (סדר, חלוקה)
|
||||
- content_addition — תוכן שנוסף (מה חסר?)
|
||||
- content_removal — תוכן שהוסר (מה מיותר?)
|
||||
- tone_change — שינוי טון (רשמי יותר/פחות)
|
||||
- error_fix — תיקון שגיאה עובדתית/משפטית
|
||||
3. הסק לקחים שניתן להפעיל בהחלטות עתידיות
|
||||
|
||||
## פלט JSON:
|
||||
{
|
||||
"changes": [
|
||||
{"type": "...", "description": "תיאור השינוי", "draft_text": "...", "final_text": "...", "lesson": "לקח לעתיד"}
|
||||
],
|
||||
"new_expressions": ["ביטוי חדש שדפנה הוסיפה"],
|
||||
"overall_assessment": "הערכה כללית (1-2 משפטים)"
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
async def analyze_changes(draft_text: str, final_text: str) -> dict:
|
||||
"""ניתוח שינויים בין טיוטה לגרסה סופית עם Claude."""
|
||||
# Truncate for context window
|
||||
max_chars = 15000
|
||||
draft_sample = draft_text[:max_chars]
|
||||
final_sample = final_text[:max_chars]
|
||||
|
||||
client = _get_anthropic()
|
||||
message = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": f"""{LESSONS_PROMPT}
|
||||
|
||||
--- טיוטה ---
|
||||
{draft_sample}
|
||||
|
||||
--- גרסה סופית ---
|
||||
{final_sample}
|
||||
""",
|
||||
}],
|
||||
)
|
||||
|
||||
raw = message.content[0].text.strip()
|
||||
try:
|
||||
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
|
||||
if json_match:
|
||||
return json.loads(json_match.group())
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse lessons response")
|
||||
return {"changes": [], "new_expressions": [], "overall_assessment": raw[:200]}
|
||||
|
||||
|
||||
async def process_final_version(
|
||||
case_id: UUID,
|
||||
final_text: str,
|
||||
) -> dict:
|
||||
"""קליטת גרסה סופית, השוואה לטיוטה, חילוץ לקחים.
|
||||
|
||||
Args:
|
||||
case_id: מזהה התיק
|
||||
final_text: טקסט הגרסה הסופית
|
||||
|
||||
Returns:
|
||||
dict עם diff stats, changes, lessons
|
||||
"""
|
||||
decision = await db.get_decision_by_case(case_id)
|
||||
if not decision:
|
||||
raise ValueError(f"No decision for case {case_id}")
|
||||
|
||||
# Get draft text (combine all blocks)
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT content FROM decision_blocks
|
||||
WHERE decision_id = $1 AND word_count > 0
|
||||
ORDER BY block_index""",
|
||||
UUID(decision["id"]),
|
||||
)
|
||||
draft_text = "\n\n".join(r["content"] for r in rows if r["content"])
|
||||
|
||||
if not draft_text:
|
||||
raise ValueError("No draft content to compare")
|
||||
|
||||
# Compute stats
|
||||
diff_stats = compute_diff_stats(draft_text, final_text)
|
||||
|
||||
# Analyze changes with AI
|
||||
analysis = await analyze_changes(draft_text, final_text)
|
||||
|
||||
# Store new expressions as style patterns
|
||||
for expr in analysis.get("new_expressions", []):
|
||||
if expr and len(expr) > 3:
|
||||
await db.upsert_style_pattern(
|
||||
pattern_type="characteristic_phrase",
|
||||
pattern_text=expr,
|
||||
context="למד מגרסה סופית",
|
||||
)
|
||||
|
||||
# Update decision status
|
||||
await db.update_decision(
|
||||
UUID(decision["id"]),
|
||||
status="final",
|
||||
)
|
||||
|
||||
# Update case status
|
||||
case = await db.get_case(case_id)
|
||||
if case:
|
||||
await db.update_case(case_id, status="final")
|
||||
|
||||
return {
|
||||
"diff_stats": diff_stats,
|
||||
"analysis": analysis,
|
||||
"lessons_count": len(analysis.get("changes", [])),
|
||||
"new_expressions": len(analysis.get("new_expressions", [])),
|
||||
}
|
||||
165
mcp-server/src/legal_mcp/services/metrics.py
Normal file
165
mcp-server/src/legal_mcp/services/metrics.py
Normal 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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
309
mcp-server/src/legal_mcp/services/qa_validator.py
Normal file
309
mcp-server/src/legal_mcp/services/qa_validator.py
Normal 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"]),
|
||||
}
|
||||
200
mcp-server/src/legal_mcp/services/references_extractor.py
Normal file
200
mcp-server/src/legal_mcp/services/references_extractor.py
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
56
scripts/backup-db.sh
Executable 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
69
scripts/restore-db.sh
Executable 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
|
||||
Reference in New Issue
Block a user