Files
legal-ai/mcp-server/src/legal_mcp/services/brainstorm.py
Chaim 28f49defff
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
LLM session: async, 30min timeout, semantic chunking + parallel
The claude_session bridge had two structural defects that made any
non-trivial document extraction unreliable:

  1. subprocess.run() blocks the asyncio event loop in the MCP server
     for the full duration of every LLM call (60-180s typical).
  2. The 120-second timeout was below the cold-cache cost of any
     document over ~12K Hebrew characters. Three back-to-back timeouts
     on case 8174-24 dropped 43 appellant claims on the floor.

Phase 1 of the remediation plan — keeps claude_session as the engine
(no Anthropic API switch) and restructures around it:

claude_session.py
  • query / query_json are now async — asyncio.create_subprocess_exec
    instead of subprocess.run, so MCP server can serve other coroutines
    while a call is in flight.
  • DEFAULT_TIMEOUT 120 → 1800 (30 min). High enough that no realistic
    document hits it; bounded so a runaway never zombifies forever.
  • LONG_TIMEOUT 300 → 3600 for opus block writing on full case context.
  • TimeoutError now actually kills the subprocess (asyncio.wait_for
    cancellation alone leaves the child running).

claims_extractor.py
  • _split_by_sections: chunks at numbered sections / Hebrew letter
    headings / "פרק" markers / markdown ##, falls back to paragraph
    breaks, then to hard splits. Targets 12K chars per chunk — small
    enough that each chunk reliably finishes inside the timeout.
  • _extract_chunk: per-chunk retry (1 attempt by default) with
    structured logging on failure. Failed chunks no longer crash the
    overall extraction; they're skipped with a partial-result warning.
  • extract_claims_with_ai now runs chunks in parallel via
    asyncio.gather bounded by a semaphore (CHUNK_CONCURRENCY=3).
    For a 25K-char appeal: was sequential 150-300s, now ~70-90s.

Updated all 9 callers (claims, appraiser facts, block writer, qa
validator, brainstorm, learning loop, style analyzer × 3) to await
the now-async API.

The one-shot scripts/extract_claims_8174.py used to recover 43
appellant claims on case 8174-24 has been moved to .archive/ — phase 1
makes it obsolete. SCRIPTS.md updated.

Phase 2 (background-task wrapper around LLM-bound MCP tools, persistent
llm_tasks table, SSE progress) is the structural follow-up — separate PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:21:35 +00:00

182 lines
5.9 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.
"""סיעור מוחות לגיבוש כיוון ההחלטה.
שלב 4א באיפיון המוצר:
1. הצגת טענות מרכזיות
2. הצעת 2-3 כיוונים אפשריים לנימוק
3. שיח אינטראקטיבי עד לכיוון מוסכם
4. יצירת מסמך כיוון
"""
from __future__ import annotations
import logging
from uuid import UUID
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import db, claude_session
logger = logging.getLogger(__name__)
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 '(אין מסמכים בתיק)'}
"""
result = await claude_session.query_json(user_content)
if result is None:
logger.warning("Failed to parse brainstorm response")
return {
"key_claims": [],
"directions": [],
"recommended_order": "",
"raw_response": "",
}
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,
}