feat(mcp): FU-14 GAP-51 — איחוד אוצר-המילים של תוצאת-תיק (set_outcome SSoT)

הכרעת-יו"ר: קנוני = 3 תוצאות אמיתיות (rejection/partial_acceptance/full_acceptance);
betterment_levy יוצא מהיותו "תוצאה" ועובר ל-override לפי practice_area.
+ עקרון "אנגלית-ב-DB, עברית-ב-UI": מפת-תוויות SSoT אחת.

lessons.py:
- VALID_OUTCOMES = 3 (הוסר betterment_levy).
- OUTCOME_LABELS_HE (SSoT לתצוגה) + LEGACY_OUTCOME_MAP + canonical_outcome().
- PRACTICE_AREA_OVERRIDES["betterment_levy"] מרכז את כל ה-guidance שהיה מפתוח כ-outcome
  (golden_ratios/opening/summary/discussion/template).
- get_lessons_for_outcome(outcome, practice_area) + format_ratios_comment(..., practice_area)
  מחילים override + מנרמלים legacy.

block_writer.py: STRUCTURE_GUIDANCE קנוני + תווית מ-OUTCOME_LABELS_HE + override betterment.
workflow.set_outcome: קנוני 3 + מיפוי-legacy סלחני; תווית מ-SSoT.
drafting.py: טבלת יחסי-זהב + get_decision_template מודעי-practice_area (override).
web-ui case.ts: הסרת betterment_levy מ-expectedOutcomes (הוא practice_area).
server.py: docstrings קנוניים.

