From 42ea1a7c58e713356d83e3d1d103d189219e0933 Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 28 Jul 2026 11:14:47 +0000 Subject: [PATCH] =?UTF-8?q?fix(writer):=20=D7=9B=D7=9C=D7=9C-=D7=94=D7=A1?= =?UTF-8?q?=D7=92=D7=A0=D7=95=D7=9F=20=D7=91=D7=A1=D7=95=D7=A3=20=D7=94?= =?UTF-8?q?=D7=A4=D7=A8=D7=95=D7=9E=D7=A4=D7=98=20=E2=80=94=20=D7=94=D7=95?= =?UTF-8?q?=D7=90=20=D7=94=D7=99=D7=94=20=D7=A9=D7=9D,=20=D7=91=D7=9E?= =?UTF-8?q?=D7=A7=D7=95=D7=9D=20=D7=A9=D7=91=D7=95=20=D7=94=D7=95=D7=90=20?= =?UTF-8?q?=D7=9C=D7=90=20=D7=AA=D7=95=D7=A4=D7=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הרשימה הקנונית של lessons.ANTI_PATTERNS כבר הוזרקה לכותב, אבל בתו ~46,781 מתוך 46,950 של style_context — שהוא עצמו מקטע אחד מתוך ~12 בפרומפט. הטיוטות המשיכו לפלוט בדיוק את מה שהיא אוסרת. A/B מדוד מול הסופיים החתומים (9 תיקים, 60 ייצורים, 2026-07-28) הראה שאותו כלל, בסוף הפרומפט, חותך anti_pattern_total ב-72–93%: 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 וב-distance: −12%/−29% (4-8), −9%/−27% (5). זה שיפור גדול פי-3 מכל הבדל שנמדד בין המודלים עצמם. - `lessons.anti_pattern_directive()` — רינדור שני של אותה רשימה קנונית (מקור אחד, שתי תצוגות — לא שני כללים). - מתווסף **אחרון** בשני מסלולי-הכתיבה: `write_block` (בתהליך) ו- `get_block_context` (סוכן legal-writer). אילו הוחל רק באחד, שני הכותבים היו נפרדים בסגנון (G2). - **תיקון בליעה-שקטה (§6):** הרשימה הקנונית רונדרה בתוך לולאת-ה-overrides, כך שכשל-DB בקטגוריה מוקדמת (golden_ratios) הפיל את הלולאה והשמיט את אינווריאנטי-הסגנון כליל — עם אזהרה גנרית בלבד. עכשיו היא מרונדרת ללא תנאי, לפני כל קריאת-DB; הערות-היו"ר מתווספות מעליה. invariants: G11 (תוכן משפטי — סגנון דפנה) · G2 (מקור-אמת יחיד לכלל, ושני מסלולי-הכתיבה מיושרים) · §6 (אין בליעה שקטה). בדיקות: 473 passed (3 חדשות — רינדור מלא, שני המסלולים, שרידות לכשל-DB). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/legal_mcp/services/block_writer.py | 30 ++++++++-- mcp-server/src/legal_mcp/services/lessons.py | 22 +++++++ .../tests/test_anti_pattern_directive.py | 60 +++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 mcp-server/tests/test_anti_pattern_directive.py 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']}"