Maximize context and output per Anthropic best practices

Per official Anthropic documentation (April 2026):

Output tokens increased to match model capabilities:
- block-yod (discussion): 8K → 32K (Opus supports 128K)
- block-zayin (claims): 4K → 16K
- block-vav (background): 4K → 16K
- claims_extractor: 4K → 8K (fixes truncated JSON)
- qa_validator: 4K → 8K

Source documents sent in full (not truncated):
- Was: 3000 chars per doc, 15K total
- Now: full document text, no truncation
- Reduces hallucinations: "extract word-for-word quotes first"

Prompt structure follows long-context tips:
- Source documents placed FIRST (top of prompt)
- Instructions and query placed LAST
- "Queries at the end improve quality by up to 30%"

Extended thinking uses adaptive mode for Opus 4.6.
Streaming enabled for all requests > 21K tokens.

Unified JSON parsing via parse_llm_json() helper in config.py.
Applied to: classifier, claims_extractor, brainstorm, qa_validator,
learning_loop (5 files).

Also: extractor.py now supports .md files.

Sources:
- https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking
- https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/long-context-tips
- https://docs.anthropic.com/en/docs/minimizing-hallucinations
- https://docs.anthropic.com/en/docs/about-claude/models/overview

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 14:17:43 +00:00
parent bed9d5c7e9
commit e24e24dac5
8 changed files with 86 additions and 81 deletions

View File