מיגרציה: migrate_gap51_outcomes.py — 9 שורות נורמלו (rejected→rejection וכו'),
גיבוי ב-data/audit/. הקוד canonicalize בקריאה ⇒ backward-compatible גם בלי מיגרציה.

אומת: py_compile (5 קבצים) + בדיקות-יחידה offline (override/legacy/labels) + אימות-DB.
עודכנו X9 §3 + gap-audit (GAP-51 ).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:34:49 +00:00
parent d3f1d04915
commit 701efab726
10 changed files with 267 additions and 73 deletions

View File

@@ -20,7 +20,13 @@ from uuid import UUID
from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session, audit
from legal_mcp.services.lessons import get_content_checklist, get_methodology_summary
from legal_mcp.services.lessons import (
OUTCOME_LABELS_HE,
PRACTICE_AREA_OVERRIDES,
canonical_outcome,
get_content_checklist,
get_methodology_summary,
)
logger = logging.getLogger(__name__)
@@ -270,10 +276,11 @@ BLOCK_PROMPTS = {
}
# Discussion structure by outcome
# GAP-51: keyed by canonical outcomes (rejection/partial_acceptance/full_acceptance).
STRUCTURE_GUIDANCE = {
"rejected": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.",
"accepted": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.",
"partial": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
"rejection": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.",
"full_acceptance": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.",
"partial_acceptance": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.",
}
@@ -327,8 +334,13 @@ async def write_block(
appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id)
post_hearing_context = await _build_post_hearing_context(case_id)
outcome = (decision or {}).get("outcome", "rejected")
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
if case.get("practice_area") == "betterment_levy":
structure_guidance = (
structure_guidance + " | היטל השבחה: "
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
).strip()
# Content checklist — tells block-yod WHAT topics to cover
content_checklist = ""
@@ -438,8 +450,8 @@ async def _collect_block_sources(case_id: UUID, block_id: str) -> dict:
# ── Context builders ──────────────────────────────────────────────
def _build_case_context(case: dict, decision: dict | None) -> str:
outcome = (decision or {}).get("outcome", "")
outcome_heb = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, "")
outcome = canonical_outcome((decision or {}).get("outcome", ""))
outcome_heb = OUTCOME_LABELS_HE.get(outcome, "")
return f"""- מספר תיק: {case['case_number']}
- כותרת: {case.get('title', '')}
- עוררים: {', '.join(case.get('appellants', []))}
@@ -877,8 +889,13 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id)
post_hearing_context = await _build_post_hearing_context(case_id)
outcome = (decision or {}).get("outcome", "rejected")
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
if case.get("practice_area") == "betterment_levy":
structure_guidance = (
structure_guidance + " | היטל השבחה: "
+ " ".join(PRACTICE_AREA_OVERRIDES["betterment_levy"]["discussion_rules"])
).strip()
# Content checklist + methodology for block-yod
content_checklist = ""

View File

@@ -7,8 +7,32 @@ Based on analysis of: Hecht 1180-1181 (rejection) and Beit HaKerem 1126/25+1141/
from __future__ import annotations
# ── Valid outcome values ────────────────────────────────────────────
# GAP-51 / INV-TOOL2: canonical = 3 real outcomes. `betterment_levy` is a
# practice_area (not an outcome) — its writing-guidance lives in
# PRACTICE_AREA_OVERRIDES below and is applied on top of the chosen outcome.
VALID_OUTCOMES = ("rejection", "partial_acceptance", "full_acceptance", "betterment_levy")
VALID_OUTCOMES = ("rejection", "partial_acceptance", "full_acceptance")
# Hebrew display labels — SSoT (אנגלית ב-DB, עברית ב-UI). Replaces the inline
# maps that lived in block_writer.py and workflow.py.
OUTCOME_LABELS_HE = {
"rejection": "דחייה",
"partial_acceptance": "קבלה חלקית",
"full_acceptance": "קבלה מלאה",
}
# Backward-compat: legacy set_outcome vocabulary → canonical. Used by callers
# that may still pass the old values (rejected/accepted/partial).
LEGACY_OUTCOME_MAP = {
"rejected": "rejection",
"accepted": "full_acceptance",
"partial": "partial_acceptance",
}
def canonical_outcome(outcome: str) -> str:
"""Normalize any outcome string to the canonical vocabulary (GAP-51)."""
return LEGACY_OUTCOME_MAP.get(outcome, outcome)
# ── Golden Ratios (section % of total) ─────────────────────────────
@@ -16,7 +40,6 @@ GOLDEN_RATIOS: dict[str, dict[str, tuple[int, int]]] = {
"rejection": {"background": (15, 25), "claims": (30, 40), "discussion": (37, 50), "summary": (2, 9)},
"full_acceptance": {"background": (30, 40), "claims": (20, 30), "discussion": (35, 45), "summary": (3, 5)},
"partial_acceptance": {"background": (25, 35), "claims": (25, 30), "discussion": (40, 47), "summary": (2, 3)},
"betterment_levy": {"background": (6, 18), "claims": (13, 25), "discussion": (32, 48), "summary": (3, 4)},
}
# ── Paragraph length guidance (word counts) ────────────────────────
@@ -71,16 +94,6 @@ OPENING_STRATEGIES = {
"ואז 'כל הנקודות לעיל עומדות לפנינו...' → מעבר לניתוח"
),
},
"betterment_levy": {
"style": "direct_factual",
"paragraphs": (1, 3),
"description": (
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
),
},
}
# ── Summary strategies by outcome ──────────────────────────────────
@@ -105,18 +118,6 @@ SUMMARY_STRATEGIES = {
"כל ההנמקה כבר בדיון — הסיכום = רק מה מתקבל, מה נדחה, ותנאים"
),
},
"betterment_levy": {
"heading": "various",
"format": "dry_operative",
"description": (
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
"אין פסקה חמה. אין חזרה על נימוקים."
),
},
}
# ── Discussion structure rules ─────────────────────────────────────
@@ -140,14 +141,6 @@ DISCUSSION_RULES: dict[str, list[str]] = {
"full_acceptance": [
"מבנה ישיר: נקודות עיקריות → ניתוח → מסקנה.",
],
"betterment_levy": [
"פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
],
}
# ── Citation technique ─────────────────────────────────────────────
@@ -270,8 +263,49 @@ DECISION_TEMPLATES: dict[str, str] = {
ניתנה היום, {date}
דפנה תמיר, יו"ר ועדת הערר
""",
}
"betterment_levy": _HEADER + """## א. רקע עובדתי
# ── Practice-area writing overrides (GAP-51) ───────────────────────
# `betterment_levy` is a practice_area, NOT an outcome. A betterment-levy case
# still has a real outcome (rejection / partial / full), but its writing style
# is distinct (dry, factual, no warm closing). These overrides are layered on
# top of the chosen outcome's guidance by the accessors below.
PRACTICE_AREA_OVERRIDES: dict[str, dict] = {
"betterment_levy": {
"golden_ratios": {"background": (6, 18), "claims": (13, 25), "discussion": (32, 48), "summary": (3, 4)},
"opening_strategy": {
"style": "direct_factual",
"paragraphs": (1, 3),
"description": (
"פתיחה ישירה ועובדתית: 'בפנינו ערר על דרישת תשלום היטל השבחה מיום [תאריך] "
"בסך של [סכום] ₪' → רקע קצר (נכס, תכנית משביחה, מימוש) → "
"תמצית טענות הצדדים (עוררים + משיבה בנפרד). "
"אין הקשר תכנוני רחב. הפתיחה = עובדות בלבד."
),
},
"summary_strategy": {
"heading": "various",
"format": "dry_operative",
"description": (
"סיום יבש ואופרטיבי. כותרת משתנה: 'סוף דבר' / 'לאור כל האמור לעיל' / ללא כותרת. "
"תוכן: 'הערר נדחה/מתקבל' + הוצאות ('כל צד ישא בהוצאותיו' / חיוב בסכום). "
"אם מתקבל: הוראות אופרטיביות (החזר, שומה מתוקנת, תנאים). "
"חתימה: 'ניתנה פה אחד היום, [תאריך עברי], [תאריך לועזי].' "
"לעיתים: 'התיק ייסגר.' / 'עומדת זכות ערר כדין.' "
"אין פסקה חמה. אין חזרה על נימוקים."
),
},
"discussion_rules": [
"פתיחת דיון: מסקנה מוקדמת ('לאחר שבחנו... מצאנו כי דין הערר להידחות/להתקבל').",
"תקן ביקורת: ציון רף ההתערבות בשומה מכרעת (בר\"ם 3644/13 גלר) — אבחנה בין שמאי למשפטי.",
"הצגת הלכה פסוקה: ציטוט ארוך מפס\"ד מרכזי → 'ברוח הדברים לעיל נבחן את טענות הצדדים'.",
"טיפול שיטתי: כל טענה/סוגיה בנפרד → ניתוח → מסקנת ביניים.",
"ביטויים: 'אין בידינו לקבל', 'לא מצאנו מקום להתערב', 'קביעה נכונה שאין מקום להתערב בה'.",
"'על מנת לא לצאת בחסר' — לנקודות obiter dicta בסוף הדיון.",
],
"decision_template": _HEADER + """## א. רקע עובדתי
<!-- {ratios_background} -->
[תיאור הרקע העובדתי של הערר]
@@ -301,18 +335,31 @@ DECISION_TEMPLATES: dict[str, str] = {
ניתנה היום, {date}
דפנה תמיר, יו"ר ועדת הערר
""",
},
}
# ── Helper function ────────────────────────────────────────────────
def get_lessons_for_outcome(outcome: str) -> dict:
"""Assemble all relevant lessons for a given expected outcome."""
def get_lessons_for_outcome(outcome: str, practice_area: str = "") -> dict:
"""Assemble all relevant lessons for an outcome, with practice_area overrides.
GAP-51: ``betterment_levy`` is a practice_area — when given, its writing
overrides (golden ratios, opening/summary strategy, discussion rules) are
layered on top of the chosen outcome.
"""
outcome = canonical_outcome(outcome)
if outcome not in VALID_OUTCOMES:
return {"error": f"outcome must be one of: {', '.join(VALID_OUTCOMES)}"}
ratios = GOLDEN_RATIOS[outcome]
rules = DISCUSSION_RULES.get("universal", []) + DISCUSSION_RULES.get(outcome, [])
override = PRACTICE_AREA_OVERRIDES.get(practice_area, {})
ratios = override.get("golden_ratios") or GOLDEN_RATIOS[outcome]
opening = override.get("opening_strategy") or OPENING_STRATEGIES[outcome]
summary = override.get("summary_strategy") or SUMMARY_STRATEGIES[outcome]
rules = (
DISCUSSION_RULES.get("universal", [])
+ (override.get("discussion_rules") or DISCUSSION_RULES.get(outcome, []))
)
# Filter transition phrases: universal + outcome-specific
phrases = [
@@ -322,11 +369,12 @@ def get_lessons_for_outcome(outcome: str) -> dict:
return {
"outcome": outcome,
"practice_area": practice_area,
"golden_ratios": {
k: f"{v[0]}-{v[1]}%" for k, v in ratios.items()
},
"opening_strategy": OPENING_STRATEGIES[outcome],
"summary_strategy": SUMMARY_STRATEGIES[outcome],
"opening_strategy": opening,
"summary_strategy": summary,
"discussion_rules": rules,
"citation_guidance": CITATION_GUIDANCE,
"transition_phrases": [
@@ -339,9 +387,11 @@ def get_lessons_for_outcome(outcome: str) -> dict:
}
def format_ratios_comment(outcome: str, section: str) -> str:
"""Format golden ratio as an HTML comment for templates."""
ratios = GOLDEN_RATIOS.get(outcome, {})
def format_ratios_comment(outcome: str, section: str, practice_area: str = "") -> str:
"""Format golden ratio as an HTML comment for templates (practice_area-aware)."""
outcome = canonical_outcome(outcome)
override = PRACTICE_AREA_OVERRIDES.get(practice_area, {})
ratios = override.get("golden_ratios") or GOLDEN_RATIOS.get(outcome, {})
if section in ratios:
lo, hi = ratios[section]
return f"יעד: {lo}-{hi}% מסך ההחלטה"