67 Commits

Author SHA1 Message Date
cb4aa703ec feat(agents-panel): היררכיה, סמן-כהושלם ידני, ובורר-יעד נגלל (5 תיקוני יו"ר)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 36s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
טאב הסוכנים בתיק — 5 תיקונים לבקשת היו"ר, מאושרים דרך שער-העיצוב
(Claude Design X17, כרטיס 18i4):

1. היררכיה — הרצה שנפתחה תחת השורש (למשל CMP-220 תחת CMP-189) מסומנת
   בתג "↳ <parent>" גם בציר-הזמן וגם בבורר-היעד (בהזחה). הנתון parent_id
   כבר הגיע מה-API — תצוגה בלבד.
2. סמן-כהושלם / בטל ידני — כל משימה פתוחה מקבלת פעולות בכותרת. פותר
   "אין אפשרות לסמן ידנית" (superseded runs נתקעו ב-in_review בלי מנגנון
   סגירה). endpoint חדש: POST /api/cases/{n}/agents/issue-status → דרך
   ה-Port (pc_set_issue_status), סגירה loop-safe ישירה ל-DB, בלי wakeup.
3. בורר-היעד נגלל — max-height + overflow פנימי, כך שהתיבה לא בורחת
   מגבולות העמוד. פותר "לא רואים את התחתית / לא מגיעים לפתיחת הליך".
4. "פתח הרצה חדשה" עלה לראש הרשימה, מעל "משימות פעילות", ומודגש.
5. קו-הפרדה בין משימות בציר-הזמן עבה יותר (border-b-2 rule).

G12: כל מגע-פלטפורמה עובר דרך agent_platform_port. אימות: lint + tsc +
next build עוברים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:25:36 +00:00
4d227a4999 Merge pull request 'fix(routing): הוראת-יו"ר תעבוד בדרך הטבעית — הערה פותחת הרצת-CEO' (#415) from worktree-natural-comment-routing into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m17s
G12 Leak-Guard / leak-guard (push) Successful in 7s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-16 12:33:29 +00:00
f58ddba93e fix(routing): הוראת-יו"ר תעבוד בדרך הטבעית — הערה פותחת הרצת-CEO
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 37s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
**התסמין:** היו"ר כותב הוראה על תיק והסוכן "מתעלם". ההרצה מדווחת succeeded/exit 0
ואפילו עולה כסף — ולכן זה שרד חודשים בלי שמישהו שם לב.

**שתי סיבות-שורש בלתי-תלויות, שתיהן מאומתות:**

1. ה-wakeup הישיר על ה-issue של התיק מבוטל תמיד: תיק שממתין ליו"ר הוא `in_review`
   ומשויך *לאדם*, ו-Paperclip עונה `issue_assignee_changed` → "אעיר את הבעלים החדש"
   → הבעלים אדם → אף סוכן לא מתעורר.
2. רשת-הביטחון בפלאגין מוסרת את ההוראה כ-`payload.prompt`, וה-runner קורא רק
   `payload.issueId` — הפרומפט נבלע. 18/18 מדידה ב-DB: 0% מסירה, אי-פעם.

**התיקון:** המסלול הטבעי (הערה בטאב-הסוכנים) מפרסם את ההערה בשרשור כרגיל **וגם**
פותח הרצת-CEO עם ההוראה, דרך `pc_open_ceo_run` — הפרימיטיב היחיד שאומת כמגיע
לסוכן (הוכח היום על 1043-02-26: 3,212→7,697 מילים). אותו תיקון ל-interaction-response,
שסובל מאותה תקלה (החצי השני של #228).

**G2 — הסרת מסלול מקביל:** ה-wakeup הנדון-לביטול הוסר מ-`post_comment`; הוא היה
המסלול השני, זה ששותק. כעת מסלול-מסירה קנוני אחד. `mark_comment_routed` מסמן את
ההערה כמנותבת כדי שה-sweep לא יירה הרצת-סרק על מה שכבר טופל.

**G12 — שער-הפלטפורמה:** המגע החדש (`mark_comment_routed`) נחשף דרך
`agent_platform_port.py` בלבד; leak-guard עובר.

התיקון יושב ב-backend (REST) ולא בפלאגין, ולכן אינו חשוף לבאג ה-invocation-scope
שמפיל את ה-sweep. תועד ב-docs/paperclip-quirks.md §7 עם המלכודות שנתקלנו בהן.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:32:20 +00:00
29970ad8b2 Merge pull request 'feat(case-files): פאנל "קבצי-התיק" — גישה מה-UI לכל תתי-התיקיות' (#414) from worktree-case-files-browser 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 12s
2026-07-07 17:31:17 +00:00
85af98cded feat(case-files): פאנל "קבצי-התיק" בטאב טיוטות — גישה מה-UI לכל תתי-התיקיות
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 31s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
היו"ר לא יכל לגשת לטיוטה שהסוכן שמר בדיסק (interim-decision-draft.md) אלא ב-SSH.
מימוש מוקאפ X17 מאושר (18m): פאנל דפדפן-קבצים בטאב "טיוטות והערות".

Backend (web/app.py) — הכללה של ה-endpoints הקיימים ל**כל** תת-תיקייה:
- `_case_file_folders` — מונה כל תת-תיקיית-תוכן תחת התיק (מקור/מחולץ/מחקר/הגהה/
  טיוטות/גיבויים), רקורסיה שכבה אחת לתוך documents/, מדלג על מוסתרות (.git/.claude)
  ו-thumbnails. `_resolve_case_file` מאמת folder-key מול הרשימה + חוסם path-traversal.
- `/local-files` מחזיר את כל התיקיות (כולל ריקות, עם files=[]); `/local-files/{folder}/{filename}`
  מגיש מכל תיקייה עם media-type לפי סיומת.

Frontend:
- `lib/api/case-files.ts` — useCaseFiles / useCaseFileText / caseFileUrl.
- `CaseFilesBrowser` — עץ-תיקיות (אקורדיון, ריקות מסומנות "ריק") + viewer מובנה
  ל-md/txt (Markdown הקיים) + הורדה לכל קובץ. נטען בטאב "טיוטות והערות".

אין response_model → אין תלות ב-api:types (מודול טיפוסים בכתב-יד).
Invariants: G1, G2 (אין מסלול-קבצים מקביל — מכליל את הקיים), אבטחת-נתיב.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:30:42 +00:00
107be235f5 Merge pull request 'feat(agents-tab): הוראה למעלה + פעילות מקופלת + בורר-יעד עם "הרצה חדשה"' (#413) from worktree-agents-tab-layout into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m20s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-07 15:49:28 +00:00
9e7d98a47a feat(agents-tab): הוראה למעלה + פעילות מקופלת + בורר-יעד עם "הרצה חדשה"
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
טאב הסוכנים בדף-התיק — מימוש מוקאפ X17 מאושר (18i3), פותר את התלונה החוזרת
שהיו"ר נחת בתחתית העמוד, שדה-ההוראה למטה, והטיוטה החדשה נבלעה בגלילה.

Frontend (web-ui/agent-activity-feed.tsx):
- **שדה-ההוראה עלה לראש הטאב** (Composer), מעל הפעילות — הכי זמין, בלי גלילה.
  בוטל ה-auto-scroll-to-bottom (endRef/useEffect הוסרו).
- **כל הפעילות מקופלת כברירת-מחדל** — קבוצות-המשימה וגם "ממתין לתשובתך"
  (defaultOpen=false); היו"ר פותח כדי להסתכל. סדר החדש-למעלה (תג "החדש למעלה").
- **בורר-יעד** (TargetSelector): מציג לאיזו משימה ההוראה תלך, בוחר משימה פעילה,
  מסמן משימות סגורות כמנוטרלות ("שליחה אליהן לא תעיר סוכן"), אזהרת יעד-סגור
  עם כפתור-שליחה מנוטרל, ואופציית **"פתח הרצה חדשה"**. מחשב את יעד-ברירת-המחדל
  בצד-לקוח במראָה ל-pick_default_comment_target.

Backend:
- `open_ceo_run` (paperclip_client) — פותח הרצת-CEO טרייה דרך פרימיטיב ה-CEO-child
  (#227): יוצר issue-ילד בבעלות CEO ומעיר עליו, ועוקף את issue_assignee_changed
  על issue בבעלות-אדם (הבאג של #228). נחשף דרך ה-Port כ-`pc_open_ceo_run`.
- `/agents/comment` מקבל `new_run:bool`; `PaperclipIssue` מחזיר `parent_id`
  (לבורר בצד-לקוח).

Invariants: G1 (נרמול-במקור), G2 (אין מסלול-ניתוב מקביל), G12 (מגע-Paperclip דרך ה-Port).
מקדם TaskMaster #228 (מנתב הרצה-חדשה דרך פרימיטיב ה-CEO-child).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 15:48:26 +00:00
0a5be942ba Merge pull request 'fix(agents): ניתוב הערת-יו"ר לא ל-issue סגור — תמיד ל-CEO החי' (#412) from worktree-comment-routing-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-07 15:07:15 +00:00
67d5835eb8 fix(agents): ניתוב הערת-יו"ר לא ל-issue סגור (cancelled) — תמיד ל-CEO החי
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 34s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
בעיה חוזרת: הקלדת הוראה בתיבת "כתוב הוראה לסוכנים..." נבלעה בשקט. השרת בחר
את היעד ב-`active[-1]` תוך סינון `done` בלבד — לא `cancelled`. כשה-child האחרון
בוטל (למשל 1027-04-26: "חישוב טיעונים" cancelled שנוצר אחרי ה-issue הראשי),
ההערה נותבה אליו; Paperclip מדלג על wakeup ל-issue מבוטל → ההוראה לא הגיעה לאיש.

תיקון (G1 — נרמול במקור, לא תיקון-בקריאה):
- `pick_default_comment_target` חדש ב-paperclip_client — מעדיף את ה-issue הראשי
  החי של ה-CEO (top-level+open), נופל ל-open כלשהו, ואז top-level, ואף פעם לא
  מעדיף child-סגור. `get_case_issues` מחזיר כעת `parent_id`.
- app.py מנתב דרכו; מחזיר `issue_status` כדי שה-UI יוכל לסמן יעד-סגור.
- נחשף דרך ה-Port (G12) כ-`pc_pick_default_comment_target`.
- 6 בדיקות ל-picker (כולל תרחיש 1027-04-26 המדויק).

Invariants: G1 (נרמול-במקור), G2 (אין מסלול-ניתוב מקביל), G12 (מגע-Paperclip דרך ה-Port).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 15:05:51 +00:00
1d8c4e29c8 Merge pull request 'docs(heartbeat): §1.5ד — קרא legal_predecessor_context בעת continuation (#220)' (#411) from worktree-heartbeat-predecessor into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 8s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-07 05:19:22 +00:00
943696efbd docs(heartbeat): §1.5ד — קרא legal_predecessor_context בעת continuation (#220)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
משלים את follow-up #1: מנחה סוכן שמתעורר-מחדש על תיק לקרוא את כלי-ה-plugin
legal_predecessor_context(case_number) לפני שיגלה-מחדש הקשר מאפס — כך ממשיך
מנקודת-העצירה ומונע blind heartbeat. הכלי חי ב-plugin (plugin-legal-ai #5+#6,
toolCount=9); ה-endpoint by-case חי (legal-ai #410).

תוקף: HEARTBEAT נקרא ע"י claude_local מ-~/legal-ai — דורש host git pull.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 05:18:32 +00:00
7d1fc49e35 Merge pull request 'feat(seance): predecessor by-case לצריכת-סוכן דרך ה-plugin (#220 המשך)' (#410) from worktree-predecessor-by-case into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 3s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-07 05:09:12 +00:00
ad04291d39 feat(seance): predecessor by-case לצריכת-סוכן דרך ה-plugin (#220 המשך)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מוסיף get_predecessor_for_case(case_number) — האח case-scoped של
get_predecessor_context (by-issue). סוכנים חושבים ב-case_number (לא issue-UUID),
לכן זהו הנתיב שכלי-ה-plugin legal_predecessor_context (PR נפרד ב-plugin-legal-ai)
יקרא. מחזיר מסקנות (summary) של ריצות-heartbeat שהסתיימו על-פני issues של התיק,
newest-first, עם summary בלבד (ריצות cancelled ריקות מסוננות).

- shaper משותף _shape_predecessor_run (G2 — נתיב-shaping יחיד ל-by-issue+by-case).
- join בטוח i.id::text = payload->>'issueId' (לא cast של payload חופשי ל-uuid).
- Port pc_get_predecessor_for_case (read-only); endpoint
  GET /api/operations/cases/{case_number}/predecessor.
- 2 בדיקות (shaping+identifier, case-לא-נמצא). אומת חי: 1043-02-26/8125-09-24
  מחזירים מסקנות-דיספוזיציה אמיתיות.

Invariants: G12 (fetch מהמעטפת, המגע מהשער; הכלי יחיה ב-plugin=מעטפת מוצהרת, לא
ב-mcp-server), G2 (shaper יחיד). המשך: PR ב-plugin-legal-ai (כלי) + הוראת-HEARTBEAT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 05:07:44 +00:00
8d0f8ad17b Merge pull request 'feat(operations): פאנל בריאות-הסוכנים + הסלמות אחרונות ב-/operations (#218+#222 UI)' (#409) from worktree-ops-agent-health-ui into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m29s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-07 04:46:08 +00:00
cf7c918bed feat(operations): פאנל בריאות-הסוכנים + הסלמות אחרונות ב-/operations (#218+#222 UI)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 30s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
הטמעת מוקאפ 02e (מאושר Claude-Design X17) — נראות שרת-הצד שנבנתה כ-backend:
- backend: get_recent_escalations() ב-paperclip_client — משחזר הסלמות מהערות
  author_type='system' (severity+reason מנותחים מהגוף, identifier מ-join); Port
  pc_get_recent_escalations (read-only); endpoint GET /api/operations/agents/escalations.
- frontend: useAgentHealth + useRecentEscalations (טיפוסים מקומיים כמו שאר
  operations.ts) + AgentHealthPanel ב-/operations תחת "בריאות-הסוכנים":
  4 stat-tiles (זומבי/תקוע/עובד/רגוע), שורות-בעיה worst-first, וקארד
  הסלמות-אוטומטיות-אחרונות (severity+תיק+סיבה+מתי).

דיוק מול המוקאפ: issue מוסלם עובר לבעלות-חיים→יוצא מרשימת-הבריאות, לכן
שורות-הבריאות = זומבים טרם-הסלמה וקארד-ההסלמות = מטופלים (משלימים).

Invariants: G12 (fetch מהמעטפת, המגע מהשער; UI צורך endpoint דומייני), G2
(מקור-בריאות/הסלמות יחיד). שער-עיצוב: מוקאפ 02e אושר לפני קוד (feedback_claude_design_gate).

בדיקות: 13 pytest (2 חדשות ל-get_recent_escalations: parse severity/reason,
fallback). frontend: tsc --noEmit + eslint נקיים (build מלא ב-CI/deploy —
symlink node_modules שובר next build ב-worktree). api:types לא נדרש (operations.ts
טיפוסים ידניים); ה-endpoint נפרס אטומית עם ה-UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 04:44:50 +00:00
a9db657541 Merge pull request 'feat(watchdog): auto-escalation של zombie-loops ל-chair — סגירת הלולאה (#218+#222)' (#408) from worktree-zombie-auto-escalate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-07 03:38:30 +00:00
c7941a63ec feat(watchdog): auto-escalation של zombie-loops ל-chair — סגירת הלולאה (#218+#222)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
מרכיב את הפרוסות שמוזגו לכלל closed-loop: watchdog שרת-צדי (_zombie_escalation_loop
ב-lifespan, כל 15 דק') שגוזר בריאות per-issue (#222) ומסלים כל zombie מתמשך
(לולאת-recovery, ≥ZOMBIE_ESCALATE_MIN_RECOVERY=2 יקיצות-שחזור) ל-chair דרך
escalate_issue הloop-safe (#218). הטלמטריה agent.escalated (#219) נדלקת מה-Port.

- מדוע שרת-צדי: סוכן תקוע-בלולאה לא יכול להסלים-עצמו אמין; ה-watchdog (מקביל
  ל-Deacon של Gastown, אבל דרך ה-reaper הקיים ולא tier חדש) עושה זאת.
- אידמפוטנטי מבנייה: issue מוסלם הופך agent-unowned → לא zombie בsweep הבא.
- החלטת-ההסלמה (סף+severity) = helper טהור zombie_escalation ב-agent_health (testable).
  severity: ≥5 יקיצות→high, אחרת→medium.
- kill-switch: AGENT_ZOMBIE_AUTO_ESCALATE=0 להשבתה (הבריאות עדיין נצפית, escalate
  ידני עדיין זמין ב-endpoint). ברירת-מחדל ON — הלולאות שורפות budget והתרופה מוכחת.
- 4 בדיקות policy חדשות (11 סה"כ). dry-run חי: 6 issues→0 hzombies→0 הסלמות
  (blast-radius מינימלי על deploy).

Invariants: מקיים G2 (מרחיב את מנגנון-ה-reaper הקיים, לא sweep מקביל; escalate דרך
המסלול הקנוני), G12 (הכל ב-web/, המגע מהשער), §6 (כשל-sweep נלכד ולא-קטלני).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 03:37:25 +00:00
dede33cdff Merge pull request 'feat(seance): אחזור-הקשר מריצות-קודמות של issue לצמצום blind-heartbeats (#220)' (#407) from worktree-seance-predecessor into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-07-07 03:16:49 +00:00
e203474548 feat(seance): אחזור-הקשר מריצות-קודמות של issue לצמצום blind-heartbeats (#220)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מוסיף get_predecessor_context(issue_id, limit) ב-paperclip_client — read-only,
מחזיר ריצות-heartbeat שהסתיימו על אותו issue (newest-first) עם ה-summary שכל
heartbeat משאיר ב-result_json, כך ש-wake טרי קורא מה קודמיו הסיקו במקום לגלות-מחדש
(ה-generic blind heartbeat). קשירת run→issue דרך wakeup_request_id→payload.issueId;
limit מוגבל 1..10.

- Port: pc_get_predecessor_context (read-only, לא עטוף-טלמטריה).
- endpoint: GET /api/operations/issues/{issue_id}/predecessor?limit=3.
- 3 בדיקות (fake-asyncpg: shaping+isoformat, clamp, empty). אומת חי read-only על
  issue בן 11 ריצות → מוחזרות מסקנות-דיספוזיציה אמיתיות.

Invariants: מקיים G12 (fetch מהמעטפת, המגע מהשער; אין REST/כלי-פלטפורמה בליבה),
G2 (מקור-predecessor יחיד). follow-up: MCP tool + HEARTBEAT (wake קורא בתחילת ריצה).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 03:16:03 +00:00
00d362ec5d Merge pull request 'feat(health): טקסונומיית-בריאות לסוכן — zombie/stalled/working/idle (#222)' (#406) from worktree-agent-health into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-07 03:06:38 +00:00
bfc352c7b5 feat(health): טקסונומיית-בריאות לסוכן — zombie/stalled/working/idle (#222)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 31s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מוסיף web/agent_health.py — מסווג-טהור classify_issue_health (agent-agnostic,
testable) שממפה כל issue פתוח+משויך-סוכן למצב-בריאות אחד, ו-get_agent_health
ב-paperclip_client שגוזר את הפרימיטיבים מ-heartbeat_runs (run חי=finished_at null
בחלון) + agent_wakeup_requests (total + recovery-markers בחלון). מרכיב על אירועי
#219 (אותם reasons של recovery).

- zombie = פתוח+משויך-סוכן, אין run חי, יש wakeup-recovery → הstranded-child שאובחן
  ידנית (reference_recovery_loop_stranded_child) עולה אוטומטית.
- stalled = ננער חוזר בלי progress; working = run חי; idle = המתנה בנונית.
- Port: pc_get_agent_health (read-only, לא עטוף-טלמטריה לפי הכלל); endpoint
  GET /api/operations/agents/health (worst-first, +counts).
- 7 בדיקות pytest (מסווג-טהור + fetch fake-asyncpg). אומת חי read-only מול
  Paperclip DB: 6 issues→idle נכון, אפס zombie.

Invariants: מקיים G12 (מסווג אגנוסטי; fetch מהמעטפת; המגע מהשער), G2 (מקור-בריאות
יחיד). UI ב-/operations = follow-up מגודר-שער-עיצוב (feedback_claude_design_gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 03:05:17 +00:00
653b951d30 Merge pull request 'feat(escalation): primitive הסלמה-לאדם loop-safe בשער-הפלטפורמה (#218)' (#405) from worktree-agent-escalation into main
All checks were successful
Lint — undefined names / undefined-names (push) Successful in 11s
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 5s
2026-07-06 21:03:44 +00:00
b3b7c48b8f feat(escalation): primitive הסלמה-לאדם loop-safe בשער-הפלטפורמה (#218)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מוסיף escalate_issue(issue_id, severity, reason) — חלופה loop-safe להשארת
issue משויך-סוכן+blocked, שהיא מקור לולאות source_scoped_recovery_action /
stranded_assigned_issue / issue_reopened_via_comment (reference_paperclip_recovery_loops).

שני אפקטים בטרנזקציית-DB אחת, במנגנון הקנוני של reset_case_agents (raw SQL, לא REST):
- מעבר-אטומי למצב human-owned {status:in_review, assignee_agent_id:null,
  assignee_user_id:CHAIM_USER_ID} (rule #7). direct-DB בכוונה — עוקף את
  disposition-resolver של Paperclip שהוא-עצמו מקור-הלולאות; נוחת human-owned בשוט
  אחד בלי done→todo flip.
- הערת-severity עמידה author_type='system' — אינרטית ל-route-pending-comments
  (מנתב רק author_type='user'), לכן לא מעירה CEO. אפס wakeup — המטרה היא מסירה-לאדם.

חיווט:
- Port: pc_escalate_issue עטוף ב-agent.escalated (מרכיב על טלמטריית #219 —
  הסלמות נספרות per-case באותו זרם כמו ה-wakeups שהן מחליפות).
- app.py: POST /api/cases/{case}/agents/escalate (Literal severity).
- 3 בדיקות pytest (fake-asyncpg): severity לא-תקין, happy-path (מעבר+system-note+
  אפס-wakeup), issue-לא-נמצא.

Invariants: מקיים G2 (אותו מנגנון-העברה-לאדם כמו reset_case_agents, לא מסלול מקביל),
G12/INV-PORT1 (המגע מהשער בלבד; ליבה נקייה), §6 (אין בליעה שקטה — severity לא-תקין
וissue-חסר מוחזרים מפורשות).

follow-up מוצהר: חיווט-סוכנים (MCP tool + HEARTBEAT §4 להחליף blocked+prose),
auto-escalation מה-reaper, והתראת-מייל (notify.py) לחיים.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:02:38 +00:00
0966a49afb Merge pull request 'feat(telemetry): אירועי-סוכן מובנים בשער-הפלטפורמה — נראוּת לולאות-recovery (#219)' (#404) from worktree-agent-telemetry into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-06 20:53:00 +00:00
5dbe970dbc feat(telemetry): אירועי-סוכן מובנים בשער-הפלטפורמה — נראוּת לולאות-recovery (#219)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 31s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
מוסיף web/agent_telemetry.py — emitter אגנוסטי-לפלטפורמה שפולט אירוע JSON
מובנה לכל פעולת-פלטפורמה משנת-מצב (wakeup/comment/interaction-reap/cancel/
run-cancel/session-reset). מטרה: להפוך דיבוג לולאות-recovery מפורנזיקה-ידנית
מול Paperclip DB (agent_wakeup_requests/heartbeat_runs) לנראוּת בזמן-אמת.

- הפליטה מחווטת ב-web/agent_platform_port.py בלבד (התפר היחיד) — כל pc_* משנה-מצב
  נעטף ב-instrument(); השמות הציבוריים והחתימות נשמרים, app.py לא משתנה.
- אגנוסטי-לפלטפורמה (G12): agent_telemetry לא מייבא Paperclip; אירועים בשמות-דומיין
  שורדים החלפת-פלטפורמה. מסלול-פליטה יחיד (G2).
- OTLP-ready ("logs first"): schema יציב + _SINK מבודד להחלפה עתידית בלי שינוי call-site.
- error מ-op נפלט כאירוע ואז נזרק-מחדש — אין בליעה שקטה (§6).
- store-UTC (feedback_israel_time). 6 בדיקות pytest (web/tests/test_agent_telemetry.py).

Invariants: מקיים G2 (מסלול-טלמטריה יחיד), G12/INV-PORT1 (פליטה מהשער, ליבה נקייה),
כלל-הנדסה §6 (אין בליעה שקטה). מזין את #222 (טקסונומיית-בריאות).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:51:20 +00:00
c4f3048248 Merge pull request 'feat(claims): אכלוס claims.party_name במקור + backfill — סגירת השדה-המת (#224)' (#403) from worktree-claims-party-name-populate into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m32s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-06 15:11:23 +00:00
f15cc896d6 feat(claims): אכלוס claims.party_name במקור + backfill — סגירת השדה-המת (#224)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 32s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
עד כה claims.party_name נשאר ריק והצבירה/הכתיבה גזרו את הפרדת-המשיבים
מ-source_document בזמן-ריצה (שדה-מת). עכשיו הוא מאוכלס במקור ונקרא ע"י הצרכנים.

- claims_extractor.extract_and_store_claims: מחתים party_name=source_document
  ל-SPLIT_PARTIES (respondent/permit_applicant); single-voice נשאר ''. אותו
  כלל SPLIT_PARTIES של הצבירה (G2, import לא-מעגלי).
- SCHEMA_V52: backfill idempotent ל-claims קיימים (רק split + party_name ריק).
- argument_aggregator + block_writer._build_claims_context: קוראים
  claims.party_name, עם fallback ל-source_document ל-claims מדור-קודם. התנהגות
  זהה (party_name==source_document) — אפס-רגרסיה, אך השדה כעת מקור-האמת (G1).

py_compile + טעינת-שרשרת-ייבוא נקיים. סוגר את הפתוח האחרון של #224.
Invariants: G1 (אכלוס במקור, לא גזירה-בקריאה), G2 (כלל-פיצול יחיד).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 15:10:50 +00:00
6ad0a387fe Merge pull request 'fix(block-yod): התייחסות פרטנית לעמדות משיבים מרובים + זיהוי עמדות-מנוגדות (#224)' (#402) from worktree-block-yod-respondent-positions 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-07-06 13:20:14 +00:00
0fa2281ada fix(block-yod): התייחסות פרטנית לעמדות משיבים מרובים + זיהוי עמדות-מנוגדות (#224)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
השלמת #224 לבלוק י' (דיון והכרעה): הקשר-הטענות כבר מופרד לפי כתב-תשובה
(PR#401, אותו _build_claims_context), אך ה-prompt של block-yod לא הנחה
להתייחס לכל משיב לגופו. נוסף כלל "צדדים מרובים": התייחס לעמדת כל משיב/כתב-תשובה
בנפרד (משיבות 2-3 מול משיבים 4-6), אל תמזג ל"טענות המשיבים" גורפות, וכשעמדות
מנוגדות — ציין והכרע בנימוק.

שינוי-prompt בלבד; משפיע על ניסוח בלוק י' בהחלטות עם כמה משיבים. py_compile ירוק.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 13:19:37 +00:00
9f1ad67536 Merge pull request 'fix(block-zayin): הפרדת-משיבים בבלוק ז' (טענות הצדדים) — השלמת #224 לשכבת-הכתיבה' (#401) from worktree-block-zayin-respondent-split into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m33s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 15s
2026-07-06 12:47:16 +00:00
1da1585ca8 fix(block-zayin): הפרדת-משיבים בבלוק ז' (טענות הצדדים) — השלמת #224 לשכבת-הכתיבה
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
בלוק ז' של טיוטת-הביניים מיזג את כל המשיבים לסעיף אחד גנרי (או השמיטם) —
כי (א) _build_claims_context קיבץ רק לפי party_role, ו-(ב) ה-prompt נעל
"מבנה קבוע 3 חלקים: עוררים / ועדה מקומית / מבקשי-היתר" — בלי "משיבים" בכלל.
לכן בתיק 1043-02-26 המשיבים 2-3 ו-4-6 לא הופיעו כצדדים מובחנים, אף שהנתונים
(129 טענות-משיבים מפוצלות לפי כתב-תשובה) תקינים ב-claims. הפרדת-המשיבים של
#224 יושמה בצבירה+פאנל בלבד (PR#392) — לא בכותב-הבלוקים.

- `_build_claims_context` מקבץ לפי (party_role, brief) כאשר brief=source_document
  ל-SPLIT_PARTIES (respondent/permit_applicant) — **אותו כלל בדיוק כמו הצבירה**
  (import SPLIT_PARTIES מ-argument_aggregator, G2). התוצאה: סעיף נפרד לכל
  כתב-תשובה ("טענות המשיבים — כתב תשובה משיבות 2 3", "— משיבים 4 6"...).
- prompt בלוק ז': מבנה-צדדים דינמי לפי הכותרות בקונטקסט; הוראה מפורשת לא למזג
  משיבים שונים ולשקף עמדות-מנוגדות; יעד-אורך גמיש לכמה כתבי-תשובה.

משפיע גם על בלוק י' (אותו _build_claims_context) — הקשר מופרד. py_compile ירוק.
עבודה על MCP → דורש host reload + הרצה-מחדש כדי לראות בפועל.
Invariants: G2 (נגזרת-הפרדה יחידה משותפת עם הצבירה), G1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:46:32 +00:00
66cddd20f0 Merge pull request 'refactor(export): שם קובץ טיוטת-הביניים → טיוטה-טענות_הצדדים_{N} (הנחיית חיים)' (#400) from worktree-interim-filename-rename into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m54s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-06 12:32:02 +00:00
9e7ef4fced refactor(export): שם קובץ טיוטת-הביניים → טיוטה-טענות_הצדדים_{N} (הנחיית חיים)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 31s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
במקום "טיוטת-ביניים-v{N}.docx" — שם מדבר יותר: "טיוטה-טענות_הצדדים_{N}.docx".
הפורמט הסופי ("טיוטה-v{N}") לא משתנה.

- docx_exporter._draft_naming: (prefix, sep) פר-מצב; interim=("טיוטה-טענות_הצדדים","_"),
  final=("טיוטה","-v"). glob/parse משתמשים ב-rsplit(sep,1) — נכון גם כשה-prefix
  עצמו מכיל "_".
- app.py _generation_output_ready + frontend pickLatestVersioned: matcher-prefix
  מעודכן; regex-הגרסה עוגן-לסוף /[_v](\d+)$/ (ה-"_" שבתוך הprefix לא מבלבל).
- docstrings + legal-ceo.md + הערות מעודכנים לשם החדש. types.ts (auto-gen)
  יתעדכן ב-api:types.

שינוי-נתון (שם-קובץ), לא layout — בגדר חריג-שער-העיצוב. tsc+eslint+py_compile
ירוקים. הערה: ייצור-השם רץ ב-MCP המקומי (host) → דורש reload; ה-matchers
בקונטיינר (Coolify).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:31:30 +00:00
a0a30092e8 Merge pull request 'feat(generate): סימון-סטטוס נשמר-שרת לריצת-הרקע של "הפקת מסמכים" (#227 ג)' (#399) from worktree-generate-status-indicator into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-07-06 11:34:26 +00:00
b296422d87 feat(generate): סימון-סטטוס לריצת-הרקע של "הפקת מסמכים" (#227 ג)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 15s
עד כה ה-"מייצר…" כיסה רק את שנייה-ה-POST; אחרי זה לא היה חיווי אם ההפקה
רצה/הצליחה/נכשלה — ריצה שבוטלה נראתה כמו הצלחה. עכשיו צ'יפ-סטטוס ליד כל
כפתור-הפקה: בתור · רץ+שעון-חי · הושלם+מתי · נכשל+סיבה+"נסה שוב".

**נשמר-שרת (דרישת חיים):** הסטטוס נגזר בשרת מ-issue-הילד של ה-CEO +
ה-heartbeat_run האחרון + קיום-הקובץ — לא מזיכרון-הדף. שעון-הריצה מעוגן
ל-started_at של השרת, כך שעזיבה+חזרה לדף מציגות את המצב והזמן הנכונים,
בלי צורך להישאר בדף.

Backend:
- paperclip_client.get_generation_run_status — קורא issue-ילד + latest run
  מ-Paperclip DB (read-only, מאחורי שער-הפלטפורמה G12); dedup לפי כותרת.
- GET /api/cases/{n}/generate/{action}/status — ממפה ל-idle/queued/running/
  done/failed + output_ready + started_at/finished_at (UTC). run שהסתיים בלי
  קובץ = failed (שלא ייראה כהצלחה).

Frontend:
- useGenerateStatus (poll 5s רק כשפעיל; עוצר בטרמינלי) + GenerateStatusChip
  (שעון-ריצה חי מ-started_at; רכיב חסר-מצב). כפתורי-ההפקה מרעננים את
  שאילתת-הסטטוס ב-onSuccess. שורה 3 (החלטה מלאה, סינכרוני) — בלי צ'יפ.

עיצוב: מוקאפ 29-generate-status-indicator אושר ע"י חיים דרך שער Claude Design (X17).
tsc + eslint + py_compile ירוקים. (api:types לריענון post-deploy.)
Invariants: G12 (Paperclip רק בשער), G1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:33:41 +00:00
21ad541659 Merge pull request 'fix(generate): הפקת מסמכים דרך issue-ילד משויך-ל-CEO — לא מבוטלת בהמתנה-ליו"ר (#227 ב)' (#398) from worktree-generate-reliability-fix into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 10s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 15s
2026-07-06 11:17:24 +00:00
3462cb7320 fix(generate): הפקת מסמכים דרך issue-ילד משויך-ל-CEO — לא מבוטלת בהמתנה-ליו"ר (#227)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 33s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
באג: כפתורי "הפקת מסמכים" (טיוטת-ביניים / סיכום-מנהלים) העירו את ה-CEO
*על ה-issue הראשי*. כשהתיק ב-in_review משויך-לחיים (המצב הרגיל של תיק
ממתין-ליו"ר), Paperclip ביטל את הריצה מיד (issue_assignee_changed) — הכלום
הופק, וה-endpoint החזיר 202 בכל מקרה, כך שהכשל היה שקוף.

תיקון: wake_ceo_for_action יוצר issue-ילד משויך-ל-CEO תחת ה-issue הראשי
ומעיר על הילד — בדיוק כדפוס wake_analyst_for_argument_aggregation שעובד.
ה-CEO בעל הילד → הריצה לא מבוטלת. ההוראה מוכלת בתיאור-הילד (הרץ את הכלי,
comment, סגור done) ולא תלויה בפענוח payload.action. legal-ceo.md מעודכן
שההערה מגיעה על issue-ילד ייעודי שיש לסגור בסיום.

מגע-Paperclip רק בשער-הפלטפורמה (G12). זהו החצי הראשון (ב) של #227; חצי
שני (ג) = סימון-סטטוס ב-UI. py_compile ירוק.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 11:16:46 +00:00
17c23ab377 Merge pull request 'refactor(positions): קיפול "עמדות וטענות", איזון שורה תחתונה, שם-כרטיס מדויק (#226)' (#397) from worktree-positions-collapse-balance 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 10s
2026-07-06 09:35:44 +00:00
972935ceb5 refactor(positions): קיפול "עמדות וטענות", איזון שורה תחתונה, שם-כרטיס מדויק (#226)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
לפי הנחיות חיים על טאב "טיעונים ועמדות":
- "עמדות וטענות" נעטף באקורדיון single/collapsible סגור-כברירת-מחדל, כמו
  "טיעונים מאוגדים לפי צד" ו"מה קרה בדיון" — הטאב נפתח כ-3 פסים מקופלים.
  התיאור עבר לטריגר; הוסר ה-lead הכפול מהרכיב.
- שורה תחתונה מאוזנת: "פסיקה מצורפת" ו"ייצוא ועדכון הניתוח" באותו גובה
  (items-stretch + h-full על שני הכרטיסים).
- כותרת הכרטיס "השלמה והעברה" → "ייצוא ועדכון הניתוח" (שם מדויק — הכרטיס
  מייצא/מעדכן/מוריד את analysis-and-research.md, לא "משלים ומעביר").
- הוסרו שתי שורות-הסטטוס "הרץ למידת-קול" + "הרץ אימות-הלכות" (post-final;
  קיימות בטאב "טיוטות והערות" — הסרת-כפילות G2). נוקו helpers+import לא-משומשים.

עיצוב: מוקאפ 28-case-positions-collapsed-balanced אושר ע"י חיים דרך שער Claude Design (X17).
tsc + eslint ירוקים. תואם INV-IA1 (דפוס-אח מאושר).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:35:12 +00:00
538d80f6ec Merge pull request 'fix(hearing): פאנל "מה קרה בדיון" סגור כברירת-מחדל (#226)' (#396) from worktree-hearing-panel-collapsed into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m19s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-06 09:13:59 +00:00
fdb0c98650 fix(hearing): פאנל "מה קרה בדיון" סגור כברירת-מחדל (#226)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 32s
Lint — undefined names / undefined-names (pull_request) Successful in 20s
עוטף את HearingChangesPanel באקורדיון single/collapsible סגור-כברירת-מחדל,
זהה לפאנל-האח "טיעונים מאוגדים לפי צד" מעליו (הנחיית חיים). הכותרת+תיאור
עוברים לטריגר-האקורדיון; מוסרת הכותרת הכפולה מהרכיב (כפתור-ההרצה נשמר,
מיושר לקצה בתצוגה המלאה). tsc + eslint ירוקים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 09:13:13 +00:00
0d0631fea5 Merge pull request 'feat(hearing): פאנל "מה קרה בדיון" — חשיפת ניתוח-הפרוטוקול ב-UI (#226)' (#395) from worktree-hearing-changes-panel into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m37s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 12s
2026-07-05 12:17:56 +00:00
ab5d34a9d3 feat(hearing): פאנל "מה קרה בדיון" — חשיפת ניתוח-הפרוטוקול ב-UI (#226)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 59s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
תוצר analyze_protocol (איך הטענות הכתובות התפתחו בדיון בעל-פה) היה שכבת
ידע-תיק ללא endpoint ותצוגה — נצרך רק downstream ע"י הכותב בבלוק-י. פאנל חדש
בתחתית טאב "טיעונים ועמדות", מתחת לכרטיס-הטיעונים (INV-IA1: לא טאב-מקביל).

Backend:
- SCHEMA_V51: cases.hearing_attendees JSONB — בית קנוני לנוכחות-בדיון
  (panel_members/appellants_present/respondents_present). נבדל סמנטית
  מ-decisions.panel_members (מותב חותם-ההחלטה) → אין מסלול-מקביל (G2).
- protocol_analyzer._extract_header: מאכלס hearing_attendees; refresh על re-run
  (מתקן #223), לא דורס עם חילוץ ריק. אישור-משתמש: "אפשרות א" (persist+הצגה).
- GET /api/cases/{n}/protocol-analysis — header (תאריך+נוכחים קנוניים) + verdicts
  מקובצים לפי צד. קריאה-בלבד, container-safe, ריק≠שגיאה.
- POST /api/cases/{n}/analyze-protocol — מעיר את המנתח (analyze_protocol מריץ
  claude מקומי, נעדר בקונטיינר) כתבנית aggregate-arguments.
- מגע-Paperclip רק דרך agent_platform_port (G12): wake_analyst_for_protocol_analysis.

Frontend:
- lib/api/protocol-analysis.ts (hooks read+trigger) + HearingChangesPanel
  (סרגל-כותרת, פסי-סינון התחזק/נטען-לראשונה, כרטיסים לפי צד, ציטוט-ראיה).
- חיווט לטאב arguments מתחת ל-LegalArgumentsPanel.

עיצוב: מוקאפ 27-case-hearing-changes-panel אושר ע"י חיים דרך שער Claude Design (X17).
Invariants: מקיים G2 (בית קנוני יחיד, לא מסלול-מקביל), G1 (נרמול-במקור),
G12 (שער-הפלטפורמה), INV-IA1 (לא טאב-מקביל), INV-AH (evidence_quote כבר נאכף במקור).
אימות: py_compile + tsc --noEmit + eslint ירוקים; runtime של ה-endpoint = post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:16:35 +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
def823b89f Merge pull request 'feat(case-ui): כרטיס "הפקת מסמכים" עם 3 כפתורי-הפק דטרמיניסטיים (#214)' (#386) from worktree-agent-a72c8793960b71772 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 10s
2026-06-30 19:56:46 +00:00
3ea5d3b536 feat(case-ui): כרטיס "הפקת מסמכים" עם 3 כפתורי-הפק דטרמיניסטיים (#214)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
מוסיף כרטיס "הפקת מסמכים" בראש טאב "טיוטות והערות" (mockup 26 — תוספת ל-18h),
תוספת לעמוד הקיים, כל הסקשנים נשמרים.

- כפתור 1 "הפק סיכום מנהלים" → endpoint חדש POST /generate/party-claims-summary
  → CEO wakeup סטרוקטורלי action="party_claims_summary" (שלב H2, host-side).
  Polling: GET /research/party-claims-summary (200=מוכן → פתח/DOCX).
- כפתור 2 "הפק טיוטה של טענות הצדדים" → endpoint חדש POST /generate/interim-draft
  → CEO wakeup action="interim_draft" (שלב H, host-side).
  Polling: רשימת ה-exports עד שמופיע טיוטת-ביניים-{case}-vN.docx.
- כפתור 3 "הפק טיוטת החלטה מלאה" → ה-useExportDocx הקיים (in-container, ללא LLM);
  הועבר מהבאנר (כפתור "הפק DOCX" הוסר משם — אין כפילות). disabled+title כשאין
  בלוקים כתובים (isDraftReady).

מסלול-עירור יחיד דרך ה-Platform Port (pc_wake_ceo_for_action) → API helper
/api/agents/{id}/wakeup, לעולם לא DB insert. Israel-time + cache-invalidation.

Invariants: G12 (מגע-Paperclip רק ב-paperclip_client + agent_platform_port;
leak-guard ירוק) · G2 (אין מסלול-עירור מקביל — שימוש חוזר ב-pc_request הקיים) ·
G10 (הפקה מגודרת-יו"ר/CEO) · INV-UI9 (Asia/Jerusalem) · X6 (UI↔API contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:54:54 +00:00
425305844a Merge pull request 'docs(status): align agent status maps to the canonical (nested) status model' (#385) from worktree-status-docs-refresh into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 19:52:52 +00:00
5867f7175f docs(status): align agent status maps to the canonical (nested) status model
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
Follow-up to the status SSoT (#382): analyst_verified + research_complete are
now first-class canonical statuses, so refresh the docs that still framed them
as legacy/decorative.

- HEARTBEAT.md §7: rewritten to point at the single source
  (case_status_model.py / GET /api/status-model), list the 12 canonical statuses
  by phase, and state the two intermediates are canonical (not legacy); genuinely
  old markers (proofread/analysis_enriched/…) are display-only fallbacks.
- legal-qa.md / legal-ceo.md status maps: dropped the "legacy/optional" framing
  on research_complete; point to the canonical model.
- scripts/backfill_case_status_trim.py: removed analyst_verified +
  research_complete from the downgrade map (they are valid statuses again — must
  NOT be downgraded) + updated the docstring.

Docs/prompts only — no behaviour change. py_compile clean.
NOTE: the host ~/legal-ai needs `git pull` for the Paperclip agents to pick up
the refreshed prompts (they run on the host tree, not the container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:52:27 +00:00
c954dded66 Merge pull request 'fix(case-ui): drafts cards in mockup order — files → final → feedback (18h)' (#384) from worktree-drafts-card-order into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 42s
G12 Leak-Guard / leak-guard (push) Successful in 4s
Lint — undefined names / undefined-names (push) Successful in 10s
2026-06-30 19:41:21 +00:00
5cf1ff65f4 fix(case-ui): drafts cards in mockup order — files → final → feedback (18h)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
The 3 drafts cards rendered final-decision before draft-files; mockup 18h has
the draft-files card first, then final+learning, then feedback. Switched the
container to flex-col and gave each card an explicit `order` so the visual order
matches the mockup without reordering any JSX (zero-risk).

tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:41:03 +00:00
91bd3ac88c Merge pull request 'feat(case-ui): agents tab — full 18i (rich roster + surfaced pending forms)' (#383) from worktree-agents-full-18i 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 10s
2026-06-30 19:37:25 +00:00
bb83ddc611 feat(case-ui): agents tab — full 18i (rich roster + surfaced pending forms)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Completes mockup 18i beyond the earlier minimal version:
- AgentRoster — a 5-up grid of agents, each with a status dot, name, role label
  and its current activity (derived from the agent's most recent comment).
  Replaces the simple AgentStatusWidget list at the top of the feed (reset still
  lives in the banner status-rail popover).
- "ממתין לתשובתך" now renders the actual pending InteractionCards (the
  approve/reject/answer forms) at the top, not just a count pointer — and those
  pending interactions are excluded from the timeline below so they aren't shown
  twice.

All interaction logic / mutations preserved. tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:37:01 +00:00
d1ca1152b1 Merge pull request 'feat(status): single source of truth for case-status model + canonicalize intermediates' (#382) from worktree-status-single-source into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m40s
G12 Leak-Guard / leak-guard (push) Successful in 5s
Lint — undefined names / undefined-names (push) Successful in 11s
2026-06-30 19:32:00 +00:00
44f6915f0c feat(status): single source of truth for the case-status model + canonicalize intermediates
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
Fixes the case where the H1 chip, the pipeline stepper and the manual-changer
dropdown disagreed (e.g. 1043-02-26 on analyst_verified): the status sat between
the canonical 10 and a legacy bucket, so each surface read a different list.

Root fix — ONE authoritative status model + canonicalize the real intermediate
states (chair-approved):

- New mcp-server/.../case_status_model.py — the single registry: ordered
  StatusDef list (key/label/description/phase/selectable/terminal/on_enter),
  the 5 phases, STATUS_ORDER, phase_of/label_of, and a drift assertion that the
  CaseStatus enum matches it. `on_enter` is the seam for a status to *do*
  something on entry, declared next to its definition (no dispatcher wired yet).
- models.CaseStatus enum: analyst_verified + research_complete promoted to
  first-class canonical statuses (the agents set them — they belong in the
  model, not a legacy fallback).
- tools/cases.py: the forward-only STATUS_ORDER guard now derives from the
  registry instead of an inline list.
- GET /api/status-model exposes the model so the frontend mirror can be
  generated against it.
- web-ui case-status.ts mirrors it: the two intermediates moved from the
  legacy map into CASE_STATUSES / PHASES(thinking) / STATUS_LABELS /
  STATUS_DESCRIPTIONS; status-badge icons+tones extended. So the chip (status),
  stepper (its phase) and dropdown (now includes it) all agree.

Invariants: G2 — collapses the scattered status definitions (enum, inline
STATUS_ORDER, two frontend label maps) onto one backend authority + a documented
frontend mirror; no parallel status list remains. Agent-prompt alignment +
backfill follow in a separate change. py_compile + tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:31:21 +00:00
52 changed files with 6124 additions and 490 deletions

View File

@@ -101,6 +101,8 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
```
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
**1.5ד. המשך-עבודה על תיק — קרא predecessor תחילה (seance, #220).** אם אתה מתעורר-מחדש על תיק שכבר עבדת עליו (continuation — issue פתוח עם היסטוריית-ריצות), **לפני** שתגלה-מחדש את ההקשר מאפס, קרא את הכלי `legal_predecessor_context(case_number=<מספר-התיק>)`. הוא מחזיר את מסקנות הריצות-הקודמות על התיק (ה-`summary` שכל heartbeat השאיר, newest-first) — כך תמשיך מנקודת-העצירה במקום לחזור על עבודה שכבר בוצעה (מונע את ה-*blind heartbeat*). אם אין ריצות-קודמות עם מסקנות — המשך רגיל.
---
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
@@ -222,18 +224,22 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
---
## §7. סטטוסי תיק תקפים (case status flow)
## §7. סטטוסי תיק (case status flow)
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
**מקור-האמת היחיד** למודל-הסטטוסים: `mcp-server/src/legal_mcp/case_status_model.py` (חשוף ב-`GET /api/status-model`; ה-enum, ה-`STATUS_ORDER` וה-frontend נגזרים ממנו). 12 הסטטוסים הקנוניים, לפי 5 השלבים:
```
new → proofread → documents_ready → analyst_verified → research_complete*
קליטה : new · processing
הכנה : documents_ready
ניתוח וכיוון : analyst_verified · research_complete · outcome_set · direction_approved
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
→ drafted → qa_passed / qa_failed → exported
כתיבת טיוטה : qa_review · drafted
סגירה : exported · reviewed · final
```
`research_complete` **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
`analyst_verified` ו-`research_complete` הם **סטטוסים קנוניים מהמעלה הראשונה** (לא legacy) — המנתח/חוקר מציבים אותם, ומקומם בשלב "ניתוח וכיוון". `research_complete` מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח; ה-CEO מטפל בו כמו `analyst_verified`.
> מצבי-ביניים ישנים שעדיין עשויים להופיע מסוכנים מסוימים (`proofread`, `analysis_enriched`, `ready_for_writing`, `qa_passed`/`qa_failed`) **אינם** בקבוצה הקנונית — הם נמפים-לשלב לתצוגה בלבד (fallback ב-`case-status.ts`) ואינם ניתנים-לבחירה ידנית. אם נדרש לקבע אחד מהם — להוסיף ל-`case_status_model.py` (המקור-היחיד).
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.

View File

@@ -226,7 +226,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
### שלב 0: בדוק למה התעוררת
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
- **פעולות סטרוקטורליות (כפתורי-UI, בלי פענוח-טקסט):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` → נתב דטרמיניסטית לפי הערך: `action == "interim_draft"`**שלב H** (טיוטת ביניים); `action == "party_claims_summary"`**שלב H2** (סיכום מנהלים). אלו side-quests — אל תסרוק תיקים אחרים, טפל רק בתיק שב-payload (`case_number`/`issueId`).
- **פעולות סטרוקטורליות (כפתורי-UI, בלי פענוח-טקסט):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` → נתב דטרמיניסטית לפי הערך: `action == "interim_draft"`**שלב H** (טיוטת ביניים); `action == "party_claims_summary"`**שלב H2** (סיכום מנהלים). אלו side-quests — אל תסרוק תיקים אחרים, טפל רק בתיק שב-payload (`case_number`/`issueId`). ה-`issueId` שב-payload הוא **issue-ילד ייעודי שמשויך אליך** (נוצר ע"י המערכת כדי שהריצה לא תבוטל כשה-issue הראשי ממתין-ליו"ר; #227) — הרץ את הפעולה, פרסם comment עם התוצאה, ו**סגור את ה-issue-ילד הזה כ-done** בסיום. אל תיגע ב-issue הראשי של התיק.
- אם ה-reason מכיל `user_commented` **ו**-`$PAPERCLIP_WAKE_PAYLOAD_JSON` כולל `issueId`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"** עם ה-issue הזה. אל תסרוק תיקים אחרים. **טפל רק בתגובה.**
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
- אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
@@ -719,13 +719,13 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
```
mcp__legal-ai__export_interim_draft(case_number="...")
```
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
מייצר `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`, מעדכן `active_draft_path`.
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
```
## טיוטת ביניים מוכנה — ערר {case_number}
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`
### מה כלול
| בלוק | כותרת | מילים |
@@ -806,7 +806,7 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |

View File

@@ -179,10 +179,11 @@ tools:
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
```
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
drafted (אתה כאן!) → qa_passed / qa_failed → exported
new → processing → documents_ready → analyst_verified → research_complete
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
exported → reviewed → final
```
(מקור-אמת יחיד: `case_status_model.py` / `GET /api/status-model`. `analyst_verified`+`research_complete` קנוניים, שלב "ניתוח וכיוון". מצבי-ביניים ישנים כמו `proofread`/`analysis_enriched`/`qa_passed` נמפים-לשלב לתצוגה בלבד.)
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.

View File

@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
- TaskMaster: `legal-ai` #149.
---
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
"כל התורים ריקים — אין עבודה".
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
ולכן זה נראה כאילו משהו נמסר.
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
שאומת כמגיע לסוכן.
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
הרצת-סרק. TaskMaster `legal-ai` #228.

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

@@ -0,0 +1,118 @@
"""Single source of truth for the case-status lifecycle (the *status model*).
Every consumer derives its order / labels / phase from this one registry:
• ``CaseStatus`` enum (models.py) — the type-level key set (kept consistent
by the assertion at the bottom of this module);
• the forward-only ``STATUS_ORDER`` guard in ``tools/cases.py``;
• the ``GET /api/status-model`` endpoint (web/app.py) that the frontend
mirror (``web-ui/src/lib/api/case-status.ts``) is generated against.
To make a status *do something* on entry (notify, kick a job, …), set its
``on_enter`` to an action key — the single place where a status's behaviour is
declared. ``tools/cases.py`` dispatches it when a case transitions into that
status (forward-only), so the behaviour lives next to the definition.
The five **phases** are the coarse pipeline the 12 statuses collapse into for the
header stepper. Intermediate analyst/research states (``analyst_verified``,
``research_complete``) are first-class canonical statuses here — the agents set
them, so they must be in the model rather than fall between the canonical set
and the legacy bucket (the bug that made the chip, stepper and manual-changer
disagree).
"""
from __future__ import annotations
from dataclasses import dataclass
# Ordered 5-phase pipeline (key → Hebrew label) — coarse view of the lifecycle.
PHASES: list[tuple[str, str]] = [
("intake", "קליטה ועיבוד"),
("prep", "הכנת תיק"),
("thinking", "ניתוח וכיוון"),
("writing", "כתיבת טיוטה"),
("done", "סגירה"),
]
PHASE_LABELS: dict[str, str] = dict(PHASES)
@dataclass(frozen=True)
class StatusDef:
key: str
label: str # Hebrew label (chip / dropdown)
description: str # Hebrew one-liner (status guide)
phase: str # one of PHASES keys
selectable: bool = True # offered in the manual status-changer dropdown
terminal: bool = False
on_enter: str | None = None # future: action key dispatched on entry
# THE lifecycle — ordered. STATUS_ORDER, the enum and the frontend all derive
# from this list. Insert intermediates in workflow order.
STATUS_DEFS: list[StatusDef] = [
StatusDef("new", "חדש", "התיק נוצר וממתין להעלאת מסמכים", "intake"),
StatusDef("processing", "בעיבוד", "המערכת מעבדת ומנתחת את המסמכים", "intake"),
StatusDef("documents_ready", "מסמכים מוכנים", "כל המסמכים עובדו ומוכנים לעבודה", "prep"),
StatusDef("analyst_verified", "ניתוח אומת", "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה של היו״ר", "thinking"),
StatusDef("research_complete", "מחקר הושלם", "חקר התקדימים הושלם (מסלול נפרד מהמנתח)", "thinking"),
StatusDef("outcome_set", "תוצאה נקבעה", "נקבעה תוצאה צפויה לערר", "thinking"),
StatusDef("direction_approved", "כיוון אושר", "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה", "thinking"),
StatusDef("qa_review", "בדיקת איכות", "הטיוטה בבדיקת איכות אוטומטית", "writing"),
StatusDef("drafted", "טיוטה", "טיוטה מוכנה לעיון", "writing"),
StatusDef("exported", "יוצא", "ההחלטה יוצאה לקובץ DOCX", "done"),
StatusDef("reviewed", "נבדק", 'ההחלטה נבדקה ע"י היו"ר', "done"),
StatusDef("final", "סופי", "החלטה סופית — מוכנה להגשה", "done", terminal=True),
]
BY_KEY: dict[str, StatusDef] = {d.key: d for d in STATUS_DEFS}
STATUS_ORDER: list[str] = [d.key for d in STATUS_DEFS]
def phase_of(status: str | None) -> str | None:
"""The pipeline phase a status belongs to (None for unknown values)."""
d = BY_KEY.get(status or "")
return d.phase if d else None
def label_of(status: str | None) -> str:
d = BY_KEY.get(status or "")
return d.label if d else (status or "")
def to_dict() -> dict:
"""Serialisable status model for ``GET /api/status-model`` (frontend SSoT)."""
return {
"statuses": [
{
"key": d.key,
"label": d.label,
"description": d.description,
"phase": d.phase,
"selectable": d.selectable,
"terminal": d.terminal,
"on_enter": d.on_enter,
}
for d in STATUS_DEFS
],
"phases": [{"key": k, "label": v} for k, v in PHASES],
}
# Drift guard — the registry IS the canonical key set; the CaseStatus enum must
# match it exactly. Importing here (models.py never imports this module) is safe.
def _assert_consistent() -> None:
from legal_mcp.models import CaseStatus
registry = {d.key for d in STATUS_DEFS}
enum_keys = {s.value for s in CaseStatus}
if registry != enum_keys:
raise RuntimeError(
"case_status_model drift: registry vs CaseStatus enum differ — "
f"only-in-registry={registry - enum_keys}, only-in-enum={enum_keys - registry}"
)
phase_keys = {k for k, _ in PHASES}
bad = {d.key: d.phase for d in STATUS_DEFS if d.phase not in phase_keys}
if bad:
raise RuntimeError(f"case_status_model: statuses with unknown phase: {bad}")
_assert_consistent()

View File

@@ -9,13 +9,18 @@ from uuid import UUID
from pydantic import BaseModel, Field
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the
# decorative mid-stage markers that no pipeline code ever set were removed).
# Core case lifecycle. The canonical key set, order, labels, phases and (future)
# per-status actions all live in ONE place — legal_mcp/case_status_model.py —
# which asserts this enum matches it. The frontend mirror is
# web-ui/src/lib/api/case-status.ts (generated against GET /api/status-model).
# The analyst/research intermediate states are first-class canonical statuses
# (the agents set them) — not legacy.
class CaseStatus(str, enum.Enum):
NEW = "new"
PROCESSING = "processing"
DOCUMENTS_READY = "documents_ready"
ANALYST_VERIFIED = "analyst_verified"
RESEARCH_COMPLETE = "research_complete"
OUTCOME_SET = "outcome_set"
DIRECTION_APPROVED = "direction_approved"
QA_REVIEW = "qa_review"

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
@@ -207,7 +221,7 @@ async def aggregate_claims_to_arguments(
# Pull all claims for this case, grouped by party.
rows = await conn.fetch(
"""SELECT id, party_role, claim_text, claim_index, source_document
"""SELECT id, party_role, claim_text, claim_index, source_document, party_name
FROM claims
WHERE case_id = $1
ORDER BY party_role, claim_index""",
@@ -221,14 +235,28 @@ 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))
# Prefer the stored party_name (stamped by the extractor, #224); fall
# back to source_document for legacy claims predating the stamping.
# Single-voice sides (appellant/committee) stay ''.
if party in SPLIT_PARTIES:
party_name = (r["party_name"] or "").strip() or (r["source_document"] or "").strip()
else:
party_name = ""
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 +271,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 +289,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 +301,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 +344,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 +369,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 +379,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"},
@@ -180,16 +192,16 @@ BLOCK_PROMPTS = {
## כללים קריטיים:
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
- **מבנה קבוע עם 3 חלקים:**
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
- **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
- "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
- **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
- "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
- כותרת: "תמצית טענות הצדדים"
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
- מספור רציף
- **יעד אורך: 800-1500 מילים**
- **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
## טענות שחולצו (קבץ טענות דומות לנושאים):
{claims_context}
@@ -272,6 +284,7 @@ BLOCK_PROMPTS = {
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
- **מספור רציף** — המשך מספור מהבלוק הקודם
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
## כיוון מאושר (חובה):
{direction_context}
@@ -461,17 +474,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 +500,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 +516,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 +527,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
@@ -611,18 +630,49 @@ async def _build_claims_context(case_id: UUID) -> str:
# Fallback to all non-block-zayin claims if no source claims exist.
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
lines = []
current_role = ""
# Group by (party_role, brief) so a multi-litigant side (respondent /
# permit_applicant) whose claims come from distinct pleadings reads as
# SEPARATE positions — e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6" — instead
# of one merged "טענות המשיבים" (#224). The brief label is the source
# pleading, and which sides split is SPLIT_PARTIES — the SAME rule the
# argument aggregator uses, so block ז and the arguments panel agree (G2).
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
claim_num = 0
role_order = ["appellant", "committee", "respondent", "permit_applicant"]
groups: dict[tuple[str, str], list[dict]] = {}
for c in source_claims:
if c["party_role"] != current_role:
current_role = c["party_role"]
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
claim_num += 1
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
role = c.get("party_role", "") or ""
# Prefer the stored party_name (stamped by the extractor, #224); fall
# back to source_document for legacy claims predating the stamping.
if role in SPLIT_PARTIES:
brief = (c.get("party_name") or "").strip() or (c.get("source_document") or "").strip()
else:
brief = ""
groups.setdefault((role, brief), []).append(c)
def _sort_key(k: tuple[str, str]) -> tuple[int, str]:
role, brief = k
idx = role_order.index(role) if role in role_order else len(role_order)
return (idx, brief)
lines: list[str] = []
claim_num = 0
for role, brief in sorted(groups.keys(), key=_sort_key):
header = role_heb.get(role, role or "טענות נוספות")
if brief:
header = f"{header}{brief}"
lines.append(f"\n### {header}")
for c in groups[(role, brief)]:
claim_num += 1
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
lines.append(
f"\n**סה\"כ {claim_num} טענות. שמור על ההפרדה בין הצדדים לעיל — "
f"לכל צד/כתב-תשובה עמדה נפרדת (אל תמזג משיבים שונים). ענה על כל טענה "
f"מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**"
)
return "\n".join(lines)

View File

@@ -368,8 +368,15 @@ async def extract_and_store_claims(
# Determine claim_type from document type and title
claim_type = _infer_claim_type(doc_type, source_name)
# Stamp party_name at the source (#224). For a multi-litigant side
# (respondent / permit_applicant) the brief label IS the source pleading, so
# opposing briefs (משיבות 2-3 vs משיבים 4-6) stay distinct downstream without
# the aggregator/block-writer having to re-derive it. SPLIT_PARTIES is the
# single rule shared with the aggregator (G2); single-voice sides stay ''.
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
for c in claims:
c["claim_type"] = claim_type
c["party_name"] = source_name if c.get("party_role") in SPLIT_PARTIES else ""
stored = await db.store_claims(case_id, claims, source_document=source_name)
# Mark this document analysed (WS2 / #201). store_claims already replaced

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,50 @@ 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);
"""
# V51 (#226): hearing-attendance provenance for the "מה קרה בדיון" panel. The
# ערר-hearing protocol names who actually appeared (עוררים/משיבים + their
# counsel) and, when stated, the presiding panel. ``_extract_header`` already
# extracts this feed but only ``hearing_date`` had a canonical home (on cases);
# the attendee lists were returned as provenance and dropped. This column gives
# them a home so the panel can display them.
#
# This is DISTINCT from ``decisions.panel_members`` (G2, not a parallel path):
# that column is the authoring tribunal recorded on a specific written-decision
# version (the DOCX signature block). ``hearing_attendees`` is a snapshot of who
# was present at the *hearing*, extracted from the protocol — hearing-event
# provenance, not decision-authorship. Shape:
# {"panel_members": [...], "appellants_present": [...], "respondents_present": [...]}
SCHEMA_V51_SQL = """
ALTER TABLE cases ADD COLUMN IF NOT EXISTS hearing_attendees JSONB NOT NULL DEFAULT '{}';
"""
# V52 (#224): backfill claims.party_name for existing rows. The extractor now
# stamps party_name = source_document for the multi-litigant sides at write time
# (claims_extractor), but claims stored before that shipped have party_name=''.
# For those, the brief label is the source pleading — same rule the aggregator
# uses (respondent / permit_applicant split). Idempotent: only fills empties, so
# re-running is a no-op. Single-voice sides (appellant/committee) stay ''.
SCHEMA_V52_SQL = """
UPDATE claims SET party_name = source_document
WHERE party_role IN ('respondent', 'permit_applicant')
AND COALESCE(party_name, '') = ''
AND COALESCE(source_document, '') <> '';
"""
# 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 +2003,9 @@ 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)
await conn.execute(SCHEMA_V51_SQL)
await conn.execute(SCHEMA_V52_SQL)
async def init_schema() -> None:
@@ -2181,7 +2228,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
set_clauses = []
values = []
for i, (key, val) in enumerate(fields.items(), start=2):
if key in ("appellants", "respondents", "tags"):
if key in ("appellants", "respondents", "tags", "hearing_attendees"):
val = json.dumps(val)
set_clauses.append(f"{key} = ${i}")
values.append(val)
@@ -2194,7 +2241,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
def _row_to_case(row: asyncpg.Record) -> dict:
d = dict(row)
for field in ("appellants", "respondents", "tags"):
for field in ("appellants", "respondents", "tags", "hearing_attendees"):
if isinstance(d.get(field), str):
d[field] = json.loads(d[field])
d["id"] = str(d["id"])

View File

@@ -399,8 +399,15 @@ _INTERIM_BLOCK_ORDER = [
]
def _draft_filename_prefix(mode: str) -> str:
return "טיוטת-ביניים" if mode == "interim" else "טיוטה"
def _draft_naming(mode: str) -> tuple[str, str]:
"""(filename prefix, version separator) per export mode.
interim → ``טיוטה-טענות_הצדדים_{N}.docx`` (chair-requested naming);
final → ``טיוטה-v{N}.docx``.
"""
if mode == "interim":
return "טיוטה-טענות_הצדדים", "_"
return "טיוטה", "-v"
async def export_decision(
@@ -485,16 +492,19 @@ async def export_decision(
if not output_path:
export_dir = config.find_case_dir(case["case_number"]) / "exports"
export_dir.mkdir(parents=True, exist_ok=True)
prefix = _draft_filename_prefix(mode)
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
prefix, sep = _draft_naming(mode)
existing = sorted(export_dir.glob(f"{prefix}{sep}*.docx"))
next_ver = 1
for p in existing:
try:
ver = int(p.stem.split("-v")[1])
# Version is the trailing integer after the separator. Using
# rsplit keeps this correct even when the prefix itself contains
# the separator char (e.g. "טיוטה-טענות_הצדדים" with sep="_").
ver = int(p.stem.rsplit(sep, 1)[1])
next_ver = max(next_ver, ver + 1)
except (IndexError, ValueError):
pass
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
output_path = str(export_dir / f"{prefix}{sep}{next_ver}.docx")
# Persist through the storage layer (INV-STG1). Under the filesystem
# backend the bytes land at output_path exactly as before; a caller-

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]:
@@ -227,21 +259,39 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
except ValueError:
logger.info("protocol header: unparseable hearing_date %r", hearing_date)
# Attendees → cases.hearing_attendees (#226). Unlike hearing_date there is no
# chair-entry path to protect, and re-running to point at the correct ערר
# protocol (#223) must refresh who appeared — so write whenever the operative
# protocol yielded any names, but never clobber good data with an empty
# extraction (all-empty feed → leave the prior snapshot intact).
attendees = {
"panel_members": feed.get("panel_members") or [],
"appellants_present": feed.get("appellants_present") or [],
"respondents_present": feed.get("respondents_present") or [],
}
if any(attendees.values()):
await db.update_case(case_id, hearing_attendees=attendees)
applied["hearing_attendees"] = True
return {
"status": "ok",
"feed": {
"hearing_date": hearing_date,
"panel_members": feed.get("panel_members") or [],
"appellants_present": feed.get("appellants_present") or [],
"respondents_present": feed.get("respondents_present") or [],
**attendees,
},
"applied_to_case": applied,
}
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 +302,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

@@ -13,6 +13,7 @@ from uuid import UUID
import httpx
from legal_mcp import config
from legal_mcp.case_status_model import STATUS_ORDER # status SSoT
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
@@ -341,16 +342,8 @@ async def case_update(
"""
from datetime import date as date_type
# Ordered core lifecycle — regression protection (forward-only).
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and
# models.CaseStatus. Trimmed from 17 → 10 (decorative statuses removed).
STATUS_ORDER = [
"new", "processing", "documents_ready",
"outcome_set", "direction_approved",
"qa_review", "drafted",
"exported", "reviewed", "final",
]
# Ordered core lifecycle (forward-only regression guard). Single source of
# truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
@@ -363,6 +356,9 @@ async def case_update(
# Only update if advancing or status is unknown to the order
if new_idx >= cur_idx or new_idx == -1:
fields["status"] = status
# per-status on_enter hook (case_status_model) — the single place a
# status's behaviour is declared. No dispatcher wired yet; when the
# first action is added, dispatch BY_KEY[status].on_enter here.
if title:
fields["title"] = title
if subject:

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)},
@@ -703,7 +716,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
דיון/סיכום/חתימות. שם הקובץ: טיוטת-ביניים-v{N}.docx.
דיון/סיכום/חתימות. שם הקובץ: טיוטה-טענות_הצדדים_{N}.docx.
Args:
case_number: מספר תיק הערר

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

@@ -12,8 +12,6 @@ Mapping (removed → kept):
uploading → processing
in_progress → outcome_set
analyst_verified → documents_ready
research_complete → documents_ready
brainstorming → outcome_set
analysis_enriched → direction_approved
ready_for_writing → direction_approved
@@ -50,12 +48,14 @@ from legal_mcp.services import db # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("status-trim")
# removed status → nearest preceding kept status
# removed status → nearest preceding kept status.
# NOTE (2026-06-30): analyst_verified + research_complete were RE-CANONICALISED
# into the status model (legal_mcp/case_status_model.py, "thinking" phase) — they
# are valid statuses again, so they were removed from this map. Do NOT downgrade
# them; only genuinely-removed markers remain below.
STATUS_MAP = {
"uploading": "processing",
"in_progress": "outcome_set",
"analyst_verified": "documents_ready",
"research_complete": "documents_ready",
"brainstorming": "outcome_set",
"analysis_enriched": "direction_approved",
"ready_for_writing": "direction_approved",

View File

@@ -14,9 +14,11 @@ import { CaseHeader } from "@/components/cases/case-header";
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
import { DocumentsPanel } from "@/components/cases/documents-panel";
import { DraftsPanel } from "@/components/cases/drafts-panel";
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
import { PositionsPanel } from "@/components/cases/positions-panel";
import { HearingChangesPanel } from "@/components/cases/hearing-changes-panel";
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
@@ -104,12 +106,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"
@@ -162,11 +158,28 @@ export default function CaseDetailPage({
issues, with the aggregated by-party arguments collapsible below
(merged from the deleted /compose editor, mockup 18f). */}
<TabsContent value="arguments" className="mt-0 space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<PositionsPanel caseNumber={caseNumber} />
</CardContent>
</Card>
{/* עמדות וטענות — collapsed by default (chair preference #226),
mirroring the two accordions below. */}
<Accordion type="single" collapsible>
<AccordionItem
value="positions"
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline">
<span className="flex flex-1 flex-col items-start">
<span className="text-navy text-[0.95rem] font-bold">
עמדות וטענות
</span>
<span className="text-ink-muted text-[0.78rem]">
סוגיות-המחלוקת ועמדות הצדדים · עורך עמדת-היו״ר מזין את בלוק י׳
</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-6 pb-5 pt-0">
<PositionsPanel caseNumber={caseNumber} />
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="single" collapsible>
<AccordionItem
value="byparty"
@@ -187,6 +200,30 @@ export default function CaseDetailPage({
</AccordionContent>
</AccordionItem>
</Accordion>
{/* מה קרה בדיון — comparative analysis of the hearing protocol vs.
the written pleadings (#226). Sits below the aggregated arguments
because it speaks to those same arguments in their oral gloss.
Collapsed by default, mirroring the "by-party" accordion above. */}
<Accordion type="single" collapsible>
<AccordionItem
value="hearing"
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline">
<span className="flex flex-1 flex-col items-start">
<span className="text-navy text-[0.95rem] font-bold">
מה קרה בדיון
</span>
<span className="text-ink-muted text-[0.78rem]">
השוואת הטענות בכתב מול הדיון בעל-פה מה התחזק ומה נטען לראשונה
</span>
</span>
</AccordionTrigger>
<AccordionContent className="px-6 pb-5 pt-0">
<HearingChangesPanel caseNumber={caseNumber} />
</AccordionContent>
</AccordionItem>
</Accordion>
</TabsContent>
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),
@@ -207,12 +244,17 @@ export default function CaseDetailPage({
</Card>
</TabsContent>
<TabsContent value="drafts" className="mt-0">
<TabsContent value="drafts" className="mt-0 space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<CaseFilesBrowser caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="agents" className="mt-0">

View File

@@ -29,12 +29,15 @@ import {
useRunLog,
useCancelRun,
useResetAgentSession,
useAgentHealth,
useRecentEscalations,
type OpsService,
type OperationsSnapshot,
type PipelineStats,
type AgentRun,
type SubscriptionUsage,
type UsageWindow,
type AgentHealthState,
} from "@/lib/api/operations";
import { formatTime, israelParts } from "@/lib/format-date";
@@ -802,6 +805,154 @@ function LiveAgentsPanel() {
);
}
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
const HEALTH_LABEL: Record<AgentHealthState, string> = {
zombie: "זומבי",
stalled: "תקוע",
working: "עובד",
idle: "רגוע",
};
function AgentHealthPanel() {
const { data, isLoading } = useAgentHealth();
const { data: esc } = useRecentEscalations();
const counts = data?.counts;
// Only zombies/stalled are "problems" — the backend already sorts worst-first.
// An escalated issue is chair-owned, so it leaves this list; the escalations
// card below is the handled-loop history.
const problems = (data?.items ?? []).filter(
(i) => i.health === "zombie" || i.health === "stalled",
);
const healthyWorking = counts?.working ?? 0;
const healthyIdle = counts?.idle ?? 0;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
{isLoading || !data ? (
<Skeleton className="h-24 w-full" />
) : (
<>
<div className="flex flex-wrap gap-2 mb-4">
<StatTile
label="זומבי"
value={counts?.zombie ?? 0}
tone="red"
title="לולאת-recovery — משויך+פעיל-כביכול אך אין ריצה חיה. מוסלם אוטומטית."
/>
<StatTile
label="תקוע"
value={counts?.stalled ?? 0}
tone="amber"
title="ננער חוזר בלי התקדמות"
/>
<StatTile label="עובד" value={counts?.working ?? 0} tone="green" />
<StatTile label="רגוע" value={counts?.idle ?? 0} tone="muted" />
</div>
{problems.length === 0 ? (
<p className="text-sm text-emerald-600">
אין סוכן תקוע כרגע כל הסוכנים תקינים.
</p>
) : (
<div className="grid gap-2">
{problems.map((p) => (
<div
key={p.issue_id}
className={
p.health === "zombie"
? "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
: "flex items-center justify-between gap-3 rounded-md border border-gold/40 bg-gold-wash px-3 py-2"
}
>
<div className="flex items-center gap-2 flex-wrap min-w-0">
<Badge
variant={p.health === "zombie" ? "destructive" : "outline"}
className={
p.health === "stalled"
? "border-warn/50 text-warn font-normal"
: "font-normal"
}
>
{HEALTH_LABEL[p.health]}
</Badge>
<span dir="ltr" className="font-mono text-[0.82rem] text-navy font-semibold">
{p.identifier}
</span>
<span className="text-[0.82rem] text-navy font-semibold">
{p.agent_name}
</span>
<span className="text-[0.7rem] text-ink-muted">
{p.recovery_wakeups > 0
? `${p.recovery_wakeups} יקיצות-שחזור`
: `${p.wakeups} יקיצות`}
</span>
</div>
<span className="text-[0.7rem] text-ink-muted shrink-0">
{p.health === "zombie" ? "יוסלם אוטומטית" : "מנוטר"}
</span>
</div>
))}
</div>
)}
<p className="mt-3 text-[0.78rem] text-ink-muted">
<b className="text-navy">{healthyWorking + healthyIdle} סוכנים תקינים</b> {" "}
{healthyWorking} עובדים, {healthyIdle} רגועים. מוצגות רק בעיות.
</p>
</>
)}
</CardContent>
</Card>
{/* handled-loop history — the watchdog's escalations, gold-wash like a gate */}
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-sm font-semibold mb-0.5">הסלמות אוטומטיות אחרונות</h3>
<p className="text-ink-muted text-[0.72rem] mb-3">
כל זומבי מתמשך (לולאת-recovery 2 יקיצות-שחזור) משויך אליך אוטומטית עם הערת-מערכת
בלי להעיר סוכן מחדש.
</p>
{!esc?.items?.length ? (
<p className="text-sm text-ink-muted">אין הסלמות אחרונות.</p>
) : (
<div className="grid gap-1">
{esc.items.map((e) => (
<div
key={`${e.issue_id}-${e.created_at ?? ""}`}
className="flex items-start gap-2.5 border-t border-gold/15 first:border-0 pt-2 first:pt-0"
>
<Badge
variant={e.severity === "medium" ? "outline" : "destructive"}
className={
e.severity === "medium"
? "border-gold/50 text-gold-deep font-normal shrink-0"
: "font-normal shrink-0"
}
>
{e.severity}
</Badge>
<div className="min-w-0 flex-1">
<span dir="ltr" className="font-mono text-[0.8rem] text-navy font-semibold">
{e.identifier}
</span>
<div className="text-[0.78rem] text-ink-soft">{e.reason}</div>
</div>
<span className="text-[0.68rem] text-ink-muted shrink-0">
{e.created_at ? ago(Date.parse(e.created_at)) : ""}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function OperationsPage() {
const { data, isLoading, error } = useOperations();
@@ -840,6 +991,9 @@ export default function OperationsPage() {
<SectionHeader>סוכנים פעילים</SectionHeader>
<LiveAgentsPanel />
<SectionHeader>בריאות-הסוכנים</SectionHeader>
<AgentHealthPanel />
<SectionHeader>מתאמי-סוכנים</SectionHeader>
<AgentAdaptersPanel />

View File

@@ -1,15 +1,16 @@
"use client";
import { useRef, useState, useEffect, useMemo } from "react";
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import {
useAgentActivity,
useSendComment,
useSubmitInteraction,
useDismissInteraction,
useSetIssueStatus,
} from "@/lib/api/agents";
import type {
Interaction,
@@ -17,6 +18,8 @@ import type {
InteractionQuestion,
InteractionTask,
PaperclipComment,
PaperclipAgentStatus,
PaperclipIssue,
} from "@/lib/api/agents";
import { formatRelative } from "@/lib/format-date";
import { toast } from "sonner";
@@ -30,6 +33,11 @@ import {
CheckCircle2,
XCircle,
HelpCircle,
ChevronDown,
Ban,
Target,
Plus,
AlertTriangle,
} from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */
@@ -82,6 +90,79 @@ function issueStatusLabel(status: string) {
return ISSUE_STATUS_LABELS[status] ?? status;
}
/* ── Agent roster (mockup 18i) — who's doing what right now ────── */
const AGENT_STATUS_LABEL: Record<string, string> = {
active: "פעיל",
running: "פעיל",
idle: "ממתין",
error: "שגיאה",
};
function agentIsActive(s: string) {
return s === "active" || s === "running";
}
function agentStatusDot(s: string) {
return agentIsActive(s)
? "bg-success shadow-[0_0_6px_rgba(74,124,89,0.6)]"
: s === "error"
? "bg-danger"
: "bg-rule";
}
function agentStatusTone(s: string) {
return agentIsActive(s)
? "bg-success-bg text-success"
: s === "error"
? "bg-danger-bg text-danger"
: "bg-rule-soft text-ink-muted";
}
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
if (!agents.length) return null;
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
return (
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 p-3">
<div className="flex items-center gap-2 mb-2.5">
<Bot className="w-4 h-4 text-gold-deep shrink-0" />
<h3 className="text-[0.82rem] font-bold text-navy">סוכני התיק</h3>
<span className="text-[0.72rem] text-ink-muted">
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
</span>
</div>
{/* 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);
return (
<div
key={a.id}
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 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>
);
})}
</div>
</div>
);
}
/* ── Time formatting ─────────────────────────────────────────── */
function timeAgo(iso: string | null): string {
@@ -507,15 +588,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(
@@ -532,6 +619,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 ${
@@ -554,15 +651,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">
@@ -573,39 +685,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">
@@ -617,6 +748,480 @@ 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 setStatus = useSetIssueStatus(caseNumber);
const closed = issue.status === "done" || issue.status === "cancelled";
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
// the live CEO root) so the hierarchy is visible, not flattened.
const parentIdentifier = issue.parent_id
? issueMap.get(issue.parent_id)
: undefined;
const close = (status: "done" | "cancelled") =>
setStatus.mutate(
{ issue_id: issue.id, status },
{
onSuccess: () =>
toast.success(
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
),
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
},
);
return (
<div className="border-b-2 border-rule last:border-b-0">
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex-1 flex items-center gap-2 text-start min-w-0"
>
<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>
{parentIdentifier && (
<span
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parentIdentifier}`}
>
{parentIdentifier}
</span>
)}
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
{issueStatusLabel(issue.status)} · {items.length}
</span>
</button>
{!closed && (
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => close("done")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
title="סמן את המשימה כהושלמה"
>
{setStatus.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
)}
הושלם
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => close("cancelled")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
title="בטל את המשימה"
>
בטל
</Button>
</div>
)}
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={open ? "כווץ" : "הרחב"}
className="shrink-0"
>
<ChevronDown
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
{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>
);
}
/* ── Compose: directive box + target selector (top of tab) ────── */
const isOpenStatus = (s: string) => s !== "done" && s !== "cancelled";
/** Client mirror of the server's pick_default_comment_target
* (web/paperclip_client.py): prefer the newest OPEN top-level issue (the live
* CEO main issue), then any open issue, then any top-level, else newest.
* `issues` arrives oldest→newest, so the last match is the newest. */
function pickDefaultTarget(issues: PaperclipIssue[]): PaperclipIssue | null {
if (!issues.length) return null;
const isTop = (i: PaperclipIssue) => i.parent_id == null;
const preds: ((i: PaperclipIssue) => boolean)[] = [
(i) => isOpenStatus(i.status) && isTop(i),
(i) => isOpenStatus(i.status),
isTop,
];
for (const p of preds) {
const m = issues.filter(p);
if (m.length) return m[m.length - 1];
}
return issues[issues.length - 1];
}
const STATUS_TONE: Record<string, string> = {
in_progress: "bg-emerald-100 text-emerald-700",
in_review: "bg-amber-100 text-amber-700",
todo: "bg-emerald-100 text-emerald-700",
backlog: "bg-emerald-100 text-emerald-700",
blocked: "bg-red-100 text-red-700",
done: "bg-gray-100 text-gray-500",
cancelled: "bg-red-50 text-red-600",
};
function statusTone(s: string) {
return STATUS_TONE[s] ?? "bg-gray-100 text-gray-600";
}
/** Where a chair instruction is routed. `auto` lets the server pick the live
* CEO main issue; `issue` targets an explicit one; `new_run` opens a fresh
* CEO-owned run (bypasses the human-owned-issue cancellation). */
type Target =
| { kind: "auto" }
| { kind: "issue"; id: string }
| { kind: "new_run" };
function TargetSelector({
issues,
target,
onChange,
}: {
issues: PaperclipIssue[];
target: Target;
onChange: (t: Target) => void;
}) {
const [open, setOpen] = useState(false);
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
const idToIdentifier = useMemo(
() => new Map(issues.map((i) => [i.id, i.identifier])),
[issues],
);
const parentTag = (i: PaperclipIssue) =>
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
// The issue the pill currently represents (explicit pick, or the auto default).
const effectiveIssue =
target.kind === "issue"
? issues.find((i) => i.id === target.id) ?? null
: target.kind === "auto"
? defaultIssue
: null;
return (
<div className="relative min-w-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-2 bg-sand-soft/60 border border-rule rounded-lg px-2.5 py-1.5 hover:border-gold/60 min-w-0 max-w-full"
>
<Target className="w-3.5 h-3.5 text-gold-deep shrink-0" />
{target.kind === "new_run" ? (
<span className="text-xs font-semibold text-navy">פתח הרצה חדשה</span>
) : effectiveIssue ? (
<>
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{effectiveIssue.identifier}
</Badge>
<span className="text-xs font-medium text-navy truncate">
{shortIssueTitle(effectiveIssue.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(effectiveIssue.status)}`}
>
{issueStatusLabel(effectiveIssue.status)}
</span>
</>
) : (
<span className="text-xs text-ink-muted">בחר יעד</span>
)}
<ChevronDown className="w-3.5 h-3.5 text-ink-faint shrink-0 ms-1" />
</button>
{open && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpen(false)}
aria-hidden
/>
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
visible without scrolling to the bottom of a long task list. */}
<button
type="button"
onClick={() => {
onChange({ kind: "new_run" });
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
>
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
<Plus className="w-4 h-4" />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold text-navy">
פתח הרצה חדשה
</span>
<span className="block text-[10px] text-ink-muted">
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
</span>
</span>
</button>
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות פעילות
</div>
{activeIssues.map((i) => {
const selected =
(target.kind === "issue" && target.id === i.id) ||
(target.kind === "auto" && defaultIssue?.id === i.id);
const parent = parentTag(i);
return (
<button
key={i.id}
type="button"
onClick={() => {
onChange({ kind: "issue", id: i.id });
setOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-soft truncate flex-1">
{shortIssueTitle(i.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
>
{issueStatusLabel(i.status)}
</span>
{selected && (
<CheckCircle2 className="w-3.5 h-3.5 text-gold-deep shrink-0" />
)}
</button>
);
})}
{closedIssues.length > 0 && (
<>
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות סגורות · שליחה אליהן לא תעיר סוכן
</div>
{closedIssues.map((i) => {
const parent = parentTag(i);
return (
<div
key={i.id}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
>
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-faint truncate flex-1">
{shortIssueTitle(i.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
>
{issueStatusLabel(i.status)}
</span>
</div>
);
})}
</>
)}
</div>
</>
)}
</div>
);
}
function Composer({
caseNumber,
issues,
}: {
caseNumber: string;
issues: PaperclipIssue[];
}) {
const sendComment = useSendComment(caseNumber);
const [body, setBody] = useState("");
const [target, setTarget] = useState<Target>({ kind: "auto" });
const selectedIssue =
target.kind === "issue" ? issues.find((i) => i.id === target.id) ?? null : null;
const closedTarget = !!selectedIssue && !isOpenStatus(selectedIssue.status);
const handleSend = () => {
if (!body.trim() || closedTarget) return;
const vars =
target.kind === "new_run"
? { body: body.trim(), new_run: true }
: target.kind === "issue"
? { body: body.trim(), issue_id: target.id }
: { body: body.trim() };
sendComment.mutate(vars, {
onSuccess: (res) => {
setBody("");
setTarget({ kind: "auto" });
toast.success(
res.new_run
? `נפתחה הרצה חדשה — ${res.issue_identifier}`
: `נשלח ל-${res.issue_identifier}`,
);
},
onError: () => toast.error("שגיאה בשליחת ההודעה"),
});
};
return (
<div className="bg-white border border-rule rounded-xl shadow-sm p-3.5">
<div className="flex items-center gap-2 mb-2.5">
<MessageSquare className="w-4 h-4 text-gold-deep" />
<h3 className="text-sm font-bold text-navy">הוראה לסוכנים</h3>
<span className="text-[11px] text-ink-faint ms-auto">
Ctrl+Enter לשליחה
</span>
</div>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-[11px] font-semibold text-ink-muted shrink-0">
שליחה אל:
</span>
<TargetSelector issues={issues} target={target} onChange={setTarget} />
</div>
{closedTarget && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-2 text-[11.5px] text-red-700 leading-snug">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<b className="font-bold">יעד סגור.</b> שליחה למשימה סגורה לא תעיר סוכן
ההוראה לא תטופל. בחר משימה פעילה או{" "}
<button
type="button"
className="font-bold underline"
onClick={() => setTarget({ kind: "new_run" })}
>
פתח הרצה חדשה
</button>
.
</div>
</div>
)}
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="כתוב הוראה לסוכנים..."
aria-label="הוראה לסוכנים"
className="min-h-[60px] resize-none text-sm"
dir="rtl"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
}}
/>
<div className="flex items-center mt-2">
<span className="text-[11px] text-ink-faint">
ההודעה תנותב דרך סוכן ה-CEO · היעד ניתן לשינוי למעלה
</span>
<Button
size="sm"
className="ms-auto"
onClick={handleSend}
disabled={!body.trim() || closedTarget || sendComment.isPending}
>
{sendComment.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 me-1" />
)}
שלח
</Button>
</div>
</div>
);
}
/* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({
@@ -625,9 +1230,6 @@ export function AgentActivityFeed({
caseNumber: string;
}) {
const { data, isLoading, error } = useAgentActivity(caseNumber);
const sendComment = useSendComment(caseNumber);
const [body, setBody] = useState("");
const endRef = useRef<HTMLDivElement>(null);
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
// and a fresh Map each render would defeat the child cards' memoization).
@@ -637,27 +1239,6 @@ export function AgentActivityFeed({
return m;
}, [data?.issues]);
// Auto-scroll on new comments or interactions
const commentCount = data?.comments?.length ?? 0;
const interactionCount = data?.interactions?.length ?? 0;
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [commentCount, interactionCount]);
const handleSend = () => {
if (!body.trim()) return;
sendComment.mutate(
{ body: body.trim() },
{
onSuccess: (res) => {
setBody("");
toast.success(`נשלח ל-${res.issue_identifier}`);
},
onError: () => toast.error("שגיאה בשליחת ההודעה"),
},
);
};
// ── Empty / loading states ──
if (isLoading) {
@@ -690,25 +1271,46 @@ export function AgentActivityFeed({
const comments = data.comments ?? [];
const interactions = data.interactions ?? [];
const pendingCount = interactions.filter((i) => i.status === "pending").length;
// Pending interactions are surfaced at the top (awaiting-you, mockup 18i) and
// 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.map<FeedItem>((i) => ({
});
}
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,
})),
].sort((a, b) => a.at - b.at);
});
}
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.
@@ -717,36 +1319,51 @@ export function AgentActivityFeed({
);
return (
<div className="flex flex-col h-full">
{/* agent roster — who's doing what right now (mockup 18i) */}
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 px-3 py-2.5">
<AgentStatusWidget caseNumber={caseNumber} />
</div>
<div className="flex flex-col h-full gap-3">
{/* directive box — moved to the TOP, above the activity (most available,
no scrolling). Carries the target selector. */}
<Composer caseNumber={caseNumber} issues={data.issues} />
{/* pending interactions surfaced to the top (mockup 18i — awaiting you) */}
{pendingCount > 0 && (
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2.5 text-[0.82rem] font-semibold text-amber-800 flex items-center gap-2">
<Clock className="w-4 h-4" />
ממתין לתשובתך {pendingCount} {pendingCount === 1 ? "בקשה" : "בקשות"} בהמשך הזרם
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
<AgentRoster agents={data.agents ?? []} />
{/* pending interactions surfaced to the top — each an accordion, all
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
{pending.length > 0 && (
<div className="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">
<Clock className="w-4 h-4" />
ממתין לתשובתך
<span className="text-[0.7rem] font-semibold bg-amber-100 rounded-full px-2 py-0.5">
{pending.length}
</span>
</div>
{pending.map((i) => (
<InteractionCard
key={`pending-${i.id}`}
interaction={i}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={false}
/>
))}
</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">
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
<span className="text-[0.68rem] font-semibold text-gold-deep bg-gold/10 rounded-full px-2 py-0.5">
החדש למעלה
</span>
<span className="text-[0.72rem] text-ink-muted ms-auto">
{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 — all COLLAPSED by default, newest group first */}
<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" />
@@ -759,59 +1376,17 @@ 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 }) => (
<IssueGroup
key={iss.id}
issue={iss}
items={items}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={false}
/>
))
)}
<div ref={endRef} />
</div>
{/* Comment input */}
<div className="border-t border-rule pt-3 mt-3 space-y-2">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="כתוב הוראה לסוכנים..."
aria-label="הוראה לסוכנים"
className="min-h-[60px] resize-none text-sm"
dir="rtl"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
}}
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-ink-faint">
ההודעה תנותב דרך סוכן ה-CEO &middot; Ctrl+Enter לשליחה
</span>
<Button
size="sm"
onClick={handleSend}
disabled={!body.trim() || sendComment.isPending}
>
{sendComment.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 me-1" />
)}
שלח
</Button>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,304 @@
"use client";
import { useMemo, useState } from "react";
import {
useCaseFiles,
useCaseFileText,
caseFileUrl,
type CaseFileFolder,
} from "@/lib/api/case-files";
import { Markdown } from "@/components/ui/markdown";
import { Badge } from "@/components/ui/badge";
import { formatDateTime } from "@/lib/format-date";
import {
Folder,
FileText,
File as FileIcon,
Download,
ChevronDown,
Loader2,
FolderOpen,
} from "lucide-react";
/* ── Helpers ─────────────────────────────────────────────────── */
const FOLDER_LABELS: Record<string, string> = {
research: "מחקר וטיוטות",
originals: "מסמכי מקור",
extracted: "טקסט מחולץ (OCR)",
proofread: "לאחר הגהה",
drafts: "טיוטות DOCX",
backup: "גיבויים",
};
// Extensions the inline viewer can render (everything else → download only).
const TEXT_EXT = new Set(["md", "txt", "json", "csv", "log", "markdown"]);
function fileExt(filename: string): string {
return filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "";
}
function folderLabel(f: CaseFileFolder): string {
return FOLDER_LABELS[f.name] ?? f.name;
}
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
const EXT_TONE: Record<string, string> = {
md: "bg-teal-100 text-teal-700",
markdown: "bg-teal-100 text-teal-700",
txt: "bg-blue-100 text-blue-700",
json: "bg-blue-100 text-blue-700",
pdf: "bg-red-100 text-red-700",
docx: "bg-indigo-100 text-indigo-700",
doc: "bg-indigo-100 text-indigo-700",
};
function extTone(ext: string): string {
return EXT_TONE[ext] ?? "bg-gray-100 text-gray-600";
}
type Selected = { folderKey: string; filename: string };
/* ── Component ───────────────────────────────────────────────── */
export function CaseFilesBrowser({ caseNumber }: { caseNumber: string }) {
const { data, isLoading, error } = useCaseFiles(caseNumber);
const folders = useMemo(() => data?.folders ?? [], [data?.folders]);
const totalFiles = folders.reduce((n, f) => n + f.files.length, 0);
// Default selection derived (not via an effect): a text file in research,
// else any text file. The chair's explicit pick overrides it.
const defaultSelected = useMemo<Selected | null>(() => {
const pick = (
pred: (f: CaseFileFolder, name: string) => boolean,
): Selected | null => {
for (const f of folders)
for (const file of f.files)
if (pred(f, file.filename))
return { folderKey: f.key, filename: file.filename };
return null;
};
return (
pick((f, name) => f.name === "research" && TEXT_EXT.has(fileExt(name))) ??
pick((_f, name) => TEXT_EXT.has(fileExt(name)))
);
}, [folders]);
const [userSelected, setUserSelected] = useState<Selected | null>(null);
const selected = userSelected ?? defaultSelected;
// A folder is open if explicitly toggled; otherwise the folder of the current
// selection (or the first non-empty folder) is open by default.
const defaultOpenKey =
selected?.folderKey ?? folders.find((f) => f.files.length)?.key ?? null;
const [openOverrides, setOpenOverrides] = useState<Map<string, boolean>>(
new Map(),
);
const isOpen = (key: string) =>
openOverrides.has(key) ? openOverrides.get(key)! : key === defaultOpenKey;
const selExt = selected ? fileExt(selected.filename) : "";
const selIsText = TEXT_EXT.has(selExt);
const {
data: fileText,
isLoading: textLoading,
error: textError,
} = useCaseFileText(
caseNumber,
selIsText ? selected?.folderKey : undefined,
selIsText ? selected?.filename : undefined,
);
const toggleFolder = (key: string) => {
const next = !isOpen(key);
setOpenOverrides((prev) => new Map(prev).set(key, next));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-10 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin me-2" />
<span>טוען קבצים...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-10 text-red-500 text-sm">
שגיאה בטעינת קבצי-התיק
</div>
);
}
return (
<div className="border border-rule rounded-xl overflow-hidden">
{/* header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-rule-soft bg-parchment">
<FolderOpen className="w-4 h-4 text-gold-deep" />
<h3 className="text-sm font-bold text-navy">קבצי-התיק</h3>
<span className="text-[11px] text-ink-muted ms-auto">
כל תתי-התיקיות · {totalFiles} קבצים
</span>
</div>
<div className="grid md:grid-cols-[320px_1fr]">
{/* file tree */}
<div className="border-b md:border-b-0 md:border-e border-rule-soft max-h-[560px] overflow-y-auto">
{folders.map((f) => {
const open = isOpen(f.key);
return (
<div key={f.key} className="border-b border-rule-soft last:border-b-0">
<button
type="button"
onClick={() => toggleFolder(f.key)}
aria-expanded={open}
className="w-full flex items-center gap-2 px-3.5 py-2.5 hover:bg-sand-soft/60 text-start"
>
<Folder className="w-4 h-4 text-gold-deep shrink-0" />
<span className="text-[0.78rem] font-bold text-navy">
{folderLabel(f)}
</span>
<span className="text-[9px] font-mono text-ink-faint hidden sm:inline">
{f.path}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 ms-auto ${f.files.length ? "bg-gray-100 text-ink-muted" : "text-ink-light"}`}
>
{f.files.length || "ריק"}
</span>
<ChevronDown
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
{open && f.files.length > 0 && (
<div className="pb-1 bg-parchment/50">
{f.files.map((file) => {
const ext = fileExt(file.filename);
const isSel =
selected?.folderKey === f.key &&
selected?.filename === file.filename;
const viewable = TEXT_EXT.has(ext);
return (
<div
key={file.filename}
className={`flex items-center gap-2.5 py-1.5 pe-3.5 ps-5 cursor-pointer border-s-2 ${isSel ? "bg-gold-wash border-gold" : "border-transparent hover:bg-sand-soft/60"}`}
onClick={() =>
viewable
? setUserSelected({
folderKey: f.key,
filename: file.filename,
})
: undefined
}
>
<span
className={`w-6 h-6 rounded-md flex items-center justify-center shrink-0 ${extTone(ext)}`}
>
{viewable ? (
<FileText className="w-3.5 h-3.5" />
) : (
<FileIcon className="w-3.5 h-3.5" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="text-[11.5px] font-medium text-ink-soft truncate">
{file.filename}
</div>
<div className="text-[9.5px] text-ink-light">
{fmtSize(file.size)} ·{" "}
{formatDateTime(file.modified_at * 1000)}
</div>
</div>
<a
href={caseFileUrl(caseNumber, f.key, file.filename)}
download={file.filename}
onClick={(e) => e.stopPropagation()}
className="w-7 h-7 rounded-md border border-rule bg-surface flex items-center justify-center text-ink-muted hover:text-navy shrink-0"
title="הורדה"
>
<Download className="w-3.5 h-3.5" />
</a>
</div>
);
})}
</div>
)}
</div>
);
})}
{folders.length === 0 && (
<div className="text-center py-10 text-ink-faint text-sm">
אין קבצים בתיק.
</div>
)}
</div>
{/* viewer */}
<div className="min-w-0 flex flex-col">
{!selected ? (
<div className="flex-1 flex flex-col items-center justify-center py-16 text-ink-faint text-sm">
<FileText className="w-8 h-8 mb-2 opacity-40" />
בחר קובץ טקסט מהרשימה לצפייה
</div>
) : (
<>
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft">
<span
className={`w-7 h-7 rounded-md flex items-center justify-center shrink-0 ${extTone(selExt)}`}
>
<FileText className="w-4 h-4" />
</span>
<div className="min-w-0">
<div className="text-[12.5px] font-bold text-navy truncate">
{selected.filename}
</div>
</div>
<a
href={caseFileUrl(
caseNumber,
selected.folderKey,
selected.filename,
)}
download={selected.filename}
className="ms-auto inline-flex items-center gap-1.5 text-[11.5px] font-semibold text-navy bg-parchment border border-rule rounded-md px-3 py-1.5 hover:bg-sand-soft"
>
<Download className="w-3.5 h-3.5" />
הורדה
</a>
</div>
<div className="px-5 py-4 overflow-y-auto max-h-[520px] bg-[#fdfcf9]">
{!selIsText ? (
<div className="text-center py-12 text-sm text-ink-muted">
קובץ מסוג <Badge variant="outline">{selExt || "?"}</Badge>
לא ניתן לצפייה מוטבעת. הורד כדי לפתוח.
</div>
) : textLoading ? (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin me-2" />
טוען תוכן...
</div>
) : textError ? (
<div className="text-center py-12 text-red-500 text-sm">
שגיאה בטעינת תוכן הקובץ
</div>
) : selExt === "md" || selExt === "markdown" ? (
<Markdown content={fileText ?? ""} />
) : (
<pre className="whitespace-pre-wrap text-xs text-ink-soft font-mono leading-relaxed">
{fileText}
</pre>
)}
</div>
</>
)}
</div>
</div>
</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

@@ -25,7 +25,15 @@ import {
useUploadFinalDecision,
useRunFinalLearning,
useRunFinalHalacha,
type ExportFile,
} from "@/lib/api/exports";
import {
usePartyClaimsSummary,
useGeneratePartyClaimsSummary,
useGenerateInterimDraft,
useGenerateStatus,
} from "@/lib/api/generate";
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
import {
useCaseFeedback,
useCreateFeedback,
@@ -55,6 +63,10 @@ import {
Stamp,
Link2,
AlertTriangle,
Zap,
ClipboardList,
Files,
ExternalLink,
} from "lucide-react";
/* Statuses at which a draft is considered ready */
@@ -76,6 +88,31 @@ function formatDate(epoch: number): string {
return formatDateTime(epoch * 1000);
}
/**
* Pick the newest export file whose name starts with `prefix`, preferring the
* highest v-number and falling back to the latest created_at. Used by the
* "הפקת מסמכים" card to surface the most recent טיוטה-טענות_הצדדים_* file (#214).
*/
function pickLatestVersioned(
files: ExportFile[] | undefined,
prefix: string,
): (ExportFile & { version: number | null }) | null {
const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix));
if (!matches.length) return null;
const withVer = matches.map((f) => {
// Version is the trailing integer, after either "-v" (final) or "_"
// (interim: טיוטה-טענות_הצדדים_{N}). Anchor to the end so the "_" inside
// the interim prefix isn't mistaken for the version separator.
const m = f.filename.match(/[_v](\d+)(?:\.\w+)?$/);
return { ...f, version: m ? parseInt(m[1], 10) : null };
});
withVer.sort((a, b) => {
if (a.version != null && b.version != null) return b.version - a.version;
return b.created_at - a.created_at;
});
return withVer[0];
}
/* ── Main component ─────────────────────────────────── */
export function DraftsPanel({
@@ -97,6 +134,13 @@ export function DraftsPanel({
const uploadFinal = useUploadFinalDecision(caseNumber);
const runLearning = useRunFinalLearning(caseNumber);
const runHalacha = useRunFinalHalacha(caseNumber);
// ── Document generation (#214) — the "הפקת מסמכים" card ──
const { data: summary } = usePartyClaimsSummary(caseNumber);
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
const genInterim = useGenerateInterimDraft(caseNumber);
// Server-derived background-run status (#227 ג) — survives navigation/reload.
const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary");
const interimStatus = useGenerateStatus(caseNumber, "interim_draft");
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
@@ -129,6 +173,38 @@ export function DraftsPanel({
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
})();
// ── "הפקת מסמכים" derived state (#214) ──
// Latest interim ("party-claims") partial draft in the exports table — the poll
// target for button 2. The CEO writes it as טיוטה-טענות_הצדדים_{N}.docx.
const latestInterim = pickLatestVersioned(exports, "טיוטה-טענות_הצדדים_");
const summaryReady = Boolean(summary?.markdown);
function handleGenerateSummary() {
genSummary.mutate(undefined, {
onSuccess: (d) => {
if (d.status === "ok") {
toast.success("הופעלה הפקת סיכום-המנהלים — רצה ברקע (אופוס)");
} else {
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת הפקת סיכום-המנהלים"),
});
}
function handleGenerateInterim() {
genInterim.mutate(undefined, {
onSuccess: (d) => {
if (d.status === "ok") {
toast.success("הופעלה הפקת טיוטת טענות-הצדדים — רצה ברקע (אופוס)");
} else {
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת הפקת הטיוטה החלקית"),
});
}
function handleUpload(file: File) {
uploadDraft.mutate(file, {
onSuccess: (data) => {
@@ -233,34 +309,194 @@ export function DraftsPanel({
}
return (
<div className="space-y-6">
{/* ── Banner ── */}
// flex + per-card `order` so the visual order matches mockup 26:
// generate-card → banner → active-draft → drafts → final+learning → feedback.
<div className="flex flex-col gap-6">
{/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */}
<section className="order-0 rounded-lg border border-rule bg-surface overflow-hidden">
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft bg-parchment">
<Zap className="w-[18px] h-[18px] text-gold-deep shrink-0" />
<h3 className="text-navy text-base font-semibold">הפקת מסמכים</h3>
</div>
<div className="p-4">
<p className="text-xs text-ink-muted leading-relaxed mb-1">
הפקה דטרמיניסטית בלחיצה בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת,
והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית.
</p>
{/* 1 — executive summary of party claims */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft first-of-type:border-t-0">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<ClipboardList className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">הפק סיכום מנהלים</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
תמצית מזוקקת של טענות הצדדים לקראת הדיון מסמך נפרד מהטיוטה.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateSummary}
disabled={genSummary.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genSummary.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
<GenerateStatusChip
status={summaryStatus.data}
onRetry={handleGenerateSummary}
retrying={genSummary.isPending}
/>
{summaryReady && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/download`,
"_blank",
)
}
>
<ExternalLink className="w-3.5 h-3.5 me-1" />
פתח
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/export-docx`,
"_blank",
)
}
>
<Download className="w-3.5 h-3.5 me-1" />
DOCX
</Button>
</>
)}
</div>
</div>
</div>
{/* 2 — partial draft (parties' claims, blocks ה–ט) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<FileText className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטה של טענות הצדדים{" "}
<span className="font-medium text-ink-muted text-xs">
(טיוטה חלקית)
</span>
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
בלוקים הט (רקע, טענות, הליכים, תכניות) עד הדיון, בלי דיון
והכרעה. נשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateInterim}
disabled={genInterim.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genInterim.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
<GenerateStatusChip
status={interimStatus.data}
onRetry={handleGenerateInterim}
retrying={genInterim.isPending}
/>
{latestInterim && (
<span className="text-[0.7rem] text-ink-muted">
{latestInterim.version
? `v${latestInterim.version} · `
: ""}
{formatDate(latestInterim.created_at)}
</span>
)}
</div>
</div>
</div>
{/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<Files className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטת החלטה מלאה
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
ההחלטה המלאה כל 12 הבלוקים, כולל דיון והכרעה. נקבעת
כמקור-האמת ונשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending || !isDraftReady}
title={!isDraftReady ? "זמין כשיש בלוקים כתובים" : undefined}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
<>
<FileOutput className="w-3.5 h-3.5 me-1.5" />
הפק
</>
)}
</Button>
{!isDraftReady && (
<span className="rounded bg-warn-bg px-2 py-0.5 text-[0.65rem] text-warn">
זמין כשיש בלוקים כתובים
</span>
)}
</div>
</div>
</div>
</div>
</section>
{/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */}
{isDraftReady && (
<div className="flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<div className="order-1 flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
<span className="text-sm font-medium text-gold-deep">
{draftLabel}
</span>
<div className="me-auto" />
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<FileOutput className="w-4 h-4 me-1.5" />
)}
הפק DOCX
</Button>
</div>
)}
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
{activeDraft?.filename && (
<div className="flex items-center gap-2 text-xs text-ink-muted">
<div className="order-2 flex items-center gap-2 text-xs text-ink-muted">
<span>מקור האמת:</span>
<Badge variant="outline" className="bg-surface">
{activeDraft.filename}
@@ -269,7 +505,7 @@ export function DraftsPanel({
)}
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
<section className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
<section className="order-4 rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
<input
ref={finalFileRef}
type="file"
@@ -353,7 +589,7 @@ export function DraftsPanel({
</section>
{/* ── Card: draft files (mockup 18h) ── */}
<section className="rounded-lg border border-rule bg-surface p-4">
<section className="order-3 rounded-lg border border-rule bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">קבצי טיוטה</h3>
<div className="flex items-center gap-2">
@@ -522,7 +758,7 @@ export function DraftsPanel({
</section>
{/* ── Card: chair feedback (mockup 18h) ── */}
<section className="rounded-lg border border-rule bg-surface p-4">
<section className="order-5 rounded-lg border border-rule bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">
הערות יו״ר

View File

@@ -0,0 +1,127 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { CheckCircle2, Clock, Loader2, RotateCw, XCircle } from "lucide-react";
import type { GenerateStatus } from "@/lib/api/generate";
/**
* Background-run status chip for the "הפקת מסמכים" generation actions (#227 ג).
*
* The chip is driven entirely by the server-derived {@link GenerateStatus} — it
* holds no run state of its own, so it renders correctly after the chair
* navigates away and back. The running timer is anchored to the server
* `started_at`, not a click-time counter, so the elapsed value is right on
* return too.
*/
function useElapsedSeconds(startedAt: string | null, active: boolean): number | null {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
const id = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(id);
}, [active]);
if (!startedAt) return null;
const start = Date.parse(startedAt);
if (Number.isNaN(start)) return null;
return Math.max(0, Math.floor((now - start) / 1000));
}
function fmtDuration(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m >= 60) {
const h = Math.floor(m / 60);
return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${m}:${String(s).padStart(2, "0")}`;
}
function relativeTime(iso: string | null): string {
if (!iso) return "";
const t = Date.parse(iso);
if (Number.isNaN(t)) return "";
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000));
if (sec < 60) return "הרגע";
const min = Math.floor(sec / 60);
if (min < 60) return `לפני ${min} דק׳`;
const hr = Math.floor(min / 60);
if (hr < 24) return `לפני ${hr} שע׳`;
return `לפני ${Math.floor(hr / 24)} ימים`;
}
const CHIP_BASE =
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold whitespace-nowrap";
export function GenerateStatusChip({
status,
onRetry,
retrying,
}: {
status: GenerateStatus | undefined;
onRetry?: () => void;
retrying?: boolean;
}) {
const state = status?.state ?? "idle";
const elapsed = useElapsedSeconds(status?.started_at ?? null, state === "running");
if (!status || state === "idle") return null;
if (state === "queued") {
return (
<span className={`${CHIP_BASE} bg-rule-soft text-ink-soft border-rule`}>
<Clock className="size-3.5" aria-hidden />
בתור
</span>
);
}
if (state === "running") {
return (
<span className={`${CHIP_BASE} bg-info-bg text-info border-info/35`}>
<Loader2 className="size-3.5 animate-spin" aria-hidden />
רץ ברקע
{elapsed !== null && (
<span className="tabular-nums">· {fmtDuration(elapsed)}</span>
)}
</span>
);
}
if (state === "done") {
const rel = relativeTime(status.finished_at);
return (
<span className={`${CHIP_BASE} bg-success-bg text-success border-success/35`}>
<CheckCircle2 className="size-3.5" aria-hidden />
הושלם{rel && ` · ${rel}`}
</span>
);
}
// failed
return (
<span className="inline-flex items-center gap-2">
<span className={`${CHIP_BASE} bg-danger-bg text-danger border-danger/35`}>
<XCircle className="size-3.5" aria-hidden />
{status.detail || "נכשל"}
</span>
{onRetry && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
disabled={retrying}
onClick={onRetry}
>
{retrying ? (
<Loader2 className="size-3.5 animate-spin me-1" />
) : (
<RotateCw className="size-3.5 me-1" />
)}
נסה שוב
</Button>
)}
</span>
);
}

View File

@@ -0,0 +1,360 @@
"use client";
import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ArrowDown,
ArrowUp,
CalendarDays,
HelpCircle,
Link2,
Loader2,
Plus,
RotateCw,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import {
CHANGE_LABELS_HE,
PROTOCOL_PARTY_LABELS_HE,
PROTOCOL_PARTY_ORDER,
useAnalyzeProtocol,
useProtocolAnalysis,
type ProtocolAnalysisRow,
type ProtocolChangeType,
type ProtocolPartyRole,
} from "@/lib/api/protocol-analysis";
const PARTY_MARK_TONE: Record<ProtocolPartyRole, string> = {
appellant: "bg-info",
respondent: "bg-gold-deep",
committee: "bg-success",
permit_applicant: "bg-warn",
unknown: "bg-ink-muted",
"": "bg-ink-muted",
};
const CHANGE_BADGE_TONE: Record<ProtocolChangeType, string> = {
strengthened: "bg-gold-wash text-gold-deep border-gold/40",
newly_raised: "bg-emerald-50 text-emerald-900 border-emerald-200",
dropped: "bg-rule-soft text-ink-soft border-rule",
};
const CHANGE_ACCENT: Record<ProtocolChangeType, string> = {
strengthened: "border-s-gold",
newly_raised: "border-s-emerald-500",
dropped: "border-s-rule",
};
function ChangeIcon({ type }: { type: ProtocolChangeType }) {
if (type === "strengthened") return <ArrowUp className="size-3.5" aria-hidden />;
if (type === "newly_raised") return <Plus className="size-3.5" aria-hidden />;
return <ArrowDown className="size-3.5" aria-hidden />;
}
type FilterKey = "all" | ProtocolChangeType;
function AttendeeColumn({
label,
dotTone,
names,
}: {
label: string;
dotTone: string;
names: string[];
}) {
if (!names.length) return null;
return (
<div className="min-w-[210px] flex-1">
<div className="text-gold-deep mb-1.5 flex items-center gap-1.5 text-[0.7rem] font-bold">
<span className={`size-1.5 flex-none rounded-full ${dotTone}`} aria-hidden />
{label}
</div>
<div className="text-ink-soft text-xs leading-relaxed">
{names.join(" · ")}
</div>
</div>
);
}
function DevelopmentCard({ row }: { row: ProtocolAnalysisRow }) {
return (
<div
className={`bg-surface border-rule overflow-hidden rounded-lg border border-s-4 shadow-sm ${CHANGE_ACCENT[row.change_type]}`}
>
<div className="space-y-2.5 px-4 py-3.5">
<div className="flex flex-wrap items-start gap-2.5">
<Badge
variant="outline"
className={`${CHANGE_BADGE_TONE[row.change_type]} flex-none gap-1 text-[0.72rem] font-bold`}
>
<ChangeIcon type={row.change_type} />
{CHANGE_LABELS_HE[row.change_type]}
</Badge>
<span className="text-navy flex-1 text-sm font-bold leading-snug">
{row.argument_title}
</span>
</div>
{row.summary && (
<p className="text-ink-soft text-[0.82rem] leading-relaxed">
{row.summary}
</p>
)}
{row.sharpened_question && (
<div className="bg-parchment border-rule-soft rounded-md border px-3 py-2">
<div className="text-gold-deep mb-1 flex items-center gap-1.5 text-[0.66rem] font-bold">
<HelpCircle className="size-3" aria-hidden />
השאלה שהתחדדה
</div>
<div className="text-ink text-xs leading-relaxed">
{row.sharpened_question}
</div>
</div>
)}
{row.evidence_quote && (
<blockquote className="border-rule text-ink-muted border-s-[3px] px-3 text-xs italic leading-relaxed">
&ldquo;{row.evidence_quote}&rdquo;
<span className="text-ink-muted mt-1 block text-[0.68rem] not-italic font-semibold">
מתוך פרוטוקול הדיון
</span>
</blockquote>
)}
<div className="flex flex-wrap items-center gap-2 pt-0.5">
{row.change_type === "newly_raised" ? (
<span className="text-emerald-800 inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
<Sparkles className="size-3.5" aria-hidden />
אין מקבילה בכתב עלה לראשונה בדיון
</span>
) : row.argument_id ? (
<span className="text-ink-muted inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
<Link2 className="size-3.5" aria-hidden />
מבוסס על טיעון כתוב קיים
</span>
) : null}
</div>
</div>
</div>
);
}
type HearingChangesPanelProps = {
caseNumber: string;
};
export function HearingChangesPanel({ caseNumber }: HearingChangesPanelProps) {
const { data, isPending, isError, error } = useProtocolAnalysis(caseNumber);
const analyze = useAnalyzeProtocol(caseNumber);
const [filter, setFilter] = useState<FilterKey>("all");
const handleAnalyze = () => {
analyze.mutate(undefined, {
onSuccess: (res) => {
if (res.status === "queued") {
toast.success("נשלח למנתח המשפטי — הניתוח ירוץ ברקע; רענן בעוד כמה דקות.");
} else {
toast.warning(
`לא ניתן להריץ אוטומטית (${res.reason}). ניתן להריץ ידנית מ-Claude Code.`,
);
}
},
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
});
};
const header = data?.header;
const counts = data?.by_change ?? {};
const strengthened = counts.strengthened ?? 0;
const newlyRaised = counts.newly_raised ?? 0;
const dropped = counts.dropped ?? 0;
const sections = useMemo(() => {
if (!data) return [];
return PROTOCOL_PARTY_ORDER.map((role) => {
const rows = (data.by_party[role] ?? []).filter(
(r) => filter === "all" || r.change_type === filter,
);
return { role, rows };
}).filter((s) => s.rows.length > 0);
}, [data, filter]);
const runButton = (
<Button
variant="outline"
size="sm"
disabled={analyze.isPending}
onClick={handleAnalyze}
>
{analyze.isPending ? (
<Loader2 className="me-1.5 size-3.5 animate-spin" />
) : (
<RotateCw className="me-1.5 size-3.5" />
)}
{data?.total ? "נתח מחדש" : "נתח את פרוטוקול הדיון"}
</Button>
);
return (
<div className="space-y-4">
{isPending ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : isError ? (
<p className="text-danger text-sm">
שגיאה בטעינת ניתוח-הפרוטוקול: {(error as Error).message}
</p>
) : !data?.total ? (
<div className="bg-surface border-rule rounded-lg border border-dashed px-6 py-8 text-center">
<h3 className="text-navy text-sm font-bold">טרם נותח פרוטוקול</h3>
<p className="text-ink-muted mx-auto mt-1.5 max-w-md text-xs leading-relaxed">
כשיש בתיק פרוטוקול של ועדת-הערר וטיעונים מאוגדים, הניתוח מזהה מה
התחזק ומה נטען לראשונה בדיון. ההרצה נשלחת למנתח המשפטי ורצה ברקע.
</p>
<div className="mt-4 flex justify-center">{runButton}</div>
</div>
) : (
<>
<div className="flex justify-end">{runButton}</div>
{/* hearing header strip */}
{header && (
<div className="bg-parchment border-rule rounded-lg border px-5 py-4 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<span className="bg-gold-wash border-rule flex size-9 flex-none items-center justify-center rounded-lg border">
<CalendarDays className="text-gold-deep size-5" aria-hidden />
</span>
<div className="flex-1">
<div className="text-navy text-[0.95rem] font-bold">
{header.protocol_title || "פרוטוקול דיון"}
</div>
<div className="text-ink-muted mt-0.5 text-xs">
{header.hearing_date
? `תאריך דיון: ${header.hearing_date}`
: "תאריך דיון לא זוהה"}
{header.panel_members.length > 0 &&
` · מותב: ${header.panel_members.join(", ")}`}
</div>
</div>
<Badge
variant="outline"
className="bg-info-bg text-info border-info/30 flex-none rounded-full text-[0.72rem] font-semibold"
>
פרוטוקול ועדת-הערר
</Badge>
</div>
{(header.appellants_present.length > 0 ||
header.respondents_present.length > 0) && (
<div className="border-rule-soft mt-3 flex flex-wrap gap-6 border-t pt-3">
<AttendeeColumn
label="נכחו מטעם העוררים"
dotTone="bg-info"
names={header.appellants_present}
/>
<AttendeeColumn
label="נכחו מטעם המשיבים"
dotTone="bg-gold-deep"
names={header.respondents_present}
/>
</div>
)}
</div>
)}
{/* filter pills */}
<div className="flex flex-wrap items-center gap-2">
<FilterPill
active={filter === "all"}
onClick={() => setFilter("all")}
label="הכל"
count={data.total}
/>
{strengthened > 0 && (
<FilterPill
active={filter === "strengthened"}
onClick={() => setFilter("strengthened")}
label="התחזקו"
count={strengthened}
icon={<ArrowUp className="size-3.5" aria-hidden />}
/>
)}
{newlyRaised > 0 && (
<FilterPill
active={filter === "newly_raised"}
onClick={() => setFilter("newly_raised")}
label="נטענו לראשונה"
count={newlyRaised}
icon={<Plus className="size-3.5" aria-hidden />}
/>
)}
{dropped > 0 && (
<FilterPill
active={filter === "dropped"}
onClick={() => setFilter("dropped")}
label="נזנחו"
count={dropped}
icon={<ArrowDown className="size-3.5" aria-hidden />}
/>
)}
</div>
{/* per-party sections */}
{sections.map(({ role, rows }) => (
<div key={role || "unassigned"} className="space-y-2.5">
<div className="flex items-center gap-2.5">
<span
className={`h-5 w-1.5 flex-none rounded ${PARTY_MARK_TONE[role]}`}
aria-hidden
/>
<span className="text-navy text-[0.95rem] font-bold">
{PROTOCOL_PARTY_LABELS_HE[role]}
</span>
<span className="text-ink-muted text-xs">{rows.length}</span>
</div>
<div className="space-y-2.5">
{rows.map((r) => (
<DevelopmentCard key={r.id} row={r} />
))}
</div>
</div>
))}
</>
)}
</div>
);
}
function FilterPill({
active,
onClick,
label,
count,
icon,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
icon?: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
active
? "bg-navy border-navy text-white"
: "bg-surface border-rule text-ink-soft hover:bg-parchment"
}`}
>
{icon}
{label}
<span className="font-bold">{count}</span>
</button>
);
}

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

@@ -8,10 +8,6 @@ import { SubsectionCard } from "@/components/compose/subsection-card";
import { PrecedentsSection } from "@/components/compose/precedents-section";
import { Markdown } from "@/components/ui/markdown";
import { useCase } from "@/lib/api/cases";
import {
useCaseLearningStatus,
type CaseLearningStatus,
} from "@/lib/api/learning";
import { useResearchAnalysis } from "@/lib/api/research";
import { useCasePrecedents } from "@/lib/api/precedents";
@@ -24,45 +20,6 @@ import { useCasePrecedents } from "@/lib/api/precedents";
* verification moved to their own top-level tabs; /compose was deleted.
*/
// ── Staged-pipeline indicator text — derived from the live learning-status,
// same source as the drafts-panel LearningStatusBadges. ──────────────────────
function voiceLearningText(s?: CaseLearningStatus): string {
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
const v = s.voice_learning;
if (v.outcome === "succeeded") {
const bits = [`${v.lessons_count} לקחים הופקו`];
if (v.lessons_proposed > 0) bits.push(`${v.lessons_proposed} הוצעו לאישור`);
return `✓ הושלם · ${bits.join(" · ")}`;
}
if (v.outcome === "failed") return v.error ? `✗ נכשל — ${v.error}` : "✗ נכשל";
return "ממתין להרצה";
}
function halachaExtractionText(s?: CaseLearningStatus): string {
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
const h = s.halacha_extraction;
if (!h.enrolled_in_corpus)
return h.not_enrolled_reason ?? "לא נכנס לקורפוס-הפסיקה";
switch (h.status) {
case "completed":
return `✓ הושלם · חולצו ${h.halachot_count} · ${h.approved} אושרו · ${h.rejected} נדחו`;
case "processing":
return "רץ עכשיו…";
case "pending":
case "busy":
return "בתור";
case "partial":
return `חלקי · חולצו ${h.halachot_count}`;
case "failed":
case "extraction_failed":
return "✗ נכשל";
case "no_chunks":
return "אין טקסט לחילוץ";
default:
return "ממתין להרצה";
}
}
function ProseSection({ title, content }: { title: string; content?: string }) {
if (!content?.trim()) return null;
return (
@@ -75,7 +32,9 @@ function ProseSection({ title, content }: { title: string; content?: string }) {
);
}
// ── "השלמה והעברה" rail card — DOCX export, analysis upload/download (real) ──
// ── "ייצוא ועדכון הניתוח" rail card — round-trips the analysis-and-research.md
// research analysis: export DOCX, upload an updated version, download the raw MD.
// (Post-final learning status lives on the drafts tab, not here — #226.) ──────
function FinishRail({
caseNumber,
hasAnalysis,
@@ -88,7 +47,6 @@ function FinishRail({
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
const learning = useCaseLearningStatus(caseNumber);
async function handleUpload(file: File) {
setUploading(true);
@@ -119,9 +77,12 @@ function FinishRail({
}
return (
<Card className="bg-surface border-rule shadow-sm">
<Card className="bg-surface border-rule shadow-sm h-full">
<CardContent className="px-4 py-4">
<h3 className="text-navy text-[0.9rem] font-semibold mb-3">השלמה והעברה</h3>
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">ייצוא ועדכון הניתוח</h3>
<p className="text-[0.72rem] text-ink-muted mb-3">
פעולות על קובץ ניתוח-המחקר (analysis-and-research.md).
</p>
<input
ref={fileRef}
@@ -176,16 +137,6 @@ function FinishRail({
{uploadMsg.text}
</p>
)}
{/* stage indicators — informational pointers, not actions */}
<div className="mt-3 space-y-0">
<div className="text-[0.78rem] text-ink-muted pt-2 border-t border-rule-soft">
<b className="text-navy">הרץ למידת-קול</b> {voiceLearningText(learning.data)}
</div>
<div className="text-[0.78rem] text-ink-muted pt-2 mt-2 border-t border-rule-soft">
<b className="text-navy">הרץ אימות-הלכות</b> {halachaExtractionText(learning.data)}
</div>
</div>
</CardContent>
</Card>
);
@@ -214,10 +165,6 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
return (
<div className="space-y-4">
<p className="text-ink-muted text-[0.78rem]">
סוגיות-המחלוקת ועמדות הצדדים. עורך עמדת-היו״ר נשמר אוטומטית ומזין את בלוק י׳ (דיון והכרעה).
</p>
{analysis.isPending ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
@@ -322,9 +269,10 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
</div>
) : null}
{/* case-level פסיקה מצורפת + finish/export gates (INV-IA3: not removed) */}
<div className="grid gap-4 lg:grid-cols-2 items-start pt-2">
<Card className="bg-surface border-rule shadow-sm">
{/* case-level פסיקה מצורפת + analysis export/update (INV-IA3: not removed).
items-stretch keeps both cards the same height (#226). */}
<div className="grid gap-4 lg:grid-cols-2 items-stretch pt-2">
<Card className="bg-surface border-rule shadow-sm h-full">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
<p className="text-[0.72rem] text-ink-muted mb-3">

View File

@@ -17,6 +17,8 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
new: FilePlus2,
processing: Loader2,
documents_ready: FileCheck,
analyst_verified: SearchCheck,
research_complete: Compass,
outcome_set: Target,
direction_approved: Compass,
qa_review: SearchCheck,
@@ -36,6 +38,8 @@ const STATUS_TONE: Record<CaseStatus, string> = {
new: "bg-rule-soft text-ink-muted border-rule",
processing: "bg-info-bg text-info border-info/30",
documents_ready: "bg-info-bg text-info border-info/40",
analyst_verified: "bg-gold-wash text-gold-deep border-gold/40",
research_complete: "bg-gold-wash text-gold-deep border-gold/40",
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
qa_review: "bg-warn-bg text-warn border-warn/40",

View File

@@ -26,6 +26,8 @@ export type PaperclipIssue = {
completed_at: string | null;
created_at: string | null;
company_id: string;
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
parent_id: string | null;
};
export type PaperclipComment = {
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
export function useSendComment(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { body: string; issue_id?: string }) =>
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
apiRequest<{
comment_id: string | null;
issue_id: string;
issue_identifier: string;
issue_status?: string;
new_run?: boolean;
}>(
`/api/cases/${caseNumber}/agents/comment`,
{ method: "POST", body: vars },
),
@@ -180,6 +188,45 @@ 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) });
}
},
});
}
/** Manually close a board issue to done/cancelled — the chair tidying the agents
* board (e.g. superseded runs left in in_review). Backend does a loop-safe
* direct close and issues no wakeup; only the two terminal statuses are allowed. */
export function useSetIssueStatus(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { issue_id: string; status: "done" | "cancelled" }) =>
apiRequest<{ ok: boolean; id: string; identifier: string; status: string }>(
`/api/cases/${caseNumber}/agents/issue-status`,
{ 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

@@ -0,0 +1,66 @@
/**
* Case files API — browse & read every content subfolder of a case on disk.
*
* Surfaces the agent-produced work files (drafts, OCR text, research, originals…)
* that previously lived only on the server filesystem. Backed by the pre-existing
* `/local-files` endpoints, generalized to all subfolders (path-safe on the
* server via `_resolve_case_file`).
*/
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type CaseFile = {
filename: string;
size: number;
/** Unix epoch seconds (float) of last modification. */
modified_at: number;
};
export type CaseFileFolder = {
/** Opaque folder key for the read endpoint (e.g. "documents__research"). */
key: string;
/** Leaf folder name (e.g. "research"). */
name: string;
/** Case-relative path (e.g. "documents/research"). */
path: string;
files: CaseFile[];
};
export type CaseFilesResponse = { folders: CaseFileFolder[] };
export function useCaseFiles(caseNumber: string | undefined) {
return useQuery({
queryKey: ["case-files", caseNumber ?? ""],
queryFn: () =>
apiRequest<CaseFilesResponse>(`/api/cases/${caseNumber}/local-files`),
enabled: !!caseNumber,
});
}
/** Direct URL to a single file — use as an <a href> for download/open. */
export function caseFileUrl(
caseNumber: string,
folderKey: string,
filename: string,
): string {
return `/api/cases/${encodeURIComponent(caseNumber)}/local-files/${encodeURIComponent(folderKey)}/${encodeURIComponent(filename)}`;
}
/** Fetch a text file's contents (md/txt/json) for the inline viewer. */
export function useCaseFileText(
caseNumber: string | undefined,
folderKey: string | undefined,
filename: string | undefined,
) {
return useQuery({
queryKey: ["case-file-text", caseNumber ?? "", folderKey ?? "", filename ?? ""],
queryFn: async () => {
const res = await fetch(caseFileUrl(caseNumber!, folderKey!, filename!));
if (!res.ok) throw new Error(`שגיאה בטעינת הקובץ (${res.status})`);
return res.text();
},
enabled: !!caseNumber && !!folderKey && !!filename,
staleTime: 30_000,
});
}

View File

@@ -1,16 +1,19 @@
/**
* Single source of truth for the case-status lifecycle (UI-B1 / G2).
* Frontend mirror of the canonical case-status lifecycle.
*
* The 17-status manual menu was trimmed to the **10 core statuses** that the
* pipeline actually sets or that gate real logic. Decorative mid-stage markers
* (uploading, analyst_verified, research_complete, brainstorming,
* analysis_enriched, ready_for_writing, drafting) plus the legacy/dead
* `in_progress` and `qa_failed` were removed — no pipeline code ever set them.
* The ONE source of truth is the backend status model
* (`mcp-server/src/legal_mcp/case_status_model.py`), exposed at
* `GET /api/status-model`; this file mirrors it (order / labels / phases) so the
* UI keeps compile-time `CaseStatus` typing. Keep the two in sync — the backend
* registry is authoritative (it also declares per-status `on_enter` actions).
*
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards,
* compose chip) imports the list / labels / phases from here instead of
* re-declaring its own array. Keep this file in sync with the backend
* STATUS_ORDER in `mcp-server/src/legal_mcp/tools/cases.py`.
* The analyst/research intermediate states (`analyst_verified`,
* `research_complete`) are first-class canonical statuses — the agents set them,
* so the chip, stepper and manual changer all agree instead of the status
* falling between the canonical set and a legacy bucket.
*
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards)
* imports the list / labels / phases from here instead of re-declaring its own.
*/
/** Ordered lifecycle — also the order shown in the manual status dropdown. */
@@ -18,6 +21,8 @@ export const CASE_STATUSES = [
"new",
"processing",
"documents_ready",
"analyst_verified",
"research_complete",
"outcome_set",
"direction_approved",
"qa_review",
@@ -35,22 +40,18 @@ export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done";
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] },
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["outcome_set", "direction_approved"] },
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["analyst_verified", "research_complete", "outcome_set", "direction_approved"] },
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
];
/**
* Legacy/intermediate statuses that the trimmed PHASES list dropped but the
* Paperclip agents still set (e.g. the analyst writes `analyst_verified`, the
* researcher writes `research_complete`). Without this map a case parked on one
* of them renders with NO active phase — the pipeline stepper goes blank and the
* chair can't see where the case stands. Map each to the phase it belongs to for
* display purposes only (these are NOT selectable statuses).
* Truly-legacy statuses that are NOT in the canonical set but might still arrive
* from an old client / un-migrated row — mapped to a phase for display only, so
* the stepper never goes blank. (The analyst/research intermediates are NO
* longer here — they are canonical statuses in CASE_STATUSES above.)
*/
const LEGACY_STATUS_PHASE: Record<string, PhaseKey> = {
analyst_verified: "thinking",
research_complete: "thinking",
analysis_enriched: "thinking",
ready_for_writing: "writing",
drafting: "writing",
@@ -80,6 +81,8 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
new: "חדש",
processing: "בעיבוד",
documents_ready: "מסמכים מוכנים",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
outcome_set: "תוצאה נקבעה",
direction_approved: "כיוון אושר",
qa_review: "בדיקת איכות",
@@ -97,8 +100,6 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
const LEGACY_STATUS_LABELS: Record<string, string> = {
in_progress: "בעבודה",
uploading: "מעלה",
analyst_verified: "ניתוח אומת",
research_complete: "מחקר הושלם",
brainstorming: "סיעור מוחות",
analysis_enriched: "ניתוח הועמק",
ready_for_writing: "מוכן לכתיבה",
@@ -124,6 +125,8 @@ export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
new: "התיק נוצר וממתין להעלאת מסמכים",
processing: "המערכת מעבדת ומנתחת את המסמכים",
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה",
research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)",
outcome_set: "נקבעה תוצאה צפויה לערר",
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
qa_review: "הטיוטה בבדיקת איכות אוטומטית",

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

@@ -0,0 +1,148 @@
/**
* Document-generation domain hooks (TaskMaster #214).
*
* The "הפקת מסמכים" card in the case "טיוטות והערות" tab fires two deterministic,
* host-side generation actions through the CEO (structured-action wakeup):
* • party_claims_summary → an executive summary of the parties' claims
* • interim_draft → a partial draft (blocks ה–ט, up to the discussion)
*
* Generation runs on the host (claude_session → claude -p), NOT in the container,
* so the trigger endpoints return 202-accepted and the UI POLLS for completion:
* • summary → GET /api/cases/{n}/research/party-claims-summary (200 = ready)
* • interim draft → the exports list grows a "טיוטה-טענות_הצדדים_{N}.docx" file
* (polled via useExports — see exports.ts).
*
* The full-decision export ("הפק טיוטת החלטה מלאה") is NOT here — it reuses the
* existing in-container useExportDocx (exports.ts), no LLM, no wakeup.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, apiRequest } from "./client";
/** Shape returned by the structured-action wakeup endpoints (#214). */
export type GenerateTriggerResult = {
status: "ok" | "skipped" | "error";
action?: string;
issue_id?: string;
ceo_id?: string;
reason?: string;
error?: string;
};
/** The two host-side background generation actions (poll target of the status chip). */
export type GenerateAction = "party_claims_summary" | "interim_draft";
export const generateKeys = {
all: ["generate"] as const,
partyClaimsSummary: (caseNumber: string) =>
[...generateKeys.all, "party-claims-summary", caseNumber] as const,
status: (caseNumber: string, action: GenerateAction) =>
[...generateKeys.all, "status", action, caseNumber] as const,
};
/**
* Server-derived background-run status (#227 ג). Reconstructed on the backend
* from the CEO child issue + its heartbeat_run + the output artifact — so it is
* correct after navigating away and back; it never depends on the browser having
* stayed on the page. `started_at`/`finished_at` are server ISO-8601 UTC stamps,
* so the client renders a correct elapsed time on return.
*/
export type GenerateState = "idle" | "queued" | "running" | "done" | "failed";
export type GenerateStatus = {
case_number: string;
action: GenerateAction;
state: GenerateState;
started_at: string | null;
finished_at: string | null;
output_ready: boolean;
detail: string;
issue_id: string | null;
};
export function useGenerateStatus(
caseNumber: string | undefined,
action: GenerateAction,
) {
return useQuery<GenerateStatus>({
queryKey: generateKeys.status(caseNumber ?? "", action),
queryFn: ({ signal }) =>
apiRequest<GenerateStatus>(
`/api/cases/${caseNumber}/generate/${action}/status`,
{ signal },
),
enabled: Boolean(caseNumber),
// Poll only while a run is in flight; a terminal state stops the interval.
refetchInterval: (query) => {
const s = query.state.data?.state;
return s === "running" || s === "queued" ? 5_000 : false;
},
staleTime: 2_000,
});
}
/* ── Read: party-claims executive summary (poll target for button 1) ──
404 (not generated yet) is a normal "not ready" state, not an error — we
coerce it to null so the panel renders the empty/idle state cleanly. */
export type PartyClaimsSummary = { markdown: string } | null;
export function usePartyClaimsSummary(caseNumber: string | undefined) {
return useQuery<PartyClaimsSummary>({
queryKey: generateKeys.partyClaimsSummary(caseNumber ?? ""),
queryFn: async ({ signal }) => {
try {
return await apiRequest<{ markdown: string }>(
`/api/cases/${caseNumber}/research/party-claims-summary`,
{ signal },
);
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
},
enabled: Boolean(caseNumber),
staleTime: 5_000,
retry: false,
});
}
/* ── Trigger: party-claims executive summary (button 1) ── */
export function useGeneratePartyClaimsSummary(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<GenerateTriggerResult>(
`/api/cases/${caseNumber}/generate/party-claims-summary`,
{ method: "POST" },
),
onSuccess: () => {
// Start polling fresh — the file will appear after the host run completes,
// and the status chip should pick up the new queued/running run.
qc.invalidateQueries({
queryKey: generateKeys.partyClaimsSummary(caseNumber),
});
qc.invalidateQueries({
queryKey: generateKeys.status(caseNumber, "party_claims_summary"),
});
},
});
}
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
export function useGenerateInterimDraft(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
apiRequest<GenerateTriggerResult>(
`/api/cases/${caseNumber}/generate/interim-draft`,
{ method: "POST" },
),
// The result lands in the exports list (טיוטה-טענות_הצדדים_*.docx, polled by
// useExports); re-arm the status chip so it shows the new run immediately.
onSuccess: () => {
qc.invalidateQueries({
queryKey: generateKeys.status(caseNumber, "interim_draft"),
});
},
});
}

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

@@ -190,6 +190,61 @@ export function useAgentRuns() {
});
}
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
export type AgentHealthState = "zombie" | "stalled" | "working" | "idle";
export type AgentHealthItem = {
issue_id: string;
identifier: string;
status: string;
agent_id: string;
agent_name: string;
health: AgentHealthState;
wakeups: number;
recovery_wakeups: number;
};
export type AgentHealthResponse = {
ok: boolean;
items: AgentHealthItem[]; // worst-first (zombie → stalled → working → idle)
counts: Record<AgentHealthState, number>;
};
/** Per-issue agent health — surfaces recovery-loop zombies automatically. */
export function useAgentHealth() {
return useQuery({
queryKey: ["operations", "agents", "health"],
queryFn: ({ signal }) =>
apiRequest<AgentHealthResponse>("/api/operations/agents/health", { signal }),
refetchInterval: 5000, // live alongside the running-agents view
staleTime: 3000,
});
}
export type EscalationItem = {
issue_id: string;
identifier: string;
severity: "critical" | "high" | "medium";
reason: string;
created_at: string | null;
};
export type RecentEscalationsResponse = {
ok: boolean;
items: EscalationItem[]; // newest-first
};
/** Recent chair escalations (watchdog + manual) — the handled-loop history. */
export function useRecentEscalations() {
return useQuery({
queryKey: ["operations", "agents", "escalations"],
queryFn: ({ signal }) =>
apiRequest<RecentEscalationsResponse>("/api/operations/agents/escalations", { signal }),
refetchInterval: 15000, // escalations change slowly (watchdog runs every 15m)
staleTime: 10000,
});
}
/** Full output log of one run — fetched on demand (drawer open). */
export function useRunLog(runId: string | null) {
return useQuery({

View File

@@ -0,0 +1,134 @@
/**
* Protocol comparative analysis — the "מה קרה בדיון" panel (#226).
*
* `analyze_protocol` compares the ערר hearing protocol against the written
* pleadings and records, per argument, whether it was strengthened, newly
* raised, or dropped at the hearing — plus the sharpened legal question and a
* verbatim evidence quote. This module exposes the read + trigger endpoints.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import type { LegalArgumentParty } from "./legal-arguments";
export type ProtocolChangeType = "strengthened" | "newly_raised" | "dropped";
/** Empty string = an unassigned party_role on a verdict row. */
export type ProtocolPartyRole = LegalArgumentParty | "";
export type ProtocolAnalysisRow = {
id: string;
case_id: string;
document_id: string;
party_role: ProtocolPartyRole;
change_type: ProtocolChangeType;
/** The pleaded argument this verdict matched (strengthened/dropped); null for newly_raised. */
argument_id: string | null;
argument_title: string;
summary: string;
sharpened_question: string;
evidence_quote: string;
page_number: number | null;
created_at?: string;
};
export type ProtocolHeader = {
hearing_date: string | null;
protocol_title: string;
protocol_document_id: string;
panel_members: string[];
appellants_present: string[];
respondents_present: string[];
};
export type ProtocolAnalysisResponse = {
case_number: string;
total: number;
header: ProtocolHeader;
by_change: Partial<Record<ProtocolChangeType, number>>;
by_party: Partial<Record<ProtocolPartyRole, ProtocolAnalysisRow[]>>;
analysis: ProtocolAnalysisRow[];
};
export const protocolAnalysisKeys = {
all: ["protocol-analysis"] as const,
byCase: (caseNumber: string) =>
[...protocolAnalysisKeys.all, caseNumber] as const,
};
export function useProtocolAnalysis(caseNumber: string | undefined) {
return useQuery({
queryKey: protocolAnalysisKeys.byCase(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<ProtocolAnalysisResponse>(
`/api/cases/${caseNumber}/protocol-analysis`,
{ signal },
),
enabled: Boolean(caseNumber),
staleTime: 10_000,
});
}
/**
* The analysis runs on the legal-analyst agent (host-side, where the `claude`
* CLI lives) — NOT inline in the FastAPI container. The endpoint delegates via
* a Paperclip wakeup (`queued`), or reports `skipped` when no analyst route is
* available (the chair can then run the MCP tool manually).
*/
export type AnalyzeProtocolResult =
| {
status: "queued";
sub_issue_id: string;
analyst_id: string;
main_issue_id: string;
}
| {
status: "skipped";
reason: "no_api_key" | "no_analyst" | "no_issue" | string;
company_id?: string;
};
export function useAnalyzeProtocol(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (documentId?: string) =>
apiRequest<AnalyzeProtocolResult>(
`/api/cases/${caseNumber}/analyze-protocol${
documentId ? `?document_id=${encodeURIComponent(documentId)}` : ""
}`,
{ method: "POST" },
),
onSuccess: () => {
if (caseNumber) {
qc.invalidateQueries({
queryKey: protocolAnalysisKeys.byCase(caseNumber),
});
}
},
});
}
export const CHANGE_LABELS_HE: Record<ProtocolChangeType, string> = {
strengthened: "התחזק בדיון",
newly_raised: "נטען לראשונה",
dropped: "נזנח בדיון",
};
export const PROTOCOL_PARTY_LABELS_HE: Record<ProtocolPartyRole, string> = {
appellant: "עוררים",
respondent: "משיבים",
committee: "ועדה מקומית",
permit_applicant: "מבקשי היתר",
unknown: "צד לא מזוהה",
"": "צד לא מסווג",
};
/** Display order for the per-party sections. */
export const PROTOCOL_PARTY_ORDER: ProtocolPartyRole[] = [
"appellant",
"committee",
"respondent",
"permit_applicant",
"unknown",
"",
];

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";

110
web/agent_health.py Normal file
View File

@@ -0,0 +1,110 @@
"""Agent health taxonomy (TaskMaster #222) — one crisp state per stuck issue.
Classifies each open, agent-assigned issue into a single health state so the
stranded-child / recovery-loop cases we diagnose by hand today
([[reference_recovery_loop_stranded_child]], [[reference_paperclip_recovery_loops]])
surface automatically in the dashboard — the ``gt feed --problems`` idea from
Gastown, grounded in *our* failure modes.
Two layers, split for testability (same shape as web.agent_telemetry):
- :func:`classify_issue_health` is a **pure function** over three primitives
(live-run flag + wakeup counts). No DB, no platform symbols — trivially
unit-tested and platform-agnostic (INV-G12).
- The Paperclip-specific fetch that derives those primitives from
``heartbeat_runs`` / ``agent_wakeup_requests`` lives in the shell
(``web.paperclip_client.get_agent_health``); this module only decides *how to
label* what the fetch measured, so the taxonomy survives a platform swap.
The wakeup/recovery signals it reads are the very ones #219 telemetry now emits
(``agent.wakeup`` with recovery reasons) — this is the consumer that turns that
stream into an at-a-glance verdict.
"""
from __future__ import annotations
# Severity order (worst first) — the dashboard sorts problems by this.
HEALTH_STATES: tuple[str, ...] = ("zombie", "stalled", "working", "idle")
# Substrings of Paperclip wakeup ``reason`` values that mark a recovery-loop
# wake (as opposed to a genuine new task). Sourced from the recovery-loop
# forensics in memory: an issue that keeps getting these while agent-owned is a
# zombie — assigned + "active" but nothing is really executing.
RECOVERY_REASON_MARKERS: tuple[str, ...] = (
"source_scoped_recovery",
"stranded_assigned",
"issue_continuation",
"issue_reopened",
"missing_disposition",
"successful_run_missing_state",
"waiting_on_review",
)
# ≥ this many wakeups inside the fetch window, with no recovery marker and no
# live run, reads as "poked repeatedly but not progressing" = stalled.
STALL_WAKEUP_THRESHOLD = 3
def is_recovery_reason(reason: str | None) -> bool:
"""True if a wakeup ``reason`` is a recovery-loop marker (not a new task)."""
if not reason:
return False
return any(marker in reason for marker in RECOVERY_REASON_MARKERS)
def classify_issue_health(
*,
has_live_run: bool,
recovery_wakeups: int,
total_wakeups: int,
) -> str:
"""Label one open, agent-assigned issue. Pure — no I/O.
- ``working`` — a live run is executing right now.
- ``zombie`` — no live run but recovery-loop wakeups are firing: the issue
looks agent-owned + active yet nothing is really running (the stranded
child we chase manually).
- ``stalled`` — no live run, no recovery markers, but poked ``>=``
:data:`STALL_WAKEUP_THRESHOLD` times in-window (repeated wakes, no
progress).
- ``idle`` — open + assigned but quiet: benign waiting.
Called only for issues that are already open and agent-assigned; unassigned
or closed issues are not health-tracked (the fetch filters them out).
"""
if has_live_run:
return "working"
if recovery_wakeups >= 1:
return "zombie"
if total_wakeups >= STALL_WAKEUP_THRESHOLD:
return "stalled"
return "idle"
# A zombie needs at least this many recovery-marker wakeups before the watchdog
# auto-escalates it — filters a single transient recovery wake from a genuine,
# budget-burning loop. Escalation reassigns the issue to the chair (via
# escalate_issue), so an escalated issue is agent-unowned next sweep and never
# re-escalates (idempotent by construction).
ZOMBIE_ESCALATE_MIN_RECOVERY = 2
def zombie_escalation(item: dict) -> tuple[str, str] | None:
"""Decide whether a health item warrants auto-escalation to the chair.
Pure (no I/O) so the watchdog's policy is unit-tested. Returns
``(severity, reason)`` for a persistent zombie, else ``None``. Severity
scales with loop intensity — a long-running loop (``>=5`` recovery wakes)
is ``high``, a fresh one ``medium``. Only ``zombie`` items escalate: a
``stalled``/``working``/``idle`` issue is not a recovery loop.
"""
if item.get("health") != "zombie":
return None
recovery = int(item.get("recovery_wakeups", 0))
if recovery < ZOMBIE_ESCALATE_MIN_RECOVERY:
return None
severity = "high" if recovery >= 5 else "medium"
reason = (
f"auto-escalation (watchdog): זוהתה לולאת-recovery — {recovery} יקיצות-שחזור "
f"על {item.get('identifier')} ({item.get('agent_name')}); ה-issue נמסר לחיים "
f"במקום להמשיך לשרוף budget."
)
return severity, reason

View File

@@ -33,37 +33,118 @@ from web.paperclip_api import (
pc_request,
require_paperclip_db_url,
)
from web.agent_telemetry import instrument
from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction,
archive_project as pc_archive_project,
cancel_run as pc_cancel_run,
create_project as pc_create_project,
create_workflow_issue as pc_create_workflow_issue,
get_agent_health as pc_get_agent_health,
get_recent_escalations as pc_get_recent_escalations,
get_predecessor_context as pc_get_predecessor_context,
get_predecessor_for_case as pc_get_predecessor_for_case,
get_agents_for_case as pc_get_agents_for_case,
get_agents_for_company as pc_get_agents,
get_case_issues as pc_get_case_issues,
pick_default_comment_target as pc_pick_default_comment_target,
get_issue_comments as pc_get_issue_comments,
get_issue_interactions as pc_get_issue_interactions,
get_project_url,
get_run_events as pc_get_run_events,
get_run_log as pc_get_run_log,
list_live_runs as pc_list_live_runs,
post_comment as pc_post_comment,
reject_interaction as pc_reject_interaction,
reset_agent_session as pc_reset_agent_session,
reset_case_agents as pc_reset_case_agents,
respond_to_interaction as pc_respond_to_interaction,
restore_project as pc_restore_project,
update_project_name as pc_update_project_name,
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
wake_ceo_agent as pc_wake_ceo,
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
wake_curator_for_final as pc_wake_curator_for_final,
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
get_generation_run_status as pc_get_generation_run_status,
# ── raw imports: wrapped with telemetry below (state-affecting ops, #219) ──
cancel_interaction as _cancel_interaction,
cancel_run as _cancel_run,
escalate_issue as _escalate_issue,
mark_comment_routed as _mark_comment_routed,
post_comment as _post_comment,
reap_stale_interactions as _reap_stale_interactions,
reset_agent_session as _reset_agent_session,
reset_case_agents as _reset_case_agents,
set_issue_status as _set_issue_status,
wake_analyst_for_appraiser_facts as _wake_analyst_for_appraiser_facts,
wake_analyst_for_argument_aggregation as _wake_analyst_for_argument_aggregation,
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
wake_ceo_agent as _wake_ceo,
wake_ceo_for_action as _wake_ceo_for_action,
open_ceo_run as _open_ceo_run,
wake_ceo_for_feedback_fold as _wake_ceo_for_feedback_fold,
wake_curator_for_final as _wake_curator_for_final,
wake_for_precedent_extraction as _wake_for_precedent_extraction,
)
# ── telemetry-wrapped platform ops (#219 / docs/spec/X15) ───────────────────
# Every state-affecting platform op the Port exposes emits one structured event
# through web.agent_telemetry, so recovery-loop behaviour (repeated wakeups,
# stranded dispositions) is observable in real time rather than reconstructed
# from the Paperclip DB. The wrappers preserve the public ``pc_*`` names and
# signatures — call sites in app.py are unchanged. Read-only observability ops
# (list_live_runs / get_run_log / get_issue_comments …) are intentionally NOT
# instrumented: they neither wake agents nor change disposition state.
pc_wake_ceo = instrument("agent.wakeup", role="ceo")(_wake_ceo)
pc_wake_ceo_for_action = instrument(
"agent.wakeup", role="ceo", keys=("case_number", "company_id", "action"),
)(_wake_ceo_for_action)
pc_open_ceo_run = instrument(
"agent.wakeup", role="ceo", keys=("case_number", "company_id"),
)(_open_ceo_run)
pc_wake_ceo_for_feedback_fold = instrument(
"agent.wakeup", role="ceo", keys=("feedback_id", "category", "block_id"),
)(_wake_ceo_for_feedback_fold)
pc_wake_curator_for_final = instrument(
"agent.wakeup", role="curator", keys=("case_number", "company_id", "task"),
)(_wake_curator_for_final)
pc_wake_for_precedent_extraction = instrument(
"agent.wakeup", role="ceo", keys=("case_law_id", "citation", "practice_area"),
)(_wake_for_precedent_extraction)
pc_wake_analyst_for_appraiser_facts = instrument(
"agent.wakeup", role="analyst",
)(_wake_analyst_for_appraiser_facts)
pc_wake_analyst_for_argument_aggregation = instrument(
"agent.wakeup", role="analyst",
)(_wake_analyst_for_argument_aggregation)
pc_wake_analyst_for_protocol_analysis = instrument(
"agent.wakeup", role="analyst", keys=("case_number", "company_id", "document_id"),
)(_wake_analyst_for_protocol_analysis)
pc_post_comment = instrument(
"agent.comment", keys=("issue_id", "company_id"),
)(_post_comment)
pc_mark_comment_routed = instrument(
"agent.comment", keys=("issue_id", "comment_id"),
)(_mark_comment_routed)
pc_cancel_interaction = instrument(
"interaction.cancelled", keys=("issue_id", "interaction_id"),
)(_cancel_interaction)
# First-class escalation-to-chair (#218): the loop-safe alternative to leaving an
# issue agent-owned+blocked. Emits a severity-tagged event so escalations are
# countable per case in the same stream as the wakeups they replace.
pc_escalate_issue = instrument(
"agent.escalated", keys=("issue_id", "severity", "company_id", "reason"),
)(_escalate_issue)
# Manual chair close of a board issue (done/cancelled) — the loop-safe primitive
# behind the agents-board "סמן כהושלם / בטל" actions. Countable per case in the
# same telemetry stream as the escalations/wakeups it sits beside.
pc_set_issue_status = instrument(
"issue.status_set", keys=("issue_id", "status", "company_id"),
)(_set_issue_status)
pc_reap_stale_interactions = instrument(
"interaction.reaped", result_keys=("cancelled",),
)(_reap_stale_interactions)
pc_cancel_run = instrument("run.cancelled", keys=("run_id",))(_cancel_run)
pc_reset_agent_session = instrument(
"agent.session_reset", keys=("agent_id",),
)(_reset_agent_session)
pc_reset_case_agents = instrument(
"agent.session_reset", keys=("case_number",),
)(_reset_case_agents)
# ── domain-named lifecycle aliases (preferred for new call sites) ───────────
archive_case_project = pc_archive_project
restore_case_project = pc_restore_project
@@ -93,23 +174,37 @@ __all__ = [
# issues / workflow
"pc_create_workflow_issue",
"pc_get_case_issues",
"pc_pick_default_comment_target",
# agents / wakeups
"pc_get_agents_for_case",
"pc_get_agents",
"pc_wake_ceo",
"pc_wake_ceo_for_action",
"pc_open_ceo_run",
"pc_wake_ceo_for_feedback_fold",
"pc_wake_curator_for_final",
"pc_wake_for_precedent_extraction",
"pc_wake_analyst_for_appraiser_facts",
"pc_wake_analyst_for_argument_aggregation",
"pc_wake_analyst_for_protocol_analysis",
"pc_get_generation_run_status",
# comments / interactions
"pc_post_comment",
"pc_mark_comment_routed",
"pc_get_issue_comments",
"pc_get_issue_interactions",
"pc_accept_interaction",
"pc_reject_interaction",
"pc_respond_to_interaction",
"pc_cancel_interaction",
"pc_reap_stale_interactions",
"pc_escalate_issue",
"pc_set_issue_status",
# agent-run observability + control (live view + smart management)
"pc_get_agent_health",
"pc_get_recent_escalations",
"pc_get_predecessor_context",
"pc_get_predecessor_for_case",
"pc_list_live_runs",
"pc_get_run_log",
"pc_get_run_events",

157
web/agent_telemetry.py Normal file
View File

@@ -0,0 +1,157 @@
"""Agent-platform telemetry (TaskMaster #219) — structured, platform-agnostic events.
Emits one structured event per agent-platform operation (wakeup, disposition
comment, interaction reap/cancel, run cancel, session reset) so recovery-loop
behaviour is **observable in real time** instead of reconstructed forensically
from the Paperclip DB (``agent_wakeup_requests`` / ``heartbeat_runs``). See
[[reference_paperclip_recovery_loops]] / [[reference_recovery_loop_stranded_child]]
for the failure modes this makes visible.
**Platform-agnostic by design (INV-G12 / docs/spec/X15).** Events are named in
domain terms and carry no Paperclip-specific symbols — this module imports
*nothing* from the Paperclip shell. The Port (``web/agent_platform_port.py``)
calls :func:`instrument` / :func:`emit` around its operations, so the telemetry
semantics survive a platform swap.
**OTLP-ready, "logs first" (#219).** Each event is a flat dict with a stable
schema, written today as one JSON line on the ``agent.telemetry`` logger
(greppable in Coolify logs). Swapping :data:`_SINK` for an OTLP exporter — or
attaching an OTLP handler to that logger — is the later step, with **no
call-site changes**. Timestamps are stored in UTC (display-in-Israel is a
read-side concern; see [[feedback_israel_time]]).
Single path (INV-G2): the only telemetry emitter for the agent platform. Do not
add a parallel structured-log format for these events elsewhere — route through
:func:`emit`.
"""
from __future__ import annotations
import functools
import inspect
import json
import logging
import time
from collections.abc import Awaitable, Callable, Sequence
from datetime import datetime, timezone
from typing import Any, TypeVar
logger = logging.getLogger("agent.telemetry")
# Schema version — bump when the event shape changes so downstream consumers
# (dashboards, the #222 health taxonomy) can migrate deliberately.
SCHEMA_VERSION = 1
# Standard argument keys lifted onto every event when present on the wrapped call.
_STANDARD_KEYS: tuple[str, ...] = ("case_number", "issue_id", "company_id", "reason")
T = TypeVar("T")
def _now_iso() -> str:
"""UTC ISO-8601 with a trailing ``Z`` — store-UTC (feedback_israel_time)."""
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def _sink(payload: dict[str, Any]) -> None:
"""Where an event goes. Today: a JSON line on the ``agent.telemetry`` logger.
Isolated so a future OTLP exporter is a one-line swap with no call-site
churn (#219 "backend later").
"""
logger.info(json.dumps(payload, ensure_ascii=False, default=str))
_SINK: Callable[[dict[str, Any]], None] = _sink
def emit(event: str, *, outcome: str = "ok", **fields: Any) -> None:
"""Emit one structured agent-platform event. Never raises.
Telemetry must never break the operation it observes — any failure in the
sink is swallowed to a warning. ``event`` is a dotted domain name
(``agent.wakeup``, ``interaction.reaped``, ``run.cancelled`` …). Extra
``fields`` are merged flat into the event; ``None`` values are dropped to
keep events terse.
"""
payload: dict[str, Any] = {
"ts": _now_iso(),
"v": SCHEMA_VERSION,
"event": event,
"outcome": outcome,
}
for key, value in fields.items():
if value is not None:
payload[key] = value
try:
_SINK(payload)
except Exception: # pragma: no cover — telemetry is best-effort, never fatal
logger.warning("agent.telemetry emit failed for event=%s", event, exc_info=True)
def _outcome_of(result: Any) -> str:
"""Derive an outcome from a platform call's return value.
Our platform helpers return either a plain dict (Paperclip API JSON) or a
``{"ok": bool, ...}`` envelope. A falsy ``ok`` is a *no-op* (e.g. an
interaction that was already resolved), distinct from a raised error.
"""
if isinstance(result, dict) and result.get("ok") is False:
return "noop"
return "ok"
def _duration_ms(t0: float) -> int:
return int((time.monotonic() - t0) * 1000)
def instrument(
event: str,
*,
role: str | None = None,
keys: Sequence[str] = _STANDARD_KEYS,
result_keys: Sequence[str] = (),
**static: Any,
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
"""Decorate an async platform op so it emits a structured event per call.
Fields are pulled from the call's bound arguments (``keys`` present on the
signature), plus a static ``role`` and any ``static`` attributes, plus
``duration_ms`` and an ``outcome`` derived from the result (or ``error`` on
exception). ``result_keys`` lifts named keys out of a dict return value onto
the event (e.g. the ``cancelled`` count from the reaper).
The emit is wrapped so a telemetry bug can never fail the underlying call.
"""
def deco(fn: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
sig = inspect.signature(fn)
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> T:
try:
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
arguments = bound.arguments
except TypeError:
arguments = {}
fields: dict[str, Any] = {k: arguments.get(k) for k in keys}
if role is not None:
fields["agent_role"] = role
fields.update(static)
t0 = time.monotonic()
try:
result = await fn(*args, **kwargs)
except Exception as exc:
emit(event, outcome="error", error=repr(exc), duration_ms=_duration_ms(t0), **fields)
raise
if isinstance(result, dict):
for rk in result_keys:
if rk in result:
fields[rk] = result[rk]
emit(event, outcome=_outcome_of(result), duration_ms=_duration_ms(t0), **fields)
return result
return wrapper
return deco

View File

@@ -57,28 +57,42 @@ 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,
pc_escalate_issue,
pc_get_agent_health,
pc_get_recent_escalations,
pc_get_predecessor_context,
pc_get_predecessor_for_case,
pc_get_agents,
pc_get_agents_for_case,
pc_get_case_issues,
pc_pick_default_comment_target,
pc_get_issue_comments,
pc_get_issue_interactions,
pc_get_run_events,
pc_get_run_log,
pc_list_live_runs,
pc_post_comment,
pc_reap_stale_interactions,
pc_reject_interaction,
pc_request,
pc_reset_agent_session,
pc_reset_case_agents,
pc_respond_to_interaction,
pc_set_issue_status,
pc_restore_project,
pc_wake_analyst_for_appraiser_facts,
pc_wake_analyst_for_argument_aggregation,
pc_wake_analyst_for_protocol_analysis,
pc_get_generation_run_status,
rename_case_project,
pc_wake_ceo,
pc_wake_ceo_for_action,
pc_open_ceo_run,
pc_mark_comment_routed,
pc_wake_ceo_for_feedback_fold,
pc_wake_curator_for_final,
pc_wake_for_precedent_extraction,
@@ -100,19 +114,86 @@ 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)
# Watchdog kill-switch — set AGENT_ZOMBIE_AUTO_ESCALATE=0 to observe health
# without acting (escalation stays available manually via the endpoint).
_ZOMBIE_AUTO_ESCALATE = os.environ.get("AGENT_ZOMBIE_AUTO_ESCALATE", "1") != "0"
async def _zombie_escalation_loop():
"""Watchdog: auto-escalate persistent recovery-loop zombies to the chair (#218+#222).
Every 15 min, derive per-issue health (#222) and hand any persistent zombie
(a recovery loop, ``>=`` ``ZOMBIE_ESCALATE_MIN_RECOVERY`` recovery wakeups)
to the human via the loop-safe escalate primitive (#218) — closing the loop
that today burns budget until a human notices. Server-side on purpose: an
agent stuck in a recovery loop cannot reliably self-escalate. Idempotent —
an escalated issue is agent-unowned next sweep, so it is not re-escalated.
"""
from web.agent_health import zombie_escalation
while True:
try:
await asyncio.sleep(900)
if not _ZOMBIE_AUTO_ESCALATE:
continue
health = await pc_get_agent_health()
for item in health.get("items", []):
decision = zombie_escalation(item)
if decision is None:
continue
severity, reason = decision
result = await pc_escalate_issue(item["issue_id"], severity, reason)
if result.get("ok"):
logger.warning(
"watchdog auto-escalated %s (%s, %d recovery wakeups) to chair [%s]",
item.get("identifier"), item.get("agent_name"),
item.get("recovery_wakeups", 0), severity,
)
else:
logger.warning(
"watchdog escalation failed for %s: %s",
item.get("identifier"), result.get("error"),
)
except asyncio.CancelledError:
raise
except Exception:
logger.warning("zombie escalation 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())
watchdog_task = asyncio.create_task(_zombie_escalation_loop())
try:
yield
finally:
sync_task.cancel()
try:
await sync_task
except asyncio.CancelledError:
pass
reaper_task.cancel()
watchdog_task.cancel()
for _t in (sync_task, reaper_task, watchdog_task):
try:
await _t
except asyncio.CancelledError:
pass
await db.close_pool()
await _progress.close()
@@ -1828,6 +1909,16 @@ async def health():
return {"status": "ok"}
@app.get("/api/status-model")
async def api_status_model():
"""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."""
from legal_mcp.case_status_model import to_dict
return to_dict()
@app.get("/api/cases")
async def list_cases(
detail: bool = False,
@@ -2627,6 +2718,97 @@ async def api_get_legal_arguments(case_number: str, party: str = ""):
}
@app.get("/api/cases/{case_number}/protocol-analysis")
async def api_get_protocol_analysis(case_number: str):
"""Hearing-protocol comparative analysis for the "מה קרה בדיון" panel (#226).
Read-only surface over the case-knowledge produced by ``analyze_protocol``:
the hearing header (date + who appeared, from the canonical case columns)
plus the per-argument verdicts (strengthened / newly_raised / dropped)
grouped by party. Container-safe — pure DB reads, no LLM. Returns an empty
analysis (not an error) when the analysis has not been run yet.
"""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
case_id = UUID(case["id"])
rows = await db.list_protocol_analysis(case_id)
# Protocol title comes from the analysed document (all rows share one
# document_id per idempotent replace). Resolve it via the case's documents.
protocol_title = ""
protocol_document_id = ""
if rows:
protocol_document_id = rows[0].get("document_id") or ""
docs = await db.list_documents(case_id)
match = next(
(d for d in docs if str(d.get("id")) == protocol_document_id), None,
)
if match:
protocol_title = match.get("title") or ""
attendees = case.get("hearing_attendees") or {}
hearing_date = case.get("hearing_date")
header = {
"hearing_date": hearing_date.isoformat() if hearing_date else None,
"protocol_title": protocol_title,
"protocol_document_id": protocol_document_id,
"panel_members": attendees.get("panel_members") or [],
"appellants_present": attendees.get("appellants_present") or [],
"respondents_present": attendees.get("respondents_present") or [],
}
# Group verdicts by party for the panel's per-side sections, in display order.
by_party: dict[str, list[dict]] = {}
by_change: dict[str, int] = {}
for r in rows:
by_party.setdefault(r.get("party_role") or "", []).append(r)
ct = r.get("change_type", "")
by_change[ct] = by_change.get(ct, 0) + 1
return {
"case_number": case_number,
"total": len(rows),
"header": header,
"by_change": by_change,
"by_party": by_party,
"analysis": rows,
}
@app.post("/api/cases/{case_number}/analyze-protocol")
async def api_analyze_protocol(case_number: str, document_id: str = ""):
"""Queue hearing-protocol analysis by waking the legal-analyst agent (#226).
Same delegation rationale as ``aggregate-arguments``: ``analyze_protocol``
calls the local ``claude`` CLI, absent in this container, so we route to the
company's analyst rather than running a doomed in-container BackgroundTask.
Response: {"status": "queued", ...} or {"status": "skipped", "reason": ...}.
"""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
prefix = case_number[:1]
company_id = (
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
else ""
)
try:
result = await pc_wake_analyst_for_protocol_analysis(
case_number, company_id=company_id, document_id=document_id,
)
except Exception as e:
logger.exception("analyst wakeup failed for protocol analysis %s", case_number)
raise HTTPException(500, f"לא ניתן לשלוח לאנליטיקאי: {e}")
return result
@app.post("/api/cases/{case_number}/direction")
async def api_set_direction(case_number: str, req: DirectionRequest):
"""Save the approved direction document for the discussion block."""
@@ -2889,42 +3071,82 @@ async def api_learn(case_number: str):
# ── Local files API — research, drafts, proofread ──
# Directories under a case that are infrastructure/cache, not chair-facing
# content. Hidden dirs (``.git``, ``.claude`` …) are skipped separately.
_CASE_FILE_EXCLUDE_DIRS = {"thumbnails"}
def _is_content_dir(p: Path) -> bool:
return p.is_dir() and not p.name.startswith(".") and p.name not in _CASE_FILE_EXCLUDE_DIRS
def _case_file_folders(case_dir: Path) -> list[tuple[str, Path]]:
"""Enumerate every content subdirectory under a case dir as (key, path).
Covers *all* subfolders (not just research) — recursing one level into
``documents/`` — while skipping hidden (``.git``/``.claude``…) and cache
dirs. The ``key`` is the case-relative path with ``/`` encoded as ``__`` so
it survives a single URL path segment; ``_resolve_case_file`` re-validates it
against this same enumeration, so the key set is the serve allowlist (no path
traversal).
"""
folders: list[tuple[str, Path]] = []
if not case_dir.exists():
return folders
for child in sorted(case_dir.iterdir()):
if not _is_content_dir(child):
continue
if child.name == "documents":
folders.extend(
(f"documents__{sub.name}", sub)
for sub in sorted(child.iterdir())
if _is_content_dir(sub)
)
else:
folders.append((child.name, child))
return folders
def _resolve_case_file(case_dir: Path, folder_key: str, filename: str) -> Path | None:
"""Safely resolve (folder_key, filename) to a file inside the case dir.
Returns None unless ``folder_key`` is one of the enumerated content folders
AND ``filename`` resolves to a regular file directly inside it (no traversal).
"""
base = dict(_case_file_folders(case_dir)).get(folder_key)
if base is None:
return None
target = (base / filename).resolve()
if target.parent != base.resolve() or not target.is_file():
return None
return target
@app.get("/api/cases/{case_number}/local-files")
async def api_local_files(case_number: str):
"""List local files from case subdirectories (research, drafts, proofread)."""
"""List local files across ALL content subfolders under a case (not just
research) — so the chair can reach any agent-produced file (drafts, OCR text,
originals…) from the UI instead of only over SSH. Empty folders are returned
too (with an empty ``files`` list) so the UI can show them as such."""
case_dir = config.find_case_dir(case_number)
result = {}
for folder in ("research", "proofread"):
folder_path = case_dir / "documents" / folder
if folder_path.exists():
files = []
for f in sorted(folder_path.iterdir()):
if f.is_file() and not f.name.startswith("."):
stat = f.stat()
files.append({
"filename": f.name,
"size": stat.st_size,
"modified_at": stat.st_mtime,
"folder": folder,
})
if files:
result[folder] = files
# Drafts are at case level, not under documents
drafts_path = case_dir / "drafts"
if drafts_path.exists():
folders = []
for key, path in _case_file_folders(case_dir):
files = []
for f in sorted(drafts_path.iterdir()):
for f in sorted(path.iterdir()):
if f.is_file() and not f.name.startswith("."):
stat = f.stat()
files.append({
"filename": f.name,
"size": stat.st_size,
"modified_at": stat.st_mtime,
"folder": "drafts",
})
if files:
result["drafts"] = files
return result
folders.append({
"key": key,
"name": path.name,
"path": str(path.relative_to(case_dir)),
"files": files,
})
return {"folders": folders}
async def serve_blob(
@@ -2994,17 +3216,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
async def api_read_local_file(case_number: str, folder: str, filename: str):
"""Read contents of a local case file."""
if folder not in ("research", "proofread", "drafts"):
raise HTTPException(400, "Invalid folder")
"""Serve a single local case file from any content subfolder.
``folder`` is a key from ``/local-files`` (``documents__research``, ``drafts``…);
``_resolve_case_file`` validates it against the folder allowlist and blocks
path traversal before the bytes are served.
"""
case_dir = config.find_case_dir(case_number)
if folder == "drafts":
path = case_dir / "drafts" / filename
else:
path = case_dir / "documents" / folder / filename
if not path.exists() or not path.is_file():
path = _resolve_case_file(case_dir, folder, filename)
if path is None:
raise HTTPException(404, "קובץ לא נמצא")
return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename)
media_type = _local_file_media_type(filename)
return await serve_blob(path, media_type=media_type, filename=filename)
def _local_file_media_type(filename: str) -> str:
"""Best-effort content type for a served case file (text renders inline,
binaries download)."""
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return {
"md": "text/markdown; charset=utf-8",
"txt": "text/plain; charset=utf-8",
"json": "application/json; charset=utf-8",
"pdf": "application/pdf",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"doc": "application/msword",
}.get(ext, "application/octet-stream")
# ── Research analysis (analysis-and-research.md) — parse + edit ────
@@ -3080,6 +3317,140 @@ async def api_party_claims_summary(case_number: str):
return {"markdown": path.read_text(encoding="utf-8")}
# ── Deterministic document generation triggers (TaskMaster #214) ─────
# The "הפקת מסמכים" buttons in the case "טיוטות והערות" tab. Generation for the
# executive summary + interim draft runs HOST-SIDE (claude_session → claude -p,
# not in the container), so these endpoints fire a *structured-action* CEO wakeup
# (the CEO reads payload.action and routes to שלב H2 / שלב H — legal-ceo.md).
# Wakeup goes through the Paperclip API helper (pc_wake_ceo_for_action → the
# platform Port → POST /api/agents/{id}/wakeup), NEVER a direct DB insert.
# Polling for completion uses the existing read endpoints (research/party-claims-
# summary for the summary; the exports list for the טיוטה-טענות_הצדדים_*.docx file).
async def _wake_ceo_action(case_number: str, action: str) -> dict:
"""Resolve the case's company + fire a structured-action CEO wakeup (#214)."""
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
prefix = case_number[:1]
company_id = (
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
else ""
)
try:
return await pc_wake_ceo_for_action(case_number, action, company_id=company_id)
except Exception as e:
logger.warning("CEO action %s wakeup failed for %s: %s", action, case_number, e)
return {"status": "error", "error": str(e)}
@app.post("/api/cases/{case_number}/generate/party-claims-summary", status_code=202)
async def api_generate_party_claims_summary(case_number: str):
"""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."""
return await _wake_ceo_action(case_number, "party_claims_summary")
@app.post("/api/cases/{case_number}/generate/interim-draft", status_code=202)
async def api_generate_interim_draft(case_number: str):
"""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 טיוטה-טענות_הצדדים_{N}.docx."""
return await _wake_ceo_action(case_number, "interim_draft")
_GENERATE_ACTIONS = {"party_claims_summary", "interim_draft"}
# Run statuses (Paperclip heartbeat_runs.status) grouped for state mapping.
_RUN_ACTIVE = {"running", "in_progress", "queued", "pending", "scheduled", "created"}
_RUN_FAILED = {"cancelled", "failed", "error", "timed_out", "lost"}
def _generation_output_ready(case_number: str, action: str) -> bool:
"""Whether the generation artifact already exists on disk (host-side)."""
if action == "party_claims_summary":
from legal_mcp.services import party_claims_summary as pcs
return pcs.summary_file_path(case_number).exists()
# interim_draft → a "טיוטה-טענות_הצדדים_*.docx" file in the case exports dir.
export_dir = config.find_case_dir(case_number) / "exports"
if not export_dir.exists():
return False
return any(
f.is_file() and f.suffix.lower() == ".docx" and f.name.startswith("טיוטה-טענות_הצדדים_")
for f in export_dir.iterdir()
)
@app.get("/api/cases/{case_number}/generate/{action}/status")
async def api_generate_status(case_number: str, action: str):
"""Server-derived status of a "הפקת מסמכים" background generation (#227 ג).
Reconstructs {idle|queued|running|done|failed} from the CEO child issue + its
latest heartbeat_run (via the platform port) plus whether the output artifact
exists — so the UI indicator is correct after navigating away and back; it
never depends on the browser staying on the page. ``started_at``/``finished_at``
are server timestamps (ISO-8601 UTC) so the client renders a correct elapsed
time on return.
"""
if action not in _GENERATE_ACTIONS:
raise HTTPException(400, f"action לא תקין: {action}")
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
output_ready = _generation_output_ready(case_number, action)
try:
run = await pc_get_generation_run_status(case_number, action)
except Exception as e:
logger.warning("generation status lookup failed for %s/%s: %s", case_number, action, e)
run = {"issue_id": None}
run_status = (run.get("run_status") or "").lower()
started_at = run.get("started_at")
active = run_status in _RUN_ACTIVE or (
run.get("issue_status") == "in_progress"
and run_status not in _RUN_FAILED
and run_status not in {"completed", "succeeded", "done"}
and not run.get("finished_at")
)
# Precedence: an in-flight run wins; then a produced artifact; then a failed
# run; else idle. (A produced file reads as "done" even if a later retry
# failed — the artifact exists.)
if active:
state = "running" if started_at else "queued"
elif output_ready:
state = "done"
elif run_status in _RUN_FAILED:
state = "failed"
elif run_status in {"completed", "succeeded", "done"}:
# Ran to completion but produced no artifact → treat as a failure so the
# chair isn't left thinking it worked.
state = "failed"
else:
state = "idle"
detail = ""
if state == "failed":
if run.get("error_code") == "issue_assignee_changed":
detail = "הריצה בוטלה (הוקצתה מחדש)"
else:
detail = run.get("error") or "הריצה נכשלה"
return {
"case_number": case_number,
"action": action,
"state": state,
"started_at": started_at,
"finished_at": run.get("finished_at"),
"output_ready": output_ready,
"detail": detail,
"issue_id": run.get("issue_id"),
}
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
async def api_party_claims_summary_download(case_number: str):
"""Download the raw party-claims-summary.md file."""
@@ -4291,14 +4662,39 @@ async def api_case_agents(case_number: str):
class AgentCommentRequest(BaseModel):
body: str
issue_id: str | None = None
# "פתח הרצה חדשה" — skip the comment entirely and only open a CEO run. Kept for
# the agents-tab target selector; the default path below now opens a run too, so
# this only differs in *not* recording the instruction on the parent thread.
new_run: bool = False
@app.post("/api/cases/{case_number}/agents/comment")
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
"""Post a comment on a Paperclip issue linked to a case.
"""Post a chair instruction to a Paperclip issue linked to a case.
If issue_id is omitted, the most recent non-done issue is used.
Routing (agents-tab target selector):
- ``new_run=True`` → open a fresh CEO-owned run (child issue assigned to the
CEO + wakeup), sidestepping the ``issue_assignee_changed`` cancellation on
a human-owned parent. Overrides ``issue_id``.
- ``issue_id`` given → post to that issue (explicit chair choice).
- neither → prefer the live CEO main issue, never a closed one
(``pick_default_comment_target``).
"""
if req.new_run:
result = await pc_open_ceo_run(case_number, req.body)
if result.get("status") != "ok":
raise HTTPException(
502,
f"פתיחת הרצה חדשה נכשלה: {result.get('reason', 'unknown')}",
)
return {
"comment_id": None,
"issue_id": result["issue_id"],
"issue_identifier": result.get("identifier", ""),
"issue_status": "in_progress",
"new_run": True,
}
issues = await pc_get_case_issues(case_number)
if not issues:
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
@@ -4308,14 +4704,35 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
if not target:
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
else:
# Pick the most recent non-done issue, or the last one
active = [i for i in issues if i["status"] != "done"]
target = active[-1] if active else issues[-1]
target = pc_pick_default_comment_target(issues)
result = await pc_post_comment(target["id"], target["company_id"], req.body)
# Find the identifier for the response
# The comment alone reaches nobody: a case issue awaiting the chair is
# `in_review` and owned by a human, so Paperclip cancels any wakeup on it
# (`issue_assignee_changed` → "wake the new owner" → the owner is a person).
# The plugin sweep that was meant to catch this delivers the instruction as
# `payload.prompt`, which the runner drops. So the comment is recorded for the
# thread, and the instruction is carried by a CEO-owned child run — the one
# path verified to reach the agent (TaskMaster #228; see paperclip-quirks.md).
run = await pc_open_ceo_run(case_number, req.body)
if run.get("status") != "ok":
raise HTTPException(
502,
f"ההערה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא יטפל בה: "
f"{run.get('reason', 'unknown')}",
)
# Claim the comment so the sweep does not re-route it: the CEO answers on the
# child issue, leaving the parent with no agent reply, which the sweep reads
# as "still pending" forever.
await pc_mark_comment_routed(target["id"], result.get("comment_id", ""))
# Echo the resolved target so the UI can show where it landed and flag it
# when the chair explicitly targeted a closed issue (whose wakeup is skipped).
result["issue_identifier"] = target.get("identifier", "")
result["issue_status"] = target.get("status", "")
result["run_issue_id"] = run["issue_id"]
result["run_issue_identifier"] = run.get("identifier", "")
return result
@@ -4332,8 +4749,10 @@ async def api_post_interaction_response(
):
"""Submit a user's answer to a Paperclip issue-thread interaction.
Routes to /respond | /accept | /reject based on `action`. Paperclip
auto-wakes the issue assignee after a successful submission.
Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
carrying the answer. Paperclip's own `wake_assignee` cannot deliver it: the
issue is owned by the chair while it waits for her, so the "assignee" it wakes
is a person and no agent ever runs (TaskMaster #228).
"""
issues = await pc_get_case_issues(case_number)
if not any(i["id"] == req.issue_id for i in issues):
@@ -4345,7 +4764,7 @@ async def api_post_interaction_response(
"reject": pc_reject_interaction,
}
try:
return await handlers[req.action](
result = await handlers[req.action](
req.issue_id, req.interaction_id, req.payload,
)
except httpx.HTTPStatusError as e:
@@ -4354,6 +4773,47 @@ async def api_post_interaction_response(
except Exception as e:
raise HTTPException(502, f"שגיאת Paperclip: {e}")
answer = json.dumps(req.payload, ensure_ascii=False)
run = await pc_open_ceo_run(
case_number,
f'תשובת היו"ר לשאלה שהוצגה לה ({req.action}):\n\n{answer}\n\n'
f"קרא את ה-interaction המקורי על ה-issue והמשך משם.",
)
if run.get("status") != "ok":
raise HTTPException(
502,
f"התשובה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא ימשיך: "
f"{run.get('reason', 'unknown')}",
)
if isinstance(result, dict):
result["run_issue_id"] = run["issue_id"]
result["run_issue_identifier"] = run.get("identifier", "")
return result
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):
@@ -4368,6 +4828,53 @@ async def api_reset_case_agents(case_number: str):
return result
class EscalateRequest(BaseModel):
issue_id: str
severity: Literal["critical", "high", "medium"]
reason: str
@app.post("/api/cases/{case_number}/agents/escalate")
async def api_escalate_issue(case_number: str, req: EscalateRequest):
"""Escalate a single stuck issue to the chair (#218).
The loop-safe alternative to leaving an issue agent-owned+blocked: hands the
issue to the human in one atomic transition (in_review + assignee_user_id)
and records a severity note, without re-waking any agent. Emits an
``agent.escalated`` telemetry event via the Port.
"""
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_escalate_issue(req.issue_id, req.severity, req.reason)
if not result.get("ok"):
raise HTTPException(400, result.get("error", "ההסלמה נכשלה"))
return result
class IssueStatusRequest(BaseModel):
issue_id: str
status: Literal["done", "cancelled"]
@app.post("/api/cases/{case_number}/agents/issue-status")
async def api_set_issue_status(case_number: str, req: IssueStatusRequest):
"""Manually close a board issue to done/cancelled from the chair UI.
Lets the chair tidy the agents board by hand — superseded "הרצה חדשה" runs
left in ``in_review`` had no manual close control and piled up. Only the two
terminal statuses are accepted; the loop-safe direct-DB close lives behind the
Port (``pc_set_issue_status``) and issues no wakeup.
"""
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_set_issue_status(req.issue_id, req.status)
if not result.get("ok"):
raise HTTPException(400, result.get("error", "עדכון הסטטוס נכשל"))
return result
# ── Settings: MCP Server Configuration ────────────────────────────
#
# Source of truth for legal-ai env vars is Coolify (see memory:
@@ -5621,13 +6128,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}")
@@ -5652,6 +6167,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(
@@ -5668,13 +6190,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:
@@ -7187,6 +7730,50 @@ async def operations_agents():
}
@app.get("/api/operations/agents/health")
async def operations_agent_health():
"""Per-issue agent health taxonomy (#222): zombie / stalled / working / idle.
Read-only. Surfaces the stranded-child / recovery-loop cases (``zombie``)
automatically — the ``gt feed --problems`` idea grounded in our failure
modes — so they no longer need hand-querying the Paperclip DB. Consumed by
the /operations dashboard (UI is design-gated, follow-up).
"""
return await pc_get_agent_health()
@app.get("/api/operations/agents/escalations")
async def operations_agent_escalations(limit: int = 10, hours: int = 24):
"""Recent chair escalations — watchdog + manual (#218/#222).
Read-only history of what was handed to the chair, for the ops health panel.
"""
return await pc_get_recent_escalations(limit, hours)
@app.get("/api/operations/issues/{issue_id}/predecessor")
async def operations_issue_predecessor(issue_id: str, limit: int = 3):
"""Recent finished runs on an issue for session continuation (#220, "seance").
Read-only. Lets a fresh heartbeat read what its predecessors on the same
issue concluded (the run ``summary`` each leaves) instead of rediscovering
context from scratch. Agent-facing wiring (MCP tool + HEARTBEAT) is a
follow-up; this is the backend it will call.
"""
return await pc_get_predecessor_context(issue_id, limit)
@app.get("/api/operations/cases/{case_number}/predecessor")
async def operations_case_predecessor(case_number: str, limit: int = 5):
"""Recent finished runs across a case's issues (#220, agent-facing "seance").
Case-scoped sibling of the by-issue endpoint — the plugin tool
``legal_predecessor_context`` calls this so a resuming agent reads what prior
sessions on the case concluded (agents key on case_number, not issue UUID).
"""
return await pc_get_predecessor_for_case(case_number, limit)
@app.get("/api/operations/agents/runs/{run_id}/log")
async def operations_agent_run_log(run_id: str):
"""Full output log (NDJSON stream) of one heartbeat run."""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
"""Tests for #222 — agent health taxonomy (pure classifier + Paperclip fetch).
Pure-classifier tests need no deps. The fetch test fakes an asyncpg connection
(routing the three queries by content) so it never touches the live DB.
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
from agent_health import ( # noqa: E402
STALL_WAKEUP_THRESHOLD,
classify_issue_health,
is_recovery_reason,
zombie_escalation,
)
from agent_health import ZOMBIE_ESCALATE_MIN_RECOVERY # noqa: E402
# ── pure classifier ────────────────────────────────────────────────────────
def test_live_run_is_working_regardless_of_wakeups():
assert classify_issue_health(has_live_run=True, recovery_wakeups=9, total_wakeups=9) == "working"
def test_recovery_wakeup_without_live_run_is_zombie():
# the stranded-child signature: assigned+open, nothing running, recovery firing
assert classify_issue_health(has_live_run=False, recovery_wakeups=1, total_wakeups=1) == "zombie"
def test_repeated_wakeups_no_recovery_is_stalled():
assert (
classify_issue_health(
has_live_run=False, recovery_wakeups=0, total_wakeups=STALL_WAKEUP_THRESHOLD
)
== "stalled"
)
def test_quiet_open_issue_is_idle():
assert classify_issue_health(has_live_run=False, recovery_wakeups=0, total_wakeups=1) == "idle"
# ── watchdog escalation policy (pure) ───────────────────────────────────────
def _item(health, recovery):
return {"health": health, "recovery_wakeups": recovery, "identifier": "CMP-141", "agent_name": "writer"}
def test_zombie_escalation_medium_for_fresh_loop():
decision = zombie_escalation(_item("zombie", ZOMBIE_ESCALATE_MIN_RECOVERY))
assert decision is not None
severity, reason = decision
assert severity == "medium"
assert "CMP-141" in reason and "recovery" in reason.lower()
def test_zombie_escalation_high_for_long_loop():
severity, _ = zombie_escalation(_item("zombie", 5))
assert severity == "high"
def test_zombie_below_threshold_is_not_escalated():
assert zombie_escalation(_item("zombie", ZOMBIE_ESCALATE_MIN_RECOVERY - 1)) is None
def test_non_zombie_never_escalates():
assert zombie_escalation(_item("stalled", 9)) is None
assert zombie_escalation(_item("idle", 0)) is None
assert zombie_escalation(_item("working", 9)) is None
def test_is_recovery_reason_markers():
assert is_recovery_reason("source_scoped_recovery_action")
assert is_recovery_reason("issue_continuation_needed")
assert is_recovery_reason("issue_reopened_via_comment")
assert not is_recovery_reason("start_workflow_8125-09-24")
assert not is_recovery_reason(None)
# ── Paperclip fetch (fake DB) ───────────────────────────────────────────────
os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip")
pc = pytest.importorskip("paperclip_client", reason="web deps unavailable")
class _FakeConn:
def __init__(self, issues, live, wakeups):
self._issues, self._live, self._wakeups = issues, live, wakeups
async def fetch(self, query, *args):
if "FROM issues" in query:
return self._issues
if "heartbeat_runs" in query:
return self._live
if "agent_wakeup_requests" in query:
return self._wakeups
raise AssertionError(f"unexpected query: {query[:40]}")
async def close(self):
pass
def test_get_agent_health_classifies_and_sorts(monkeypatch):
# zombie agent A (recovery wake, no live run); working agent B (live run);
# idle agent C (quiet).
issues = [
{"id": "iA", "identifier": "CMP-1", "status": "blocked", "assignee_agent_id": "agA", "agent_name": "writer"},
{"id": "iB", "identifier": "CMP-2", "status": "in_progress", "assignee_agent_id": "agB", "agent_name": "analyst"},
{"id": "iC", "identifier": "CMP-3", "status": "todo", "assignee_agent_id": "agC", "agent_name": "qa"},
]
live = [{"agent_id": "agB"}]
wakeups = [
{"agent_id": "agA", "issue_id": "iA", "reason": "source_scoped_recovery_action"},
{"agent_id": "agC", "issue_id": "iC", "reason": "start_workflow_x"},
]
async def _connect(_url):
return _FakeConn(issues, live, wakeups)
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
result = asyncio.run(pc.get_agent_health())
assert result["ok"] is True
by_issue = {it["issue_id"]: it["health"] for it in result["items"]}
assert by_issue == {"iA": "zombie", "iB": "working", "iC": "idle"}
assert result["counts"]["zombie"] == 1
assert result["counts"]["working"] == 1
assert result["counts"]["idle"] == 1
# worst-first: zombie leads
assert result["items"][0]["health"] == "zombie"
assert result["items"][0]["recovery_wakeups"] == 1
def test_get_agent_health_empty(monkeypatch):
async def _connect(_url):
return _FakeConn([], [], [])
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
result = asyncio.run(pc.get_agent_health())
assert result == {"ok": True, "items": [], "counts": {"zombie": 0, "stalled": 0, "working": 0, "idle": 0}}
# ── recent escalations (fake DB) — parses severity + reason from system notes ──
class _FetchConn:
def __init__(self, rows):
self._rows = rows
async def fetch(self, query, *args):
return self._rows
async def close(self):
pass
def test_get_recent_escalations_parses_severity_and_reason(monkeypatch):
from datetime import datetime
rows = [
{
"issue_id": "iss-1",
"identifier": "CMP-141",
"body": "🚨 הסלמה [high] לחיים\n\nזוהתה לולאת-recovery — 4 יקיצות-שחזור; נמסר לחיים.",
"created_at": datetime(2026, 7, 7, 3, 30, 0),
},
{
"issue_id": "iss-2",
"identifier": "CMPA-112",
"body": "🚨 הסלמה [medium] לחיים\n\nזוהתה לולאת-recovery — 2 יקיצות.",
"created_at": datetime(2026, 7, 7, 2, 49, 0),
},
]
async def _connect(_url):
return _FetchConn(rows)
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
result = asyncio.run(pc.get_recent_escalations())
assert result["ok"] is True
a, b = result["items"]
assert a["identifier"] == "CMP-141" and a["severity"] == "high"
assert "4 יקיצות" in a["reason"] and "🚨" not in a["reason"]
assert a["created_at"] == "2026-07-07T03:30:00"
assert b["severity"] == "medium"
def test_get_recent_escalations_defaults_severity_when_untagged(monkeypatch):
rows = [{"issue_id": "x", "identifier": "CMP-9", "body": "🚨 הסלמה לחיים", "created_at": None}]
async def _connect(_url):
return _FetchConn(rows)
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
(item,) = asyncio.run(pc.get_recent_escalations())["items"]
assert item["severity"] == "medium" # fallback
assert item["created_at"] is None
# ── predecessor by case (#220 agent-facing) — resolve project then shape runs ──
class _ProjectRunsConn:
def __init__(self, project, runs):
self._project, self._runs = project, runs
async def fetchrow(self, query, *args):
return self._project
async def fetch(self, query, *args):
return self._runs
async def close(self):
pass
def test_get_predecessor_for_case_shapes_runs(monkeypatch):
from datetime import datetime
runs = [
{
"id": "run-1", "status": "succeeded",
"started_at": datetime(2026, 7, 5, 10, 0, 0),
"finished_at": datetime(2026, 7, 5, 10, 5, 0),
"summary": "הלולאה נסגרה כראוי", "error_code": None,
"session_id_after": "sess-9", "agent_name": "עוזר משפטי",
"identifier": "CMP-141",
},
]
async def _connect(_url):
return _ProjectRunsConn({"id": "proj-1"}, runs)
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
result = asyncio.run(pc.get_predecessor_for_case("1043-02-26"))
assert result["ok"] is True and result["case_number"] == "1043-02-26"
(run,) = result["runs"]
assert run["run_id"] == "run-1"
assert run["identifier"] == "CMP-141" # by-case adds which issue
assert run["started_at"] == "2026-07-05T10:00:00"
assert "הלולאה" in run["summary"]
def test_get_predecessor_for_case_unknown_case_is_empty(monkeypatch):
async def _connect(_url):
return _ProjectRunsConn(None, []) # no project row
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
result = asyncio.run(pc.get_predecessor_for_case("9999-99-99"))
assert result == {"ok": True, "case_number": "9999-99-99", "runs": []}

View File

@@ -0,0 +1,104 @@
"""Tests for #219 — web.agent_telemetry structured agent-platform events.
Verifies the emitter contract the recovery-loop observability relies on:
- ``emit`` stamps ts/v/event/outcome and drops ``None`` fields.
- ``instrument`` lifts standard + result keys, times the call, and derives the
outcome (ok / noop for ``{"ok": False}`` / error on exception).
- an exception is re-raised after the error event — the wrapped op is **never
silently swallowed** (constitution §6 / INV-G4).
- the decorator is transparent: wrapped functions stay coroutine functions.
Pure-stdlib module (no Paperclip/web deps), so this runs without importorskip.
"""
from __future__ import annotations
import asyncio
import inspect
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
from agent_telemetry import SCHEMA_VERSION, emit, instrument # noqa: E402
import agent_telemetry # noqa: E402
@pytest.fixture
def captured(monkeypatch):
"""Intercept the sink; yield the list of emitted event dicts."""
events: list[dict] = []
monkeypatch.setattr(agent_telemetry, "_SINK", events.append)
return events
def test_emit_stamps_schema_and_drops_none(captured):
emit("x.test", role="ceo", case_number="8125-09-24", issue_id=None)
(e,) = captured
assert e["event"] == "x.test"
assert e["v"] == SCHEMA_VERSION
assert e["outcome"] == "ok"
assert e["ts"].endswith("Z")
assert e["case_number"] == "8125-09-24"
assert "issue_id" not in e # None fields dropped
def test_instrument_success_lifts_result_keys(captured):
@instrument("interaction.reaped", result_keys=("cancelled",))
async def reap():
return {"ok": True, "cancelled": 3}
result = asyncio.run(reap())
assert result == {"ok": True, "cancelled": 3}
(e,) = captured
assert e["event"] == "interaction.reaped"
assert e["outcome"] == "ok"
assert e["cancelled"] == 3
assert "duration_ms" in e
def test_instrument_ok_false_is_noop(captured):
@instrument("interaction.cancelled", keys=("issue_id",))
async def cancel(issue_id, interaction_id):
return {"ok": False, "error": "already resolved"}
asyncio.run(cancel("iss-1", "int-9"))
(e,) = captured
assert e["outcome"] == "noop"
assert e["issue_id"] == "iss-1"
def test_instrument_reraises_after_error_event(captured):
@instrument("agent.wakeup", role="ceo")
async def wake(case_number, company_id=""):
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
asyncio.run(wake("1043-04-26", company_id="cmp"))
(e,) = captured
assert e["outcome"] == "error"
assert "boom" in e["error"]
assert e["agent_role"] == "ceo"
assert e["case_number"] == "1043-04-26"
assert e["company_id"] == "cmp"
def test_decorator_is_transparent():
@instrument("agent.wakeup", role="analyst")
async def wake(case_number, company_id=""):
return {"ok": True}
assert inspect.iscoroutinefunction(wake)
assert wake.__name__ == "wake"
def test_emit_never_raises_on_sink_failure(monkeypatch):
def boom(_payload):
raise ValueError("sink down")
monkeypatch.setattr(agent_telemetry, "_SINK", boom)
# Must not propagate — telemetry is best-effort, never fatal to the op.
emit("agent.wakeup", role="ceo")

View File

@@ -0,0 +1,78 @@
"""Tests for pick_default_comment_target — chair-comment routing.
Regression guard for the recurring bug where a chair instruction typed into the
"כתוב הוראה לסוכנים..." box was routed to a *cancelled* child issue, so
Paperclip skipped the wakeup and the instruction was silently swallowed.
The picker must never default to a closed (done/cancelled) issue and must prefer
the live top-level CEO issue. Pure function — no DB, no FastAPI.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip")
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
pc = pytest.importorskip("paperclip_client", reason="web deps unavailable")
def _iss(id_, status, parent_id=None):
return {"id": id_, "status": status, "parent_id": parent_id, "identifier": id_}
def test_prefers_open_top_level_over_newer_cancelled_child():
"""The exact 1027-04-26 scenario: newest non-done issue is a cancelled child;
routing must still land on the live top-level CEO issue."""
issues = [
_iss("main-old", "done"), # oldest top-level, closed
_iss("main-live", "in_review"), # live CEO main issue
_iss("child-cancelled", "cancelled", "main-live"), # newest non-done → the trap
_iss("child-done", "done", "main-live"),
]
assert pc.pick_default_comment_target(issues)["id"] == "main-live"
def test_never_returns_cancelled_when_an_open_issue_exists():
issues = [
_iss("a", "done"),
_iss("b", "in_progress", "a"),
_iss("c", "cancelled", "a"),
]
assert pc.pick_default_comment_target(issues)["status"] != "cancelled"
def test_falls_back_to_open_child_when_no_open_top_level():
issues = [
_iss("main", "done"),
_iss("child-live", "in_progress", "main"),
]
assert pc.pick_default_comment_target(issues)["id"] == "child-live"
def test_prefers_top_level_when_everything_closed():
"""No open issue anywhere — pick the newest top-level (better than a child)."""
issues = [
_iss("main-1", "done"),
_iss("main-2", "cancelled"),
_iss("child", "done", "main-2"),
]
assert pc.pick_default_comment_target(issues)["id"] == "main-2"
def test_single_issue():
issues = [_iss("only", "cancelled")]
assert pc.pick_default_comment_target(issues)["id"] == "only"
def test_picks_newest_open_top_level():
issues = [
_iss("main-1", "in_review"),
_iss("main-2", "in_review"),
]
assert pc.pick_default_comment_target(issues)["id"] == "main-2"

View File

@@ -0,0 +1,118 @@
"""Tests for #218 — paperclip_client.escalate_issue (loop-safe chair escalation).
Verifies the primitive that replaces the fragile manual PATCH dance:
- invalid severity is rejected before any DB work.
- the happy path performs ONE atomic human-owned transition
(``status='in_review'``, ``assignee_user_id=CHAIM_USER_ID``) and records a
``author_type='system'`` severity note carrying the reason.
- **no wakeup / REST call is issued** — escalation hands off to the human, it
must never re-invoke an agent (that is what caused the recovery loops).
- a missing issue reports ``ok:False`` and writes no comment.
Uses a fake asyncpg connection — never touches the live Paperclip DB.
"""
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
import pytest
os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip")
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
pc = pytest.importorskip("paperclip_client", reason="web deps unavailable")
class _FakeTxn:
async def __aenter__(self):
return None
async def __aexit__(self, *exc):
return False
class _FakeConn:
def __init__(self, row):
self._row = row
self.updates: list[tuple] = []
self.inserts: list[tuple] = []
self.closed = False
def transaction(self):
return _FakeTxn()
async def fetchrow(self, query, *args):
self.updates.append((query, args))
return self._row
async def execute(self, query, *args):
self.inserts.append((query, args))
async def close(self):
self.closed = True
@pytest.fixture
def fake_db(monkeypatch):
"""Patch asyncpg.connect + guard that no wakeup/REST is issued."""
conns: list[_FakeConn] = []
row = {"id": "iss-uuid", "identifier": "CMP-141", "company_id": "cmp-uuid"}
holder = {"row": row}
async def _connect(_url):
conn = _FakeConn(holder["row"])
conns.append(conn)
return conn
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
async def _forbidden(*a, **k): # any REST/wakeup would be a loop-risk
raise AssertionError("escalate_issue must not issue a wakeup/REST call")
monkeypatch.setattr(pc, "pc_request", _forbidden, raising=False)
return {"conns": conns, "holder": holder}
def test_invalid_severity_rejected_before_db(fake_db):
result = asyncio.run(pc.escalate_issue("iss-1", "urgent", "boom"))
assert result["ok"] is False
assert "invalid severity" in result["error"]
assert fake_db["conns"] == [] # never connected
def test_happy_path_atomic_transition_and_system_note(fake_db):
result = asyncio.run(
pc.escalate_issue("iss-uuid", "high", "analyst wedged on protocol parse")
)
assert result == {
"ok": True,
"id": "iss-uuid",
"identifier": "CMP-141",
"severity": "high",
"status": "in_review",
}
conn = fake_db["conns"][0]
# 1) one human-owned UPDATE with the chair user + in_review
(update_sql, update_args) = conn.updates[0]
assert "status='in_review'" in update_sql
assert "assignee_agent_id=null" in update_sql
assert update_args[0] == pc.CHAIM_USER_ID
assert update_args[1] == "iss-uuid"
# 2) a system-authored severity note carrying the reason
(insert_sql, insert_args) = conn.inserts[0]
assert "issue_comments" in insert_sql
assert "'system'" in insert_sql
assert "high" in insert_args[3] and "wedged on protocol parse" in insert_args[3]
assert conn.closed is True
def test_missing_issue_reports_not_found_and_writes_no_comment(fake_db):
fake_db["holder"]["row"] = None
result = asyncio.run(pc.escalate_issue("ghost", "medium", "nope"))
assert result["ok"] is False and "not found" in result["error"]
conn = fake_db["conns"][0]
assert conn.inserts == [] # no comment on a non-existent issue

View File

@@ -0,0 +1,84 @@
"""Tests for #220 — paperclip_client.get_predecessor_context ("seance").
Fakes an asyncpg connection so it never touches the live DB. Verifies the
limit clamp, row shaping (timestamps → isoformat), and the empty case.
"""
from __future__ import annotations
import asyncio
import os
import sys
from datetime import datetime
from pathlib import Path
import pytest
os.environ.setdefault("PAPERCLIP_DB_URL", "postgres://x:x@127.0.0.1:54329/paperclip")
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/
pc = pytest.importorskip("paperclip_client", reason="web deps unavailable")
class _FakeConn:
def __init__(self, rows):
self._rows = rows
self.calls: list[tuple] = []
async def fetch(self, query, *args):
self.calls.append((query, args))
return self._rows
async def close(self):
pass
def _row():
return {
"id": "run-1",
"status": "succeeded",
"started_at": datetime(2026, 7, 5, 10, 0, 0),
"finished_at": datetime(2026, 7, 5, 10, 5, 0),
"summary": "כתבתי בלוק ז — 3,206 מילים",
"error_code": None,
"session_id_after": "sess-abc",
"agent_name": "writer",
}
@pytest.fixture
def fake(monkeypatch):
holder = {"rows": [_row()], "conn": None}
async def _connect(_url):
holder["conn"] = _FakeConn(holder["rows"])
return holder["conn"]
monkeypatch.setattr(pc.asyncpg, "connect", _connect)
return holder
def test_shapes_runs_newest_first(fake):
result = asyncio.run(pc.get_predecessor_context("iss-1"))
assert result["ok"] is True and result["issue_id"] == "iss-1"
(run,) = result["runs"]
assert run["run_id"] == "run-1"
assert run["status"] == "succeeded"
assert run["started_at"] == "2026-07-05T10:00:00"
assert run["finished_at"] == "2026-07-05T10:05:00"
assert "בלוק ז" in run["summary"]
assert run["session_id"] == "sess-abc"
assert run["agent_name"] == "writer"
def test_limit_is_clamped(fake):
asyncio.run(pc.get_predecessor_context("iss-1", limit=99))
assert fake["conn"].calls[0][1] == ("iss-1", 10) # clamped high
asyncio.run(pc.get_predecessor_context("iss-1", limit=0))
assert fake["conn"].calls[0][1] == ("iss-1", 1) # clamped low
def test_empty(fake):
fake["rows"] = []
result = asyncio.run(pc.get_predecessor_context("ghost"))
assert result == {"ok": True, "issue_id": "ghost", "runs": []}