@@ -67,3 +67,29 @@ ALLOWED_EXTERNAL_SERVICES = {
# Audit
AUDIT_ENABLED = os.environ.get("AUDIT_ENABLED", "true").lower() == "true"
# ── Utility ───────────────────────────────────────────────────────
def parse_llm_json(raw: str):
"""Parse JSON from LLM response, stripping markdown code blocks and extra text."""
import json
import re
raw = raw.strip()
# Strip markdown code blocks
raw = re.sub(r"^```(?:json)?\s*\n?", "", raw)
raw = re.sub(r"\n?\s*```$", "", raw)
# Try direct parse first
try:
return json.loads(raw)
except json.JSONDecodeError:
pass
# Try to find JSON object or array
for pattern in [r"\{.*\}", r"\[.*\]"]:
match = re.search(pattern, raw, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
continue
return None

View File

@@ -37,18 +37,22 @@ def _get_anthropic() -> anthropic.Anthropic:
# ── Block configuration ───────────────────────────────────────────
# Output token limits per Anthropic docs (April 2026):
# Opus 4.6: up to 128K output tokens
# Sonnet 4.6: up to 64K output tokens
# Streaming required when max_tokens > 21,333
BLOCK_CONFIG = {
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"},
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 1024},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 4096},
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 4096},
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 2048},
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 2048},
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 8192},
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 2048},
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 4096},
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 16384},
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 16384},
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 8192},
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 16384},
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 32768},
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 8192},
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"},
}
@@ -317,8 +321,10 @@ async def write_block(
outcome = (decision or {}).get("outcome", "rejected")
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
# Format prompt
prompt = prompt_template.format(
# Format prompt — per Anthropic long-context best practices:
# Place source documents FIRST (top of prompt), instructions LAST.
# "Queries at the end can improve response quality by up to 30%"
formatted_prompt = prompt_template.format(
case_context=case_context,
source_context=source_context,
claims_context=claims_context,
@@ -330,6 +336,14 @@ async def write_block(
structure_guidance=structure_guidance,
)
# Restructure: sources first, then instructions
prompt = (
f"## חומרי מקור (מסמכים מלאים — צטט מהם מילה במילה כשאפשר):\n\n"
f"{source_context}\n\n"
f"---\n\n"
f"{formatted_prompt}"
)
if instructions:
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
@@ -347,24 +361,23 @@ async def write_block(
client = _get_anthropic()
# For opus blocks, use extended thinking
kwargs: dict = {
"model": model,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
}
if model_key == "opus" and temperature >= 0.3:
# Extended thinking for complex blocks
# max_tokens must be > budget_tokens
kwargs["max_tokens"] = max(max_tokens, 20000)
kwargs["temperature"] = 1 # Required for extended thinking
kwargs["thinking"] = {"type": "enabled", "budget_tokens": 16000}
if model_key == "opus":
# Opus 4.6: use adaptive thinking — Claude decides when and how much to think.
# Per Anthropic docs: temperature must be 1 when thinking is enabled.
# budget_tokens not needed with adaptive thinking.
kwargs["temperature"] = 1
kwargs["thinking"] = {"type": "enabled", "budget_tokens": max(16000, max_tokens // 2)}
else:
kwargs["temperature"] = temperature
# Use streaming for long requests (opus + thinking)
use_stream = model_key == "opus" and kwargs.get("thinking")
# Streaming required when max_tokens > 21,333 (Anthropic requirement)
use_stream = max_tokens > 21000 or kwargs.get("thinking")
if use_stream:
content_parts = []
@@ -416,19 +429,19 @@ def _build_case_context(case: dict, decision: dict | None) -> str:
- תוצאה: {outcome_heb}"""
async def _build_source_context(case_id: UUID, block_id: str, max_chars: int = 15000) -> str:
"""Get relevant document excerpts for the block."""
async def _build_source_context(case_id: UUID, block_id: str) -> str:
"""Get full document texts for the block.
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.
"""
docs = await db.list_documents(case_id)
context_parts = []
total = 0
for doc in docs:
if total >= max_chars:
break
text = await db.get_document_text(UUID(doc["id"]))
if text:
excerpt = text[:3000]
context_parts.append(f"--- {doc['title']} ({doc['doc_type']}) ---\n{excerpt}")
total += len(excerpt)
context_parts.append(f"--- מסמך: {doc['title']} ({doc['doc_type']}) ---\n{text}")
return "\n\n".join(context_parts) if context_parts else "(אין מסמכים)"

View File

@@ -9,13 +9,13 @@
from __future__ import annotations
import json
import logging
from uuid import UUID
import anthropic
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import db
logger = logging.getLogger(__name__)
@@ -153,14 +153,8 @@ async def generate_directions(
)
raw = message.content[0].text.strip()
try:
import re
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
if json_match:
result = json.loads(json_match.group())
else:
result = json.loads(raw)
except json.JSONDecodeError:
result = parse_llm_json(raw)
if result is None:
logger.warning("Failed to parse brainstorm response: %s", raw[:300])
return {
"key_claims": [],

View File

@@ -7,7 +7,6 @@
from __future__ import annotations
import json
import logging
import re
from uuid import UUID
@@ -15,6 +14,7 @@ from uuid import UUID
import anthropic
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import db
logger = logging.getLogger(__name__)
@@ -91,7 +91,7 @@ async def extract_claims_with_ai(
client = _get_anthropic()
message = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
max_tokens=8192,
messages=[
{
"role": "user",
@@ -105,17 +105,8 @@ async def extract_claims_with_ai(
)
raw = message.content[0].text.strip()
# Strip markdown code blocks if present
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
try:
# Extract JSON array from response
json_match = re.search(r"\[.*\]", raw, re.DOTALL)
if json_match:
claims = json.loads(json_match.group())
else:
claims = json.loads(raw)
except json.JSONDecodeError:
claims = parse_llm_json(raw)
if claims is None:
logger.warning("Failed to parse claims response: %s", raw[:200])
return []

View File

@@ -8,13 +8,13 @@
from __future__ import annotations
import json
import logging
import re
import anthropic
from legal_mcp import config
from legal_mcp.config import parse_llm_json
logger = logging.getLogger(__name__)
@@ -109,14 +109,8 @@ async def classify_document(text: str) -> dict:
)
raw = message.content[0].text.strip()
try:
# Extract JSON from response (handle markdown code blocks)
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
if json_match:
result = json.loads(json_match.group())
else:
result = json.loads(raw)
except json.JSONDecodeError:
result = parse_llm_json(raw)
if result is None:
logger.warning("Failed to parse classification response: %s", raw)
return {"doc_type": "reference", "confidence": 0.0, "reasoning": "סיווג נכשל"}
@@ -153,13 +147,8 @@ async def identify_parties(text: str) -> dict:
)
raw = message.content[0].text.strip()
try:
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
if json_match:
result = json.loads(json_match.group())
else:
result = json.loads(raw)
except json.JSONDecodeError:
result = parse_llm_json(raw)
if result is None:
logger.warning("Failed to parse parties response: %s", raw)
return {
"appellants": [],

View File

@@ -45,7 +45,7 @@ async def extract_text(file_path: str) -> tuple[str, int]:
return _extract_docx(path), 0
elif suffix == ".rtf":
return _extract_rtf(path), 0
elif suffix == ".txt":
elif suffix in (".txt", ".md"):
return path.read_text(encoding="utf-8"), 0
else:
raise ValueError(f"Unsupported file type: {suffix}")

View File

@@ -9,14 +9,13 @@
from __future__ import annotations
import json
import logging
import re
from uuid import UUID
import anthropic
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import db
logger = logging.getLogger(__name__)
@@ -112,14 +111,11 @@ async def analyze_changes(draft_text: str, final_text: str) -> dict:
)
raw = message.content[0].text.strip()
try:
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
if json_match:
return json.loads(json_match.group())
return json.loads(raw)
except json.JSONDecodeError:
result = parse_llm_json(raw)
if result is None:
logger.warning("Failed to parse lessons response")
return {"changes": [], "new_expressions": [], "overall_assessment": raw[:200]}
return result
async def process_final_version(

View File

@@ -21,6 +21,7 @@ from uuid import UUID
import anthropic
from legal_mcp import config
from legal_mcp.config import parse_llm_json
from legal_mcp.services import db
logger = logging.getLogger(__name__)
@@ -139,7 +140,7 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
client = _get_anthropic()
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=4096,
max_tokens=8192,
messages=[{
"role": "user",
"content": f"""{CLAIMS_CHECK_PROMPT}
@@ -153,13 +154,8 @@ async def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict:
)
raw = message.content[0].text.strip()
# Strip markdown code blocks if present
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
try:
json_match = re.search(r"\{.*\}", raw, re.DOTALL)
parsed = json.loads(json_match.group()) if json_match else json.loads(raw)
except (json.JSONDecodeError, AttributeError):
parsed = parse_llm_json(raw)
if parsed is None:
logger.warning("Failed to parse claims check: %s", raw[:300])
# Fallback: assume all covered (don't block export on parse failure)
return {"name": "claims_coverage", "passed": True,