42 Commits

Author SHA1 Message Date
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
33 changed files with 4060 additions and 289 deletions

View File

@@ -101,6 +101,8 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
``` ```
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי. **אסור** לפענח טקסט מ-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** ## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**

View File

@@ -226,7 +226,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
### שלב 0: בדוק למה התעוררת ### שלב 0: בדוק למה התעוררת
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`): **לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$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 מכיל `user_commented` **ו**-`$PAPERCLIP_WAKE_PAYLOAD_JSON` כולל `issueId`**דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"** עם ה-issue הזה. אל תסרוק תיקים אחרים. **טפל רק בתגובה.**
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים - אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
- אם ה-reason מכיל `precedent_extraction_`**דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה. - אם ה-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="...") 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`): 5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
``` ```
## טיוטת ביניים מוכנה — ערר {case_number} ## טיוטת ביניים מוכנה — ערר {case_number}
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx` 📄 **קובץ:** `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`
### מה כלול ### מה כלול
| בלוק | כותרת | מילים | | בלוק | כותרת | מילים |

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`). - **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי. - **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
- TaskMaster: `legal-ai` #149. - 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

@@ -221,7 +221,7 @@ async def aggregate_claims_to_arguments(
# Pull all claims for this case, grouped by party. # Pull all claims for this case, grouped by party.
rows = await conn.fetch( 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 FROM claims
WHERE case_id = $1 WHERE case_id = $1
ORDER BY party_role, claim_index""", ORDER BY party_role, claim_index""",
@@ -249,10 +249,13 @@ async def aggregate_claims_to_arguments(
# Map deprecated 'appraiser' or unknown labels to 'unknown'. # Map deprecated 'appraiser' or unknown labels to 'unknown'.
if party not in ALLOWED_PARTIES: if party not in ALLOWED_PARTIES:
party = "unknown" party = "unknown"
party_name = ( # Prefer the stored party_name (stamped by the extractor, #224); fall
(r["source_document"] or "").strip() # back to source_document for legacy claims predating the stamping.
if party in SPLIT_PARTIES else "" # 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)) by_group.setdefault((party, party_name), []).append(dict(r))
# Valid claim_ids for this case == the ids of the claims we just fetched. # Valid claim_ids for this case == the ids of the claims we just fetched.

View File

@@ -192,16 +192,16 @@ BLOCK_PROMPTS = {
## כללים קריטיים: ## כללים קריטיים:
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד. - **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..." - גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
- **מבנה קבוע עם 3 חלקים:** - **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא - "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
2. "עמדת הוועדה המקומית" — 5-8 סעיפים - **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים - "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
- כותרת: "תמצית טענות הצדדים" - כותרת: "תמצית טענות הצדדים"
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות - נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה") - אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
- רק מכתבי טענות מקוריים (לא השלמות טיעון) - רק מכתבי טענות מקוריים (לא השלמות טיעון)
- מספור רציף - מספור רציף
- **יעד אורך: 800-1500 מילים** - **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
## טענות שחולצו (קבץ טענות דומות לנושאים): ## טענות שחולצו (קבץ טענות דומות לנושאים):
{claims_context} {claims_context}
@@ -284,6 +284,7 @@ BLOCK_PROMPTS = {
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל" - **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
- **מספור רציף** — המשך מספור מהבלוק הקודם - **מספור רציף** — המשך מספור מהבלוק הקודם
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין - מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
## כיוון מאושר (חובה): ## כיוון מאושר (חובה):
{direction_context} {direction_context}
@@ -629,18 +630,49 @@ async def _build_claims_context(case_id: UUID) -> str:
# Fallback to all non-block-zayin claims if no source claims exist. # 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 source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
lines = [] # Group by (party_role, brief) so a multi-litigant side (respondent /
current_role = "" # 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": "טענות המשיבים", role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"} "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: for c in source_claims:
if c["party_role"] != current_role: role = c.get("party_role", "") or ""
current_role = c["party_role"] # Prefer the stored party_name (stamped by the extractor, #224); fall
lines.append(f"\n### {role_heb.get(current_role, current_role)}") # back to source_document for legacy claims predating the stamping.
claim_num += 1 if role in SPLIT_PARTIES:
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}") brief = (c.get("party_name") or "").strip() or (c.get("source_document") or "").strip()
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**") 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) return "\n".join(lines)

View File

@@ -368,8 +368,15 @@ async def extract_and_store_claims(
# Determine claim_type from document type and title # Determine claim_type from document type and title
claim_type = _infer_claim_type(doc_type, source_name) 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: for c in claims:
c["claim_type"] = claim_type 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) stored = await db.store_claims(case_id, claims, source_document=source_name)
# Mark this document analysed (WS2 / #201). store_claims already replaced # Mark this document analysed (WS2 / #201). store_claims already replaced

View File

@@ -1900,6 +1900,38 @@ CREATE INDEX IF NOT EXISTS idx_legal_arguments_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 # Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services) # schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes # re-runs the idempotent migrations on startup; without this lock two processes
@@ -1972,6 +2004,8 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V48_SQL) await conn.execute(SCHEMA_V48_SQL)
await conn.execute(SCHEMA_V49_SQL) await conn.execute(SCHEMA_V49_SQL)
await conn.execute(SCHEMA_V50_SQL) await conn.execute(SCHEMA_V50_SQL)
await conn.execute(SCHEMA_V51_SQL)
await conn.execute(SCHEMA_V52_SQL)
async def init_schema() -> None: async def init_schema() -> None:
@@ -2194,7 +2228,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
set_clauses = [] set_clauses = []
values = [] values = []
for i, (key, val) in enumerate(fields.items(), start=2): 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) val = json.dumps(val)
set_clauses.append(f"{key} = ${i}") set_clauses.append(f"{key} = ${i}")
values.append(val) values.append(val)
@@ -2207,7 +2241,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
def _row_to_case(row: asyncpg.Record) -> dict: def _row_to_case(row: asyncpg.Record) -> dict:
d = dict(row) d = dict(row)
for field in ("appellants", "respondents", "tags"): for field in ("appellants", "respondents", "tags", "hearing_attendees"):
if isinstance(d.get(field), str): if isinstance(d.get(field), str):
d[field] = json.loads(d[field]) d[field] = json.loads(d[field])
d["id"] = str(d["id"]) d["id"] = str(d["id"])

View File

@@ -399,8 +399,15 @@ _INTERIM_BLOCK_ORDER = [
] ]
def _draft_filename_prefix(mode: str) -> str: def _draft_naming(mode: str) -> tuple[str, str]:
return "טיוטת-ביניים" if mode == "interim" else "טיוטה" """(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( async def export_decision(
@@ -485,16 +492,19 @@ async def export_decision(
if not output_path: if not output_path:
export_dir = config.find_case_dir(case["case_number"]) / "exports" export_dir = config.find_case_dir(case["case_number"]) / "exports"
export_dir.mkdir(parents=True, exist_ok=True) export_dir.mkdir(parents=True, exist_ok=True)
prefix = _draft_filename_prefix(mode) prefix, sep = _draft_naming(mode)
existing = sorted(export_dir.glob(f"{prefix}-v*.docx")) existing = sorted(export_dir.glob(f"{prefix}{sep}*.docx"))
next_ver = 1 next_ver = 1
for p in existing: for p in existing:
try: 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) next_ver = max(next_ver, ver + 1)
except (IndexError, ValueError): except (IndexError, ValueError):
pass 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 # Persist through the storage layer (INV-STG1). Under the filesystem
# backend the bytes land at output_path exactly as before; a caller- # backend the bytes land at output_path exactly as before; a caller-

View File

@@ -259,13 +259,25 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
except ValueError: except ValueError:
logger.info("protocol header: unparseable hearing_date %r", hearing_date) 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 { return {
"status": "ok", "status": "ok",
"feed": { "feed": {
"hearing_date": hearing_date, "hearing_date": hearing_date,
"panel_members": feed.get("panel_members") or [], **attendees,
"appellants_present": feed.get("appellants_present") or [],
"respondents_present": feed.get("respondents_present") or [],
}, },
"applied_to_case": applied, "applied_to_case": applied,
} }

View File

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

View File

@@ -14,9 +14,11 @@ import { CaseHeader } from "@/components/cases/case-header";
import { CaseEditDialog } from "@/components/cases/case-edit-dialog"; import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
import { DocumentsPanel } from "@/components/cases/documents-panel"; import { DocumentsPanel } from "@/components/cases/documents-panel";
import { DraftsPanel } from "@/components/cases/drafts-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 { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel"; import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
import { PositionsPanel } from "@/components/cases/positions-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 { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed"; import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview"; import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
@@ -156,11 +158,28 @@ export default function CaseDetailPage({
issues, with the aggregated by-party arguments collapsible below issues, with the aggregated by-party arguments collapsible below
(merged from the deleted /compose editor, mockup 18f). */} (merged from the deleted /compose editor, mockup 18f). */}
<TabsContent value="arguments" className="mt-0 space-y-4"> <TabsContent value="arguments" className="mt-0 space-y-4">
<Card className="bg-surface border-rule shadow-sm"> {/* עמדות וטענות — collapsed by default (chair preference #226),
<CardContent className="px-6 py-5"> mirroring the two accordions below. */}
<PositionsPanel caseNumber={caseNumber} /> <Accordion type="single" collapsible>
</CardContent> <AccordionItem
</Card> 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> <Accordion type="single" collapsible>
<AccordionItem <AccordionItem
value="byparty" value="byparty"
@@ -181,6 +200,30 @@ export default function CaseDetailPage({
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </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> </TabsContent>
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154), {/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),
@@ -201,12 +244,17 @@ export default function CaseDetailPage({
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="drafts" className="mt-0"> <TabsContent value="drafts" className="mt-0 space-y-4">
<Card className="bg-surface border-rule shadow-sm"> <Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5"> <CardContent className="px-6 py-5">
<DraftsPanel caseNumber={caseNumber} status={data?.status} /> <DraftsPanel caseNumber={caseNumber} status={data?.status} />
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<CaseFilesBrowser caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="agents" className="mt-0"> <TabsContent value="agents" className="mt-0">

View File

@@ -29,12 +29,15 @@ import {
useRunLog, useRunLog,
useCancelRun, useCancelRun,
useResetAgentSession, useResetAgentSession,
useAgentHealth,
useRecentEscalations,
type OpsService, type OpsService,
type OperationsSnapshot, type OperationsSnapshot,
type PipelineStats, type PipelineStats,
type AgentRun, type AgentRun,
type SubscriptionUsage, type SubscriptionUsage,
type UsageWindow, type UsageWindow,
type AgentHealthState,
} from "@/lib/api/operations"; } from "@/lib/api/operations";
import { formatTime, israelParts } from "@/lib/format-date"; 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() { export default function OperationsPage() {
const { data, isLoading, error } = useOperations(); const { data, isLoading, error } = useOperations();
@@ -840,6 +991,9 @@ export default function OperationsPage() {
<SectionHeader>סוכנים פעילים</SectionHeader> <SectionHeader>סוכנים פעילים</SectionHeader>
<LiveAgentsPanel /> <LiveAgentsPanel />
<SectionHeader>בריאות-הסוכנים</SectionHeader>
<AgentHealthPanel />
<SectionHeader>מתאמי-סוכנים</SectionHeader> <SectionHeader>מתאמי-סוכנים</SectionHeader>
<AgentAdaptersPanel /> <AgentAdaptersPanel />

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState, useEffect, useMemo } from "react"; import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -34,6 +34,9 @@ import {
HelpCircle, HelpCircle,
ChevronDown, ChevronDown,
Ban, Ban,
Target,
Plus,
AlertTriangle,
} from "lucide-react"; } from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */ /* ── Role → color mapping ────────────────────────────────────── */
@@ -818,6 +821,312 @@ function IssueGroup({
); );
} }
/* ── 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));
// 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] bg-white border border-rule rounded-xl shadow-lg p-1.5">
<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);
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 ${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>
<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) => (
<div
key={i.id}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed"
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>
<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 className="h-px bg-rule-soft my-1.5 mx-1" />
<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 hover:bg-sand-soft"
>
<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>
</>
)}
</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 ───────────────────────────────────────────────── */ /* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({ export function AgentActivityFeed({
@@ -826,9 +1135,6 @@ export function AgentActivityFeed({
caseNumber: string; caseNumber: string;
}) { }) {
const { data, isLoading, error } = useAgentActivity(caseNumber); 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, // Build issue_id → identifier map (memoized — the feed refetches every 10s,
// and a fresh Map each render would defeat the child cards' memoization). // and a fresh Map each render would defeat the child cards' memoization).
@@ -838,27 +1144,6 @@ export function AgentActivityFeed({
return m; return m;
}, [data?.issues]); }, [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 ── // ── Empty / loading states ──
if (isLoading) { if (isLoading) {
@@ -939,14 +1224,18 @@ export function AgentActivityFeed({
); );
return ( return (
<div className="flex flex-col h-full"> <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} />
{/* agent roster — who's who, compact one-row (mockup 18i v2) */} {/* agent roster — who's who, compact one-row (mockup 18i v2) */}
<AgentRoster agents={data.agents ?? []} /> <AgentRoster agents={data.agents ?? []} />
{/* pending interactions surfaced to the top — each an accordion, first {/* pending interactions surfaced to the top — each an accordion, all
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */} COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
{pending.length > 0 && ( {pending.length > 0 && (
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2"> <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"> <div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
ממתין לתשובתך ממתין לתשובתך
@@ -954,27 +1243,30 @@ export function AgentActivityFeed({
{pending.length} {pending.length}
</span> </span>
</div> </div>
{pending.map((i, idx) => ( {pending.map((i) => (
<InteractionCard <InteractionCard
key={`pending-${i.id}`} key={`pending-${i.id}`}
interaction={i} interaction={i}
caseNumber={caseNumber} caseNumber={caseNumber}
issueMap={issueMap} issueMap={issueMap}
defaultOpen={idx === 0} defaultOpen={false}
/> />
))} ))}
</div> </div>
)} )}
{/* Timeline header */} {/* Timeline header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1"> <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.82rem] font-bold text-navy">פעילות לפי משימה</span>
<span className="text-[0.72rem] text-ink-muted"> <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} הודעות {data.issues.length} משימות · {totalFeedItems} הודעות
</span> </span>
</div> </div>
{/* Per-issue accordions */} {/* Per-issue accordions — all COLLAPSED by default, newest group first */}
<div className="flex-1 overflow-y-auto max-h-[500px]"> <div className="flex-1 overflow-y-auto max-h-[500px]">
{totalFeedItems === 0 ? ( {totalFeedItems === 0 ? (
hasActiveIssue ? ( hasActiveIssue ? (
@@ -989,56 +1281,17 @@ export function AgentActivityFeed({
</div> </div>
) )
) : ( ) : (
issueGroups.map(({ iss, items }, idx) => ( issueGroups.map(({ iss, items }) => (
<IssueGroup <IssueGroup
key={iss.id} key={iss.id}
issue={iss} issue={iss}
items={items} items={items}
caseNumber={caseNumber} caseNumber={caseNumber}
issueMap={issueMap} issueMap={issueMap}
defaultOpen={ defaultOpen={false}
idx === 0 ||
(iss.status !== "done" && iss.status !== "cancelled")
}
/> />
)) ))
)} )}
<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>
</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

@@ -31,7 +31,9 @@ import {
usePartyClaimsSummary, usePartyClaimsSummary,
useGeneratePartyClaimsSummary, useGeneratePartyClaimsSummary,
useGenerateInterimDraft, useGenerateInterimDraft,
useGenerateStatus,
} from "@/lib/api/generate"; } from "@/lib/api/generate";
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
import { import {
useCaseFeedback, useCaseFeedback,
useCreateFeedback, useCreateFeedback,
@@ -89,7 +91,7 @@ function formatDate(epoch: number): string {
/** /**
* Pick the newest export file whose name starts with `prefix`, preferring the * 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 * highest v-number and falling back to the latest created_at. Used by the
* "הפקת מסמכים" card to surface the most recent טיוטת-ביניים-* file (#214). * "הפקת מסמכים" card to surface the most recent טיוטה-טענות_הצדדים_* file (#214).
*/ */
function pickLatestVersioned( function pickLatestVersioned(
files: ExportFile[] | undefined, files: ExportFile[] | undefined,
@@ -98,7 +100,10 @@ function pickLatestVersioned(
const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix)); const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix));
if (!matches.length) return null; if (!matches.length) return null;
const withVer = matches.map((f) => { const withVer = matches.map((f) => {
const m = f.filename.match(/v(\d+)/); // 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 }; return { ...f, version: m ? parseInt(m[1], 10) : null };
}); });
withVer.sort((a, b) => { withVer.sort((a, b) => {
@@ -133,6 +138,9 @@ export function DraftsPanel({
const { data: summary } = usePartyClaimsSummary(caseNumber); const { data: summary } = usePartyClaimsSummary(caseNumber);
const genSummary = useGeneratePartyClaimsSummary(caseNumber); const genSummary = useGeneratePartyClaimsSummary(caseNumber);
const genInterim = useGenerateInterimDraft(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 qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -167,8 +175,8 @@ export function DraftsPanel({
// ── "הפקת מסמכים" derived state (#214) ── // ── "הפקת מסמכים" derived state (#214) ──
// Latest interim ("party-claims") partial draft in the exports table — the poll // Latest interim ("party-claims") partial draft in the exports table — the poll
// target for button 2. The CEO writes it as טיוטת-ביניים-{case}-vN.docx. // target for button 2. The CEO writes it as טיוטה-טענות_הצדדים_{N}.docx.
const latestInterim = pickLatestVersioned(exports, "טיוטת-ביניים-"); const latestInterim = pickLatestVersioned(exports, "טיוטה-טענות_הצדדים_");
const summaryReady = Boolean(summary?.markdown); const summaryReady = Boolean(summary?.markdown);
function handleGenerateSummary() { function handleGenerateSummary() {
@@ -342,6 +350,11 @@ export function DraftsPanel({
"הפק" "הפק"
)} )}
</Button> </Button>
<GenerateStatusChip
status={summaryStatus.data}
onRetry={handleGenerateSummary}
retrying={genSummary.isPending}
/>
{summaryReady && ( {summaryReady && (
<> <>
<Button <Button
@@ -410,6 +423,11 @@ export function DraftsPanel({
"הפק" "הפק"
)} )}
</Button> </Button>
<GenerateStatusChip
status={interimStatus.data}
onRetry={handleGenerateInterim}
retrying={genInterim.isPending}
/>
{latestInterim && ( {latestInterim && (
<span className="text-[0.7rem] text-ink-muted"> <span className="text-[0.7rem] text-ink-muted">
{latestInterim.version {latestInterim.version

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

@@ -8,10 +8,6 @@ import { SubsectionCard } from "@/components/compose/subsection-card";
import { PrecedentsSection } from "@/components/compose/precedents-section"; import { PrecedentsSection } from "@/components/compose/precedents-section";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { useCase } from "@/lib/api/cases"; import { useCase } from "@/lib/api/cases";
import {
useCaseLearningStatus,
type CaseLearningStatus,
} from "@/lib/api/learning";
import { useResearchAnalysis } from "@/lib/api/research"; import { useResearchAnalysis } from "@/lib/api/research";
import { useCasePrecedents } from "@/lib/api/precedents"; 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. * 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 }) { function ProseSection({ title, content }: { title: string; content?: string }) {
if (!content?.trim()) return null; if (!content?.trim()) return null;
return ( 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({ function FinishRail({
caseNumber, caseNumber,
hasAnalysis, hasAnalysis,
@@ -88,7 +47,6 @@ function FinishRail({
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null); const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
const learning = useCaseLearningStatus(caseNumber);
async function handleUpload(file: File) { async function handleUpload(file: File) {
setUploading(true); setUploading(true);
@@ -119,9 +77,12 @@ function FinishRail({
} }
return ( 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"> <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 <input
ref={fileRef} ref={fileRef}
@@ -176,16 +137,6 @@ function FinishRail({
{uploadMsg.text} {uploadMsg.text}
</p> </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> </CardContent>
</Card> </Card>
); );
@@ -214,10 +165,6 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-ink-muted text-[0.78rem]">
סוגיות-המחלוקת ועמדות הצדדים. עורך עמדת-היו״ר נשמר אוטומטית ומזין את בלוק י׳ (דיון והכרעה).
</p>
{analysis.isPending ? ( {analysis.isPending ? (
<Card className="bg-surface border-rule shadow-sm"> <Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3"> <CardContent className="px-6 py-5 space-y-3">
@@ -322,9 +269,10 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
</div> </div>
) : null} ) : null}
{/* case-level פסיקה מצורפת + finish/export gates (INV-IA3: not removed) */} {/* case-level פסיקה מצורפת + analysis export/update (INV-IA3: not removed).
<div className="grid gap-4 lg:grid-cols-2 items-start pt-2"> items-stretch keeps both cards the same height (#226). */}
<Card className="bg-surface border-rule shadow-sm"> <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"> <CardContent className="px-5 py-4">
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3> <h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
<p className="text-[0.72rem] text-ink-muted mb-3"> <p className="text-[0.72rem] text-ink-muted mb-3">

View File

@@ -26,6 +26,8 @@ export type PaperclipIssue = {
completed_at: string | null; completed_at: string | null;
created_at: string | null; created_at: string | null;
company_id: string; company_id: string;
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
parent_id: string | null;
}; };
export type PaperclipComment = { export type PaperclipComment = {
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
export function useSendComment(caseNumber: string | undefined) { export function useSendComment(caseNumber: string | undefined) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (vars: { body: string; issue_id?: string }) => mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>( apiRequest<{
comment_id: string | null;
issue_id: string;
issue_identifier: string;
issue_status?: string;
new_run?: boolean;
}>(
`/api/cases/${caseNumber}/agents/comment`, `/api/cases/${caseNumber}/agents/comment`,
{ method: "POST", body: vars }, { method: "POST", body: vars },
), ),

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

@@ -9,7 +9,7 @@
* Generation runs on the host (claude_session → claude -p), NOT in the container, * 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: * so the trigger endpoints return 202-accepted and the UI POLLS for completion:
* • summary → GET /api/cases/{n}/research/party-claims-summary (200 = ready) * • summary → GET /api/cases/{n}/research/party-claims-summary (200 = ready)
* • interim draft → the exports list grows a "טיוטת-ביניים-{n}-vN.docx" file * • interim draft → the exports list grows a "טיוטה-טענות_הצדדים_{N}.docx" file
* (polled via useExports — see exports.ts). * (polled via useExports — see exports.ts).
* *
* The full-decision export ("הפק טיוטת החלטה מלאה") is NOT here — it reuses the * The full-decision export ("הפק טיוטת החלטה מלאה") is NOT here — it reuses the
@@ -29,12 +29,58 @@ export type GenerateTriggerResult = {
error?: 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 = { export const generateKeys = {
all: ["generate"] as const, all: ["generate"] as const,
partyClaimsSummary: (caseNumber: string) => partyClaimsSummary: (caseNumber: string) =>
[...generateKeys.all, "party-claims-summary", caseNumber] as const, [...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) ── /* ── Read: party-claims executive summary (poll target for button 1) ──
404 (not generated yet) is a normal "not ready" state, not an error — we 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. */ coerce it to null so the panel renders the empty/idle state cleanly. */
@@ -70,23 +116,33 @@ export function useGeneratePartyClaimsSummary(caseNumber: string) {
{ method: "POST" }, { method: "POST" },
), ),
onSuccess: () => { onSuccess: () => {
// Start polling fresh — the file will appear after the host run completes. // 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({ qc.invalidateQueries({
queryKey: generateKeys.partyClaimsSummary(caseNumber), queryKey: generateKeys.partyClaimsSummary(caseNumber),
}); });
qc.invalidateQueries({
queryKey: generateKeys.status(caseNumber, "party_claims_summary"),
});
}, },
}); });
} }
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */ /* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
export function useGenerateInterimDraft(caseNumber: string) { export function useGenerateInterimDraft(caseNumber: string) {
const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => mutationFn: () =>
apiRequest<GenerateTriggerResult>( apiRequest<GenerateTriggerResult>(
`/api/cases/${caseNumber}/generate/interim-draft`, `/api/cases/${caseNumber}/generate/interim-draft`,
{ method: "POST" }, { method: "POST" },
), ),
// The result lands in the exports list (טיוטת-ביניים-*.docx); useExports // The result lands in the exports list (טיוטה-טענות_הצדדים_*.docx, polled by
// already polls on a 5s interval, so no extra invalidation is needed here. // useExports); re-arm the status chip so it shows the new run immediately.
onSuccess: () => {
qc.invalidateQueries({
queryKey: generateKeys.status(caseNumber, "interim_draft"),
});
},
}); });
} }

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). */ /** Full output log of one run — fetched on demand (drawer open). */
export function useRunLog(runId: string | null) { export function useRunLog(runId: string | null) {
return useQuery({ 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",
"",
];

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,40 +33,111 @@ from web.paperclip_api import (
pc_request, pc_request,
require_paperclip_db_url, require_paperclip_db_url,
) )
from web.agent_telemetry import instrument
from web.paperclip_client import ( from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES, COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction, accept_interaction as pc_accept_interaction,
archive_project as pc_archive_project, archive_project as pc_archive_project,
cancel_interaction as pc_cancel_interaction,
cancel_run as pc_cancel_run,
create_project as pc_create_project, create_project as pc_create_project,
create_workflow_issue as pc_create_workflow_issue, 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_case as pc_get_agents_for_case,
get_agents_for_company as pc_get_agents, get_agents_for_company as pc_get_agents,
get_case_issues as pc_get_case_issues, 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_comments as pc_get_issue_comments,
get_issue_interactions as pc_get_issue_interactions, get_issue_interactions as pc_get_issue_interactions,
get_project_url, get_project_url,
get_run_events as pc_get_run_events, get_run_events as pc_get_run_events,
get_run_log as pc_get_run_log, get_run_log as pc_get_run_log,
list_live_runs as pc_list_live_runs, list_live_runs as pc_list_live_runs,
post_comment as pc_post_comment,
reap_stale_interactions as pc_reap_stale_interactions,
reject_interaction as pc_reject_interaction, 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, respond_to_interaction as pc_respond_to_interaction,
restore_project as pc_restore_project, restore_project as pc_restore_project,
update_project_name as pc_update_project_name, update_project_name as pc_update_project_name,
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts, get_generation_run_status as pc_get_generation_run_status,
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation, # ── raw imports: wrapped with telemetry below (state-affecting ops, #219) ──
wake_ceo_agent as pc_wake_ceo, cancel_interaction as _cancel_interaction,
wake_ceo_for_action as pc_wake_ceo_for_action, cancel_run as _cancel_run,
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold, escalate_issue as _escalate_issue,
wake_curator_for_final as pc_wake_curator_for_final, mark_comment_routed as _mark_comment_routed,
wake_for_precedent_extraction as pc_wake_for_precedent_extraction, 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,
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)
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) ─────────── # ── domain-named lifecycle aliases (preferred for new call sites) ───────────
archive_case_project = pc_archive_project archive_case_project = pc_archive_project
restore_case_project = pc_restore_project restore_case_project = pc_restore_project
@@ -96,18 +167,23 @@ __all__ = [
# issues / workflow # issues / workflow
"pc_create_workflow_issue", "pc_create_workflow_issue",
"pc_get_case_issues", "pc_get_case_issues",
"pc_pick_default_comment_target",
# agents / wakeups # agents / wakeups
"pc_get_agents_for_case", "pc_get_agents_for_case",
"pc_get_agents", "pc_get_agents",
"pc_wake_ceo", "pc_wake_ceo",
"pc_wake_ceo_for_action", "pc_wake_ceo_for_action",
"pc_open_ceo_run",
"pc_wake_ceo_for_feedback_fold", "pc_wake_ceo_for_feedback_fold",
"pc_wake_curator_for_final", "pc_wake_curator_for_final",
"pc_wake_for_precedent_extraction", "pc_wake_for_precedent_extraction",
"pc_wake_analyst_for_appraiser_facts", "pc_wake_analyst_for_appraiser_facts",
"pc_wake_analyst_for_argument_aggregation", "pc_wake_analyst_for_argument_aggregation",
"pc_wake_analyst_for_protocol_analysis",
"pc_get_generation_run_status",
# comments / interactions # comments / interactions
"pc_post_comment", "pc_post_comment",
"pc_mark_comment_routed",
"pc_get_issue_comments", "pc_get_issue_comments",
"pc_get_issue_interactions", "pc_get_issue_interactions",
"pc_accept_interaction", "pc_accept_interaction",
@@ -115,7 +191,12 @@ __all__ = [
"pc_respond_to_interaction", "pc_respond_to_interaction",
"pc_cancel_interaction", "pc_cancel_interaction",
"pc_reap_stale_interactions", "pc_reap_stale_interactions",
"pc_escalate_issue",
# agent-run observability + control (live view + smart management) # 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_list_live_runs",
"pc_get_run_log", "pc_get_run_log",
"pc_get_run_events", "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

@@ -61,9 +61,15 @@ from web.agent_platform_port import (
pc_cancel_run, pc_cancel_run,
pc_create_project, pc_create_project,
pc_create_workflow_issue, 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,
pc_get_agents_for_case, pc_get_agents_for_case,
pc_get_case_issues, pc_get_case_issues,
pc_pick_default_comment_target,
pc_get_issue_comments, pc_get_issue_comments,
pc_get_issue_interactions, pc_get_issue_interactions,
pc_get_run_events, pc_get_run_events,
@@ -79,9 +85,13 @@ from web.agent_platform_port import (
pc_restore_project, pc_restore_project,
pc_wake_analyst_for_appraiser_facts, pc_wake_analyst_for_appraiser_facts,
pc_wake_analyst_for_argument_aggregation, pc_wake_analyst_for_argument_aggregation,
pc_wake_analyst_for_protocol_analysis,
pc_get_generation_run_status,
rename_case_project, rename_case_project,
pc_wake_ceo, pc_wake_ceo,
pc_wake_ceo_for_action, pc_wake_ceo_for_action,
pc_open_ceo_run,
pc_mark_comment_routed,
pc_wake_ceo_for_feedback_fold, pc_wake_ceo_for_feedback_fold,
pc_wake_curator_for_final, pc_wake_curator_for_final,
pc_wake_for_precedent_extraction, pc_wake_for_precedent_extraction,
@@ -119,18 +129,66 @@ async def _interaction_reaper_loop():
logger.warning("interaction reaper sweep failed", exc_info=True) 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
await db.init_schema() await db.init_schema()
sync_task = asyncio.create_task(git_sync.sweep_loop()) sync_task = asyncio.create_task(git_sync.sweep_loop())
reaper_task = asyncio.create_task(_interaction_reaper_loop()) reaper_task = asyncio.create_task(_interaction_reaper_loop())
watchdog_task = asyncio.create_task(_zombie_escalation_loop())
try: try:
yield yield
finally: finally:
sync_task.cancel() sync_task.cancel()
reaper_task.cancel() reaper_task.cancel()
for _t in (sync_task, reaper_task): watchdog_task.cancel()
for _t in (sync_task, reaper_task, watchdog_task):
try: try:
await _t await _t
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -2659,6 +2717,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") @app.post("/api/cases/{case_number}/direction")
async def api_set_direction(case_number: str, req: DirectionRequest): async def api_set_direction(case_number: str, req: DirectionRequest):
"""Save the approved direction document for the discussion block.""" """Save the approved direction document for the discussion block."""
@@ -2921,42 +3070,82 @@ async def api_learn(case_number: str):
# ── Local files API — research, drafts, proofread ── # ── 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") @app.get("/api/cases/{case_number}/local-files")
async def api_local_files(case_number: str): 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) case_dir = config.find_case_dir(case_number)
result = {} folders = []
for folder in ("research", "proofread"): for key, path in _case_file_folders(case_dir):
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():
files = [] files = []
for f in sorted(drafts_path.iterdir()): for f in sorted(path.iterdir()):
if f.is_file() and not f.name.startswith("."): if f.is_file() and not f.name.startswith("."):
stat = f.stat() stat = f.stat()
files.append({ files.append({
"filename": f.name, "filename": f.name,
"size": stat.st_size, "size": stat.st_size,
"modified_at": stat.st_mtime, "modified_at": stat.st_mtime,
"folder": "drafts",
}) })
if files: folders.append({
result["drafts"] = files "key": key,
return result "name": path.name,
"path": str(path.relative_to(case_dir)),
"files": files,
})
return {"folders": folders}
async def serve_blob( async def serve_blob(
@@ -3026,17 +3215,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}") @app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
async def api_read_local_file(case_number: str, folder: str, filename: str): async def api_read_local_file(case_number: str, folder: str, filename: str):
"""Read contents of a local case file.""" """Serve a single local case file from any content subfolder.
if folder not in ("research", "proofread", "drafts"):
raise HTTPException(400, "Invalid folder") ``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) case_dir = config.find_case_dir(case_number)
if folder == "drafts": path = _resolve_case_file(case_dir, folder, filename)
path = case_dir / "drafts" / filename if path is None:
else:
path = case_dir / "documents" / folder / filename
if not path.exists() or not path.is_file():
raise HTTPException(404, "קובץ לא נמצא") 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 ──── # ── Research analysis (analysis-and-research.md) — parse + edit ────
@@ -3120,7 +3324,7 @@ async def api_party_claims_summary(case_number: str):
# Wakeup goes through the Paperclip API helper (pc_wake_ceo_for_action → the # 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. # platform Port → POST /api/agents/{id}/wakeup), NEVER a direct DB insert.
# Polling for completion uses the existing read endpoints (research/party-claims- # Polling for completion uses the existing read endpoints (research/party-claims-
# summary for the summary; the exports list for the טיוטת-ביניים-*.docx file). # summary for the summary; the exports list for the טיוטה-טענות_הצדדים_*.docx file).
async def _wake_ceo_action(case_number: str, action: str) -> dict: async def _wake_ceo_action(case_number: str, action: str) -> dict:
@@ -3153,10 +3357,99 @@ async def api_generate_party_claims_summary(case_number: str):
async def api_generate_interim_draft(case_number: str): async def api_generate_interim_draft(case_number: str):
"""Fire-and-accept: wake the CEO to generate the partial "party-claims" draft """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 (שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
exports list for the new טיוטת-ביניים-{case}-vN.docx.""" exports list for the new טיוטה-טענות_הצדדים_{N}.docx."""
return await _wake_ceo_action(case_number, "interim_draft") 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") @app.get("/api/cases/{case_number}/research/party-claims-summary/download")
async def api_party_claims_summary_download(case_number: str): async def api_party_claims_summary_download(case_number: str):
"""Download the raw party-claims-summary.md file.""" """Download the raw party-claims-summary.md file."""
@@ -4368,14 +4661,39 @@ async def api_case_agents(case_number: str):
class AgentCommentRequest(BaseModel): class AgentCommentRequest(BaseModel):
body: str body: str
issue_id: str | None = None 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") @app.post("/api/cases/{case_number}/agents/comment")
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest): 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) issues = await pc_get_case_issues(case_number)
if not issues: if not issues:
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}") raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
@@ -4385,14 +4703,35 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
if not target: if not target:
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}") raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
else: else:
# Pick the most recent non-done issue, or the last one target = pc_pick_default_comment_target(issues)
active = [i for i in issues if i["status"] != "done"]
target = active[-1] if active else issues[-1]
result = await pc_post_comment(target["id"], target["company_id"], req.body) 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_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 return result
@@ -4409,8 +4748,10 @@ async def api_post_interaction_response(
): ):
"""Submit a user's answer to a Paperclip issue-thread interaction. """Submit a user's answer to a Paperclip issue-thread interaction.
Routes to /respond | /accept | /reject based on `action`. Paperclip Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
auto-wakes the issue assignee after a successful submission. 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) issues = await pc_get_case_issues(case_number)
if not any(i["id"] == req.issue_id for i in issues): if not any(i["id"] == req.issue_id for i in issues):
@@ -4422,7 +4763,7 @@ async def api_post_interaction_response(
"reject": pc_reject_interaction, "reject": pc_reject_interaction,
} }
try: try:
return await handlers[req.action]( result = await handlers[req.action](
req.issue_id, req.interaction_id, req.payload, req.issue_id, req.interaction_id, req.payload,
) )
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@@ -4431,6 +4772,23 @@ async def api_post_interaction_response(
except Exception as e: except Exception as e:
raise HTTPException(502, f"שגיאת Paperclip: {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): class InteractionDismissRequest(BaseModel):
issue_id: str issue_id: str
@@ -4469,6 +4827,30 @@ async def api_reset_case_agents(case_number: str):
return result 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
# ── Settings: MCP Server Configuration ──────────────────────────── # ── Settings: MCP Server Configuration ────────────────────────────
# #
# Source of truth for legal-ai env vars is Coolify (see memory: # Source of truth for legal-ai env vars is Coolify (see memory:
@@ -7324,6 +7706,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") @app.get("/api/operations/agents/runs/{run_id}/log")
async def operations_agent_run_log(run_id: str): async def operations_agent_run_log(run_id: str):
"""Full output log (NDJSON stream) of one heartbeat run.""" """Full output log (NDJSON stream) of one heartbeat run."""

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import json import json
import logging import logging
import os import os
import re
import uuid import uuid
import asyncpg import asyncpg
@@ -326,6 +327,40 @@ async def _create_issue(
return issue_id, identifier return issue_id, identifier
async def mark_comment_routed(issue_id: str, comment_id: str) -> dict:
"""Claim a chair comment as already routed, so the plugin sweep skips it.
The plugin's `route-pending-comments` sweep re-routes any user comment newer
than the newest agent comment on the same issue. A chair instruction answered
on a CEO **child** issue leaves the parent with no agent reply, so the sweep
would keep re-routing it forever. Writing the sweep's own marker
(`last-routed-comment-id`, plugin state, issue scope) is what tells it the
comment is handled — this is the same key `markCommentRouted` sets in
`plugin-legal-ai/src/worker.ts`.
"""
if not comment_id:
return {"ok": False, "error": "no_comment_id"}
try:
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await conn.execute(
"""INSERT INTO plugin_state (plugin_id, scope_kind, scope_id, namespace, state_key, value_json)
VALUES ($1::uuid, 'issue', $2, 'default', 'last-routed-comment-id', $3::jsonb)
ON CONFLICT (plugin_id, scope_kind, scope_id, namespace, state_key)
DO UPDATE SET value_json = $3::jsonb""",
PLUGIN_ID, issue_id, json.dumps(comment_id),
)
finally:
await conn.close()
return {"ok": True, "issue_id": issue_id, "comment_id": comment_id}
except Exception as e:
logger.warning(
"mark_comment_routed failed for issue %s comment %s: %s",
issue_id, comment_id, e,
)
return {"ok": False, "error": str(e)}
async def _link_case_to_issue(conn: asyncpg.Connection, issue_id: str, case_number: str) -> None: async def _link_case_to_issue(conn: asyncpg.Connection, issue_id: str, case_number: str) -> None:
"""Store the legal-ai case number in plugin state, linked to the issue.""" """Store the legal-ai case number in plugin state, linked to the issue."""
await conn.execute( await conn.execute(
@@ -483,7 +518,8 @@ async def get_case_issues(case_number: str) -> list[dict]:
"""SELECT DISTINCT ON (i.id) """SELECT DISTINCT ON (i.id)
i.id, i.title, i.status, i.identifier, i.priority, i.id, i.title, i.status, i.identifier, i.priority,
i.assignee_agent_id, a.name AS assignee_name, i.assignee_agent_id, a.name AS assignee_name,
i.started_at, i.completed_at, i.created_at, i.company_id i.started_at, i.completed_at, i.created_at, i.company_id,
i.parent_id
FROM issues i FROM issues i
LEFT JOIN agents a ON i.assignee_agent_id = a.id LEFT JOIN agents a ON i.assignee_agent_id = a.id
LEFT JOIN plugin_state ps ON ps.scope_id = i.id::text LEFT JOIN plugin_state ps ON ps.scope_id = i.id::text
@@ -509,6 +545,7 @@ async def get_case_issues(case_number: str) -> list[dict]:
"completed_at": r["completed_at"].isoformat() if r["completed_at"] else None, "completed_at": r["completed_at"].isoformat() if r["completed_at"] else None,
"created_at": r["created_at"].isoformat() if r["created_at"] else None, "created_at": r["created_at"].isoformat() if r["created_at"] else None,
"company_id": str(r["company_id"]), "company_id": str(r["company_id"]),
"parent_id": str(r["parent_id"]) if r["parent_id"] else None,
} }
for r in sorted_rows for r in sorted_rows
] ]
@@ -516,6 +553,43 @@ async def get_case_issues(case_number: str) -> list[dict]:
await conn.close() await conn.close()
# A comment posted to a closed issue never wakes an agent — Paperclip skips the
# wakeup for done/cancelled issues, so the chair's instruction is silently
# swallowed. The default-target picker MUST therefore avoid these statuses.
CLOSED_ISSUE_STATUSES = frozenset({"done", "cancelled"})
def pick_default_comment_target(issues: list[dict]) -> dict:
"""Choose which issue a chair comment routes to when none is specified.
``issues`` is ordered oldest→newest (see :func:`get_case_issues`). Comment
routing is meant to reach the CEO, whose live "התחל תהליך ניסוח" issue is
top-level (``parent_id is None``) and open. Preference order:
1. newest OPEN top-level issue — the live CEO main issue;
2. newest OPEN issue of any depth — a live sub-issue if no main is open;
3. newest top-level issue even if closed — better than a closed child;
4. newest issue overall — last resort.
Excluding closed statuses is the fix for the recurring bug where the newest
non-done issue was a *cancelled* child, so the wakeup was skipped and the
instruction vanished. ``issues`` must be non-empty.
"""
def is_open(i: dict) -> bool:
return i["status"] not in CLOSED_ISSUE_STATUSES
def is_top(i: dict) -> bool:
return i.get("parent_id") is None
for pred in (
lambda i: is_open(i) and is_top(i),
is_open,
is_top,
):
matches = [i for i in issues if pred(i)]
if matches:
return matches[-1]
return issues[-1]
async def get_issue_comments(issue_ids: list[str]) -> list[dict]: async def get_issue_comments(issue_ids: list[str]) -> list[dict]:
"""Get all comments on a list of Paperclip issues, with agent metadata.""" """Get all comments on a list of Paperclip issues, with agent metadata."""
if not issue_ids: if not issue_ids:
@@ -615,10 +689,14 @@ async def get_agents_for_case(company_id: str, issue_ids: list[str]) -> list[dic
async def post_comment(issue_id: str, company_id: str, body: str) -> dict: async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
"""Post a comment on a Paperclip issue. """Post a comment on a Paperclip issue. Records only — wakes nobody.
Tries the Board API first (triggers plugin events for CEO routing). Delivering the instruction is the caller's job, via :func:`open_ceo_run`. This
Falls back to direct DB insert + CEO wakeup if API fails. function used to also wake the CEO on ``issue_id``, but that wakeup is dead on
arrival for the case issues the chair actually comments on: they are
`in_review` and owned by her, so Paperclip cancels the run with
`issue_assignee_changed` before it starts. Keeping it would leave two parallel
delivery paths — one that works and one that silently doesn't (INV-G2).
""" """
# Try Board API first — this triggers the event bus # Try Board API first — this triggers the event bus
if PAPERCLIP_BOARD_API_KEY: if PAPERCLIP_BOARD_API_KEY:
@@ -635,7 +713,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
except Exception: except Exception:
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id) logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
# Fallback: direct DB insert + explicit CEO wakeup
comment_id = str(uuid.uuid4()) comment_id = str(uuid.uuid4())
conn = await asyncpg.connect(PAPERCLIP_DB_URL) conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try: try:
@@ -648,23 +725,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
finally: finally:
await conn.close() await conn.close()
# Wake the correct CEO for this company
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
try:
await pc_request(
"POST",
f"/api/agents/{ceo_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"user_comment_{issue_id}",
"payload": {"issueId": issue_id, "mutation": "comment"},
},
raise_on_error=True,
)
except Exception:
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"} return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
@@ -856,6 +916,78 @@ async def reset_case_agents(case_number: str) -> dict:
} }
ESCALATION_SEVERITIES = ("critical", "high", "medium")
async def escalate_issue(
issue_id: str, severity: str, reason: str, company_id: str = "",
) -> dict:
"""First-class, severity-routed escalation of a stuck issue to the chair (#218).
Replaces the fragile manual "PATCH dance" (reassign + set in_review + open an
interaction) that repeatedly tripped Paperclip's recovery loops
(``source_scoped_recovery_action`` / ``stranded_assigned_issue`` /
``issue_reopened_via_comment``; see memory reference_paperclip_recovery_loops).
Two effects in **one DB transaction**, mirroring the proven loop-safe path of
:func:`reset_case_agents` (raw SQL, not REST):
1. **Atomic human-owned transition** — a single ``UPDATE`` to the stable end
state ``{status:'in_review', assignee_agent_id:null,
assignee_user_id:CHAIM_USER_ID}`` (recovery-loops rule #7). Direct-DB on
purpose: it bypasses Paperclip's disposition resolver — the very machinery
whose multi-PATCH/`issue.released` behaviour *causes* the loops — so the
issue lands human-owned in one shot with no ``done→todo`` flip.
2. **Durable severity note** — an ``author_type='system'`` comment recording
severity + reason. System-authored on purpose: the ``route-pending-comments``
sweep routes only ``author_type='user'`` (chair) comments to the CEO, so a
system note is inert w.r.t. routing and will **not** re-wake an agent
(project_comment_delivery_guarantee). No wakeup is issued — the whole point
is to hand off to the human, not re-invoke an agent.
The ``agent.escalated`` telemetry event is emitted by the Port wrapper
(docs/spec/X15) — this is the loop-safe counterpart the CEO/analysts reach
for instead of leaving an issue agent-owned+blocked.
"""
if severity not in ESCALATION_SEVERITIES:
return {
"ok": False,
"error": f"invalid severity {severity!r}; expected one of {ESCALATION_SEVERITIES}",
}
body = f"🚨 הסלמה [{severity}] לחיים\n\n{reason}"
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
async with conn.transaction():
row = await conn.fetchrow(
"""UPDATE issues
SET status='in_review', assignee_agent_id=null,
assignee_user_id=$1, updated_at=now()
WHERE id=$2::uuid
RETURNING id, identifier, company_id""",
CHAIM_USER_ID, issue_id,
)
if not row:
return {"ok": False, "error": f"issue {issue_id} not found"}
cid = company_id or str(row["company_id"])
await conn.execute(
"""INSERT INTO issue_comments (id, company_id, issue_id, body, author_type)
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, 'system')""",
str(uuid.uuid4()), cid, issue_id, body,
)
finally:
await conn.close()
logger.info("Escalated issue %s to chair [severity=%s]", issue_id, severity)
return {
"ok": True,
"id": str(row["id"]),
"identifier": row["identifier"],
"severity": severity,
"status": "in_review",
}
async def respond_to_interaction( async def respond_to_interaction(
issue_id: str, interaction_id: str, payload: dict, issue_id: str, interaction_id: str, payload: dict,
) -> dict: ) -> dict:
@@ -962,6 +1094,249 @@ async def reap_stale_interactions() -> dict:
return {"ok": True, "cancelled": len(rows)} return {"ok": True, "cancelled": len(rows)}
# Windows for the agent-health taxonomy (#222). A run with no finished_at older
# than the live window is treated as dead (not "working"); wakeups are counted
# over the recovery window to spot loops.
_HEALTH_LIVE_RUN_WINDOW = "30 minutes"
_HEALTH_WAKEUP_WINDOW = "2 hours"
async def get_agent_health() -> dict:
"""Classify every open, agent-assigned issue into a health state (#222).
Read-only. Derives, per issue, the three primitives the pure classifier
(:func:`web.agent_health.classify_issue_health`) needs, from Paperclip's
``heartbeat_runs`` (a live run = ``finished_at IS NULL`` within the live
window) and ``agent_wakeup_requests`` (total + recovery-marker counts within
the recovery window). Scoped to the two legal-ai companies.
Surfaces the stranded-child / recovery-loop cases (``zombie``) automatically
instead of by hand-querying the DB. Returns items sorted worst-first.
"""
from web.agent_health import ( # local import: keep the shell → agnostic dep inward
HEALTH_STATES,
classify_issue_health,
is_recovery_reason,
)
company_ids = list(COMPANIES.values())
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
issues = await conn.fetch(
"""SELECT i.id, i.identifier, i.status, i.assignee_agent_id,
a.name AS agent_name
FROM issues i
JOIN agents a ON a.id = i.assignee_agent_id
WHERE i.company_id = ANY($1::uuid[])
AND i.assignee_agent_id IS NOT NULL
AND i.status IN ('backlog','todo','in_progress','blocked','in_review')""",
company_ids,
)
if not issues:
return {"ok": True, "items": [], "counts": {s: 0 for s in HEALTH_STATES}}
agent_ids = list({str(r["assignee_agent_id"]) for r in issues})
live_rows = await conn.fetch(
f"""SELECT DISTINCT agent_id FROM heartbeat_runs
WHERE agent_id = ANY($1::uuid[])
AND finished_at IS NULL
AND started_at > now() - interval '{_HEALTH_LIVE_RUN_WINDOW}'""",
agent_ids,
)
live_agents = {str(r["agent_id"]) for r in live_rows}
wake_rows = await conn.fetch(
f"""SELECT agent_id, payload->>'issueId' AS issue_id, reason
FROM agent_wakeup_requests
WHERE agent_id = ANY($1::uuid[])
AND requested_at > now() - interval '{_HEALTH_WAKEUP_WINDOW}'""",
agent_ids,
)
finally:
await conn.close()
total_by_issue: dict[str, int] = {}
recovery_by_issue: dict[str, int] = {}
for w in wake_rows:
iid = w["issue_id"]
if not iid:
continue
total_by_issue[iid] = total_by_issue.get(iid, 0) + 1
if is_recovery_reason(w["reason"]):
recovery_by_issue[iid] = recovery_by_issue.get(iid, 0) + 1
items = []
counts = {s: 0 for s in HEALTH_STATES}
for r in issues:
iid = str(r["id"])
state = classify_issue_health(
has_live_run=str(r["assignee_agent_id"]) in live_agents,
recovery_wakeups=recovery_by_issue.get(iid, 0),
total_wakeups=total_by_issue.get(iid, 0),
)
counts[state] += 1
items.append({
"issue_id": iid,
"identifier": r["identifier"],
"status": r["status"],
"agent_id": str(r["assignee_agent_id"]),
"agent_name": r["agent_name"],
"health": state,
"wakeups": total_by_issue.get(iid, 0),
"recovery_wakeups": recovery_by_issue.get(iid, 0),
})
order = {s: i for i, s in enumerate(HEALTH_STATES)}
items.sort(key=lambda it: (order[it["health"]], -it["recovery_wakeups"]))
return {"ok": True, "items": items, "counts": counts}
# The escalation note escalate_issue() writes as an author_type='system' comment.
# One prefix + a [severity] tag — parsed back here for the ops panel (#218/#222 UI).
_ESCALATION_BODY_PREFIX = "🚨 הסלמה"
_ESCALATION_SEVERITY_RE = re.compile(r"\[(critical|high|medium)\]")
async def get_recent_escalations(limit: int = 10, hours: int = 24) -> dict:
"""Recent chair escalations (watchdog + manual), newest-first (#218/#222 UI).
Read-only. Reconstructs escalations from the ``author_type='system'`` notes
escalate_issue() leaves (``🚨 הסלמה [severity] לחיים\\n\\n<reason>``) — severity
parsed from the tag, reason from the body, identifier joined from the issue.
Scoped to the two legal-ai companies; ``limit`` clamped 1..50, ``hours`` 1..168.
"""
limit = max(1, min(int(limit), 50))
hours = max(1, min(int(hours), 168))
company_ids = list(COMPANIES.values())
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
f"""SELECT ic.issue_id, ic.body, ic.created_at, i.identifier
FROM issue_comments ic
JOIN issues i ON i.id = ic.issue_id
WHERE ic.author_type = 'system'
AND ic.company_id = ANY($1::uuid[])
AND ic.body LIKE $2
AND ic.deleted_at IS NULL
AND ic.created_at > now() - interval '{hours} hours'
ORDER BY ic.created_at DESC
LIMIT $3""",
company_ids, f"{_ESCALATION_BODY_PREFIX}%", limit,
)
finally:
await conn.close()
items = []
for r in rows:
body = r["body"] or ""
m = _ESCALATION_SEVERITY_RE.search(body)
severity = m.group(1) if m else "medium"
reason = body.split("\n\n", 1)[1].strip() if "\n\n" in body else body
items.append({
"issue_id": str(r["issue_id"]),
"identifier": r["identifier"],
"severity": severity,
"reason": reason,
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
})
return {"ok": True, "items": items}
async def get_predecessor_context(issue_id: str, limit: int = 3) -> dict:
"""Recent finished runs on an issue, newest-first, for session continuation (#220).
The "seance" idea from Gastown, grounded in our data: each ephemeral heartbeat
rediscovers context every wake (the generic *blind heartbeat*; see
[[project_comment_delivery_guarantee]]). This lets a fresh wake read what its
predecessors on the SAME issue concluded — the run ``summary`` each heartbeat
leaves in ``heartbeat_runs.result_json`` — instead of re-deriving from scratch.
Read-only. Runs are tied to the issue via ``wakeup_request_id`` →
``agent_wakeup_requests.payload->>'issueId'`` (only finished runs are useful
as predecessors). ``limit`` is clamped to 1..10.
"""
limit = max(1, min(int(limit), 10))
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
rows = await conn.fetch(
"""SELECT h.id, h.status, h.started_at, h.finished_at,
h.result_json->>'summary' AS summary,
h.error_code, h.session_id_after, a.name AS agent_name
FROM heartbeat_runs h
JOIN agent_wakeup_requests w ON w.id = h.wakeup_request_id
LEFT JOIN agents a ON a.id = h.agent_id
WHERE w.payload->>'issueId' = $1
AND h.finished_at IS NOT NULL
ORDER BY h.started_at DESC
LIMIT $2""",
issue_id, limit,
)
finally:
await conn.close()
runs = [_shape_predecessor_run(r) for r in rows]
return {"ok": True, "issue_id": issue_id, "runs": runs}
def _shape_predecessor_run(r) -> dict:
"""Shared row → predecessor-run dict (by-issue and by-case entry points, G2)."""
return {
"run_id": str(r["id"]),
"status": r["status"],
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
"finished_at": r["finished_at"].isoformat() if r["finished_at"] else None,
"summary": r["summary"],
"error_code": r["error_code"],
"session_id": r["session_id_after"],
"agent_name": r["agent_name"],
}
async def get_predecessor_for_case(case_number: str, limit: int = 5) -> dict:
"""Recent finished runs across a CASE's issues, newest-first (#220, agent-facing).
The case-scoped sibling of :func:`get_predecessor_context`. The agent knows its
``case_number`` (not the Paperclip issue UUID), so the plugin tool
``legal_predecessor_context`` calls this. Resolves the case's Paperclip project,
then returns finished heartbeat-run summaries across its issues — so a resuming
wake reads what prior sessions on the case concluded instead of re-deriving.
Read-only. The issue join casts the known-valid ``issues.id`` to text (rather
than the free-form ``payload->>'issueId'`` to uuid) so a malformed payload
can't break the query. ``limit`` clamped 1..10.
"""
limit = max(1, min(int(limit), 10))
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
project = await conn.fetchrow(
"SELECT id FROM projects WHERE name LIKE $1 LIMIT 1", f"%{case_number}%",
)
if not project:
return {"ok": True, "case_number": case_number, "runs": []}
rows = await conn.fetch(
"""SELECT h.id, h.status, h.started_at, h.finished_at,
h.result_json->>'summary' AS summary,
h.error_code, h.session_id_after, a.name AS agent_name,
i.identifier
FROM heartbeat_runs h
JOIN agent_wakeup_requests w ON w.id = h.wakeup_request_id
JOIN issues i ON i.id::text = w.payload->>'issueId'
LEFT JOIN agents a ON a.id = h.agent_id
WHERE i.project_id = $1
AND h.finished_at IS NOT NULL
AND h.result_json->>'summary' IS NOT NULL
ORDER BY h.started_at DESC
LIMIT $2""",
project["id"], limit,
)
finally:
await conn.close()
runs = [{**_shape_predecessor_run(r), "identifier": r["identifier"]} for r in rows]
return {"ok": True, "case_number": case_number, "runs": runs}
# Singleton project for the precedent-library extraction queue. One issue per # Singleton project for the precedent-library extraction queue. One issue per
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor. # uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ" _LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
@@ -1258,25 +1633,58 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
return result return result
def _ceo_action_brief(action: str, case_number: str) -> tuple[str, str]:
"""(child-issue title, description) for a deterministic CEO generation action.
The instruction is self-contained in the description (like
``wake_analyst_for_argument_aggregation``) so the run does not depend on the
CEO parsing ``payload.action`` — it just runs the named tool and closes the
child. Both actions run host-side (claude_session → local claude CLI).
"""
if action == "party_claims_summary":
title = f"[ערר {case_number}] הפקת סיכום-מנהלים לטענות הצדדים"
description = (
f"חיים ביקש הפקת סיכום-מנהלים לטענות הצדדים בתיק {case_number}.\n\n"
f"הרץ `mcp__legal-ai__summarize_party_claims(case_number=\"{case_number}\")`, "
f"כתוב comment קצר בעברית עם תמצית התוצאה, וסגור issue זה כ-done. "
f"אם חסרים נתונים (אין טענות מחולצות) — דווח ב-comment מה חסר וסגור כ-blocked."
)
return title, description
# default: interim_draft
title = f"[ערר {case_number}] הפקת טיוטת טענות-הצדדים (טיוטת-ביניים)"
description = (
f"חיים ביקש הפקת טיוטת טענות-הצדדים (טיוטת-ביניים) בתיק {case_number}.\n\n"
f"הרץ `mcp__legal-ai__write_interim_draft(case_number=\"{case_number}\")` ואז "
f"`mcp__legal-ai__export_interim_draft(case_number=\"{case_number}\")`, כתוב comment "
f"קצר בעברית עם שם הקובץ שנוצר, וסגור issue זה כ-done. אם חסרים נתונים — דווח "
f"ב-comment וסגור כ-blocked."
)
return title, description
async def wake_ceo_for_action( async def wake_ceo_for_action(
case_number: str, case_number: str,
action: str, action: str,
company_id: str = "", company_id: str = "",
) -> dict: ) -> dict:
"""Wake the CEO with a deterministic *structured action* on the case's MAIN issue. """Wake the CEO to run a deterministic generation action (TaskMaster #214).
Drives the UI "הפקת מסמכים" buttons (TaskMaster #214): the CEO reads Drives the UI "הפקת מסמכים" buttons:
``payload.action`` and routes deterministically to its שלב H / שלב H2 action="party_claims_summary" → summarize_party_claims (שלב H2)
side-quests (legal-ceo.md §"פעולות סטרוקטורליות") — no free-text parsing. action="interim_draft" → write_interim_draft + export_interim_draft (שלב H)
action="party_claims_summary" → שלב H2 (summarize_party_claims) A **child issue assigned to the CEO** is created under the case's main issue
action="interim_draft" → שלב H (write_interim_draft + export_interim_draft) and the wakeup targets that child — NOT the main issue directly (#227). The
main issue is usually ``in_review`` assigned to the chair (human) while a case
waits; waking the CEO *on* a human-owned issue makes Paperclip cancel the run
immediately (``issue_assignee_changed``) — the exact silent failure this
fixes. The CEO owns the child, so its run is never cancelled. Same delegation
shape as ``wake_analyst_for_argument_aggregation``.
These are side-quests on the EXISTING ``[ערר {case_number}]`` issue — no child Wakeup goes through the Paperclip API (``POST /api/agents/{id}/wakeup``), never
issue is created (the CEO must not change ``cases.status`` or spawn sub-agents). a direct DB insert (CLAUDE.md hard rule). Returns
Wakeup goes through the Paperclip API (``POST /api/agents/{id}/wakeup``), never a ``{"status": "ok", "issue_id"(=child), "ceo_id", ...}`` or
direct DB insert (CLAUDE.md hard rule). Returns ``{"status": "skipped", "reason": ...}``.
``{"status": "ok"|"skipped", ...}``.
""" """
if not PAPERCLIP_BOARD_API_KEY: if not PAPERCLIP_BOARD_API_KEY:
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO action wakeup") logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO action wakeup")
@@ -1287,17 +1695,42 @@ async def wake_ceo_for_action(
logger.warning("No Paperclip issues found for case %s — skipping CEO action", case_number) logger.warning("No Paperclip issues found for case %s — skipping CEO action", case_number)
return {"status": "skipped", "reason": "no_issue"} return {"status": "skipped", "reason": "no_issue"}
# The main case issue — prefer the in-progress one, else the canonical
# "[ערר {case_number}]" issue, else the oldest. (Same selection spirit as
# wake_curator_for_final, but we never spawn a child — it's a side-quest.)
main_issue = ( main_issue = (
next((i for i in issues if i.get("status") == "in_progress"), None) next((i for i in issues if i.get("status") == "in_progress"), None)
or next((i for i in issues if f"[ערר {case_number}]" in (i.get("title") or "")), None) or next((i for i in issues if f"[ערר {case_number}]" in (i.get("title") or "")), None)
or issues[0] or issues[0]
) )
main_issue_id = main_issue["id"] main_issue_id = main_issue["id"]
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID) ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
# Child issue assigned to the CEO — so the queued run is owned by the CEO and
# not cancelled by the human assignee on the parent (#227).
title, description = _ceo_action_brief(action, case_number)
child_resp = await pc_request(
"POST",
f"/api/issues/{main_issue_id}/children",
json={
"title": title,
"description": description,
"status": "in_progress",
"priority": "medium",
"assigneeAgentId": ceo_id,
},
raise_on_error=True,
)
sub_issue = child_resp.json()
sub_issue_id = sub_issue["id"]
# Tag plugin_state so the case page surfaces this sub-issue too.
try:
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await _link_case_to_issue(conn, sub_issue_id, case_number)
finally:
await conn.close()
except Exception as e:
logger.warning("plugin_state link failed for sub_issue=%s: %s", sub_issue_id, e)
wake_resp = await pc_request( wake_resp = await pc_request(
"POST", "POST",
f"/api/agents/{ceo_id}/wakeup", f"/api/agents/{ceo_id}/wakeup",
@@ -1306,7 +1739,7 @@ async def wake_ceo_for_action(
"triggerDetail": "manual", "triggerDetail": "manual",
"reason": f"generate_{action}_{case_number}", "reason": f"generate_{action}_{case_number}",
"payload": { "payload": {
"issueId": main_issue_id, "issueId": sub_issue_id,
"action": action, "action": action,
"case_number": case_number, "case_number": case_number,
}, },
@@ -1314,17 +1747,172 @@ async def wake_ceo_for_action(
raise_on_error=True, raise_on_error=True,
) )
logger.info( logger.info(
"CEO action wakeup for case %s: action=%s issue=%s ceo=%s status=%s", "CEO action wakeup for case %s: action=%s child_issue=%s ceo=%s status=%s",
case_number, action, main_issue_id, ceo_id, wake_resp.status_code, case_number, action, sub_issue_id, ceo_id, wake_resp.status_code,
) )
return { return {
"status": "ok", "status": "ok",
"action": action, "action": action,
"issue_id": main_issue_id, "issue_id": sub_issue_id,
"main_issue_id": main_issue_id,
"ceo_id": ceo_id, "ceo_id": ceo_id,
} }
async def open_ceo_run(
case_number: str,
instruction: str,
company_id: str = "",
) -> dict:
"""Open a FRESH CEO-owned run carrying a free-form chair instruction.
Drives the "פתח הרצה חדשה" option of the agents-tab target selector: instead
of appending the instruction to an existing (possibly human-owned or closed)
issue, a **child issue assigned to the CEO** is created under the case's main
issue and the CEO is woken on it. This is the same CEO-child primitive as
:func:`wake_ceo_for_action` (#227) — it sidesteps the ``issue_assignee_changed``
cancellation that fires when an agent is woken on a human-owned parent (the
exact silent failure the chair hit on 1027-04-26; see TaskMaster #228).
Wakeup goes through the Paperclip API, never a direct DB insert. Returns
``{"status":"ok", "issue_id"(=child), "identifier", ...}`` or
``{"status":"skipped", "reason": ...}``.
"""
if not PAPERCLIP_BOARD_API_KEY:
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO new-run")
return {"status": "skipped", "reason": "no_api_key"}
issues = await get_case_issues(case_number)
if not issues:
logger.warning("No Paperclip issues for case %s — skipping CEO new-run", case_number)
return {"status": "skipped", "reason": "no_issue"}
# Parent the new run under the live main issue (never a closed child).
main_issue = pick_default_comment_target(issues)
main_issue_id = main_issue["id"]
if not company_id:
company_id = main_issue.get("company_id", "")
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
title = f'[ערר {case_number}] הוראת יו"ר — הרצה חדשה'
description = (
'הוראת יו"ר שנשלחה כ"הרצה חדשה" מטאב הסוכנים. קרא את מצב התיק, טפל בהוראה '
"בסגנון דפנה, ומסור disposition תקין בסיום.\n\n---\n\n" + instruction
)
child_resp = await pc_request(
"POST",
f"/api/issues/{main_issue_id}/children",
json={
"title": title,
"description": description,
"status": "in_progress",
"priority": "medium",
"assigneeAgentId": ceo_id,
},
raise_on_error=True,
)
sub_issue = child_resp.json()
sub_issue_id = sub_issue["id"]
try:
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await _link_case_to_issue(conn, sub_issue_id, case_number)
finally:
await conn.close()
except Exception as e:
logger.warning("plugin_state link failed for new-run sub_issue=%s: %s", sub_issue_id, e)
await pc_request(
"POST",
f"/api/agents/{ceo_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"chair_new_run_{case_number}",
"payload": {"issueId": sub_issue_id, "case_number": case_number},
},
raise_on_error=True,
)
logger.info(
"CEO new-run for case %s: child_issue=%s ceo=%s", case_number, sub_issue_id, ceo_id,
)
return {
"status": "ok",
"issue_id": sub_issue_id,
"identifier": sub_issue.get("identifier", ""),
"main_issue_id": main_issue_id,
"ceo_id": ceo_id,
}
# Substring of the generation child-issue title per action (see _ceo_action_brief).
GENERATION_TITLE_SUBSTR = {
"party_claims_summary": "סיכום-מנהלים",
"interim_draft": "טיוטת טענות-הצדדים",
}
async def get_generation_run_status(case_number: str, action: str) -> dict:
"""Server-derived run status for a "הפקת מסמכים" generation action (#227 ג).
Reconstructs the state of the most recent generation child issue for
(case, action) from Paperclip — the child issue + its latest heartbeat_run —
so the UI indicator survives navigation/reload (it never depends on the
browser having stayed on the page). Read-only Paperclip DB access, behind the
platform port (G12).
Returns a serializable dict::
{"issue_id", "issue_status", "run_status", "started_at", "finished_at",
"error", "error_code"}
with all timestamps as ISO-8601 UTC strings (or None). ``issue_id`` is None
when no generation child issue exists yet (→ the endpoint reports 'idle').
"""
substr = GENERATION_TITLE_SUBSTR.get(action)
if not substr:
return {"issue_id": None}
def _iso(dt) -> str | None:
return dt.isoformat() if dt is not None else None
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
issue = await conn.fetchrow(
"""SELECT id, status
FROM issues
WHERE title LIKE ('%' || $1 || '%') AND title LIKE ('%' || $2 || '%')
ORDER BY created_at DESC
LIMIT 1""",
case_number, substr,
)
if issue is None:
return {"issue_id": None}
run = await conn.fetchrow(
"""SELECT hr.status, hr.started_at, hr.finished_at, hr.error, hr.error_code
FROM heartbeat_runs hr
JOIN agent_wakeup_requests awr ON hr.wakeup_request_id = awr.id
WHERE awr.payload->>'issueId' = $1
ORDER BY hr.created_at DESC
LIMIT 1""",
str(issue["id"]),
)
finally:
await conn.close()
return {
"issue_id": str(issue["id"]),
"issue_status": issue["status"],
"run_status": run["status"] if run else None,
"started_at": _iso(run["started_at"]) if run else None,
"finished_at": _iso(run["finished_at"]) if run else None,
"error": (run["error"] if run else None) or None,
"error_code": (run["error_code"] if run else None) or None,
}
def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]: def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]:
"""Build the (sub-issue title, description) for a staged final-decision task. """Build the (sub-issue title, description) for a staged final-decision task.
@@ -1684,3 +2272,109 @@ async def wake_analyst_for_argument_aggregation(
"analyst_id": analyst_id, "analyst_id": analyst_id,
"main_issue_id": main_issue_id, "main_issue_id": main_issue_id,
} }
async def wake_analyst_for_protocol_analysis(
case_number: str,
company_id: str,
document_id: str = "",
) -> dict:
"""Wake the legal-analyst to run the hearing-protocol comparative analysis.
Triggered by the chair clicking "נתח את פרוטוקול הדיון" / "נתח מחדש" in the
"מה קרה בדיון" panel (#226). Same delegation shape as
``wake_analyst_for_argument_aggregation``: the FastAPI container cannot run
``analyze_protocol`` directly (it calls ``claude_session.query_json()``,
which needs the host-side ``claude`` CLI), so we create a child issue under
the case's main Paperclip issue, assign it to the company's analyst, and
trigger a wakeup. The analyst runs the MCP tool locally and reports back.
``document_id`` optionally pins the analysis to a specific protocol document
(when a case holds several protocols — #223).
Returns a dict shaped for the FastAPI endpoint to serialize as-is:
{"status": "queued", "sub_issue_id", "analyst_id", "main_issue_id"}
or {"status": "skipped", "reason": "..."} for non-fatal early outs.
"""
if not PAPERCLIP_BOARD_API_KEY:
logger.warning(
"PAPERCLIP_BOARD_API_KEY not set — cannot queue analyst wakeup "
"for protocol analysis on %s",
case_number,
)
return {"status": "skipped", "reason": "no_api_key"}
analyst_id = ANALYST_AGENTS.get(company_id)
if not analyst_id:
logger.info("No analyst configured for company %s — skipping", company_id)
return {"status": "skipped", "reason": "no_analyst", "company_id": company_id}
issues = await get_case_issues(case_number)
if not issues:
logger.warning(
"No Paperclip issues found for case %s — cannot queue analyst", case_number,
)
return {"status": "skipped", "reason": "no_issue"}
main_issue = next((i for i in issues if i.get("status") == "in_progress"), None) or issues[0]
main_issue_id = main_issue["id"]
doc_clause = f", document_id=\"{document_id}\"" if document_id.strip() else ""
description = (
f"חיים ביקש ניתוח פרוטוקול-דיון בתיק {case_number}.\n\n"
f"הרץ `mcp__legal-ai__analyze_protocol(case_number=\"{case_number}\"{doc_clause})` "
f"וכתוב comment בעברית עם תוצאת הניתוח — כמה טענות התחזקו בדיון, כמה נטענו "
f"לראשונה, וכמה ירדו. אם אין פרוטוקול ועדת-ערר בתיק או שאין טיעונים מאוגדים "
f"להשוואה, דווח ב-comment מה חסר וסגור את ה-issue כ-blocked."
)
child_resp = await pc_request(
"POST",
f"/api/issues/{main_issue_id}/children",
json={
"title": f"[ערר {case_number}] ניתוח פרוטוקול-דיון",
"description": description,
"status": "in_progress",
"priority": "medium",
"assigneeAgentId": analyst_id,
},
raise_on_error=True,
)
sub_issue = child_resp.json()
sub_issue_id = sub_issue["id"]
# Tag plugin_state so the case page surfaces this sub-issue too.
try:
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await _link_case_to_issue(conn, sub_issue_id, case_number)
finally:
await conn.close()
except Exception as e:
logger.warning("plugin_state link failed for sub_issue=%s: %s", sub_issue_id, e)
wake_resp = await pc_request(
"POST",
f"/api/agents/{analyst_id}/wakeup",
json={
"source": "on_demand",
"triggerDetail": "manual",
"reason": f"analyze_protocol_{case_number}",
"payload": {
"issueId": sub_issue_id,
"mutation": "assignment",
"caseNumber": case_number,
},
},
raise_on_error=True,
)
logger.info(
"Analyst wakeup for protocol analysis on case %s: sub_issue=%s "
"analyst=%s doc=%s wake=%s",
case_number, sub_issue_id, analyst_id, document_id or "auto", wake_resp.status_code,
)
return {
"status": "queued",
"sub_issue_id": sub_issue_id,
"analyst_id": analyst_id,
"main_issue_id": main_issue_id,
}

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": []}