Files
legal-ai/mcp-server/src/legal_mcp/server.py
Chaim 3e14cd6798 feat: link related precedents across court instances (SCHEMA_V11)
Add ability to mark case_law records as related (e.g. same appeal
through ועדת ערר → מנהלי → עליון):
- DB: case_law_relations join table (bidirectional, V11 migration)
- DB CRUD: add/remove/get_case_law_relations
- Service: get_precedent() now returns related_cases[]
- MCP: precedent_link_cases + precedent_unlink_cases tools
- REST: POST/DELETE /api/precedent-library/{id}/relations
- UI: RelatedCasesSection on detail page with search dialog and unlink

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:52:29 +00:00

695 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ezer Mishpati - MCP Server entry point.
Run with: python -m legal_mcp.server
"""
from __future__ import annotations
import logging
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from mcp.server.fastmcp import FastMCP
# Configure logging to stderr (stdout is reserved for JSON-RPC)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
stream=sys.stderr,
)
logger = logging.getLogger("legal_mcp")
@asynccontextmanager
async def lifespan(server: FastMCP) -> AsyncIterator[None]:
"""Server startup is now non-blocking.
Schema init was moved out of the lifespan to fix a race where Claude Code
would call a tool before `tools/list` had been answered — manifesting as
"No such tool available". Lifespan now returns immediately so the MCP
handshake completes in milliseconds; the schema is initialized lazily on
the first DB access via services/db.get_pool().
"""
from legal_mcp.services.db import close_pool
logger.info("Ezer Mishpati MCP server ready (schema init deferred)")
try:
yield
finally:
await close_pool()
logger.info("Ezer Mishpati MCP server stopped")
# Create MCP server
mcp = FastMCP(
"Ezer Mishpati - עוזר משפטי",
instructions="מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר",
lifespan=lifespan,
)
# ── Import and register tools ───────────────────────────────────────
from legal_mcp.tools import ( # noqa: E402
cases, documents, search, drafting, workflow, precedents,
precedent_library as plib,
)
# Case management
@mcp.tool()
async def case_create(
case_number: str,
title: str,
appellants: list[str] | None = None,
respondents: list[str] | None = None,
subject: str = "",
property_address: str = "",
permit_number: str = "",
committee_type: str = "ועדה מקומית",
hearing_date: str = "",
notes: str = "",
expected_outcome: str = "",
) -> str:
"""יצירת תיק ערר חדש. expected_outcome: rejection/partial_acceptance/full_acceptance/betterment_levy."""
return await cases.case_create(
case_number, title, appellants, respondents,
subject, property_address, permit_number, committee_type,
hearing_date, notes, expected_outcome,
)
@mcp.tool()
async def case_list(status: str = "", limit: int = 50) -> str:
"""רשימת תיקי ערר. סינון אופציונלי לפי סטטוס (new/in_progress/drafted/reviewed/final)."""
return await cases.case_list(status, limit)
@mcp.tool()
async def case_get(case_number: str) -> str:
"""פרטי תיק מלאים כולל רשימת מסמכים."""
return await cases.case_get(case_number)
@mcp.tool()
async def case_update(
case_number: str,
status: str = "",
title: str = "",
subject: str = "",
notes: str = "",
hearing_date: str = "",
decision_date: str = "",
tags: list[str] | None = None,
expected_outcome: str = "",
) -> str:
"""עדכון פרטי תיק. expected_outcome: rejection/partial_acceptance/full_acceptance/betterment_levy."""
return await cases.case_update(
case_number, status, title, subject, notes,
hearing_date, decision_date, tags, expected_outcome,
)
@mcp.tool()
async def case_delete(case_number: str, remove_files: bool = False) -> str:
"""מחיקת תיק ערר. קבצים בדיסק נשארים אלא אם remove_files=true."""
return await cases.case_delete(case_number, remove_files)
@mcp.tool()
async def case_get_final_text(case_number: str, max_chars: int = 0) -> str:
"""קליטת טקסט ההחלטה הסופית (`סופי-{case}.docx` בתיקיית exports).
max_chars: 0=הכל, אחרת חיתוך לאורך הנתון. שימושי ל-Hermes Knowledge Curator."""
return await cases.case_get_final_text(case_number, max_chars)
# Precedent attachments (user-supplied legal support for the compose phase)
@mcp.tool()
async def precedent_attach(
case_number: str,
quote: str,
citation: str,
section_id: str = "",
chair_note: str = "",
pdf_document_id: str = "",
) -> str:
"""צירוף פסיקה תומכת לתיק. section_id ריק = כללי לתיק; אחרת threshold_1/issue_3."""
return await precedents.precedent_attach(
case_number, quote, citation, section_id, chair_note, pdf_document_id,
)
@mcp.tool()
async def precedent_list(case_number: str) -> str:
"""רשימת כל הפסיקות שצורפו לתיק."""
return await precedents.precedent_list(case_number)
@mcp.tool()
async def precedent_remove(precedent_id: str) -> str:
"""הסרת פסיקה מצורפת."""
return await precedents.precedent_remove(precedent_id)
@mcp.tool()
async def precedent_search_library(
query: str, practice_area: str = "", limit: int = 10,
) -> str:
"""חיפוש בציטוטים שדפנה צירפה ידנית לתיקים בעבר (case_precedents).
שונה מ-search_precedent_library שמחפש בקורפוס הפסיקה הסמכותית."""
return await precedents.precedent_search_library(query, practice_area, limit)
# ── External Precedent Library — authoritative case-law corpus ─────
# Distinct from precedent_search_library above (chair-attached quotes)
# and from search_decisions (Daphna's style corpus).
@mcp.tool()
async def precedent_library_upload(
file_path: str,
citation: str,
case_name: str = "",
court: str = "",
decision_date: str = "",
source_type: str = "",
precedent_level: str = "",
practice_area: str = "",
appeal_subtype: str = "",
subject_tags: list[str] | None = None,
is_binding: bool = True,
headnote: str = "",
summary: str = "",
) -> str:
"""העלאת פסיקה חיצונית (פס"ד / החלטה של ועדה אחרת) לקורפוס הסמכותי. מחלץ הלכות אוטומטית — כולן ממתינות לאישור היו"ר. practice_area: rishuy_uvniya / betterment_levy / compensation_197."""
return await plib.precedent_library_upload(
file_path, citation, case_name, court, decision_date,
source_type, precedent_level, practice_area, appeal_subtype,
subject_tags, is_binding, headnote, summary,
)
@mcp.tool()
async def precedent_library_list(
practice_area: str = "",
court: str = "",
precedent_level: str = "",
source_type: str = "",
search: str = "",
limit: int = 100,
) -> str:
"""רשימת הפסיקה בקורפוס הסמכותי, עם פילטרים."""
return await plib.precedent_library_list(
practice_area, court, precedent_level, source_type, search, limit,
)
@mcp.tool()
async def precedent_library_get(case_law_id: str) -> str:
"""פסיקה ספציפית בקורפוס + רשימת ההלכות שחולצו ממנה (כולל ממתינות לאישור)."""
return await plib.precedent_library_get(case_law_id)
@mcp.tool()
async def precedent_library_delete(case_law_id: str) -> str:
"""מחיקת פסיקה מהקורפוס (cascade: chunks + halachot)."""
return await plib.precedent_library_delete(case_law_id)
@mcp.tool()
async def precedent_link_cases(
case_law_id_a: str,
case_law_id_b: str,
relation_type: str = "same_case_chain",
) -> str:
"""קישור שתי פסיקות כקשורות (דו-כיווני, idempotent). relation_type: same_case_chain | overruled_by | distinguished."""
return await plib.precedent_link_cases(case_law_id_a, case_law_id_b, relation_type)
@mcp.tool()
async def precedent_unlink_cases(case_law_id_a: str, case_law_id_b: str) -> str:
"""הסרת קישור בין שתי פסיקות (דו-כיווני)."""
return await plib.precedent_unlink_cases(case_law_id_a, case_law_id_b)
@mcp.tool()
async def precedent_extract_halachot(case_law_id: str) -> str:
"""הרצה מחדש של חילוץ הלכות לפסיקה קיימת. ההלכות הקיימות נמחקות, החדשות חוזרות לסטטוס pending_review."""
return await plib.precedent_extract_halachot(case_law_id)
@mcp.tool()
async def precedent_extract_metadata(case_law_id: str) -> str:
"""חילוץ מטא-דאטה (case_name קצר, summary, headnote, key_quote, subject_tags, appeal_subtype, date, level, court, source_type) מהטקסט. ממלא רק שדות ריקים."""
return await plib.precedent_extract_metadata(case_law_id)
@mcp.tool()
async def precedent_process_pending(kind: str = "metadata", limit: int = 20) -> str:
"""ריקון תור בקשות חילוץ שנשלחו מ-UI. kind: 'metadata' או 'halacha'. מריץ extractor מקומית עם CLI על כל פריט בתור, ומנקה את הסימון אחרי הצלחה."""
return await plib.precedent_process_pending(kind, limit)
@mcp.tool()
async def search_precedent_library(
query: str,
practice_area: str = "",
court: str = "",
precedent_level: str = "",
appeal_subtype: str = "",
subject_tag: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> str:
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית. מחזיר הלכות (מאושרות בלבד) + קטעי טקסט. השתמש כש-legal-writer צריך לצטט פסיקה מחייבת בבלוק י (CREAC: rule + explanation)."""
return await plib.search_precedent_library(
query, practice_area, court, precedent_level, appeal_subtype,
None, subject_tag, limit, include_halachot,
)
@mcp.tool()
async def halacha_review(
halacha_id: str,
status: str,
reviewer: str = "דפנה",
rule_statement: str = "",
reasoning_summary: str = "",
subject_tags: list[str] | None = None,
practice_areas: list[str] | None = None,
) -> str:
"""אישור / דחייה / עריכה של הלכה שחולצה אוטומטית. status: pending_review / approved / rejected / published."""
return await plib.halacha_review(
halacha_id, status, reviewer, rule_statement, reasoning_summary,
subject_tags, practice_areas,
)
@mcp.tool()
async def halachot_pending(limit: int = 100) -> str:
"""תור ההלכות הממתינות לאישור."""
return await plib.halachot_pending(limit)
# Documents
@mcp.tool()
async def document_upload(
case_number: str,
file_path: str,
doc_type: str = "appeal",
title: str = "",
) -> str:
"""העלאה ועיבוד מסמך לתיק ערר (PDF/DOCX/RTF/TXT). מחלץ טקסט ויוצר embeddings."""
return await documents.document_upload(case_number, file_path, doc_type, title)
@mcp.tool()
async def document_upload_training(
file_path: str,
decision_number: str = "",
decision_date: str = "",
subject_categories: list[str] | None = None,
title: str = "",
practice_area: str = "appeals_committee",
appeal_subtype: str = "",
) -> str:
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון. קטגוריות: בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197. סוג ערר: building_permit / betterment_levy / compensation_197 (ריק = אוטומטי ממספר ההחלטה)."""
return await documents.document_upload_training(
file_path, decision_number, decision_date, subject_categories, title,
practice_area, appeal_subtype,
)
@mcp.tool()
async def document_get_text(case_number: str, doc_title: str = "") -> str:
"""קבלת טקסט מלא של מסמך מתוך תיק."""
return await documents.document_get_text(case_number, doc_title)
@mcp.tool()
async def document_list(case_number: str) -> str:
"""רשימת מסמכים בתיק."""
return await documents.document_list(case_number)
@mcp.tool()
async def document_update(
case_number: str,
doc_id: str,
doc_type: str = "",
appraiser_side: str = "",
) -> str:
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side (committee/appellant/deciding). ריק = ללא שינוי."""
return await documents.document_update(case_number, doc_id, doc_type, appraiser_side)
# 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(
query: str,
limit: int = 10,
section_type: str = "",
practice_area: str = "",
appeal_subtype: str = "",
case_number: str = "",
) -> str:
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי."""
return await search.search_decisions(
query, limit, section_type, practice_area, appeal_subtype, case_number,
)
@mcp.tool()
async def search_case_documents(
case_number: str,
query: str,
limit: int = 10,
) -> str:
"""חיפוש סמנטי בתוך מסמכי תיק ספציפי."""
return await search.search_case_documents(case_number, query, limit)
@mcp.tool()
async def find_similar_cases(
description: str,
limit: int = 5,
practice_area: str = "",
appeal_subtype: str = "",
case_number: str = "",
) -> str:
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי."""
return await search.find_similar_cases(
description, limit, practice_area, appeal_subtype, case_number,
)
@mcp.tool()
async def search_internal_decisions(
query: str,
practice_area: str = "",
appeal_subtype: str = "",
district: str = "",
chair_name: str = "",
limit: int = 10,
include_halachot: bool = True,
) -> str:
"""חיפוש בהחלטות ועדות ערר לתכנון ובנייה (כל המחוזות).
מחזיר החלטות מהקורפוס הפנימי של ועדות הערר — נפרד מפסיקת בתי המשפט.
השתמש בו במקביל ל-search_precedent_library להצגת שתי שכבות נפרדות.
Args:
query: שאילתת חיפוש בעברית
practice_area: rishuy_uvniya / betterment_levy / compensation_197
appeal_subtype: סינון לפי תת-סוג ערר
district: מחוז — ירושלים / מרכז / תל אביב / צפון / דרום / ארצי. ריק = כל המחוזות
chair_name: שם יו"ר הוועדה לסינון. ריק = כל היו"רים
limit: מספר תוצאות מקסימלי
include_halachot: האם לכלול הלכות שחולצו
"""
return await search.search_internal_decisions(
query, practice_area, appeal_subtype, district, chair_name, limit, include_halachot,
)
# Drafting
@mcp.tool()
async def get_style_guide() -> str:
"""שליפת דפוסי הסגנון של דפנה - נוסחאות, ביטויים אופייניים ומבנה."""
return await drafting.get_style_guide()
@mcp.tool()
async def draft_section(
case_number: str,
section: str,
instructions: str = "",
) -> str:
"""הרכבת הקשר מלא לניסוח סעיף (עובדות + תקדימים + סגנון)."""
return await drafting.draft_section(case_number, section, instructions)
@mcp.tool()
async def get_chair_directions(case_number: str) -> str:
"""שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר כ-direction_doc לכותב.
קורא מ-analysis-and-research.md שמולא ע"י דפנה דרך ה-UI.
מחזיר סטטוס (missing/empty/partial/complete) + עמדות מובנות.
"""
return await drafting.get_chair_directions(case_number)
@mcp.tool()
async def get_decision_template(case_number: str) -> str:
"""תבנית מבנית להחלטה מלאה עם פרטי התיק."""
return await drafting.get_decision_template(case_number)
@mcp.tool()
async def get_block_context(
case_number: str,
block_id: str,
instructions: str = "",
) -> str:
"""קבלת הקשר מלא לכתיבת בלוק — ללא API. Claude Code כותב ושומר."""
return await drafting.get_block_context(case_number, block_id, instructions)
@mcp.tool()
async def save_block_content(
case_number: str,
block_id: str,
content: str,
) -> str:
"""שמירת בלוק שנכתב ע"י Claude Code ב-DB."""
return await drafting.save_block_content(case_number, block_id, content)
@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 extract_appraiser_facts(case_number: str) -> str:
"""חילוץ תכניות והיתרים מכל השומות בתיק וזיהוי סתירות בין שמאים. הכנה לטיוטת ביניים."""
return await drafting.extract_appraiser_facts(case_number)
@mcp.tool()
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
"""כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט."""
return await drafting.write_interim_draft(case_number, instructions)
@mcp.tool()
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
"""ייצוא טיוטת ביניים ל-DOCX — סדר חדש (רקע → תכניות+היתרים → טענות → הליכים), ללא דיון/סיכום."""
return await drafting.export_interim_draft(case_number, output_path)
@mcp.tool()
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
return await drafting.apply_user_edit(case_number, edit_filename)
@mcp.tool()
async def list_bookmarks(case_number: str) -> str:
"""רשימת bookmarks הקיימים ב-active_draft של התיק (אנקורים ל-revisions)."""
return await drafting.list_bookmarks(case_number)
@mcp.tool()
async def revise_draft(case_number: str, revisions_json: str,
author: str = "מערכת AI") -> str:
"""החלת revisions (Track Changes) על ה-active_draft, יוצר טיוטה-v{N+1}.docx חדשה."""
return await drafting.revise_draft(case_number, revisions_json, author)
@mcp.tool()
async def analyze_style(appeal_subtype: str = "") -> str:
"""ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה. סוג ערר: building_permit / betterment_levy / compensation_197 (ריק = הכל)."""
return await drafting.analyze_style(appeal_subtype)
@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:
"""סטטוס תהליך עבודה מלא לתיק."""
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)
@mcp.tool()
async def internal_decision_migrate(
source: str = "both",
dry_run: bool = True,
) -> str:
"""העברת החלטות ועדת ערר קיימות לקורפוס הפנימי (פעולת admin).
source: 'style_corpus' | 'external_corpus' | 'both'
dry_run: אם true — מציג מה יקרה ללא כתיבה
"""
import json as _json
from legal_mcp.services import internal_decisions as int_svc
if source not in {"style_corpus", "external_corpus", "both"}:
return "source חייב להיות style_corpus / external_corpus / both"
results: dict = {}
if source in {"style_corpus", "both"}:
results["style_corpus"] = await int_svc.migrate_from_style_corpus(dry_run=dry_run)
if source in {"external_corpus", "both"}:
results["external_corpus"] = await int_svc.migrate_from_external_corpus(dry_run=dry_run)
return _json.dumps(results, ensure_ascii=False, indent=2)
@mcp.tool()
async def internal_decision_enrich(
dry_run: bool = True,
) -> str:
"""העשרת החלטות שהומגרו (חד-פעמי): תיקון מספר ערר + שם + תאריך + תור להלכות.
dry_run=True — מציג כמה רשומות יטופלו ללא כתיבה.
dry_run=False — מריץ בפועל: metadata extraction (תיקון case_number/case_name/date) ואחר כך תור חילוץ הלכות.
"""
import json as _json
from legal_mcp.services import internal_decisions as int_svc
result = await int_svc.enrich_migrated_entries(dry_run=dry_run)
return _json.dumps(result, ensure_ascii=False, indent=2)
@mcp.tool()
async def record_chair_feedback(
case_number: str,
feedback_text: str,
block_id: str = "block-yod",
category: str = "missing_content",
lesson_extracted: str = "",
) -> str:
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה — חסר, שגיאה, סגנון."""
return await workflow.record_chair_feedback(
case_number, feedback_text, block_id, category, lesson_extracted,
)
@mcp.tool()
async def list_chair_feedback(
case_number: str = "",
category: str = "",
unresolved_only: bool = True,
) -> str:
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
return await workflow.list_chair_feedback(case_number, category, unresolved_only)
def main():
mcp.run(transport="stdio")
if __name__ == "__main__":
main()