From a9cd8aeb12d076422f1542b0aef1df791def45bd Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 10 May 2026 10:49:47 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20prevent=20write=5Finterim=5Fdraft=20?= =?UTF-8?q?context=20overflow=20(465K=20=E2=86=92=20=E2=89=A4300K=20chars)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caused all 5 interim blocks to fail with "Claude CLI failed (exit 1): unknown error": 1. source_context was embedded BOTH inside the prompt template (via {source_context}) AND prepended again in write_block — doubling every block's context size (232K chars × 2 = 465K chars). 2. _build_source_context loaded all 9 case documents for every block regardless of relevance. Fixes: - Remove the duplicate source_context prepend in write_block; the template already contains it via {source_context} - Add per-block document filtering (_BLOCK_DOC_TYPES): block-he/zayin → empty, block-chet → protocol only, block-tet → appraisals only - Add 400K char guard before calling claude -p with a descriptive error (vs opaque "exit 1: unknown error") - Add prompt-size warning and size info in claude_session error messages Result: block-he 0 chars, block-zayin 0 chars, block-vav ~172K, block-chet ~45K, block-tet ~300K (all under 400K limit) Co-Authored-By: Claude Sonnet 4.6 --- .../src/legal_mcp/services/block_writer.py | 48 +++++++++++++++---- .../src/legal_mcp/services/claude_session.py | 6 ++- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/block_writer.py b/mcp-server/src/legal_mcp/services/block_writer.py index db030ed..bc99832 100644 --- a/mcp-server/src/legal_mcp/services/block_writer.py +++ b/mcp-server/src/legal_mcp/services/block_writer.py @@ -360,13 +360,9 @@ async def write_block( post_hearing_context=post_hearing_context, ) - # Restructure: sources first, then instructions - prompt = ( - f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n" - f"{source_context}\n\n" - f"---\n\n" - f"{formatted_prompt}" - ) + # source_context is already embedded inside formatted_prompt via {source_context} in the + # template. Do NOT prepend it again — doing so doubles the prompt size (was 465K chars). + prompt = formatted_prompt if instructions: prompt += f"\n\n## הנחיות נוספות:\n{instructions}" @@ -377,6 +373,19 @@ async def write_block( if not dir_doc.get("approved"): raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.") + # Guard against context overflow before calling claude -p. + # Sonnet: 200K context → ~800K chars max; Opus: 200K context → same. + # In practice the CLI has crashed on prompts above ~400K chars, so use + # that as a conservative ceiling (well below the token limit). + _MAX_PROMPT_CHARS = 400_000 + if len(prompt) > _MAX_PROMPT_CHARS: + raise RuntimeError( + f"Prompt too large for {block_id}: {len(prompt):,} chars " + f"(limit {_MAX_PROMPT_CHARS:,}). " + f"source_context: {len(source_context):,} chars. " + f"Reduce documents or call extract_appraiser_facts first." + ) + # Call Claude via Claude Code session (no API) model_key = block_cfg["model"] timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT @@ -414,16 +423,35 @@ def _build_case_context(case: dict, decision: dict | None) -> str: - תוצאה: {outcome_heb}""" +# Which doc_types are relevant per block. +# None → skip source docs entirely (block uses other context, e.g. claims_context) +# [] → include all doc types (default for unspecified blocks) +# [..] → include only the listed doc_type values +_BLOCK_DOC_TYPES: dict[str, list[str] | None] = { + "block-he": None, # only case_context needed; no full docs + "block-vav": ["appeal", "protocol"], # כתב ערר + פרוטוקול ועדה + "block-zayin": None, # claims_context is sufficient + "block-chet": ["protocol"], # פרוטוקול + השלמות טיעון + "block-tet": ["appraisal"], # שומות בלבד + # block-yod, block-yod-alef, block-he etc. default → all docs +} + + async def _build_source_context(case_id: UUID, block_id: str) -> str: - """Get full document texts for the block. + """Get document texts for the block, filtered by relevance. Per Anthropic best practices: send full source documents, not truncated excerpts. - Place documents at the TOP of the prompt (before instructions) for 30% better recall. - For grounding: instruct Claude to cite word-for-word from these documents. + Per-block filtering prevents context overflow on large cases (9+ docs). """ + allowed = _BLOCK_DOC_TYPES.get(block_id, []) # [] sentinel = not in map → all docs + if allowed is None: + return "" # this block doesn't need raw source docs + docs = await db.list_documents(case_id) context_parts = [] for doc in docs: + if allowed and doc["doc_type"] not in allowed: + continue text = await db.get_document_text(UUID(doc["id"])) if text: context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}") diff --git a/mcp-server/src/legal_mcp/services/claude_session.py b/mcp-server/src/legal_mcp/services/claude_session.py index 2a892d1..8db82f2 100644 --- a/mcp-server/src/legal_mcp/services/claude_session.py +++ b/mcp-server/src/legal_mcp/services/claude_session.py @@ -72,6 +72,9 @@ async def query( """ full_prompt = f"{system}\n\n{prompt}" if system else prompt + if len(full_prompt) > 150_000: + logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt)) + cmd = [ "claude", "-p", "--output-format", "json", @@ -110,7 +113,8 @@ async def query( if proc.returncode != 0: stderr = stderr_b.decode("utf-8", errors="replace").strip()[:500] or "unknown error" - raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}") + size_info = f"; prompt_len={len(full_prompt):,} chars" if len(full_prompt) > 100_000 else "" + raise RuntimeError(f"Claude CLI failed (exit {proc.returncode}): {stderr}{size_info}") stdout = stdout_b.decode("utf-8", errors="replace").strip() if not stdout: -- 2.49.1 From 50649baeedfcb8c9e24be781cf56b6ca07cd0018 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 10 May 2026 18:30:49 +0000 Subject: [PATCH 2/2] fix: prevent JSONB double-encoding on methodology save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass req.value directly to asyncpg instead of json.dumps(req.value). When a Python string was passed with ::jsonb, asyncpg encoded it as a JSONB string (not an array), causing the frontend spread operator to split it into individual characters — one textarea per character. Also fix typo in DISCUSSION_RULES default: "אסה" → "מאסה". Co-Authored-By: Claude Sonnet 4.6 --- mcp-server/src/legal_mcp/services/lessons.py | 2 +- web/app.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mcp-server/src/legal_mcp/services/lessons.py b/mcp-server/src/legal_mcp/services/lessons.py index c9e7fe9..e1fc91b 100644 --- a/mcp-server/src/legal_mcp/services/lessons.py +++ b/mcp-server/src/legal_mcp/services/lessons.py @@ -123,7 +123,7 @@ SUMMARY_STRATEGIES = { DISCUSSION_RULES: dict[str, list[str]] = { "universal": [ - "פרק הדיון = אסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.", + "פרק הדיון = מאסה רציפה. אין כותרות משנה (H2/H3). מעברים רק עם ביטויי מעבר טקסטואליים.", "חריג יחיד לכותרות משנה: נושאים נפרדים לחלוטין (למשל: הקלה בגובה + התייחסות לטענות נוספות).", "טווח אורך סעיפים: 20 עד 600+ מילים. סעיף עם ציטוט מקיף = בלוק אחד שלם, לא שבירה לסעיפים קצרים.", ], diff --git a/web/app.py b/web/app.py index 1f07e36..60a521e 100644 --- a/web/app.py +++ b/web/app.py @@ -3095,11 +3095,14 @@ async def api_update_methodology(category: str, key: str, req: MethodologyUpdate raise HTTPException(422, "content_checklists value must be a non-empty string") pool = await db.get_pool() + # Pass req.value directly — asyncpg serializes Python list/dict to JSONB. + # json.dumps() caused double-encoding: string passed to ::jsonb became a JSONB string, + # not a JSONB array, making the frontend spread it as individual chars. await pool.execute( "INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) " "VALUES (gen_random_uuid(), '_global', $1, $2, $3::jsonb) " "ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::jsonb", - category, key, json.dumps(req.value, ensure_ascii=False), + category, key, req.value, ) return {"key": key, "value": req.value, "is_override": True} -- 2.49.1