diff --git a/mcp-server/src/legal_mcp/services/block_writer.py b/mcp-server/src/legal_mcp/services/block_writer.py index ad24357..ff8294b 100644 --- a/mcp-server/src/legal_mcp/services/block_writer.py +++ b/mcp-server/src/legal_mcp/services/block_writer.py @@ -23,8 +23,10 @@ from pathlib import Path from legal_mcp import config from legal_mcp.services import db, embeddings, claude_session, audit, storage from legal_mcp.services.lessons import ( + ANTI_PATTERNS as _ANTI_PATTERNS, OUTCOME_LABELS_HE, PRACTICE_AREA_OVERRIDES, + anti_pattern_directive, canonical_outcome, get_content_checklist, get_methodology_summary, @@ -468,6 +470,12 @@ async def write_block( if instructions: prompt += f"\n\n## הנחיות נוספות:\n{instructions}" + # LAST in the prompt, deliberately (see lessons.anti_pattern_directive): the + # same canonical rule already appears inside style_context, but ~47K chars + # deep, where it measurably fails to bind. Restating it here is the only + # change the A/B isolated as effective — so nothing may be appended after it. + prompt += "\n\n" + anti_pattern_directive() + # Block י requires approved direction if block_id == "block-yod": dir_doc = (decision or {}).get("direction_doc") or {} @@ -1107,6 +1115,16 @@ async def _build_style_context(practice_area: str = "") -> str: # ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ── # גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב. learned: list[str] = [] + # The canonical anti-patterns are rendered UNCONDITIONALLY, before any DB + # call. They used to be produced inside the overrides loop below — so a + # failure on an EARLIER category (e.g. golden_ratios) aborted the loop and + # dropped the style invariants from the prompt silently, with only a generic + # "overrides not loaded" warning to show for it (§6). A chair-override + # outage must not be able to un-teach Dafna's structural style. + learned.append("\n**אנטי-דפוסים (להימנע) — כתוב נרטיב משפטי רציף; הימנע מ:**") + for ap in _ANTI_PATTERNS: + learned.append(f"- {ap['note']}") + try: for cat, label in ( ("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"), @@ -1125,10 +1143,8 @@ async def _build_style_context(practice_area: str = "") -> str: # corrects them, and drafts keep emitting them (the gap that left # 8137 with 28 hits). Chair additions layer on top; they never # remove the canonical ones. - from legal_mcp.services.lessons import ANTI_PATTERNS as _ANTI - learned.append(f"\n**{label} — כתוב נרטיב משפטי רציף; הימנע מ:**") - for ap in _ANTI: - learned.append(f"- {ap['note']}") + # The canonical list is already rendered above, outside this try — + # here we only layer the chair's ADDITIONS on top of it. for k, v in (ov or {}).items(): learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}") continue @@ -1264,6 +1280,12 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = "" if instructions: formatted_prompt += f"\n\n## הנחיות נוספות:\n{instructions}" + # Same closing directive, same position, same canonical source as write_block. + # This is the EXTERNAL-writer path (legal-writer agent) — if the rule were + # applied only in write_block, agent-written blocks would keep emitting the + # anti-patterns and the two writers would drift apart (G2). + formatted_prompt += "\n\n" + anti_pattern_directive() + # Block י requires approved direction if block_id == "block-yod": dir_doc = (decision or {}).get("direction_doc") or {} diff --git a/mcp-server/src/legal_mcp/services/lessons.py b/mcp-server/src/legal_mcp/services/lessons.py index 29448dc..0825d57 100644 --- a/mcp-server/src/legal_mcp/services/lessons.py +++ b/mcp-server/src/legal_mcp/services/lessons.py @@ -59,6 +59,28 @@ ANTI_PATTERNS: list[dict] = [ "note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"}, ] + +def anti_pattern_directive() -> str: + """The closing style directive, rendered from ANTI_PATTERNS (the same list + style_distance scores against — one source, two renderings, not two rules). + + WHY THIS EXISTS SEPARATELY FROM the style-context rendering: the rule was + already reaching the writer, buried ~47K chars deep inside style_context, + and drafts kept emitting the very patterns it forbids. A measured A/B over + the signed finals (9 cases, 60 generations, 2026-07-28) showed that the SAME + rule restated at the END of the assembled prompt cuts anti-pattern hits by + 72–93% on both blocks and both models: + + block-vav opus-4-8 1.75 → 0.12 | opus-5 2.25 → 0.62 + block-zayin opus-4-8 4.57 → 0.43 | opus-5 4.43 → 0.43 + + So this is a POSITION fix, not a new instruction. Keep it last in the prompt. + """ + lines = ["## כלל-סגנון מחייב (גובר על כל דוגמה בהקשר שלמעלה)", + "כתוב נרטיב משפטי רציף בלבד — פסקאות שלמות. אסור:"] + lines += [f"- {ap['note']}" for ap in ANTI_PATTERNS] + return "\n".join(lines) + # ── Paragraph length guidance (word counts) ──────────────────────── PARAGRAPH_LENGTHS = { diff --git a/mcp-server/tests/test_anti_pattern_directive.py b/mcp-server/tests/test_anti_pattern_directive.py new file mode 100644 index 0000000..ceca7ff --- /dev/null +++ b/mcp-server/tests/test_anti_pattern_directive.py @@ -0,0 +1,60 @@ +"""The style invariants must actually REACH the writer. + +Both tests here cover defects found by the 2026-07-28 model×prompt A/B over the +signed finals: the canonical anti-patterns were present in the prompt but buried +~47K chars into style_context (where they measurably failed to bind), and they +were rendered inside a try/except that an unrelated DB failure could abort. +""" +import pytest + +from legal_mcp.services import block_writer +from legal_mcp.services.lessons import ANTI_PATTERNS, anti_pattern_directive + + +def test_directive_renders_every_canonical_anti_pattern(): + """One source, two renderings — the directive may not drift from the list + style_distance scores against.""" + text = anti_pattern_directive() + for ap in ANTI_PATTERNS: + assert ap["note"] in text, f"missing anti-pattern in directive: {ap['name']}" + + +def test_both_writer_paths_append_the_directive_last(): + """write_block (in-process) and get_block_context (legal-writer agent) must + both close with the directive — otherwise the two writers drift (G2).""" + import inspect + src = inspect.getsource(block_writer) + for fn in ("async def write_block(", "async def get_block_context("): + start = src.index(fn) + # bound the search to this function: up to the next top-level def + rest = src[start + len(fn):] + nxt = rest.find("\nasync def ") + body = rest[: nxt if nxt != -1 else len(rest)] + assert "anti_pattern_directive()" in body, f"{fn} does not append the style directive" + + +@pytest.mark.asyncio +async def test_style_context_keeps_anti_patterns_when_overrides_fail(monkeypatch): + """A chair-override outage must not silently un-teach the structural style. + + Regression: the canonical list used to be emitted inside the overrides loop, + so a throw on an EARLIER category (golden_ratios) dropped it entirely. + """ + async def _boom(*a, **k): + raise RuntimeError("methodology table unavailable") + + async def _empty(*a, **k): + return [] + + # Every DB accessor this function touches is stubbed — the test must not open + # a real connection (a live pool here leaks across the shared event loop and + # breaks unrelated tests later in the run). + monkeypatch.setattr(block_writer.db, "get_style_patterns", _empty) + monkeypatch.setattr(block_writer.db, "get_methodology_overrides", _boom) + monkeypatch.setattr(block_writer.db, "get_recent_decision_lessons", _empty) + + ctx = await block_writer._build_style_context("היטל השבחה") + + assert "נרטיב משפטי רציף" in ctx + for ap in ANTI_PATTERNS: + assert ap["note"] in ctx, f"anti-pattern dropped on override failure: {ap['name']}"