7 Commits

Author SHA1 Message Date
dde44b8095 docs(lessons): עדכון התזה המשפטית לפי הסופי של דפנה ב-8125-09-24
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 32s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מ-8125-09-24 (שובל/רמות אלון) דפנה הפכה את התזה שלנו: אישרה "הסבת שטחים"
מחיר-למשתכן→שוק-חופשי כהשבחה תכנונית בת-חיוב, וקבעה ששומה מכרעת סמוכה
משכנעת-לא-מחייבת (ריסון דוקטרינת השוויון). מקודד ב-legal-decision-lessons.md
(#47–#50) + מצביע ב-SKILL §7.3 (תת-מסלול שמאי-מייעץ).

Invariants: G11 (תוכן משפטי — סמכות היו"ר; ה-PR הוא שער-האישור, INV-LRN1).
INV-LRN5 מקוים — מהות במסמך-הלקחים/SKILL, לא בשכבת-הקול. חבירה לבית-אב
canonical_halachot נדחתה למשימת-תכנון #225.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:22:09 +00:00
6414e5f94a Merge pull request 'fix(protocol): בחירת פרוטוקול ועדת-ערר לפי protocol_scope + document_id (#223)' (#393) from worktree-protocol-scope-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m13s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-05 10:00:34 +00:00
f4ce8332fe fix(protocol): בחירת פרוטוקול ועדת-ערר לפי protocol_scope + document_id (#223)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
analyze_protocol בחר את מסמך-הפרוטוקול הראשון (doc_type='protocol') והתעלם
מ-protocol_scope. בתיק עם כמה פרוטוקולים — נספח ועדה-מקומית + דיון ועדת-הערר —
הוא ניתח את הלא-נכון (התגלה ב-1043-02-26: ניתח "נספח 18 השתלשלות פרוטוקולים"
במקום "פרוטוקול דיון 23.6.26").

- _find_protocol מעדיף פרוטוקול ועדת-ערר (scope שאינו 'lower'); פרוטוקול
  'lower' (ועדה מקומית/מחוזית) לא נכנס להשוואה — הוא רקע לבלוק ו בלבד.
- כמה פרוטוקולי-ערר → האחרון (created_at) גובר; document_id מכוון במפורש.
- analyze_protocol (service + drafting tool + MCP tool) מקבל document_id/
  target_document_id אופציונלי.
- _protocol_scope: קורא metadata.protocol_scope (ריק=appeal, כמו ה-UI).
- 5 בדיקות חדשות: דילוג-lower, all-lower→None, most-recent, targeting, unknown-id.

טווח: Fix1 (קוד). ניקוי 1043 (תיוג נספח 18 כ-lower + מחיקת 11 רשומות שגויות
+ הרצה מכוונת ל-23.6.26) דורש טעינה-מחדש של ה-MCP — אחרי מיזוג. פרוצדורת
ניתוח-מסמך-נוסף הכללית — המשך ב-#223.

Invariants: G1 (נרמול-במקור — קריאת scope מ-metadata, לא ניחוש-בקריאה) ·
G2 (אותו נתיב ניתוח, בורר מדויק) · §6 (no_protocol מחזיר סטטוס מפורש, לא בליעה).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:59:30 +00:00
e5c7455284 Merge pull request 'feat(arguments): הפרדת-משיבים בצבירה לפי כתב-תשובה (#224)' (#392) from worktree-respondent-party-separation into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m11s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-05 09:53:27 +00:00
68c2be2a7b feat(arguments): הפרדת-משיבים בצבירה לפי כתב-תשובה (#224)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 11m48s
Lint — undefined names / undefined-names (pull_request) Successful in 12s
הצבירה קיבצה את כל המשיבים תחת מפלגה אחת "respondent", כך שכתבי-תשובה
נפרדים (משיבות 2-3 מול משיבים 4-6) עם עמדות שעלולות להיות מנוגדות נבלעו
לרשימה אחת. התגלה בתיק 1043-02-26.

השינוי מפצל את הצדדים הרב-משתתפים (respondent/permit_applicant) לפי
source_document — כל כתב-תשובה משותף = יחידת-ליטיגציה קוהרנטית נפרדת —
ומאחסן את התווית ב-legal_arguments.party_name (מיגרציה V50). עוררים/ועדה
נשארים קבוצה יחידה. בונוס: פיצול ה-129 טענות ל-קריאות-Claude קטנות מתקן
את הכשל-בשקט המקורי (קריאה של 100+ פרופוזיציות החזירה non-JSON והפילה את
כל הצד).

- db.py: SCHEMA_V50_SQL — legal_arguments.party_name (ADD COLUMN IF NOT EXISTS, אידמפוטנטי)
- argument_aggregator: קיבוץ (party, party_name); _build_prompt מקבל שם-כתב; SELECT מחזיר party_name
- UI: PartySection מתת-קבץ לפי כתב-תשובה; טיפוס LegalArgument.party_name

טווח v1: הקיבוץ לפי source_document; אכלוס party_name ב-extractor וזיהוי
עמדות-מנוגדות בבלוק ז/י — המשך ב-#224.

Invariants: G1 (נרמול-במקור — party_name בסכמה, לא תיקון-בקריאה) · G2 (אין
מסלול-צבירה מקביל, אותו pipeline) · §6 (אין בליעה שקטה — הכשל-בשקט תוקן).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:42:09 +00:00
acb887845c Merge pull request 'chore(case-ui): הסרת כפתור "פתח עורך החלטה" הכפול + regen api types' (#391) from worktree-remove-decision-editor-btn into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-05 07:19:44 +00:00
93cce0505e chore(case-ui): הסרת כפתור "פתח עורך החלטה" הכפול + regen api types
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
הכפתור בבאנר-התיק רק ביצע setTab("decision") — כפילות מדויקת של טאב
"ההחלטה" שכבר קיים ברצועת-הטאבים (עורך-ההחלטה אוחד לתוכה, /compose נמחק).
שריד שאיבד משמעות אחרי איחוד-המסכים; הוסר.

בנוסף: regen של src/lib/api/types.ts מול הסכמה החיה — קולט את שדות
DocumentPatchRequest (is_post_hearing/protocol_scope) שנוספו ב-#390.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 07:19:14 +00:00
12 changed files with 354 additions and 50 deletions

View File

@@ -531,3 +531,33 @@ Two 1200-25 entries recorded a case-specific issue order (threshold → plan int
traffic) with no generalizable rule. They are case artifacts, captured in that case's traffic) with no generalizable rule. They are case artifacts, captured in that case's
analysis-and-research.md — no general lesson folded. analysis-and-research.md — no general lesson folded.
---
## Doctrinal Lessons — Chair Final vs. Our Draft, 8125-09-24 (July 5, 2026)
### Source
- Case: 8125-09-24 (שובל טוב רמות / רמות אלון — ערר על שומה מכרעת, היטל השבחה; קבלה
חלקית + מינוי שמאי מייעץ חדש). Dafna's signed final vs. our full draft (block י).
- These are **substantive doctrine**, not style. Recorded verbatim from the signed
decision; also captured as `chair_feedback` ד1ד4 on the case (category `other`),
pending a proper canonical-principle home (TaskMaster #225).
-**#47 and #48 SUPERSEDE our prior draft's legal thesis.** Our draft parked the
chargeability question as an "open interpretive question" and over-weighted the
equality doctrine toward the appellant; Dafna decided the opposite. Follow the chair.
### 47. "Space Conversion" מחיר-למשתכן→שוק-חופשי IS a Chargeable Planning Betterment (SUPERSEDES prior thesis)
- **Lesson:** כאשר הקלה מוסיפה יחידות דיור (בלי תוספת זכויות/שטח עיקרי) ומשנה את תמהיל הדירות כך ששטחים שבמצב הקודם היו כפופים למגבלת "מחיר למשתכן" משתחררים לשיווק בשוק חופשי — זו **השבחה תכנונית בת-חיוב**, ומתודת ה"הסבה" (הפרש השווי מחיר-למשתכן↔שוק-חופשי על השטחים שהוסבו) לגיטימית ומנומקת: "לא ייתכן כי יזם 'ייהנה' משווי נמוך יותר לצורך חיוב בהיטל לאור מגבלות מחיר למשתכן כשזה משרת אותו". **אין** לגדֵר זאת כשאלה משפטית-פרשנית פתוחה בדבר "גדרי השבחה תכנונית" ולהטיל ספק בחיוב מכוח דרישת הקשר-הסיבתי (בית הכרם/פמיני) — זו הייתה גישת הטיוטה שלנו, והיא נדחתה.
- **Rule:** In מחיר-למשתכן projects, treat the free-market "space conversion" enabled by a unit-count relief as a chargeable planning betterment and uphold the difference-in-value method. Do NOT frame it as an open interpretive question or cast causal-nexus doubt on chargeability.
### 48. A Neighboring Final Assessment Does Not Compel Alignment; Equality Does Not Perpetuate Error
- **Lesson:** קיום שומה מכרעת חלוטה בנכס סמוך (אף באותו מכרז ותנאים) **אינו מחייב** את השמאי המכריע/הוועדה ליישר קו עמה; שומה מנומקת היטב רשאית לסטות ממנה — "לא נוכל להיאחז בשומה מכרעת אחרת רק מכיוון והיא קיימת". עקרון השוויון בגביית מס אינו מנציח טעות. אין להעמיס את דוקטרינת השוויון (דיבון שלום/גנדליס) כ"חובת הנמקה מוגברת" המטה לקבלת הערר — כפי שעשתה הטיוטה שלנו. עדיין: פער ניכר מול שומה סמוכה מצדיק בחינה והנמקה, לא אימוץ אוטומטי. **הערת-לוואי (ד3):** ועדה מקומית החולקת על שומה מכרעת ובוחרת שלא לערור עליה — ראוי שתיידע את הציבור; שינוי-עמדה שלה עם הזמן אפשרי אך צריך להיות החריג ומחייב הנמקה מוגברת.
- **Rule:** A final neighboring assessment is persuasive, not binding; a well-reasoned assessment may depart from it. Do not over-weight the equality doctrine to favor the appellant — flag the gap, require reasoning, but do not auto-align.
### 49. When Remand-to-the-Original-Assessor Is Blocked, Appoint a New Advisory Assessor
- **Lesson:** כשדרך-המלך (השבת השומה לשמאי המכריע לתיקון/הבהרה) חסומה מפני שהשמאי המכריע פרש/סיים כהונתו — מינוי **שמאי מייעץ חדש** לבחינה מחודשת של הרכיב שבמחלוקת הוא הפתרון, מכוח סמכות הוועדה הרחבה (אשקלוני) והיגיון גנדליס.
- **Rule:** If the deciding assessor has left office and cannot receive the file back, appoint a new advisory assessor to re-examine the disputed component under the committee's guidance.
### 50. Prefer a Reasoned Holding Over a Parked "Open Question" (methodology)
- **Lesson:** במקום לגדר סוגיה מהותית כ"שאלה משפטית פתוחה לשתי גישות לגיטימיות" ולהעבירה כמות-שהיא להשבה, דפנה **הכריעה** בה כשהחומר תמך בכך ("אנו סבורים כי השמאי המכריע פעל כדין ונסביר… בנקודה זו צדק לדעתנו השמאי המכריע"), והשיבה רק את הרכיב השמאי-היישומי הצר. הכרעה מנומקת עדיפה על היסוס אקדמי.
- **Rule:** Decide the interpretive question when the record supports a holding; reserve remand for the narrow appraisal-application residue. Don't park a decidable מהות issue as an "open question."

View File

@@ -759,13 +759,15 @@ async def get_appraiser_facts(case_number: str) -> str:
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ──── # ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
@mcp.tool() @mcp.tool()
async def analyze_protocol(case_number: str) -> str: async def analyze_protocol(case_number: str, document_id: str = "") -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד. """ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' + בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent). מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
re-run מחליף (idempotent).
""" """
return await drafting.analyze_protocol(case_number) return await drafting.analyze_protocol(case_number, document_id=document_id)
@mcp.tool() @mcp.tool()

View File

@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
# Allowed enum values mirror the DB CHECK constraints. # Allowed enum values mirror the DB CHECK constraints.
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"} ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
# Sides that may comprise multiple distinct litigants with potentially opposing
# positions — aggregated per source pleading (party_name) rather than collapsed
# into one bucket (#224). Appellant/committee speak with a single voice.
SPLIT_PARTIES = {"respondent", "permit_applicant"}
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"} ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
# Hebrew labels for the prompt (Claude needs context in the same # Hebrew labels for the prompt (Claude needs context in the same
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
""" """
def _build_prompt(party: str, propositions: list[dict]) -> str: def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
"""Compose the per-party aggregation prompt.""" """Compose the per-party aggregation prompt."""
n = len(propositions) n = len(propositions)
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12. # Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
target_max = max(target_min + 1, min(12, n // 2 + 1)) target_max = max(target_min + 1, min(12, n // 2 + 1))
party_he = PARTY_LABELS_HE.get(party, party) party_he = PARTY_LABELS_HE.get(party, party)
# For a split side (e.g. a specific respondent brief), name the brief so
# Claude aggregates only that litigant's position and does not conflate it
# with a co-respondent's separate — possibly opposing — pleading (#224).
if party_name:
party_he = f"{party_he}{party_name}"
# Strip noise from propositions for the prompt — Claude only needs # Strip noise from propositions for the prompt — Claude only needs
# the id and the text to do the grouping. # the id and the text to do the grouping.
compact = [ compact = [
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
async def _aggregate_party( async def _aggregate_party(
party: str, propositions: list[dict], party: str, propositions: list[dict], party_name: str = "",
) -> list[dict]: ) -> list[dict]:
"""Ask Claude to group one party's propositions; return normalized rows.""" """Ask Claude to group one party's propositions; return normalized rows.
``party_name`` names the specific pleading when this is a split side
(respondent / permit_applicant brief), so the prompt scopes to that
litigant's position only (#224).
"""
if not propositions: if not propositions:
return [] return []
prompt = _build_prompt(party, propositions) prompt = _build_prompt(party, propositions, party_name=party_name)
try: try:
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
@@ -221,14 +235,25 @@ async def aggregate_claims_to_arguments(
"total": 0, "total": 0,
} }
# Group propositions by party. # Group propositions by party — and, for the multi-party sides (respondent /
by_party: dict[str, list[dict]] = {} # permit_applicant), further by their source pleading so opposing joint
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
# the large respondent set per-brief also keeps each Claude call small enough
# to succeed — a single 100+ proposition call previously returned non-JSON
# and silently dropped the whole side.
by_group: dict[tuple[str, str], list[dict]] = {}
for r in rows: for r in rows:
party = r["party_role"] party = r["party_role"]
# Map deprecated 'appraiser' or unknown labels to 'unknown'. # Map deprecated 'appraiser' or unknown labels to 'unknown'.
if party not in ALLOWED_PARTIES: if party not in ALLOWED_PARTIES:
party = "unknown" party = "unknown"
by_party.setdefault(party, []).append(dict(r)) party_name = (
(r["source_document"] or "").strip()
if party in SPLIT_PARTIES else ""
)
by_group.setdefault((party, party_name), []).append(dict(r))
# Valid claim_ids for this case == the ids of the claims we just fetched. # Valid claim_ids for this case == the ids of the claims we just fetched.
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate # The LLM is asked to echo back supporting claim_ids, but it may hallucinate
@@ -243,9 +268,11 @@ async def aggregate_claims_to_arguments(
inserted = 0 inserted = 0
errors: list[str] = [] errors: list[str] = []
for party, props in by_party.items(): for (party, party_name), props in by_group.items():
# Display key for the per-side summary: keep opposing briefs distinct.
group_key = f"{party}·{party_name}" if party_name else party
try: try:
arguments = await _aggregate_party(party, props) arguments = await _aggregate_party(party, props, party_name=party_name)
except RuntimeError as e: except RuntimeError as e:
# Most likely cause: Claude CLI not installed (running from # Most likely cause: Claude CLI not installed (running from
# the container). Don't crash — record the gap and continue. # the container). Don't crash — record the gap and continue.
@@ -259,11 +286,11 @@ async def aggregate_claims_to_arguments(
), ),
"total": 0, "total": 0,
} }
errors.append(f"{party}: {msg}") errors.append(f"{group_key}: {msg}")
continue continue
if not arguments: if not arguments:
party_counts[party] = 0 party_counts[group_key] = 0
continue continue
async with pool.acquire() as conn: async with pool.acquire() as conn:
@@ -271,12 +298,13 @@ async def aggregate_claims_to_arguments(
for idx, arg in enumerate(arguments): for idx, arg in enumerate(arguments):
arg_id = await conn.fetchval( arg_id = await conn.fetchval(
"""INSERT INTO legal_arguments """INSERT INTO legal_arguments
(case_id, party, argument_index, argument_title, (case_id, party, party_name, argument_index,
argument_body, legal_topic, priority) argument_title, argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id""", RETURNING id""",
case_id, case_id,
party, party,
party_name,
idx + 1, idx + 1,
arg["title"], arg["title"],
arg["body"], arg["body"],
@@ -313,7 +341,7 @@ async def aggregate_claims_to_arguments(
cid, arg_id, e, cid, arg_id, e,
) )
inserted += 1 inserted += 1
party_counts[party] = len(arguments) party_counts[group_key] = len(arguments)
result: dict = { result: dict = {
"status": "completed", "status": "completed",
@@ -338,9 +366,9 @@ async def get_legal_arguments(
async with pool.acquire() as conn: async with pool.acquire() as conn:
if party and party in ALLOWED_PARTIES: if party and party in ALLOWED_PARTIES:
rows = await conn.fetch( rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title, """SELECT id, case_id, party, party_name, argument_index,
argument_body, legal_topic, priority, cited_precedents, argument_title, argument_body, legal_topic, priority,
created_at, updated_at cited_precedents, created_at, updated_at
FROM legal_arguments FROM legal_arguments
WHERE case_id = $1 AND party = $2 WHERE case_id = $1 AND party = $2
ORDER BY priority, argument_index""", ORDER BY priority, argument_index""",
@@ -348,12 +376,12 @@ async def get_legal_arguments(
) )
else: else:
rows = await conn.fetch( rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title, """SELECT id, case_id, party, party_name, argument_index,
argument_body, legal_topic, priority, cited_precedents, argument_title, argument_body, legal_topic, priority,
created_at, updated_at cited_precedents, created_at, updated_at
FROM legal_arguments FROM legal_arguments
WHERE case_id = $1 WHERE case_id = $1
ORDER BY party, priority, argument_index""", ORDER BY party, party_name, priority, argument_index""",
case_id, case_id,
) )

View File

@@ -1888,6 +1888,18 @@ CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
""" """
# V50 (#224): per-respondent-brief separation. The aggregator groups respondent /
# permit_applicant claims by their source pleading — each joint response brief
# (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") is one coherent litigation
# position, so opposing briefs no longer collapse into a single "respondent"
# bucket. ``party_name`` carries the brief label; appellant/committee stay ''.
SCHEMA_V50_SQL = """
ALTER TABLE legal_arguments ADD COLUMN IF NOT EXISTS party_name TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name
ON legal_arguments(case_id, party, party_name);
"""
# Stable, arbitrary key for the session-level advisory lock that serialises # Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services) # schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes # re-runs the idempotent migrations on startup; without this lock two processes
@@ -1959,6 +1971,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V47_SQL) await conn.execute(SCHEMA_V47_SQL)
await conn.execute(SCHEMA_V48_SQL) await conn.execute(SCHEMA_V48_SQL)
await conn.execute(SCHEMA_V49_SQL) await conn.execute(SCHEMA_V49_SQL)
await conn.execute(SCHEMA_V50_SQL)
async def init_schema() -> None: async def init_schema() -> None:

View File

@@ -120,15 +120,47 @@ HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול די
MAX_PROTOCOL_CHARS = 120_000 MAX_PROTOCOL_CHARS = 120_000
def _find_protocol(docs: list[dict]) -> dict | None: def _protocol_scope(doc: dict) -> str:
"""The protocol document for the case, if present (doc_type or title).""" """Protocol scope from ``metadata.protocol_scope``.
for d in docs:
if d.get("doc_type") == "protocol": Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
return d document-type-editor convention where only the non-default ``'lower'``
for d in docs: (ועדה מקומית/מחוזית) is persisted.
if "פרוטוקול" in (d.get("title") or ""): """
return d meta = doc.get("metadata") or {}
return (meta.get("protocol_scope") or "").strip() or "appeal"
def _find_protocol(
docs: list[dict], document_id: UUID | None = None,
) -> dict | None:
"""The ועדת-הערר hearing protocol to compare against the pleadings.
- ``document_id`` given → return exactly that document (explicit target),
so a case with several protocols can be pointed at the right hearing.
- Otherwise prefer a protocol scoped to the appeals committee
(``scope != 'lower'``). A ``'lower'`` protocol is the local/district
committee's proceedings — it feeds background (block ו) only and must NOT
drive the hearing-vs-pleadings comparison (#223). If every protocol is
lower-scoped there is no ערר-hearing to compare → return None.
- Among appeal-scoped protocols the most recent (by ``created_at``) wins —
the operative hearing; ties fall back to list order.
"""
if document_id is not None:
return next(
(d for d in docs if str(d.get("id")) == str(document_id)), None,
)
protocols = [d for d in docs if d.get("doc_type") == "protocol"]
if not protocols:
# Legacy fallback: untyped docs whose title says "פרוטוקול".
protocols = [d for d in docs if "פרוטוקול" in (d.get("title") or "")]
appeal_scoped = [d for d in protocols if _protocol_scope(d) != "lower"]
if not appeal_scoped:
return None return None
appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True)
return appeal_scoped[0]
def _compact_arguments(arguments: list[dict]) -> list[dict]: def _compact_arguments(arguments: list[dict]) -> list[dict]:
@@ -239,9 +271,15 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
} }
async def analyze_protocol(case_id: UUID) -> dict: async def analyze_protocol(
case_id: UUID, target_document_id: UUID | None = None,
) -> dict:
"""Comparative analysis of the case's hearing protocol vs. its pleadings. """Comparative analysis of the case's hearing protocol vs. its pleadings.
``target_document_id`` pins the analysis to a specific protocol document —
required when a case holds several protocols (e.g. a lower-committee annex
plus the ועדת-הערר hearing) and the auto-pick would be ambiguous (#223).
1. Locates the protocol document. 1. Locates the protocol document.
2. Pulls the aggregated legal_arguments (the written-pleadings baseline). 2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped / 3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
@@ -252,11 +290,20 @@ async def analyze_protocol(case_id: UUID) -> dict:
Returns a serializable summary dict. Returns a serializable summary dict.
""" """
docs = await db.list_documents(case_id) docs = await db.list_documents(case_id)
protocol = _find_protocol(docs) protocol = _find_protocol(docs, document_id=target_document_id)
if not protocol: if not protocol:
if target_document_id is not None:
return { return {
"status": "no_protocol", "status": "no_protocol",
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.", "message": f"מסמך {target_document_id} לא נמצא בתיק.",
}
return {
"status": "no_protocol",
"message": (
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
),
} }
document_id = UUID(protocol["id"]) document_id = UUID(protocol["id"])

View File

@@ -580,7 +580,7 @@ async def get_appraiser_facts(case_number: str) -> str:
return err(str(e)) return err(str(e))
async def analyze_protocol(case_number: str) -> str: async def analyze_protocol(case_number: str, document_id: str = "") -> str:
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203). """ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**, מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
@@ -588,12 +588,17 @@ async def analyze_protocol(case_number: str) -> str:
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
מוזן חזרה לעמודה הקנונית cases.hearing_date. מוזן חזרה לעמודה הקנונית cases.hearing_date.
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
ציין document_id כדי לכוון לדיון המדויק (#223).
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high); (aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent). re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
Args: Args:
case_number: מספר תיק הערר case_number: מספר תיק הערר
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
""" """
from legal_mcp.services import protocol_analyzer from legal_mcp.services import protocol_analyzer
@@ -601,8 +606,16 @@ async def analyze_protocol(case_number: str) -> str:
if not case: if not case:
return err(f"תיק {case_number} לא נמצא.") return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"]) case_id = UUID(case["id"])
target_doc_id: UUID | None = None
if document_id.strip():
try: try:
result = await protocol_analyzer.analyze_protocol(case_id) target_doc_id = UUID(document_id.strip())
except ValueError:
return err(f"document_id לא תקין: {document_id}")
try:
result = await protocol_analyzer.analyze_protocol(
case_id, target_document_id=target_doc_id,
)
await audit.log_action_safe( await audit.log_action_safe(
"analyze_protocol", case_id=case_id, "analyze_protocol", case_id=case_id,
details={"status": result.get("status"), "total": result.get("total", 0)}, details={"status": result.get("status"), "total": result.get("total", 0)},

View File

@@ -49,6 +49,65 @@ def test_find_protocol_none():
assert pa._find_protocol(docs) is None assert pa._find_protocol(docs) is None
def test_find_protocol_skips_lower_scope():
# A local/district-committee protocol (scope='lower') must NOT be picked for
# the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223).
docs = [
{"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית",
"metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"},
{"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26",
"metadata": {}, "created_at": "2026-06-23"},
]
assert pa._find_protocol(docs)["id"] == "appeal"
def test_find_protocol_all_lower_returns_none():
# If every protocol is lower-scoped there is no ערר-hearing to compare.
docs = [
{"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי",
"metadata": {"protocol_scope": "lower"}},
]
assert pa._find_protocol(docs) is None
def test_find_protocol_prefers_most_recent_appeal():
docs = [
{"id": "old", "doc_type": "protocol", "title": "פרוטוקול א",
"metadata": {}, "created_at": "2026-03-01"},
{"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב",
"metadata": {}, "created_at": "2026-06-23"},
]
assert pa._find_protocol(docs)["id"] == "new"
def test_find_protocol_explicit_document_id_overrides():
# Explicit target wins even over scope/recency heuristics — including the
# ability to point at a lower-scoped doc if the caller insists.
docs = [
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
"title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"},
{"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol",
"title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"},
"created_at": "2026-01-01"},
]
from uuid import UUID
picked = pa._find_protocol(
docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"),
)
assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002"
def test_find_protocol_unknown_document_id_returns_none():
from uuid import UUID
docs = [
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
"title": "פרוטוקול", "metadata": {}},
]
assert pa._find_protocol(
docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"),
) is None
# ── _normalize_change — anti-hallucination gate (INV-AH) ─────────────────── # ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
_AID = "11111111-1111-1111-1111-111111111111" _AID = "11111111-1111-1111-1111-111111111111"

View File

@@ -287,6 +287,7 @@ description: This skill should be used when writing legal decisions (החלטו
**ערר היטל השבחה — הכרעה מפוצלת (שמאי מייעץ):** **ערר היטל השבחה — הכרעה מפוצלת (שמאי מייעץ):**
תת-מסלול חוזר: הוועדה מאשרת את עצם החבות אך אינה קובעת את גובה ההיטל — ממנה שמאי מייעץ. פתיחה: זהה למסלול הכללי — "עניינו של ערר זה בדרישת תשלום היטל השבחה...". ללא סיפור תכנוני רחב. דיון — שלב משפטי: הכרעה בשאלת עצם החבות. ניתוח הטענה המרכזית (בד"כ "זכות מוקנית") מול ההקלה שהתבקשה. ציטוטי פסיקה inline — הפניה להחלטות ועדת ערר קודמות ללא בלוק ציטוט מלא. ביטויי מפתח: "אנו סבורים כי...", "בניגוד לעמדת העורר...", "לא ניתן לטעון כי...". מעבר לשלב השמאי — 3 ביטויי גישור: (א) "בכל הנוגע לטענות לגבי מקדמים... וכל טענה בעלת אופי שמאי **ניתן יהיה לעלות בפני השמאי המייעץ**." (ב) "על כן, לאור האמור **אנו ממנים שמאי מייעץ** אשר יערוך שומה להערכת ההשבחה במקרקעין כתוצאה מאישור ההקלה..." (ג) "השמאי המייעץ ינהל את הדיון **בהתאם לתקנות התכנון והבניה (סדרי דין בבקשה להכרעה לפני שמאי מכריע או שמאי מייעץ), התשס"ט-2008**." — נוסחה קבועה. הוראות המשך: "לאחר קבלת השומה המייעצת יהיו רשאים הצדדים להגיש את השגותיהם בתוך 30 יום לוועדת הערר ולאחר מכן תתקבל החלטה באשר לאופן קידום ההליך." סיום: **ללא** כותרת "סיכום" / "סוף דבר" — זורם ישירות מהוראות המינוי לחתימה "ניתנה פה אחד היום...". הוצאות: לא מוזכרות (ההליך טרם הסתיים). ראה: נווה יעקב 8070/25. תת-מסלול חוזר: הוועדה מאשרת את עצם החבות אך אינה קובעת את גובה ההיטל — ממנה שמאי מייעץ. פתיחה: זהה למסלול הכללי — "עניינו של ערר זה בדרישת תשלום היטל השבחה...". ללא סיפור תכנוני רחב. דיון — שלב משפטי: הכרעה בשאלת עצם החבות. ניתוח הטענה המרכזית (בד"כ "זכות מוקנית") מול ההקלה שהתבקשה. ציטוטי פסיקה inline — הפניה להחלטות ועדת ערר קודמות ללא בלוק ציטוט מלא. ביטויי מפתח: "אנו סבורים כי...", "בניגוד לעמדת העורר...", "לא ניתן לטעון כי...". מעבר לשלב השמאי — 3 ביטויי גישור: (א) "בכל הנוגע לטענות לגבי מקדמים... וכל טענה בעלת אופי שמאי **ניתן יהיה לעלות בפני השמאי המייעץ**." (ב) "על כן, לאור האמור **אנו ממנים שמאי מייעץ** אשר יערוך שומה להערכת ההשבחה במקרקעין כתוצאה מאישור ההקלה..." (ג) "השמאי המייעץ ינהל את הדיון **בהתאם לתקנות התכנון והבניה (סדרי דין בבקשה להכרעה לפני שמאי מכריע או שמאי מייעץ), התשס"ט-2008**." — נוסחה קבועה. הוראות המשך: "לאחר קבלת השומה המייעצת יהיו רשאים הצדדים להגיש את השגותיהם בתוך 30 יום לוועדת הערר ולאחר מכן תתקבל החלטה באשר לאופן קידום ההליך." סיום: **ללא** כותרת "סיכום" / "סוף דבר" — זורם ישירות מהוראות המינוי לחתימה "ניתנה פה אחד היום...". הוצאות: לא מוזכרות (ההליך טרם הסתיים). ראה: נווה יעקב 8070/25.
*הכרעות-מהות בתת-מסלול זה (מחיר-למשתכן / שומה סמוכה — 8125-09-24, ראה `docs/legal-decision-lessons.md` #47#50):* (א) "הסבת שטחים" ממחיר-למשתכן לשוק-חופשי בעקבות הקלת תוספת יח"ד = **השבחה תכנונית בת-חיוב** — להכריע בחיוב, לא לגדֵר כשאלה פתוחה או להטיל ספק מכוח קשר-סיבתי (בית הכרם/פמיני). (ב) שומה מכרעת חלוטה סמוכה = **משכנעת, לא מחייבת**; שומה מנומקת רשאית לסטות ("לא נוכל להיאחז בשומה מכרעת אחרת רק מכיוון והיא קיימת") — לא להעמיס דוקטרינת שוויון לטובת העוררת. (ג) שמאי מכריע פרש → מינוי **שמאי מייעץ חדש** (סמכות אשקלוני/גנדליס).
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":** **ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות: כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:

View File

@@ -104,12 +104,6 @@ export default function CaseDetailPage({
<> <>
{data && <CaseEditDialog data={data} />} {data && <CaseEditDialog data={data} />}
<UploadSheet caseNumber={caseNumber} /> <UploadSheet caseNumber={caseNumber} />
<Button
className="bg-gold text-white hover:bg-gold-deep border-transparent"
onClick={() => setTab("decision")}
>
פתח עורך החלטה
</Button>
{canStartWorkflow && ( {canStartWorkflow && (
<Button <Button
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment" className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"

View File

@@ -76,11 +76,50 @@ type PartySectionProps = {
}; };
/** /**
* Inner body of a single party — the priority-grouping + per-argument inner * Inner body of a single party. For a multi-litigant side (respondent /
* accordion. The party header (name / sub / count) now lives on the enclosing * permit_applicant) whose arguments carry distinct ``party_name`` brief labels,
* party-accordion trigger (mockup 25b), so this renders the groups only. * we sub-group by brief first — so opposing joint briefs (e.g. "משיבות 2-3" vs
* "משיבים 4-6") read as separate positions rather than one merged list (#224).
* Single-voice sides (one empty brief) render the priority groups directly.
*/ */
function PartySection({ args }: PartySectionProps) { function PartySection({ args }: PartySectionProps) {
const briefs = useMemo(() => {
const byBrief = new Map<string, LegalArgument[]>();
for (const a of args) {
const key = a.party_name?.trim() ?? "";
const list = byBrief.get(key);
if (list) list.push(a);
else byBrief.set(key, [a]);
}
return [...byBrief.entries()];
}, [args]);
// No real per-brief split (single, unnamed group) → render groups directly.
if (briefs.length <= 1) {
return <PriorityGroups args={briefs[0]?.[1] ?? args} />;
}
return (
<div className="space-y-5">
{briefs.map(([brief, briefArgs]) => (
<div key={brief || "—"} className="space-y-2">
<div className="flex items-center gap-2 border-s-2 border-gold-deep ps-2">
<span className="text-navy text-sm font-semibold">
{brief || "ללא שיוך לכתב"}
</span>
<span className="text-ink-muted text-xs">
{briefArgs.length} טיעונים
</span>
</div>
<PriorityGroups args={briefArgs} />
</div>
))}
</div>
);
}
/** Priority-grouped argument accordion for one coherent set of arguments. */
function PriorityGroups({ args }: PartySectionProps) {
const grouped = useMemo(() => groupByPriority(args), [args]); const grouped = useMemo(() => groupByPriority(args), [args]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -32,6 +32,12 @@ export type LegalArgument = {
id: string; id: string;
case_id: string; case_id: string;
party: LegalArgumentParty; party: LegalArgumentParty;
/**
* The specific pleading this argument belongs to, for sides that may comprise
* several litigants with opposing positions (respondent / permit_applicant
* briefs). Empty for single-voice sides (appellant / committee). (#224)
*/
party_name?: string;
argument_index: number; argument_index: number;
argument_title: string; argument_title: string;
argument_body: string; argument_body: string;

View File

@@ -2127,6 +2127,30 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/cases/{case_number}/agents/interaction-dismiss": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Api Dismiss Interaction
* @description Dismiss a pending interaction WITHOUT waking the issue assignee.
*
* For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
* this does not resolve via Paperclip's wake_assignee path — it cancels the row
* so the chair can clear noise without triggering an agent run.
*/
post: operations["api_dismiss_interaction_api_cases__case_number__agents_interaction_dismiss_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/agents/reset": { "/api/cases/{case_number}/agents/reset": {
parameters: { parameters: {
query?: never; query?: never;
@@ -4653,13 +4677,19 @@ export interface components {
}; };
/** /**
* DocumentPatchRequest * DocumentPatchRequest
* @description Patch payload for a single document. Both fields are optional. * @description Patch payload for a single document. All fields are optional; only the
* ones present are applied. The metadata.* flags are display/processing hints
* that never change the stored doc_type.
*/ */
DocumentPatchRequest: { DocumentPatchRequest: {
/** Doc Type */ /** Doc Type */
doc_type?: string | null; doc_type?: string | null;
/** Appraiser Side */ /** Appraiser Side */
appraiser_side?: string | null; appraiser_side?: string | null;
/** Is Post Hearing */
is_post_hearing?: boolean | null;
/** Protocol Scope */
protocol_scope?: string | null;
}; };
/** EquivalentLinkRequest */ /** EquivalentLinkRequest */
EquivalentLinkRequest: { EquivalentLinkRequest: {
@@ -4824,6 +4854,13 @@ export interface components {
/** Canonical Statement */ /** Canonical Statement */
canonical_statement?: string | null; canonical_statement?: string | null;
}; };
/** InteractionDismissRequest */
InteractionDismissRequest: {
/** Issue Id */
issue_id: string;
/** Interaction Id */
interaction_id: string;
};
/** InteractionResponseRequest */ /** InteractionResponseRequest */
InteractionResponseRequest: { InteractionResponseRequest: {
/** Issue Id */ /** Issue Id */
@@ -8372,6 +8409,41 @@ export interface operations {
}; };
}; };
}; };
api_dismiss_interaction_api_cases__case_number__agents_interaction_dismiss_post: {
parameters: {
query?: never;
header?: never;
path: {
case_number: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["InteractionDismissRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_reset_case_agents_api_cases__case_number__agents_reset_post: { api_reset_case_agents_api_cases__case_number__agents_reset_post: {
parameters: { parameters: {
query?: never; query?: never;