All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m28s
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>
182 lines
5.9 KiB
Python
182 lines
5.9 KiB
Python
"""סיעור מוחות לגיבוש כיוון ההחלטה.
|
||
|
||
שלב 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,
|
||
}
|