15 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
cc48896a0e Merge pull request 'feat(case-ui): חלוקת רשימת-המסמכים בטאב-הסקירה ל-4 קבוצות' (#390) from worktree-docs-grouped-overview into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m45s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-05 06:54:16 +00:00
cc8af903b7 feat(case-ui): חלוקת רשימת-המסמכים בטאב-הסקירה ל-4 קבוצות
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 36s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
חלוקה פנימית (לנוחות בלבד, לא משנה doc_type) של רשימת מסמכי-התיק:
1. עיקריים (appeal/response/appraisal) · 2. נלווים · 3. פרוטוקול ועדת הערר ·
4. לאחר הדיון. הסיווג נגזר בצד-הלקוח מ-doc_type + שני דגלי-metadata.

שני פקדים חדשים בעורך-התיוג:
- מתג "התקבל אחרי הדיון" → metadata.is_post_hearing (כבר נצרך ע"י בלוק-ח);
  גובר על הסיווג-לפי-סוג ומעביר לקבוצה 4.
- בורר "פרוטוקול של: ועדת הערר / ועדה מקומית-מחוזית" → metadata.protocol_scope
  (נראה רק כשהסוג protocol); ברירת-מחדל appeal→קבוצה 3, lower→קבוצה 2.

Backend: PATCH /documents/{id} + MCP document_update מקבלים is_post_hearing
ו-protocol_scope, נשמרים ב-metadata JSONB (אותו מסלול כמו appraiser_side).

עיצוב אושר ב-Claude Design (X17): 18k-case-documents-grouped.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:52:17 +00:00
bc0a4bb895 Merge pull request 'fix(block-writer): רתימת חלון-1M של Opus 4.8 לבלוק-י (#216)' (#389) from worktree-blockyod-1m into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m34s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 20:40:48 +00:00
9d88255050 Merge pull request 'feat(case-ui): agents tab v2 + dismiss/reaper for stale interactions (#215)' (#388) from worktree-agents-tab-v2 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 20:38:16 +00:00
2b68d6cbeb fix(block-writer): רתימת חלון-1M של Opus 4.8 לבלוק-י (#216)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
הגבול _MAX_PROMPT_CHARS=400_000 היה שומר מלאכותי שתאם את קיר ה-200K-טוקן
של בניית-Opus הסטנדרטית (~400K תווים עברית @~2 תווים/טוקן) — לא גבול-מודל.
Opus 4.8 מציע חלון 1M-טוקן במחיר רגיל; `claude -p` חושף אותו ישירות כ-model-id
`claude-opus-4-8[1m]` (אומת CLI 2.1.196, בלי beta-header). בלוק-י (דיון), שנושא
את התיק המלא כ-source-context (805K/891K תווים), נחסם רק ע"י השומר המיושן.

שינויים:
- block_writer: GENERATION_MODEL_1M + סף אסקלציה _CTX_1M_THRESHOLD_CHARS=350_000.
  פרומפט > הסף → `[1m]`; אחרת הבנייה הסטנדרטית (בלוקים קטנים נשארים זולים/מהירים).
- _MAX_PROMPT_CHARS 400_000 → 1_500_000 (~750K טוקנים; מרווח מתחת ל-1M) כשומר-קשיח
  סופי גם על בניית-ה-1M; הודעת-השגיאה מפנה לצמצום-source-context.
- model_used (provenance) משקף את הבנייה בפועל (200K/[1m]).
- claude_session: אזהרת-גודל מעודכנת (350K + רמז ל-[1m]).
- ספ 04-analysis-writing §1.3: תיעוד האסקלציה כעידון של אותו pin (לא מסלול מקביל, G2).

Invariants: מקיים G2 (אותו מודל-נעוץ, חלון רחב — לא מסלול מקביל), G1, INV-WR*;
לא בולע שגיאות (שומר-קשיח סופי נשאר). xhigh לבלוק-י ללא שינוי.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:38:00 +00:00
581a4ba36f feat(case-ui): agents tab v2 + dismiss/reaper for stale interactions (#215)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מוקאפ 18i v2 (מאושר ב-Claude Design) + תיקון-מקור להצטברות pending interactions.

UI (agent-activity-feed.tsx):
- רוסטר-סוכנים קומפקטי, כל הסוכנים בשורה אחת (grid-cols-9); הוסרה שורת
  "מה הסוכן עושה עכשיו" — היא שייכה תגובה לפי role-fallback, כך שסוכנים
  בעלי אותו תפקיד הציגו טקסט זהה.
- כל אירוע "ממתין לתשובתך" מקופל כאקורדיון (הראשון פתוח).
- כפתור "התעלם" לכל בקשה ממתינה — מבטל שאלה כפולה/מיושנת בלי להעיר את הסוכן.
- ציר-הזמן מקובץ לפי משימה (issue), כל קבוצה אקורדיון עם מזהה+סטטוס+מונה.

Backend (#215 — מניעת הצטברות pending):
- cancel_interaction: ביטול interaction בודד ישירות ל-cancelled, ללא
  wake_assignee (resolve רגיל היה מעיר את הסוכן). אין triggers על הטבלה.
- reap_stale_interactions: reaper שמבטל pending על issues סגורים
  (done/cancelled) — המקור הדומיננטי לערימה. רץ כל 15 דק' ב-lifespan.
- endpoint POST /api/cases/{case}/agents/interaction-dismiss (מאחורי כפתור
  "התעלם"). הכל דרך agent_platform_port (G12).

supersede-at-creation נדחה (תועד ב-#215): ביטול-אוטומטי של שאלה על issue
פעיל אחד על-פני אחר אינו דטרמיניסטי; הליבה הבטוחה = reaper-ליתומים +
כפתור-ביטול, והשורש האמיתי הוא ריסון לולאות-recovery של Paperclip.

Invariants: מקיים G12 (מגע-Paperclip רק דרך הפורט), G2 (אין מסלול מקביל —
מרחיב את endpoints הסוכנים הקיימים), §6 (אין בליעת-שגיאות שקטה — הביטול
מחזיר {ok,error}, ה-reaper מתעד warning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:36:56 +00:00
88977b2f10 Merge pull request 'chore(web-ui): regen api types — document-generation endpoints (#214)' (#387) from worktree-regen-api-types-214 into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 43s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 20:06:32 +00:00
40a2032e0d chore(web-ui): regen api types — document-generation endpoints (#214)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Regenerate web-ui/src/lib/api/types.ts from the PROD OpenAPI schema
(https://legal-ai.nautilus.marcusgroup.org/openapi.json) via `npm run api:types`.

Adds 3 drifted paths (pure additions, generated — not hand-edited):
- POST /api/cases/{case_number}/generate/party-claims-summary  (#214)
- POST /api/cases/{case_number}/generate/interim-draft          (#214)
- /api/status-model                                             (drift)

Invariants:
- X6 (UI<->API contract in sync) — generated types now match the deployed surface.
- G2 (single generated client) — openapi-typescript output only, no hand edits.

Verified: `npx tsc --noEmit` passes clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:05:31 +00:00
25 changed files with 1225 additions and 221 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
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

@@ -85,6 +85,11 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
**חלון-הקשר (#216):** הבנייה הסטנדרטית רצה 200K-טוקן (~400K תווים עברית). פרומפט גדול
(בעיקר בלוק-י, שנושא את התיק המלא כ-source-context) מוסלם אוטומטית לבניית-ה-1M של *אותו*
מודל — `claude-opus-4-8[1m]` (1M-טוקן, מחיר רגיל; `claude -p` חושף אותה ישירות כ-model-id,
בלי beta-header). **עידון של אותו pin, לא מסלול-מודל מקביל (G2).** הסף ב-`block_writer`:
פרומפט > ~350K תווים → `[1m]`; אחרת הבנייה הסטנדרטית. תקרת-קשיחה: 1.5M תווים.
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)

View File

@@ -759,13 +759,15 @@ async def get_appraiser_facts(case_number: str) -> str:
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
@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' +
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent).
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (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()

View File

@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
# Allowed enum values mirror the DB CHECK constraints.
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"}
# 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."""
n = len(propositions)
# 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))
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
# the id and the text to do the grouping.
compact = [
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
async def _aggregate_party(
party: str, propositions: list[dict],
party: str, propositions: list[dict], party_name: str = "",
) -> 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:
return []
prompt = _build_prompt(party, propositions)
prompt = _build_prompt(party, propositions, party_name=party_name)
try:
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,
}
# Group propositions by party.
by_party: dict[str, list[dict]] = {}
# Group propositions by party — and, for the multi-party sides (respondent /
# 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:
party = r["party_role"]
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
if party not in ALLOWED_PARTIES:
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.
# 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
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:
arguments = await _aggregate_party(party, props)
arguments = await _aggregate_party(party, props, party_name=party_name)
except RuntimeError as e:
# Most likely cause: Claude CLI not installed (running from
# the container). Don't crash — record the gap and continue.
@@ -259,11 +286,11 @@ async def aggregate_claims_to_arguments(
),
"total": 0,
}
errors.append(f"{party}: {msg}")
errors.append(f"{group_key}: {msg}")
continue
if not arguments:
party_counts[party] = 0
party_counts[group_key] = 0
continue
async with pool.acquire() as conn:
@@ -271,12 +298,13 @@ async def aggregate_claims_to_arguments(
for idx, arg in enumerate(arguments):
arg_id = await conn.fetchval(
"""INSERT INTO legal_arguments
(case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7)
(case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id""",
case_id,
party,
party_name,
idx + 1,
arg["title"],
arg["body"],
@@ -313,7 +341,7 @@ async def aggregate_claims_to_arguments(
cid, arg_id, e,
)
inserted += 1
party_counts[party] = len(arguments)
party_counts[group_key] = len(arguments)
result: dict = {
"status": "completed",
@@ -338,9 +366,9 @@ async def get_legal_arguments(
async with pool.acquire() as conn:
if party and party in ALLOWED_PARTIES:
rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
"""SELECT id, case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority,
cited_precedents, created_at, updated_at
FROM legal_arguments
WHERE case_id = $1 AND party = $2
ORDER BY priority, argument_index""",
@@ -348,12 +376,12 @@ async def get_legal_arguments(
)
else:
rows = await conn.fetch(
"""SELECT id, case_id, party, argument_index, argument_title,
argument_body, legal_topic, priority, cited_precedents,
created_at, updated_at
"""SELECT id, case_id, party, party_name, argument_index,
argument_title, argument_body, legal_topic, priority,
cited_precedents, created_at, updated_at
FROM legal_arguments
WHERE case_id = $1
ORDER BY party, priority, argument_index""",
ORDER BY party, party_name, priority, argument_index""",
case_id,
)

View File

@@ -58,6 +58,18 @@ logger = logging.getLogger(__name__)
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
# 1M-context build of the SAME pinned model (#216). The default build runs a
# 200K-token context (~400K Hebrew chars at ~2 chars/token); large prompts —
# notably block-yod, which carries the full case as source-context — overflow it.
# Opus 4.8 offers a 1M-token window at standard pricing; `claude -p` exposes it
# via the `[1m]` model id directly (no beta header — verified CLI 2.1.196). We
# escalate to it only when the prompt is large, so small blocks stay on the
# cheaper/faster 200K build. NOT a parallel model (G2) — same model, wider window.
GENERATION_MODEL_1M = "claude-opus-4-8[1m]"
# Escalate to the 1M build above this prompt size. Set below the 200K-token wall
# (~400K chars) with headroom for output tokens (max_tokens up to 16K) + tokenizer
# variance, so we switch before the standard build can overflow.
_CTX_1M_THRESHOLD_CHARS = 350_000
BLOCK_CONFIG = {
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
@@ -461,17 +473,21 @@ async def write_block(
if not dir_doc.get("approved"):
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
# Guard against context overflow before calling claude -p.
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
# In practice the CLI has crashed on prompts above ~400K chars, so use
# that as a conservative ceiling (well below the token limit).
_MAX_PROMPT_CHARS = 400_000
# Pick the model build by prompt size (#216). Above the 200K-token wall we
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
# block-yod legitimately carries the whole case as source-context. The 400K
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
_MAX_PROMPT_CHARS = 1_500_000
if len(prompt) > _MAX_PROMPT_CHARS:
raise RuntimeError(
f"Prompt too large for {block_id}: {len(prompt):,} chars "
f"(limit {_MAX_PROMPT_CHARS:,}). "
f"(limit {_MAX_PROMPT_CHARS:,}, even on the 1M-context build). "
f"source_context: {len(source_context):,} chars. "
f"Reduce documents or call extract_appraiser_facts first."
f"Reduce source-context (summaries / appraiser_facts / focused RAG)."
)
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
@@ -483,14 +499,14 @@ async def write_block(
content = await claude_session.query(
prompt,
timeout=timeout,
model=GENERATION_MODEL,
model=gen_model,
effort=effort,
tools="", # prose gen — no tool_use → no error_max_turns
)
sources = await _collect_block_sources(case_id, block_id)
sources["case_law_ids"] = _precedent_case_law_ids
result = _build_result(block_id, content, block_cfg)
result = _build_result(block_id, content, block_cfg, model_used=gen_model)
# Record the EFFECTIVE effort (override wins) so the harness can attribute
# the measured distance to the effort that actually produced the text.
if result.get("effort") is not None:
@@ -499,7 +515,8 @@ async def write_block(
return result
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
def _build_result(block_id: str, content: str, block_cfg: dict,
model_used: str = GENERATION_MODEL) -> dict:
word_count = len(content.split())
is_ai = block_cfg["model"] == "ai"
return {
@@ -509,8 +526,9 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
"content": content,
"word_count": word_count,
"generation_type": block_cfg["gen_type"],
# AI blocks record the pinned model; template blocks record "script".
"model_used": GENERATION_MODEL if is_ai else block_cfg["model"],
# AI blocks record the model build actually used (200K or [1m], #216);
# template blocks record "script".
"model_used": model_used if is_ai else block_cfg["model"],
# The real generation knob (#204). None for template/script blocks.
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it

View File

@@ -146,8 +146,15 @@ async def query(
"""
full_prompt = f"{system}\n\n{prompt}" if system else prompt
if len(full_prompt) > 150_000:
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
# chars/token). Above it callers should pass the 1M-context model id
# (`claude-opus-4-8[1m]`) — block_writer escalates automatically (#216).
if len(full_prompt) > 350_000 and "[1m]" not in (model or ""):
logger.warning(
"Large prompt: %d chars on a non-[1m] model (%s) — may overflow the "
"200K-token context. Pass claude-opus-4-8[1m] for the 1M window.",
len(full_prompt), model or "CLI-default",
)
cmd = [
"claude", "-p",

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
# schema DDL across processes. Every short-lived process (cron drains, services)
# 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_V48_SQL)
await conn.execute(SCHEMA_V49_SQL)
await conn.execute(SCHEMA_V50_SQL)
async def init_schema() -> None:

View File

@@ -120,15 +120,47 @@ HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול די
MAX_PROTOCOL_CHARS = 120_000
def _find_protocol(docs: list[dict]) -> dict | None:
"""The protocol document for the case, if present (doc_type or title)."""
for d in docs:
if d.get("doc_type") == "protocol":
return d
for d in docs:
if "פרוטוקול" in (d.get("title") or ""):
return d
return None
def _protocol_scope(doc: dict) -> str:
"""Protocol scope from ``metadata.protocol_scope``.
Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
document-type-editor convention where only the non-default ``'lower'``
(ועדה מקומית/מחוזית) is persisted.
"""
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
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]:
@@ -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.
``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.
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
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.
"""
docs = await db.list_documents(case_id)
protocol = _find_protocol(docs)
protocol = _find_protocol(docs, document_id=target_document_id)
if not protocol:
if target_document_id is not None:
return {
"status": "no_protocol",
"message": f"מסמך {target_document_id} לא נמצא בתיק.",
}
return {
"status": "no_protocol",
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.",
"message": (
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
),
}
document_id = UUID(protocol["id"])

View File

@@ -433,17 +433,24 @@ ALLOWED_DOC_TYPES = {
# Allowed appraiser_side values; '' (empty) clears the tag.
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
async def document_update(
case_number: str,
doc_id: str,
doc_type: str = "",
appraiser_side: str = "",
is_post_hearing: bool | None = None,
protocol_scope: str = "",
) -> str:
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
Args:
case_number: מספר תיק הערר (לאישור שייכות)
@@ -452,6 +459,9 @@ async def document_update(
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
"""
case = await db.get_case_by_number(case_number)
if not case:
@@ -477,17 +487,37 @@ async def document_update(
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
updates["doc_type"] = doc_type
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
# "set to empty string", so we use the convention: only update if non-empty.
# To clear, the operator must edit metadata directly (rare).
# appraiser_side / protocol_scope are optional. The MCP tool can't
# distinguish "skip" from "set to empty string", so we use the convention:
# only update if non-empty. To clear, edit metadata directly (rare).
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata_dirty = False
if appraiser_side:
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
return err(f"appraiser_side לא תקין: {appraiser_side}",
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata["appraiser_side"] = appraiser_side
metadata_dirty = True
if is_post_hearing is not None:
if is_post_hearing:
metadata["is_post_hearing"] = True
else:
metadata.pop("is_post_hearing", None)
metadata_dirty = True
if protocol_scope:
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
return err(f"protocol_scope לא תקין: {protocol_scope}",
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
metadata["protocol_scope"] = protocol_scope
metadata_dirty = True
if metadata_dirty:
updates["metadata"] = metadata
if not updates:

View File

@@ -580,7 +580,7 @@ async def get_appraiser_facts(case_number: str) -> str:
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).
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
@@ -588,12 +588,17 @@ async def analyze_protocol(case_number: str) -> str:
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
מוזן חזרה לעמודה הקנונית cases.hearing_date.
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
ציין document_id כדי לכוון לדיון המדויק (#223).
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
Args:
case_number: מספר תיק הערר
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
"""
from legal_mcp.services import protocol_analyzer
@@ -601,8 +606,16 @@ async def analyze_protocol(case_number: str) -> str:
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
target_doc_id: UUID | None = None
if document_id.strip():
try:
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)
result = await protocol_analyzer.analyze_protocol(
case_id, target_document_id=target_doc_id,
)
await audit.log_action_safe(
"analyze_protocol", case_id=case_id,
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
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) ───────────────────
_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.
*הכרעות-מהות בתת-מסלול זה (מחיר-למשתכן / שומה סמוכה — 8125-09-24, ראה `docs/legal-decision-lessons.md` #47#50):* (א) "הסבת שטחים" ממחיר-למשתכן לשוק-חופשי בעקבות הקלת תוספת יח"ד = **השבחה תכנונית בת-חיוב** — להכריע בחיוב, לא לגדֵר כשאלה פתוחה או להטיל ספק מכוח קשר-סיבתי (בית הכרם/פמיני). (ב) שומה מכרעת חלוטה סמוכה = **משכנעת, לא מחייבת**; שומה מנומקת רשאית לסטות ("לא נוכל להיאחז בשומה מכרעת אחרת רק מכיוון והיא קיימת") — לא להעמיס דוקטרינת שוויון לטובת העוררת. (ג) שמאי מכריע פרש → מינוי **שמאי מייעץ חדש** (סמכות אשקלוני/גנדליס).
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:

View File

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

View File

@@ -9,6 +9,7 @@ import {
useAgentActivity,
useSendComment,
useSubmitInteraction,
useDismissInteraction,
} from "@/lib/api/agents";
import type {
Interaction,
@@ -17,6 +18,7 @@ import type {
InteractionTask,
PaperclipComment,
PaperclipAgentStatus,
PaperclipIssue,
} from "@/lib/api/agents";
import { formatRelative } from "@/lib/format-date";
import { toast } from "sonner";
@@ -30,6 +32,8 @@ import {
CheckCircle2,
XCircle,
HelpCircle,
ChevronDown,
Ban,
} from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */
@@ -111,34 +115,9 @@ function agentStatusTone(s: string) {
: "bg-rule-soft text-ink-muted";
}
function AgentRoster({
agents,
comments,
}: {
agents: PaperclipAgentStatus[];
comments: PaperclipComment[];
}) {
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
if (!agents.length) return null;
// "current activity" per agent = its most recent comment body (by name/role).
const latest = new Map<string, string>();
for (const a of agents) {
let best: PaperclipComment | null = null;
let bestAt = -1;
for (const c of comments) {
const mine =
(c.agent_name && c.agent_name === a.name) ||
(c.agent_role && c.agent_role === a.role);
if (!mine) continue;
const at = c.created_at ? new Date(c.created_at).getTime() : 0;
if (at >= bestAt) {
bestAt = at;
best = c;
}
}
if (best) latest.set(a.id, best.body);
}
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
return (
@@ -150,28 +129,28 @@ function AgentRoster({
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{/* Compact roster — all agents on one row (mockup 18i v2): status · name ·
role only. The per-agent "current activity" line was removed: it
matched comments by role-fallback, so agents sharing a role showed the
same text. */}
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
{agents.map((a) => {
const active = agentIsActive(a.status);
const activity = latest.get(a.id);
return (
<div
key={a.id}
className={`rounded-lg border p-2.5 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
>
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
<span className="text-[0.78rem] font-bold text-navy truncate">{a.name}</span>
<span
className={`ms-auto text-[0.6rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
>
{AGENT_STATUS_LABEL[a.status] ?? a.status}
</span>
</div>
<div className="text-[0.66rem] text-ink-muted mt-0.5">{roleLabel(a.role)}</div>
<div className="text-[0.7rem] text-ink-soft mt-1.5 line-clamp-2 leading-tight">
{activity || "—"}
<div className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
</div>
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
<span
className={`inline-block mt-1 text-[0.55rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
>
{AGENT_STATUS_LABEL[a.status] ?? a.status}
</span>
</div>
);
})}
@@ -605,15 +584,21 @@ function InteractionCard({
interaction,
caseNumber,
issueMap,
defaultOpen = true,
}: {
interaction: Interaction;
caseNumber: string;
issueMap: Map<string, string>;
defaultOpen?: boolean;
}) {
const submit = useSubmitInteraction(caseNumber);
const dismiss = useDismissInteraction(caseNumber);
const identifier = issueMap.get(interaction.issue_id) ?? "";
const isPending = interaction.status === "pending";
const summary = summaryAnswer(interaction);
// Pending interactions collapse into an accordion (mockup 18i v2); resolved
// ones always render their one-line summary.
const [open, setOpen] = useState(defaultOpen);
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
submit.mutate(
@@ -630,6 +615,16 @@ function InteractionCard({
);
};
const handleDismiss = () => {
dismiss.mutate(
{ issue_id: interaction.issue_id, interaction_id: interaction.id },
{
onSuccess: () => toast.success("הבקשה בוטלה"),
onError: () => toast.error("שגיאה בביטול הבקשה"),
},
);
};
return (
<div
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
@@ -652,15 +647,30 @@ function InteractionCard({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
{isPending ? (
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
ממתין לתשובה
</span>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-2 flex-wrap text-start min-w-0"
>
<ChevronDown
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
ממתין לתשובה
</span>
</button>
) : (
<ResolvedBadge status={interaction.status} />
<>
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
<ResolvedBadge status={interaction.status} />
</>
)}
{identifier && (
<Badge variant="outline" className="text-[10px] font-mono">
@@ -671,39 +681,58 @@ function InteractionCard({
<Clock className="w-3 h-3" />
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
</span>
{isPending && (
<Button
size="sm"
variant="ghost"
onClick={handleDismiss}
disabled={dismiss.isPending}
className="h-7 px-2 text-ink-muted hover:text-danger"
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
>
{dismiss.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Ban className="w-3.5 h-3.5 me-1" />
)}
התעלם
</Button>
)}
</div>
{interaction.summary && (
{interaction.summary && (!isPending || open) && (
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
)}
{isPending ? (
interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsForm
interaction={interaction}
onSubmit={(answers) => send("respond", { answers })}
pending={submit.isPending}
/>
) : interaction.kind === "request_confirmation" ? (
<RequestConfirmationForm
interaction={interaction}
onAccept={() => send("accept", {})}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : interaction.kind === "suggest_tasks" ? (
<SuggestTasksForm
interaction={interaction}
onAccept={(keys) =>
send("accept", keys.length ? { selectedClientKeys: keys } : {})
}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
open ? (
interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsForm
interaction={interaction}
onSubmit={(answers) => send("respond", { answers })}
pending={submit.isPending}
/>
) : interaction.kind === "request_confirmation" ? (
<RequestConfirmationForm
interaction={interaction}
onAccept={() => send("accept", {})}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : interaction.kind === "suggest_tasks" ? (
<SuggestTasksForm
interaction={interaction}
onAccept={(keys) =>
send("accept", keys.length ? { selectedClientKeys: keys } : {})
}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : null
) : null
) : summary ? (
<div className="text-sm">
@@ -715,6 +744,80 @@ function InteractionCard({
);
}
/* ── Issue group (timeline grouped by issue, accordion) ──────── */
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
/** Drop the "[ערר 1043-02-26] " prefix Paperclip puts on every issue title. */
function shortIssueTitle(title: string): string {
return title.replace(/^\s*\[[^\]]*\]\s*/, "").trim() || title;
}
function IssueGroup({
issue,
items,
caseNumber,
issueMap,
defaultOpen,
}: {
issue: PaperclipIssue;
items: FeedItem[];
caseNumber: string;
issueMap: Map<string, string>;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const closed = issue.status === "done" || issue.status === "cancelled";
return (
<div className="border-b border-rule-soft last:border-b-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
>
<Badge
variant={closed ? "secondary" : "default"}
className="text-[10px] font-mono shrink-0"
>
{issue.identifier}
</Badge>
<span className="text-[0.78rem] font-semibold text-navy truncate">
{shortIssueTitle(issue.title)}
</span>
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
{issueStatusLabel(issue.status)} · {items.length}
</span>
<ChevronDown
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div className="pb-1">
{items.map((item) =>
item.kind === "comment" ? (
<CommentCard
key={`c-${item.comment.id}`}
comment={item.comment}
issueMap={issueMap}
/>
) : (
<InteractionCard
key={`i-${item.interaction.id}`}
interaction={item.interaction}
caseNumber={caseNumber}
issueMap={issueMap}
/>
),
)}
</div>
)}
</div>
);
}
/* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({
@@ -792,25 +895,42 @@ export function AgentActivityFeed({
// NOT repeated in the timeline below.
const pending = interactions.filter((i) => i.status === "pending");
// Unified, time-sorted feed: comments + interactions interleaved.
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
const feed: FeedItem[] = [
...comments.map<FeedItem>((c) => ({
// Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
// resolved interactions; pending ones are surfaced above and not repeated.
const itemsByIssue = new Map<string, FeedItem[]>();
const pushItem = (issueId: string, item: FeedItem) => {
const arr = itemsByIssue.get(issueId);
if (arr) arr.push(item);
else itemsByIssue.set(issueId, [item]);
};
for (const c of comments) {
pushItem(c.issue_id, {
kind: "comment",
at: c.created_at ? new Date(c.created_at).getTime() : 0,
comment: c,
})),
...interactions
.filter((i) => i.status !== "pending")
.map<FeedItem>((i) => ({
kind: "interaction",
at: i.created_at ? new Date(i.created_at).getTime() : 0,
interaction: i,
})),
].sort((a, b) => a.at - b.at);
});
}
for (const i of interactions) {
if (i.status === "pending") continue;
pushItem(i.issue_id, {
kind: "interaction",
at: i.created_at ? new Date(i.created_at).getTime() : 0,
interaction: i,
});
}
for (const arr of itemsByIssue.values()) arr.sort((a, b) => a.at - b.at);
// Order groups by most-recent activity so live work floats to the top.
const issueGroups = data.issues
.map((iss) => {
const items = itemsByIssue.get(iss.id) ?? [];
const maxAt = items.reduce((m, it) => Math.max(m, it.at), 0);
return { iss, items, maxAt };
})
.filter((g) => g.items.length > 0)
.sort((a, b) => b.maxAt - a.maxAt);
const totalFeedItems = issueGroups.reduce((n, g) => n + g.items.length, 0);
// An issue is "active" if it's not done/cancelled. When everything is closed
// we should NOT show the "agents are working, waiting for report" spinner.
@@ -820,11 +940,11 @@ export function AgentActivityFeed({
return (
<div className="flex flex-col h-full">
{/* agent roster — who's doing what right now (mockup 18i) */}
<AgentRoster agents={data.agents ?? []} comments={comments} />
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
<AgentRoster agents={data.agents ?? []} />
{/* pending interactions surfaced to the top — the actual forms, not a
pointer (mockup 18i — awaiting you). Excluded from the timeline. */}
{/* pending interactions surfaced to the top — each an accordion, first
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
{pending.length > 0 && (
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
@@ -834,33 +954,29 @@ export function AgentActivityFeed({
{pending.length}
</span>
</div>
{pending.map((i) => (
{pending.map((i, idx) => (
<InteractionCard
key={`pending-${i.id}`}
interaction={i}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={idx === 0}
/>
))}
</div>
)}
{/* Issue summary bar */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
{data.issues.map((iss) => (
<Badge
key={iss.id}
variant={iss.status === "done" ? "secondary" : "default"}
className="text-[11px] font-mono"
>
{iss.identifier} {issueStatusLabel(iss.status)}
</Badge>
))}
{/* Timeline header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
<span className="text-[0.72rem] text-ink-muted">
{data.issues.length} משימות · {totalFeedItems} הודעות
</span>
</div>
{/* Comments + interactions stream */}
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
{feed.length === 0 ? (
{/* Per-issue accordions */}
<div className="flex-1 overflow-y-auto max-h-[500px]">
{totalFeedItems === 0 ? (
hasActiveIssue ? (
<div className="text-center py-8 text-ink-faint text-sm">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
@@ -873,22 +989,19 @@ export function AgentActivityFeed({
</div>
)
) : (
feed.map((item) =>
item.kind === "comment" ? (
<CommentCard
key={`c-${item.comment.id}`}
comment={item.comment}
issueMap={issueMap}
/>
) : (
<InteractionCard
key={`i-${item.interaction.id}`}
interaction={item.interaction}
caseNumber={caseNumber}
issueMap={issueMap}
/>
),
)
issueGroups.map(({ iss, items }, idx) => (
<IssueGroup
key={iss.id}
issue={iss}
items={items}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={
idx === 0 ||
(iss.status !== "done" && iss.status !== "cancelled")
}
/>
))
)}
<div ref={endRef} />
</div>

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Popover,
PopoverContent,
@@ -20,6 +21,7 @@ import {
APPRAISER_SIDE_LABELS,
APPRAISER_SIDE_OPTIONS,
DOC_TYPE_OPTIONS,
PROTOCOL_SCOPE_OPTIONS,
appraiserSideLabel,
doctypeLabel,
doctypeTone,
@@ -29,14 +31,17 @@ import {
import {
useExtractAppraiserFacts,
usePatchDocument,
type DocumentPatch,
type ExtractAppraiserFactsResponse,
} from "@/lib/api/documents";
/*
* Inline editor for a document's tags. Renders a colored Badge that opens a
* Popover with two Selects:
* Popover with:
* 1. doc_type (always shown)
* 2. appraiser_side (only when doc_type === "appraisal")
* 2. appraiser_side (only when doc_type === "appraisal")
* 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית
* 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4
*
* After a successful save we swap the Popover body to a confirmation view
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
@@ -49,15 +54,25 @@ export function DocumentTypeEditor({
docId,
docType,
appraiserSide,
isPostHearing,
protocolScope,
}: {
caseNumber: string;
docId: string;
docType: string;
appraiserSide?: string;
isPostHearing?: boolean;
protocolScope?: string;
}) {
// Effective stored scope: absent === "appeal" (ועדת הערר) by convention.
const storedScope = protocolScope === "lower" ? "lower" : "appeal";
const storedPost = isPostHearing === true;
const [open, setOpen] = useState(false);
const [draftType, setDraftType] = useState<string>(docType || "");
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
const [draftScope, setDraftScope] = useState<string>(storedScope);
const [draftPost, setDraftPost] = useState<boolean>(storedPost);
const [saved, setSaved] = useState(false);
const [extractResult, setExtractResult] =
useState<ExtractAppraiserFactsResponse | null>(null);
@@ -67,6 +82,8 @@ export function DocumentTypeEditor({
function reset() {
setDraftType(docType || "");
setDraftSide(appraiserSide || "");
setDraftScope(storedScope);
setDraftPost(storedPost);
setSaved(false);
setExtractResult(null);
patch.reset();
@@ -74,14 +91,17 @@ export function DocumentTypeEditor({
}
const isAppraisal = draftType === "appraisal";
const isProtocol = draftType === "protocol";
const sideMissing = isAppraisal && !draftSide;
const dirty =
draftType !== docType ||
(isAppraisal && draftSide !== (appraiserSide || ""));
(isAppraisal && draftSide !== (appraiserSide || "")) ||
(isProtocol && draftScope !== storedScope) ||
draftPost !== storedPost;
async function handleSave() {
if (sideMissing || !dirty) return;
const body: { doc_type?: string; appraiser_side?: string } = {};
const body: DocumentPatch = {};
if (draftType !== docType) body.doc_type = draftType;
if (isAppraisal && draftSide !== (appraiserSide || "")) {
body.appraiser_side = draftSide;
@@ -90,6 +110,16 @@ export function DocumentTypeEditor({
// clear it so it doesn't dangle confusingly in metadata.
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
// protocol_scope: store only the non-default "lower"; anything else clears
// back to the implicit ועדת-הערר default. Also clear when leaving protocol.
if (isProtocol && draftScope !== storedScope) {
body.protocol_scope = draftScope === "lower" ? "lower" : "";
} else if (!isProtocol && protocolScope) {
body.protocol_scope = "";
}
if (draftPost !== storedPost) body.is_post_hearing = draftPost;
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
// rejection from this async click handler would otherwise leak.
try {
@@ -190,6 +220,46 @@ export function DocumentTypeEditor({
</div>
)}
{isProtocol && (
<div className="space-y-1.5">
<label className="text-xs text-ink-muted">פרוטוקול של</label>
<Select value={draftScope} onValueChange={setDraftScope} dir="rtl">
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROTOCOL_SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.65rem] text-ink-muted leading-tight">
ועדת הערר קבוצת הפרוטוקול · מקומית/מחוזית מסמכים נלווים
</p>
</div>
)}
<div className="flex items-start justify-between gap-3 rounded-md border border-rule bg-parchment/40 px-3 py-2.5">
<div className="space-y-0.5">
<label
htmlFor={`post-${docId}`}
className="text-xs font-medium text-ink block"
>
התקבל אחרי הדיון
</label>
<p className="text-[0.65rem] text-ink-muted leading-tight">
מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח
</p>
</div>
<Switch
id={`post-${docId}`}
checked={draftPost}
onCheckedChange={setDraftPost}
/>
</div>
{patch.isError && (
<p className="text-[0.7rem] text-danger">
שמירה נכשלה. נסה שוב.

View File

@@ -25,6 +25,13 @@ import { casesKeys } from "@/lib/api/cases";
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
import { formatDateShort as formatDate } from "@/lib/format-date";
import {
documentGroup,
DOC_GROUP_ORDER,
DOC_GROUP_LABELS,
DOC_GROUP_HINTS,
type DocGroup,
} from "@/lib/doc-types";
/*
* Document list for the case detail "מסמכים" tab. Uses the real document
@@ -224,6 +231,13 @@ function DocumentRow({
const canPreview =
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
const meta = doc.metadata as
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
| undefined;
const isPostHearing = meta?.is_post_hearing === true;
const protocolScope = meta?.protocol_scope;
const isProtocol = doc.doc_type === "protocol";
return (
<>
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
@@ -246,15 +260,24 @@ function DocumentRow({
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
</div>
</button>
{isProtocol && (
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
</span>
)}
{isPostHearing && (
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
לאחר הדיון
</span>
)}
{doc.doc_type && (
<DocumentTypeEditor
caseNumber={caseNumber}
docId={doc.id}
docType={doc.doc_type}
appraiserSide={
(doc.metadata as { appraiser_side?: string } | undefined)
?.appraiser_side
}
appraiserSide={meta?.appraiser_side}
isPostHearing={isPostHearing}
protocolScope={protocolScope}
/>
)}
<button
@@ -306,6 +329,30 @@ function DocumentsShell({ count, children }: { count: number; children: ReactNod
);
}
/* ── Group section header ──────────────────────────────────────────
* The list is split into four convenience buckets (see documentGroup).
* The number is the fixed 1-4 position in DOC_GROUP_ORDER so a group keeps
* its identity even when earlier groups are empty and hidden. */
function GroupHeader({ group, count }: { group: DocGroup; count: number }) {
const index = DOC_GROUP_ORDER.indexOf(group) + 1;
return (
<div className="flex items-center gap-2.5 px-2 -mx-2 py-2 border-b border-rule-soft bg-parchment/50">
<span className="w-[19px] h-[19px] shrink-0 rounded-full bg-navy text-cream text-[0.66rem] font-bold flex items-center justify-center tabular-nums">
{index}
</span>
<span className="text-[0.8rem] font-bold text-navy shrink-0">
{DOC_GROUP_LABELS[group]}
</span>
<span className="shrink-0 text-[0.62rem] font-bold text-gold-deep bg-gold-wash border border-rule rounded-full px-1.5 tabular-nums">
{count}
</span>
<span className="text-[0.66rem] text-ink-muted font-medium truncate">
{DOC_GROUP_HINTS[group]}
</span>
</div>
);
}
export function DocumentsPanel({
data,
}: {
@@ -329,6 +376,16 @@ export function DocumentsPanel({
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
);
// Bucket into the four overview groups. Iterating `sorted` preserves the
// within-group status ordering. Grouping is display-only — see documentGroup.
const grouped = new Map<DocGroup, CaseDocument[]>();
for (const doc of sorted) {
const g = documentGroup(doc.doc_type, doc.metadata);
const bucket = grouped.get(g);
if (bucket) bucket.push(doc);
else grouped.set(g, [doc]);
}
const done = docs.filter(
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
).length;
@@ -378,12 +435,21 @@ export function DocumentsPanel({
</div>
)}
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden" dir="rtl">
<ul className="divide-y divide-rule" dir="rtl">
{sorted.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden space-y-2" dir="rtl">
{DOC_GROUP_ORDER.map((group) => {
const groupDocs = grouped.get(group);
if (!groupDocs || groupDocs.length === 0) return null;
return (
<section key={group}>
<GroupHeader group={group} count={groupDocs.length} />
<ul className="divide-y divide-rule" dir="rtl">
{groupDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
))}
</ul>
</section>
);
})}
</div>
</div>
</DocumentsShell>

View File

@@ -76,11 +76,50 @@ type PartySectionProps = {
};
/**
* Inner body of a single party — the priority-grouping + per-argument inner
* accordion. The party header (name / sub / count) now lives on the enclosing
* party-accordion trigger (mockup 25b), so this renders the groups only.
* Inner body of a single party. For a multi-litigant side (respondent /
* permit_applicant) whose arguments carry distinct ``party_name`` brief labels,
* 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) {
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]);
return (
<div className="space-y-4">

View File

@@ -180,6 +180,26 @@ export function useSubmitInteraction(caseNumber: string | undefined) {
});
}
/** Dismiss a pending interaction WITHOUT waking the issue assignee — used for
* stale/duplicate questions. Backend marks it `cancelled` directly (the
* `wake_assignee` continuation policy means a normal resolve would wake the
* agent; dismiss must not). */
export function useDismissInteraction(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { issue_id: string; interaction_id: string }) =>
apiRequest<{ ok: boolean; cancelled: boolean }>(
`/api/cases/${caseNumber}/agents/interaction-dismiss`,
{ method: "POST", body: vars },
),
onSuccess: () => {
if (caseNumber) {
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
}
},
});
}
export type AgentResetResult = {
ok: boolean;
reassigned_issues: { id: string; identifier: string }[];

View File

@@ -92,6 +92,8 @@ export function useUploadDocument(caseNumber: string) {
export type DocumentPatch = {
doc_type?: string;
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
is_post_hearing?: boolean; // true = received after the hearing (→ group 4); false clears
protocol_scope?: string; // "" clears; "appeal" | "lower" (protocol docs only)
};
export type PatchDocumentResponse = {

View File

@@ -32,6 +32,12 @@ export type LegalArgument = {
id: string;
case_id: string;
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_title: string;
argument_body: string;

View File

@@ -692,6 +692,28 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/status-model": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Status Model
* @description Canonical case-status model — ordered statuses (key/label/description/
* phase/selectable/terminal/on_enter) + the 5 phases. Single source of truth
* (legal_mcp/case_status_model.py); the frontend case-status.ts mirrors it.
*/
get: operations["api_status_model_api_status_model_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases": {
parameters: {
query?: never;
@@ -1353,6 +1375,50 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/generate/party-claims-summary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Api Generate Party Claims Summary
* @description Fire-and-accept: wake the CEO to generate the party-claims executive summary
* (שלב H2 → summarize_party_claims). Runs host-side; poll
* GET /api/cases/{n}/research/party-claims-summary for completion.
*/
post: operations["api_generate_party_claims_summary_api_cases__case_number__generate_party_claims_summary_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/generate/interim-draft": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Api Generate Interim Draft
* @description Fire-and-accept: wake the CEO to generate the partial "party-claims" draft
* (שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
* exports list for the new טיוטת-ביניים-{case}-vN.docx.
*/
post: operations["api_generate_interim_draft_api_cases__case_number__generate_interim_draft_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/research/party-claims-summary/download": {
parameters: {
query?: never;
@@ -2061,6 +2127,30 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -4587,13 +4677,19 @@ export interface components {
};
/**
* 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: {
/** Doc Type */
doc_type?: string | null;
/** Appraiser Side */
appraiser_side?: string | null;
/** Is Post Hearing */
is_post_hearing?: boolean | null;
/** Protocol Scope */
protocol_scope?: string | null;
};
/** EquivalentLinkRequest */
EquivalentLinkRequest: {
@@ -4758,6 +4854,13 @@ export interface components {
/** Canonical Statement */
canonical_statement?: string | null;
};
/** InteractionDismissRequest */
InteractionDismissRequest: {
/** Issue Id */
issue_id: string;
/** Interaction Id */
interaction_id: string;
};
/** InteractionResponseRequest */
InteractionResponseRequest: {
/** Issue Id */
@@ -6147,6 +6250,26 @@ export interface operations {
};
};
};
api_status_model_api_status_model_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
list_cases_api_cases_get: {
parameters: {
query?: {
@@ -7151,6 +7274,68 @@ export interface operations {
};
};
};
api_generate_party_claims_summary_api_cases__case_number__generate_party_claims_summary_post: {
parameters: {
query?: never;
header?: never;
path: {
case_number: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_generate_interim_draft_api_cases__case_number__generate_interim_draft_post: {
parameters: {
query?: never;
header?: never;
path: {
case_number: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_party_claims_summary_download_api_cases__case_number__research_party_claims_summary_download_get: {
parameters: {
query?: never;
@@ -8224,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: {
parameters: {
query?: never;

View File

@@ -64,6 +64,79 @@ export function doctypeTone(value: string): string {
}
}
// ── Overview groups (display-only convenience buckets) ─────────────
//
// The case-overview document list is split into four sections purely for
// the chair's convenience — grouping NEVER changes a document's stored
// doc_type. Classification is derived on the client from doc_type plus two
// metadata flags (is_post_hearing, protocol_scope).
export type DocGroup = "primary" | "ancillary" | "hearing_protocol" | "post_hearing";
export const DOC_GROUP_LABELS: Record<DocGroup, string> = {
primary: "מסמכים עיקריים",
ancillary: "מסמכים נלווים",
hearing_protocol: "פרוטוקול דיון ועדת הערר",
post_hearing: "מסמכים לאחר הדיון",
};
export const DOC_GROUP_HINTS: Record<DocGroup, string> = {
primary: "כתבי ערר, תשובות ושומות שהוגשו לפני הדיון",
ancillary: "פרוטוקולי/החלטות ועדה מקומית-מחוזית, תכניות, וכל השאר",
hearing_protocol: "הדיון בפני הוועדה",
post_hearing: "השלמות ותגובות שהתקבלו לאחר הדיון",
};
/** Fixed render order of the groups in the overview panel. */
export const DOC_GROUP_ORDER: DocGroup[] = [
"primary",
"ancillary",
"hearing_protocol",
"post_hearing",
];
/** doc_types that count as "primary" pre-hearing filings (group 1). */
const PRIMARY_DOC_TYPES = new Set<DocType>(["appeal", "response", "appraisal"]);
/**
* Classify a document into one of the four overview groups.
*
* Priority order matters:
* 1. is_post_hearing wins over everything — a post-hearing כתב תשובה
* belongs in group 4, not group 1.
* 2. A protocol goes to group 3 (ועדת הערר) by default, or group 2 when
* explicitly tagged as a lower-committee protocol (protocol_scope==="lower").
* 3. appeal/response/appraisal → group 1; everything else → group 2.
*/
export function documentGroup(
docType: string,
metadata?: Record<string, unknown> | null,
): DocGroup {
if (metadata?.is_post_hearing === true) return "post_hearing";
if (docType === "protocol") {
return metadata?.protocol_scope === "lower" ? "ancillary" : "hearing_protocol";
}
if (PRIMARY_DOC_TYPES.has(docType as DocType)) return "primary";
return "ancillary";
}
// ── Protocol scope (only relevant when doc_type === "protocol") ────
// Distinguishes the appeal-committee hearing protocol (group 3) from a
// lower (local/district) committee protocol (group 2). Stored in
// documents.metadata.protocol_scope; absent === "appeal" (the default).
export type ProtocolScope = "appeal" | "lower";
export const PROTOCOL_SCOPE_LABELS: Record<ProtocolScope, string> = {
appeal: "ועדת הערר",
lower: "ועדה מקומית/מחוזית",
};
export const PROTOCOL_SCOPE_OPTIONS: { value: ProtocolScope; label: string }[] = [
{ value: "appeal", label: PROTOCOL_SCOPE_LABELS.appeal },
{ value: "lower", label: PROTOCOL_SCOPE_LABELS.lower },
];
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
export type AppraiserSide = "committee" | "appellant" | "deciding";

View File

@@ -37,6 +37,7 @@ from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction,
archive_project as pc_archive_project,
cancel_interaction as pc_cancel_interaction,
cancel_run as pc_cancel_run,
create_project as pc_create_project,
create_workflow_issue as pc_create_workflow_issue,
@@ -50,6 +51,7 @@ from web.paperclip_client import (
get_run_log as pc_get_run_log,
list_live_runs as pc_list_live_runs,
post_comment as pc_post_comment,
reap_stale_interactions as pc_reap_stale_interactions,
reject_interaction as pc_reject_interaction,
reset_agent_session as pc_reset_agent_session,
reset_case_agents as pc_reset_case_agents,
@@ -111,6 +113,8 @@ __all__ = [
"pc_accept_interaction",
"pc_reject_interaction",
"pc_respond_to_interaction",
"pc_cancel_interaction",
"pc_reap_stale_interactions",
# agent-run observability + control (live view + smart management)
"pc_list_live_runs",
"pc_get_run_log",

View File

@@ -57,6 +57,7 @@ from web.agent_platform_port import (
get_project_url,
pc_accept_interaction,
pc_archive_project,
pc_cancel_interaction,
pc_cancel_run,
pc_create_project,
pc_create_workflow_issue,
@@ -69,6 +70,7 @@ from web.agent_platform_port import (
pc_get_run_log,
pc_list_live_runs,
pc_post_comment,
pc_reap_stale_interactions,
pc_reject_interaction,
pc_request,
pc_reset_agent_session,
@@ -101,19 +103,38 @@ PROGRESS_TTL_SECONDS = 300
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
async def _interaction_reaper_loop():
"""Safety-net reaper for stranded pending interactions (TaskMaster #215).
Every 15 min, cancel pending interactions on closed (done/cancelled) issues
so the chair's "awaiting you" count never accumulates dead questions.
"""
while True:
try:
await asyncio.sleep(900)
await pc_reap_stale_interactions()
except asyncio.CancelledError:
raise
except Exception:
logger.warning("interaction reaper sweep failed", exc_info=True)
@asynccontextmanager
async def lifespan(app: FastAPI):
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
await db.init_schema()
sync_task = asyncio.create_task(git_sync.sweep_loop())
reaper_task = asyncio.create_task(_interaction_reaper_loop())
try:
yield
finally:
sync_task.cancel()
try:
await sync_task
except asyncio.CancelledError:
pass
reaper_task.cancel()
for _t in (sync_task, reaper_task):
try:
await _t
except asyncio.CancelledError:
pass
await db.close_pool()
await _progress.close()
@@ -4411,6 +4432,30 @@ async def api_post_interaction_response(
raise HTTPException(502, f"שגיאת Paperclip: {e}")
class InteractionDismissRequest(BaseModel):
issue_id: str
interaction_id: str
@app.post("/api/cases/{case_number}/agents/interaction-dismiss")
async def api_dismiss_interaction(
case_number: str, req: InteractionDismissRequest,
):
"""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.
"""
issues = await pc_get_case_issues(case_number)
if not any(i["id"] == req.issue_id for i in issues):
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
result = await pc_cancel_interaction(req.issue_id, req.interaction_id)
if not result.get("ok"):
raise HTTPException(404, result.get("error", "ביטול הבקשה נכשל"))
return result
@app.post("/api/cases/{case_number}/agents/reset")
async def api_reset_case_agents(case_number: str):
"""Reset stuck agents for a case.
@@ -5677,13 +5722,21 @@ async def api_reprocess_document(case_number: str, doc_id: str):
_ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
# metadata.protocol_scope — only meaningful when doc_type == "protocol".
# "appeal" = ועדת הערר (default when absent), "lower" = ועדה מקומית/מחוזית.
# Used by the overview panel to sort a protocol into group 3 vs group 2.
_ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
class DocumentPatchRequest(BaseModel):
"""Patch payload for a single document. Both fields are optional."""
"""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."""
doc_type: str | None = None
appraiser_side: str | None = None # committee | appellant | deciding | "" to clear
is_post_hearing: bool | None = None # material submitted after the hearing (→ group 4, block-chet)
protocol_scope: str | None = None # appeal | lower | "" to clear (protocol docs only)
@app.patch("/api/cases/{case_number}/documents/{doc_id}")
@@ -5708,6 +5761,13 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
updates: dict = {}
# Load metadata once — appraiser_side, is_post_hearing and protocol_scope
# all live in the same JSONB blob, so a single patch may touch several.
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
metadata_dirty = False
if req.doc_type is not None:
if req.doc_type not in DOC_TYPE_NAMES:
raise HTTPException(
@@ -5724,13 +5784,34 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
f"appraiser_side לא תקין: {req.appraiser_side}. ערכים מותרים: "
f"{', '.join(sorted(_ALLOWED_APPRAISER_SIDES))}",
)
metadata = doc.get("metadata") or {}
if isinstance(metadata, str):
metadata = json.loads(metadata)
if req.appraiser_side:
metadata["appraiser_side"] = req.appraiser_side
else:
metadata.pop("appraiser_side", None)
metadata_dirty = True
if req.is_post_hearing is not None:
# Store True; drop the key entirely when False so absence == "before".
if req.is_post_hearing:
metadata["is_post_hearing"] = True
else:
metadata.pop("is_post_hearing", None)
metadata_dirty = True
if req.protocol_scope is not None:
if req.protocol_scope and req.protocol_scope not in _ALLOWED_PROTOCOL_SCOPES:
raise HTTPException(
422,
f"protocol_scope לא תקין: {req.protocol_scope}. ערכים מותרים: "
f"{', '.join(sorted(_ALLOWED_PROTOCOL_SCOPES))}",
)
if req.protocol_scope:
metadata["protocol_scope"] = req.protocol_scope
else:
metadata.pop("protocol_scope", None)
metadata_dirty = True
if metadata_dirty:
updates["metadata"] = metadata
if not updates:

View File

@@ -899,6 +899,69 @@ async def reject_interaction(
return resp.json()
async def cancel_interaction(issue_id: str, interaction_id: str) -> dict:
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
For stale/duplicate questions (TaskMaster #215). We flip the row to
``cancelled`` directly rather than calling Paperclip's resolve endpoints:
those carry the interaction's ``wake_assignee`` continuation policy and would
re-wake the agent. A direct status flip has no side effects — the table has
no triggers (verified) and nothing reaps ``cancelled`` rows.
"""
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
row = await conn.fetchrow(
"""UPDATE issue_thread_interactions
SET status='cancelled', resolved_at=now(),
resolved_by_user_id=$3, updated_at=now()
WHERE id=$1::uuid AND issue_id=$2::uuid AND status='pending'
RETURNING id, status""",
interaction_id, issue_id, CHAIM_USER_ID,
)
finally:
await conn.close()
if not row:
return {
"ok": False,
"cancelled": False,
"error": "interaction not found or already resolved",
}
return {"ok": True, "cancelled": True, "id": str(row["id"]), "status": row["status"]}
async def reap_stale_interactions() -> dict:
"""Cancel pending interactions stranded on closed issues (done/cancelled).
Safety-net reaper (TaskMaster #215): a question on a closed issue can never
be answered and only inflates the chair's "awaiting you" count — this was the
dominant source of the pending pile-up. Scoped to the two legal-ai companies.
Direct status flip — no wakeups, no side effects.
"""
company_ids = list(COMPANIES.values())
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""UPDATE issue_thread_interactions it
SET status='cancelled', resolved_at=now(),
resolved_by_user_id=$2, updated_at=now()
FROM issues i
WHERE i.id = it.issue_id
AND it.status='pending'
AND it.company_id = ANY($1::uuid[])
AND i.status IN ('done', 'cancelled')
RETURNING it.id""",
company_ids, CHAIM_USER_ID,
)
finally:
await conn.close()
if rows:
logger.info(
"reap_stale_interactions: cancelled %d stranded pending interactions",
len(rows),
)
return {"ok": True, "cancelled": len(rows)}
# Singleton project for the precedent-library extraction queue. One issue per
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"