Merge remote-tracking branch 'origin/main' into rebase-357
# Conflicts: # mcp-server/src/legal_mcp/services/db.py
This commit is contained in:
@@ -35,29 +35,53 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Block configuration ───────────────────────────────────────────
|
||||
|
||||
# Output token limits per Anthropic docs:
|
||||
# Opus 4.7: up to 128K output tokens (new tokenizer — ~35% more tokens)
|
||||
# Sonnet 4.6: up to 64K output tokens
|
||||
# Streaming required when max_tokens > 21,333
|
||||
# Generation is structurally deterministic (#204 / WS5): every AI block is
|
||||
# pinned to ONE model (Opus 4.8) with a per-block reasoning `effort`. The
|
||||
# generation path is claude_session.query → `claude -p` (subscription, near-zero
|
||||
# cost; local-only — see reference_claude_generation_path / claude_session
|
||||
# docstring). Opus 4.7/4.8 REMOVED temperature/top_p/top_k (sending them → HTTP
|
||||
# 400); the only knob is `effort` (low/medium/high/xhigh/max; default high).
|
||||
#
|
||||
# DEPRECATED FIELDS — DO NOT REINTRODUCE: earlier revisions carried per-block
|
||||
# `temp` (0/0.1/0.4) and `model` ("sonnet"/"opus"/"script"). Both were DEAD
|
||||
# METADATA: write_block called claude_session.query(prompt, …) WITHOUT model/
|
||||
# temp/effort, so generation ran on the CLI's default model with no temperature
|
||||
# knob at all (the table only stored a number in decision_blocks.temperature and
|
||||
# fed MODEL_MAP→timeout). They are removed here so the table no longer misleads.
|
||||
# The conceptual origin (gen_type → thinking-budget) now lives in `effort`; see
|
||||
# docs/block-schema.md §3.
|
||||
#
|
||||
# `gen_type` is retained (documentation/UI metadata + audit `generation_type`).
|
||||
# `model` below is the DISPATCH key only: "script" = template-fill (no LLM),
|
||||
# "ai" = generated via the pinned Opus model. It is NOT a model alias anymore.
|
||||
#
|
||||
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
|
||||
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
|
||||
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
|
||||
|
||||
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": 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": 16384},
|
||||
"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"},
|
||||
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
|
||||
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "model": "script"},
|
||||
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
|
||||
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"},
|
||||
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096},
|
||||
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 16384},
|
||||
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192},
|
||||
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "model": "ai", "effort": "xhigh", "max_tokens": 16384},
|
||||
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 8192},
|
||||
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "model": "script"},
|
||||
}
|
||||
|
||||
MODEL_MAP = {
|
||||
"sonnet": "claude-sonnet-4-20250514",
|
||||
"opus": "claude-opus-4-7",
|
||||
}
|
||||
# Default effort when a block lacks an explicit one (defensive; every AI block
|
||||
# above sets one). High is the safe default per the generation-path reference.
|
||||
DEFAULT_EFFORT = "high"
|
||||
|
||||
# Blocks that take longer (deep reasoning) get the LONG timeout. The interim set
|
||||
# [he, vav, tet, zayin, chet] and the discussion block all run on the same pinned
|
||||
# Opus model, so timeout is driven by effort, not by a model split.
|
||||
_LONG_EFFORTS = frozenset({"high", "xhigh", "max"})
|
||||
|
||||
|
||||
# ── Template blocks (א-ד, יב) ────────────────────────────────────
|
||||
@@ -120,11 +144,14 @@ TEMPLATE_WRITERS = {
|
||||
BLOCK_PROMPTS = {
|
||||
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
|
||||
|
||||
## כללים:
|
||||
- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..."
|
||||
- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש
|
||||
- 1-2 סעיפים בלבד
|
||||
- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים
|
||||
## מבנה קבוע (חובה — אותו מבנה בכל תיק):
|
||||
- **המשפט הראשון פותח תמיד במילה "לפנינו"** — נוסח קבוע: "לפנינו ערר על החלטת
|
||||
[הוועדה המקומית] מיום [תאריך] בעניין [נושא הבקשה/התכנית]." (אל תשתמש ב"עניינה של
|
||||
החלטה זו" או בכל פתיח חלופי — הפתיח אחיד.)
|
||||
- סעיף 1: הצגת הערר במשפט הקבוע לעיל + הגדרת ה"להלן" המרכזיים בסדר קבוע:
|
||||
הוועדה המקומית → התכנית/הבקשה → המגרש/המקרקעין.
|
||||
- סעיף 2 (רק אם נדרש להשלמת "להלן" נוספים): הגדרות-נוספות בלבד.
|
||||
- **בדיוק 1-2 סעיפים.** אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים.
|
||||
- מספור: 1.
|
||||
|
||||
## פרטי התיק:
|
||||
@@ -441,10 +468,19 @@ async def write_block(
|
||||
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
|
||||
content = await claude_session.query(prompt, timeout=timeout, tools="") # prose gen — no tool_use → no error_max_turns
|
||||
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
||||
# reasoning effort so generation is structurally deterministic — these were
|
||||
# previously NOT forwarded (the source of inconsistency). model/effort flow
|
||||
# through claude_session.query → `claude -p --model … --effort …`.
|
||||
effort = block_cfg.get("effort", DEFAULT_EFFORT)
|
||||
timeout = claude_session.LONG_TIMEOUT if effort in _LONG_EFFORTS else claude_session.DEFAULT_TIMEOUT
|
||||
content = await claude_session.query(
|
||||
prompt,
|
||||
timeout=timeout,
|
||||
model=GENERATION_MODEL,
|
||||
effort=effort,
|
||||
tools="", # prose gen — no tool_use → no error_max_turns
|
||||
)
|
||||
|
||||
sources = await _collect_block_sources(case_id, block_id)
|
||||
sources["case_law_ids"] = _precedent_case_law_ids
|
||||
@@ -455,6 +491,7 @@ async def write_block(
|
||||
|
||||
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
||||
word_count = len(content.split())
|
||||
is_ai = block_cfg["model"] == "ai"
|
||||
return {
|
||||
"block_id": block_id,
|
||||
"block_index": block_cfg["index"],
|
||||
@@ -462,8 +499,14 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
||||
"content": content,
|
||||
"word_count": word_count,
|
||||
"generation_type": block_cfg["gen_type"],
|
||||
"model_used": block_cfg["model"],
|
||||
"temperature": block_cfg["temp"],
|
||||
# AI blocks record the pinned model; template blocks record "script".
|
||||
"model_used": GENERATION_MODEL if is_ai else block_cfg["model"],
|
||||
# The real generation knob (#204). None for template/script blocks.
|
||||
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
|
||||
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
|
||||
# → HTTP 400). Kept only to satisfy decision_blocks.temperature
|
||||
# NUMERIC(3,2); always 0. Read `effort` instead.
|
||||
"temperature": 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,43 @@ from legal_mcp.services import court_citation, halacha_quality, principles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Primary-document classification (WS2 / task #200) ────────────────
|
||||
# Canonical, single-source list of "primary" (מסמך עיקרי) doc_types — the
|
||||
# substantive case documents the chair tracks for analysis (appeal, the
|
||||
# replies/objections, the hearing protocol, the appraisal, and the
|
||||
# committee decision). Every OTHER doc_type (plan, permit, court_decision,
|
||||
# exhibit, reference…) is secondary. Chair-approved set (plan WS2 §1).
|
||||
#
|
||||
# G1/G2/INV-DM7: `is_primary` is DERIVED from `doc_type`, never an
|
||||
# independently-written column. There is ONE source of truth: this tuple.
|
||||
# The DB column `documents.is_primary` is a GENERATED ALWAYS … STORED column
|
||||
# (V47) computed by Postgres from `doc_type`, so it can never drift; the
|
||||
# Python helper below mirrors the same list for read-time derivation and for
|
||||
# building the generated-column expression. No parallel write path.
|
||||
PRIMARY_DOC_TYPES: tuple[str, ...] = (
|
||||
"appeal", # כתב-ערר
|
||||
"response", # תשובה / תגובה
|
||||
"objection", # התנגדות
|
||||
"protocol", # פרוטוקול-דיון
|
||||
"appraisal", # שומה
|
||||
"decision", # החלטת-ועדה
|
||||
)
|
||||
|
||||
|
||||
def is_primary_doc_type(doc_type: str | None) -> bool:
|
||||
"""Whether a doc_type is a 'primary' (מסמך עיקרי) document. Single source
|
||||
of truth = PRIMARY_DOC_TYPES; mirrors the V47 generated column."""
|
||||
return doc_type in PRIMARY_DOC_TYPES
|
||||
|
||||
|
||||
def _primary_doc_types_sql_array() -> str:
|
||||
"""SQL ARRAY[...] literal of PRIMARY_DOC_TYPES for the generated column.
|
||||
Values are fixed identifiers from this module (no user input) — safe to
|
||||
inline; kept derived from the tuple so the list has ONE definition."""
|
||||
quoted = ", ".join("'" + t.replace("'", "''") + "'" for t in PRIMARY_DOC_TYPES)
|
||||
return f"ARRAY[{quoted}]::text[]"
|
||||
|
||||
|
||||
_pool: asyncpg.Pool | None = None
|
||||
_schema_ready: bool = False
|
||||
_init_lock: asyncio.Lock = asyncio.Lock()
|
||||
@@ -1782,7 +1819,23 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
|
||||
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
|
||||
"""
|
||||
|
||||
# ── V47: Protocol comparative analysis (WS4 / #203) ───────────────
|
||||
# V47 (WS2 / task #200): "primary document" (מסמך עיקרי) concept.
|
||||
# `documents.is_primary` is a GENERATED ALWAYS … STORED column derived purely
|
||||
# from `doc_type` against the canonical PRIMARY_DOC_TYPES list (built from the
|
||||
# single-source tuple via _primary_doc_types_sql_array). Because Postgres
|
||||
# computes it, there is NO parallel write path and it can never drift from
|
||||
# doc_type (G1/G2/INV-DM7 — same drift-free pattern as the GENERATED tsvectors,
|
||||
# INV-DM3). Idempotent: ADD COLUMN IF NOT EXISTS; the generated expression is
|
||||
# fixed, so re-running is a no-op. The partial index serves the
|
||||
# "primary docs not yet analysed" queries that #201 builds on.
|
||||
SCHEMA_V47_SQL = f"""
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_primary BOOLEAN
|
||||
GENERATED ALWAYS AS (doc_type = ANY({_primary_doc_types_sql_array()})) STORED;
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_primary
|
||||
ON documents(case_id) WHERE is_primary;
|
||||
"""
|
||||
|
||||
# ── V48: Protocol comparative analysis (WS4 / #203) ───────────────
|
||||
#
|
||||
# protocol_analysis: case-knowledge derived from a hearing-protocol document by
|
||||
# comparing the oral arguments raised at the hearing against the written
|
||||
@@ -1798,7 +1851,7 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
|
||||
# back to the canonical `cases` columns (panel via decisions, hearing_date on
|
||||
# cases) — NOT duplicated here — and a copy of the raw extracted feed is kept on
|
||||
# the analysis row for provenance only (G9).
|
||||
SCHEMA_V47_SQL = """
|
||||
SCHEMA_V48_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS protocol_analysis (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
|
||||
@@ -1887,6 +1940,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(SCHEMA_V45_SQL)
|
||||
await conn.execute(SCHEMA_V46_SQL)
|
||||
await conn.execute(SCHEMA_V47_SQL)
|
||||
await conn.execute(SCHEMA_V48_SQL)
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -2259,6 +2313,14 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
||||
d["case_id"] = str(d["case_id"])
|
||||
if isinstance(d.get("metadata"), str):
|
||||
d["metadata"] = json.loads(d["metadata"])
|
||||
# Primary/secondary classification (WS2 / #200). `is_primary` is the
|
||||
# generated DB column (V47); derive it at read-time too so the field is
|
||||
# always present even on rows fetched before the migration ran, and expose
|
||||
# a human-facing `doc_category`. Single source of truth: PRIMARY_DOC_TYPES.
|
||||
is_primary = bool(d["is_primary"]) if d.get("is_primary") is not None \
|
||||
else is_primary_doc_type(d.get("doc_type"))
|
||||
d["is_primary"] = is_primary
|
||||
d["doc_category"] = "primary" if is_primary else "secondary"
|
||||
return d
|
||||
|
||||
|
||||
@@ -4003,7 +4065,7 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
|
||||
return conflicts
|
||||
|
||||
|
||||
# ── Protocol comparative analysis (V47 / WS4 #203) ────────────────
|
||||
# ── Protocol comparative analysis (V48 / WS4 #203) ────────────────
|
||||
|
||||
async def replace_protocol_analysis(
|
||||
case_id: UUID,
|
||||
|
||||
@@ -264,6 +264,11 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
|
||||
async def document_list(case_number: str) -> str:
|
||||
"""רשימת מסמכים בתיק.
|
||||
|
||||
כל מסמך כולל `doc_type`, וכן את הסיווג הנגזר `is_primary` (bool) ו-
|
||||
`doc_category` ("primary"/"secondary") — מסמך-עיקרי (ערר/תשובה/התנגדות/
|
||||
פרוטוקול/שומה/החלטת-ועדה) מול משני. נגזר מ-`doc_type` (db.PRIMARY_DOC_TYPES),
|
||||
אינו נכתב ידנית.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
"""
|
||||
|
||||
@@ -516,9 +516,18 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
||||
|
||||
# ── Interim draft (pre-ruling) ────────────────────────────────────
|
||||
|
||||
# Blocks written for the interim draft, in display order.
|
||||
# Blocks written for the interim draft, in a FIXED order.
|
||||
# This is the same content the chair sees in the final decision (same template,
|
||||
# same skill, same prompts) — minus opening, ruling, summary, signatures.
|
||||
# same skill, same prompts, same single canonical write path — block_writer) —
|
||||
# minus ruling, summary, signatures.
|
||||
#
|
||||
# Determinism (#204 / WS5): the interim draft is STRUCTURALLY deterministic.
|
||||
# Each block is generated by block_writer.write_block, which now pins the model
|
||||
# (Opus 4.8) and a per-block reasoning effort, and uses fixed structural prompts
|
||||
# (e.g. block-he's opening is always "לפנינו ערר…"). The display order on export
|
||||
# is fixed separately by docx_exporter._INTERIM_BLOCK_ORDER. There is no parallel
|
||||
# generation path (G2): write_interim_draft is a thin orchestrator over the same
|
||||
# write_and_store_block used by the full decision.
|
||||
_INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user