On main: Pre-merge: synced agent files

This commit is contained in:
2026-04-13 12:42:00 +00:00
16 changed files with 933 additions and 94 deletions

View File

@@ -204,6 +204,16 @@ async def get_claims(
return await documents.get_claims(case_number, party_role)
@mcp.tool()
async def document_update_status(
case_number: str,
doc_title: str,
status: str,
) -> str:
"""עדכון סטטוס עיבוד מסמך. status: pending/extracted/proofread/error."""
return await documents.document_update_status(case_number, doc_title, status)
# References
@mcp.tool()
async def extract_references(
@@ -261,6 +271,22 @@ async def draft_section(
return await drafting.draft_section(case_number, section, instructions)
@mcp.tool()
async def get_research_findings(case_number: str) -> str:
"""שליפת ממצאי מחקר — סיכומי פסיקה, מיפוי תכניות, ציר זמן, והמלצות.
קורא מ-research-findings.md שנוצר ע"י חוקר התקדימים.
"""
return await drafting.get_research_findings(case_number)
@mcp.tool()
async def get_full_analysis(case_number: str) -> str:
"""שליפת הניתוח המשפטי המלא — טענות, תשובות, חוזקות/חולשות, שאלות מחקר,
חקיקה, תקדימים ועמדות יו"ר. הכלי המרכזי לכותב לפני כתיבת בלוק י.
"""
return await drafting.get_full_analysis(case_number)
@mcp.tool()
async def get_chair_directions(case_number: str) -> str:
"""שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר כ-direction_doc לכותב.
@@ -296,6 +322,15 @@ async def save_block_content(
return await drafting.save_block_content(case_number, block_id, content)
@mcp.tool()
async def get_decision_blocks(
case_number: str,
block_id: str = "",
) -> str:
"""שליפת בלוקים שנכתבו — תוכן, מילים, משקלות. ריק = כל הבלוקים."""
return await drafting.get_decision_blocks(case_number, block_id)
@mcp.tool()
async def validate_decision(case_number: str) -> str:
"""בדיקת QA — 6 בדיקות איכות על ההחלטה. אם בדיקה קריטית נכשלת — ייצוא חסום."""

View File

@@ -525,14 +525,31 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
text = r["key_quote"] or r["summary"] or ""
if text:
parts.append(
f"סיקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
f"<EFBFBD><EFBFBD>יקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
f"score={r['score']:.3f}\n{text[:400]}"
)
# Search 3: case_precedents (user-attached quotes by Daphna)
# These are hand-picked citations — highest priority.
attached = await db.list_case_precedents(case_id)
if attached:
parts.insert(0, "## תקדימים שצורפו ידנית ע\"י יו\"ר הוועדה\n")
for i, prec in enumerate(attached):
section = prec.get("section_id") or "כללי"
citation = prec.get("citation", "")
quote = prec.get("quote", "")
note = prec.get("chair_note", "")
entry = f"[תקדים מצורף #{i+1} — סוגיה: {section}] {citation}"
if quote:
entry += f"\nציטוט: {quote[:600]}"
if note:
entry += f"\nהערת יו\"ר: {note}"
parts.insert(i + 1, entry)
except Exception as e:
logger.warning("Failed to fetch precedents: %s", e)
return "\n\n".join(parts) if parts else "(אין תקדימים)"
return "\n\n".join(parts) if parts else "(אין תקד<EFBFBD><EFBFBD>מים)"
async def _build_style_context() -> str:

View File

@@ -26,6 +26,13 @@ CHAIR_POSITION_PLACEHOLDERS = (
"[טרם מולא]",
)
# Any text starting with these prefixes is also a placeholder
# (the analyst sometimes adds explanatory text after the bracket)
CHAIR_POSITION_PLACEHOLDER_PREFIXES = (
"[ימולא",
"ימולא ע",
)
CHAIR_POSITION_LABEL = "עמדת ועדת הערר"
# Matches "## N. title" or "## title" for main sections
@@ -47,6 +54,9 @@ CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILIN
DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE)
RESEARCH_FINDINGS_FILENAME = "research-findings.md"
def _is_placeholder(text: str) -> bool:
"""Check if a field value is one of the placeholder strings (empty)."""
stripped = text.strip()
@@ -55,6 +65,9 @@ def _is_placeholder(text: str) -> bool:
for ph in CHAIR_POSITION_PLACEHOLDERS:
if ph in stripped:
return True
for prefix in CHAIR_POSITION_PLACEHOLDER_PREFIXES:
if stripped.startswith(prefix):
return True
return False
@@ -434,3 +447,199 @@ def extract_chair_directions(file_path: Path) -> dict[str, Any]:
"threshold_claims": threshold,
"issues": issues,
}
# ── Full analysis extraction (for legal-writer) ──────────────────
# Map Hebrew field labels → stable English keys for JSON output
_FIELD_KEY_MAP = {
"טענה": "claims",
"טענה (claim)": "claims",
"טענות": "claims",
"תשובה": "responses",
"תשובה (response)": "responses",
"תשובות": "responses",
"תגובה": "replies",
"תגובה (reply)": "replies",
"תגובות": "replies",
# Analyst sometimes appends party name to the label
# e.g. "תגובה (reply — קובר)" — catch the pattern dynamically below
"ניתוח אסטרטגי": "strategic_analysis",
"חוזקות": "strengths",
"חולשות": "weaknesses",
"הזדמנויות": "opportunities",
"שאלות משפטיות": "legal_questions",
"חיפוש תקדימים": "precedent_search",
"חקיקה רלוונטית": "relevant_legislation",
"תקדימים מהקורפוס הפנימי": "internal_precedents",
}
def _fields_to_dict(fields: list[dict]) -> dict[str, str]:
"""Convert ordered field list to a dict with stable English keys.
Unknown labels are kept as-is (Hebrew) so no data is lost.
Handles dynamic labels like "תגובה (reply — קובר)" by matching prefix.
"""
result: dict[str, str] = {}
for f in fields:
label = f["label"]
key = _FIELD_KEY_MAP.get(label)
if key is None:
# Try prefix matching for dynamic labels (e.g. "תגובה (reply — name)")
if label.startswith("תגובה"):
key = "replies"
elif label.startswith("טענה"):
key = "claims"
elif label.startswith("תשובה"):
key = "responses"
else:
key = label
result[key] = f["content"]
return result
def extract_full_analysis(file_path: Path) -> dict[str, Any]:
"""Extract the complete strategic analysis from analysis-and-research.md.
Unlike extract_chair_directions (which returns only chair positions),
this returns ALL fields per issue: claims, responses, replies,
strengths/weaknesses/opportunities, legal questions, legislation,
and internal precedents — everything the legal-writer needs to
produce block-yod (discussion).
Returns the same envelope as extract_chair_directions (status, counts)
plus full field data in each item.
"""
if not file_path.exists():
return {
"file_exists": False,
"status": "missing",
"error": "analysis-and-research.md not found",
"procedural_background": "",
"agreed_facts": "",
"disputed_facts": "",
"conclusions": "",
"threshold_claims": [],
"issues": [],
"total_items": 0,
"filled_count": 0,
"empty_count": 0,
}
parsed = parse(file_path)
def enrich_item(item: dict) -> dict:
"""Return full item with all fields as a flat dict."""
enriched = {
"id": item["id"],
"number": item["number"],
"title": item["title"],
"direction": item.get("chair_position", "") or "",
}
# Add all extracted fields with stable keys
enriched.update(_fields_to_dict(item.get("fields", [])))
return enriched
threshold = [enrich_item(t) for t in parsed.get("threshold_claims", [])]
issues = [enrich_item(i) for i in parsed.get("issues", [])]
all_items = threshold + issues
total = len(all_items)
filled = sum(1 for x in all_items if x["direction"].strip())
empty = total - filled
if total == 0:
status = "missing"
elif filled == 0:
status = "empty"
elif filled == total:
status = "complete"
else:
status = "partial"
return {
"file_exists": True,
"file_path": str(file_path),
"case_number": parsed.get("header", {}).get("case_number", ""),
"modified_at": parsed.get("header", {}).get("modified_at", ""),
"status": status,
"total_items": total,
"filled_count": filled,
"empty_count": empty,
"procedural_background": parsed.get("procedural_background", ""),
"agreed_facts": parsed.get("agreed_facts", ""),
"disputed_facts": parsed.get("disputed_facts", ""),
"conclusions": parsed.get("conclusions", ""),
"threshold_claims": threshold,
"issues": issues,
}
# ── Research findings extraction ──────────────────────────────────
def extract_research_findings(file_path: Path) -> dict[str, Any]:
"""Extract structured research findings from research-findings.md.
The file is produced by the legal-researcher agent and contains:
precedent summaries, plan mappings, timeline, and recommendations.
Returns a structured dict or a status-only dict if file is missing.
"""
if not file_path.exists():
return {
"file_exists": False,
"status": "missing",
"error": "research-findings.md not found",
}
content = file_path.read_text(encoding="utf-8")
stat = file_path.stat()
mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat()
sections = _split_main_sections(content)
result: dict[str, Any] = {
"file_exists": True,
"file_path": str(file_path),
"modified_at": mtime_iso,
"file_size": stat.st_size,
"precedent_summaries": [],
"plan_mappings": [],
"timeline": "",
"recommendations": "",
"other_sections": [],
}
for _number, title, body in sections:
title_norm = title.strip()
if "סיכום פסיקה" in title_norm or "פסיקה" in title_norm:
subs = _split_subsections(body)
for sub_title, sub_body in subs:
fields = _extract_fields(sub_body)
result["precedent_summaries"].append({
"title": sub_title,
"fields": {f["label"]: f["content"] for f in fields},
"raw": sub_body if not fields else "",
})
elif "מיפוי תכנית" in title_norm or "תכנית" in title_norm:
subs = _split_subsections(body)
for sub_title, sub_body in subs:
fields = _extract_fields(sub_body)
result["plan_mappings"].append({
"title": sub_title,
"fields": {f["label"]: f["content"] for f in fields},
"raw": sub_body if not fields else "",
})
elif "ציר זמן" in title_norm:
result["timeline"] = body
elif "המלצות" in title_norm:
result["recommendations"] = body
else:
result["other_sections"].append({
"title": title_norm,
"body": body,
})
return result

View File

@@ -383,3 +383,49 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
})
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2)
async def document_update_status(
case_number: str,
doc_title: str,
status: str,
) -> str:
"""עדכון סטטוס עיבוד מסמך (extraction_status).
ערכים אפשריים: pending, extracted, proofread, error.
Args:
case_number: מספר תיק הערר
doc_title: שם/כותרת המסמך (או חלק ממנו)
status: הסטטוס החדש
"""
valid_statuses = ("pending", "extracted", "proofread", "error")
if status not in valid_statuses:
return f"סטטוס לא חוקי: {status}. ערכים אפשריים: {', '.join(valid_statuses)}"
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
docs = await db.get_documents(case_id)
# Find matching document by title (partial match)
matched = None
for d in docs:
if doc_title.lower() in d.get("title", "").lower():
matched = d
break
if not matched:
titles = [d.get("title", "?") for d in docs]
return f"מסמך '{doc_title}' לא נמצא בתיק {case_number}. מסמכים קיימים: {titles}"
doc_id = UUID(matched["id"])
await db.update_document(doc_id, extraction_status=status)
return json.dumps({
"updated": True,
"document": matched["title"],
"new_status": status,
}, ensure_ascii=False, indent=2)

View File

@@ -281,6 +281,39 @@ async def draft_section(
return json.dumps(context, ensure_ascii=False, indent=2)
async def get_research_findings(case_number: str) -> str:
"""שליפת ממצאי מחקר — סיכומי פסיקה, מיפוי תכניות, ציר זמן, והמלצות.
קורא מ-research-findings.md שנוצר ע"י סוכן חוקר התקדימים (legal-researcher).
מחזיר JSON מובנה עם הממצאים, או status=missing אם הקובץ לא קיים עדיין.
Args:
case_number: מספר תיק הערר
"""
case_dir = config.find_case_dir(case_number)
file_path = case_dir / "documents" / "research" / research_md.RESEARCH_FINDINGS_FILENAME
result = research_md.extract_research_findings(file_path)
return json.dumps(result, ensure_ascii=False, indent=2)
async def get_full_analysis(case_number: str) -> str:
"""שליפת הניתוח המשפטי המלא מ-analysis-and-research.md — כולל טענות, תשובות,
תגובות, ניתוח אסטרטגי (חוזקות/חולשות/הזדמנויות), שאלות מחקר, חקיקה רלוונטית,
תקדימים פנימיים, ועמדות יו"ר הוועדה.
זה הכלי המרכזי שכותב ההחלטה צריך לקרוא **לפני** כתיבת בלוק י (דיון).
מחזיר JSON מובנה עם כל השדות שהניתוח המשפטי הפיק, ולא רק עמדות כמו
get_chair_directions.
Args:
case_number: מספר תיק הערר
"""
case_dir = config.find_case_dir(case_number)
file_path = case_dir / "documents" / "research" / "analysis-and-research.md"
result = research_md.extract_full_analysis(file_path)
return json.dumps(result, ensure_ascii=False, indent=2)
async def get_chair_directions(case_number: str) -> str:
"""שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר, לצורך יצירת direction_doc
לכותב. קורא מ-analysis-and-research.md (שנוצר ע"י legal-analyst ומולא ע"י
@@ -454,6 +487,71 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s
return str(e)
async def get_decision_blocks(case_number: str, block_id: str = "") -> str:
"""שליפת בלוקים שנכתבו בהחלטה — תוכן, ספירת מילים, משקלות.
אם block_id ריק — מחזיר את כל הבלוקים. אם מצוין — רק בלוק ספציפי.
שימושי לבודק איכות (QA) שצריך לקרוא בלוקים בודדים.
Args:
case_number: מספר תיק הערר
block_id: מזהה בלוק (ריק = כולם). למשל: block-yod, block-vav
"""
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 f"אין החלטה בתיק {case_number}."
decision_id = UUID(decision["id"])
pool = await db.get_pool()
async with pool.acquire() as conn:
if block_id:
rows = await conn.fetch(
"SELECT block_id, block_index, title, content, word_count, "
"weight_percent, status FROM decision_blocks "
"WHERE decision_id = $1 AND block_id = $2 "
"ORDER BY block_index",
decision_id, block_id,
)
else:
rows = await conn.fetch(
"SELECT block_id, block_index, title, content, word_count, "
"weight_percent, status FROM decision_blocks "
"WHERE decision_id = $1 ORDER BY block_index",
decision_id,
)
if not rows:
if block_id:
return f"בלוק {block_id} לא נמצא בהחלטה."
return "אין בלוקים בהחלטה."
blocks = []
total_words = 0
for r in rows:
total_words += r["word_count"] or 0
blocks.append({
"block_id": r["block_id"],
"index": r["block_index"],
"title": r["title"],
"content": r["content"],
"word_count": r["word_count"],
"weight_percent": float(r["weight_percent"] or 0),
"status": r["status"],
})
return json.dumps({
"case_number": case_number,
"total_blocks": len(blocks),
"total_words": total_words,
"blocks": blocks,
}, default=str, ensure_ascii=False, indent=2)
async def analyze_style() -> str:
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם."""
from legal_mcp.services.style_analyzer import analyze_corpus