fix(writer): כלל-הסגנון בסוף הפרומפט — חותך אנטי-דפוסים ב-72–93% #421

Merged
chaim merged 2 commits from worktree-anti-pattern-directive-position into main 2026-07-28 11:53:58 +00:00
3 changed files with 108 additions and 4 deletions
Showing only changes of commit 42ea1a7c58 - Show all commits

View File

@@ -23,8 +23,10 @@ from pathlib import Path
from legal_mcp import config from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session, audit, storage from legal_mcp.services import db, embeddings, claude_session, audit, storage
from legal_mcp.services.lessons import ( from legal_mcp.services.lessons import (
ANTI_PATTERNS as _ANTI_PATTERNS,
OUTCOME_LABELS_HE, OUTCOME_LABELS_HE,
PRACTICE_AREA_OVERRIDES, PRACTICE_AREA_OVERRIDES,
anti_pattern_directive,
canonical_outcome, canonical_outcome,
get_content_checklist, get_content_checklist,
get_methodology_summary, get_methodology_summary,
@@ -468,6 +470,12 @@ async def write_block(
if instructions: if instructions:
prompt += f"\n\n## הנחיות נוספות:\n{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 # Block י requires approved direction
if block_id == "block-yod": if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {} dir_doc = (decision or {}).get("direction_doc") or {}
@@ -1107,6 +1115,16 @@ async def _build_style_context(practice_area: str = "") -> str:
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ── # ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב. # גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
learned: list[str] = [] 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: try:
for cat, label in ( for cat, label in (
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"), ("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 # corrects them, and drafts keep emitting them (the gap that left
# 8137 with 28 hits). Chair additions layer on top; they never # 8137 with 28 hits). Chair additions layer on top; they never
# remove the canonical ones. # remove the canonical ones.
from legal_mcp.services.lessons import ANTI_PATTERNS as _ANTI # The canonical list is already rendered above, outside this try —
learned.append(f"\n**{label} — כתוב נרטיב משפטי רציף; הימנע מ:**") # here we only layer the chair's ADDITIONS on top of it.
for ap in _ANTI:
learned.append(f"- {ap['note']}")
for k, v in (ov or {}).items(): for k, v in (ov or {}).items():
learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}") learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}")
continue continue
@@ -1264,6 +1280,12 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
if instructions: if instructions:
formatted_prompt += f"\n\n## הנחיות נוספות:\n{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 # Block י requires approved direction
if block_id == "block-yod": if block_id == "block-yod":
dir_doc = (decision or {}).get("direction_doc") or {} dir_doc = (decision or {}).get("direction_doc") or {}

View File

@@ -59,6 +59,28 @@ ANTI_PATTERNS: list[dict] = [
"note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"}, "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
7293% 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 length guidance (word counts) ────────────────────────
PARAGRAPH_LENGTHS = { PARAGRAPH_LENGTHS = {

View File

@@ -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']}"