Compare commits
73 Commits
66cddd20f0
...
worktree-r
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a2a989e9b | |||
| df60636876 | |||
| d65c335a4a | |||
| dc203c77eb | |||
| 180f70bcc0 | |||
| 1544669c7a | |||
| 63387c0d7d | |||
| 70c28c87ef | |||
| 2f50a8cc79 | |||
| 079a489f0e | |||
| 720057bc72 | |||
| d8bb2c7c0c | |||
| 5f5b13c6a4 | |||
| b394177207 | |||
| 3f48dc0e11 | |||
| c218864f36 | |||
| c22935c008 | |||
| 58a2f33187 | |||
| 363485d025 | |||
| eebb193fd3 | |||
| 6aac4a73f6 | |||
| 81ea43a70a | |||
| b8f6226fb1 | |||
| 20a1da0a0e | |||
| 90de9831c3 | |||
| f91cb2a660 | |||
| bf9559c61a | |||
| fbdbc64366 | |||
| 08419e4434 | |||
| 2ebaa82f85 | |||
| 79d9ea55d8 | |||
| 86e66cc5bd | |||
| 42ea1a7c58 | |||
| 10e05700cc | |||
| 11acdac337 | |||
| 4eb3312e9b | |||
| 574998021e | |||
| cc4d757fce | |||
| 253f8ff97b | |||
| 044aecb034 | |||
| 1c7464c2ce | |||
| 8bcf05df26 | |||
| cb4aa703ec | |||
| 4d227a4999 | |||
| f58ddba93e | |||
| 29970ad8b2 | |||
| 85af98cded | |||
| 107be235f5 | |||
| 9e7d98a47a | |||
| 0a5be942ba | |||
| 67d5835eb8 | |||
| 1d8c4e29c8 | |||
| 943696efbd | |||
| 7d1fc49e35 | |||
| ad04291d39 | |||
| 8d0f8ad17b | |||
| cf7c918bed | |||
| a9db657541 | |||
| c7941a63ec | |||
| dede33cdff | |||
| e203474548 | |||
| 00d362ec5d | |||
| bfc352c7b5 | |||
| 653b951d30 | |||
| b3b7c48b8f | |||
| 0966a49afb | |||
| 5dbe970dbc | |||
| c4f3048248 | |||
| f15cc896d6 | |||
| 6ad0a387fe | |||
| 0fa2281ada | |||
| 9f1ad67536 | |||
| 1da1585ca8 |
@@ -101,6 +101,8 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
||||
```
|
||||
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||
|
||||
**1.5ד. המשך-עבודה על תיק — קרא predecessor תחילה (seance, #220).** אם אתה מתעורר-מחדש על תיק שכבר עבדת עליו (continuation — issue פתוח עם היסטוריית-ריצות), **לפני** שתגלה-מחדש את ההקשר מאפס, קרא את הכלי `legal_predecessor_context(case_number=<מספר-התיק>)`. הוא מחזיר את מסקנות הריצות-הקודמות על התיק (ה-`summary` שכל heartbeat השאיר, newest-first) — כך תמשיך מנקודת-העצירה במקום לחזור על עבודה שכבר בוצעה (מונע את ה-*blind heartbeat*). אם אין ריצות-קודמות עם מסקנות — המשך רגיל.
|
||||
|
||||
---
|
||||
|
||||
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||
|
||||
@@ -15,8 +15,12 @@ tools:
|
||||
- mcp__legal-ai__document_get_text
|
||||
- mcp__legal-ai__extract_claims
|
||||
- mcp__legal-ai__extract_appraiser_facts
|
||||
- mcp__legal-ai__get_appraiser_facts
|
||||
- mcp__legal-ai__get_claims
|
||||
- mcp__legal-ai__aggregate_claims_to_arguments
|
||||
- mcp__legal-ai__get_legal_arguments
|
||||
- mcp__legal-ai__analyze_protocol
|
||||
- mcp__legal-ai__get_protocol_analysis
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- mcp__legal-ai__search_decisions
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
@@ -123,9 +127,9 @@ tools:
|
||||
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
|
||||
- **מסמך גדול (>15,000 תווים):** מאז phase 1 של מערכת הניתוח, ה-chunking הסמנטי + מקבילות + retry מטופל אוטומטית. גם מסמך של 100K+ תווים ירוץ עד הסוף. אם בכל זאת נכשל — דווח ב-issue.
|
||||
- **טיפול בכשל:** אם `extract_claims` החזיר `partial=true` או 0 טענות ממסמך לא ריק — נסה שוב פעם אחת. אם עדיין נכשל — סטטוס issue = `blocked`, פרסם comment עם הפירוט.
|
||||
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.**
|
||||
5. **חלץ עובדות שמאי** — לכל מסמך `doc_type='appraisal'` בתיק, הרץ `extract_appraiser_facts(case_number)` (פעם אחת לתיק, מטפל בכל השומות). **חובה בכל ערר השבחה (8xxx) ופיצויים (9xxx) — בלי זה ה-writer לא יוכל לכתוב את בלוק ז עם מספרים מדויקים.** מיד אחריו הרץ `mcp__legal-ai__get_appraiser_facts(case_number)` כדי **לקרוא בחזרה** את מה שנשמר ולוודא שהחילוץ אכן נחת — אל תדווח על חילוץ שלא אימתת בקריאה.
|
||||
6. וודא שכל פריט מסווג ל-claim_type הנכון
|
||||
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור (ראה `get_legal_arguments`).
|
||||
7. **קבץ טענות לטיעונים משפטיים** — לאחר שכל הטענות חולצו וסוּוגו, הרץ `aggregate_claims_to_arguments(case_number)` שמקבץ את הפרופוזיציות הגולמיות לטיעונים משפטיים מובחנים (~6-12 לכל צד). זהו קלט מובנה לבלוק ז (טענות הצדדים) ולבלוק י (דיון) — הכותב נשען עליו. אם 0 טענות חולצו — דלג. הפלט עובר שער-אישור — קרא אותו בחזרה עם `mcp__legal-ai__get_legal_arguments(case_number)` ואמת שמספר הטיעונים לכל צד סביר לפני שאתה ממשיך.
|
||||
|
||||
### שלב 2: ניתוח מעמיק
|
||||
הצג במבנה הבא:
|
||||
@@ -143,6 +147,8 @@ tools:
|
||||
**טענות סף** (אם קיימות):
|
||||
חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. לכל טענת סף הוסף **עמדת ועדת הערר** (שדה ריק ליו"ר). אם אין — כתוב: "לא זוהו טענות סף."
|
||||
|
||||
**מבנה השדות — זהה לזה של הסוגיות:** כל טענת סף היא H3 משלה, והשדות בתוכה נכתבים בתחילת שורה (`**טענה (claim):**`) — **לא** כפריטי רשימה (`- **טענה (claim):**`) ולא בכותרת מעוטרת. השדה `**עמדת ועדת הערר:**` נכתב בשורה נפרדת והערך מתחתיו. זהו השדה שדפנה עורכת ישירות מה-UI — סטייה מהפורמט מסתירה אותו ואת יתר השדות של אותה טענה מהמסך.
|
||||
|
||||
**תקן ביקורת**: ציין את תקן הביקורת של הוועדה בתיק זה — "הוועדה מפעילה שיקול דעת תכנוני עצמאי" (ברישוי) או "הוועדה בוחנת את תקינות השומה המכרעת" (בהיטל השבחה) או תקן אחר לפי סוג ההליך.
|
||||
|
||||
**מפת דרכים**: לאחר זיהוי טענות הסף ולפני הדיון בסוגיות — כתוב פסקת מפה: "X שאלות עומדות להכרעה: (1)...; (2)...; (3)..." — כדי שהקורא ידע מראש מה לצפות.
|
||||
@@ -234,7 +240,7 @@ search_precedent_library(
|
||||
|
||||
### 5ד. תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`analysis-and-research.md`
|
||||
|
||||
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא:
|
||||
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא. (זהו סעיף 7א שמופיע גם בתבנית-הפלט המלאה שבהמשך — **אותו סעיף אחד**, לא תבנית נפרדת.)
|
||||
|
||||
```markdown
|
||||
## 7א. שאילתות לקורפוסים — log מלא
|
||||
@@ -268,6 +274,29 @@ search_precedent_library(
|
||||
|
||||
**מינימום:** מספר queries ב-Q1+Q2+Q3 לקורפוס הסמכותי = מספר טענות סף + מספר סוגיות מרכזיות. אם זיהית 5 סוגיות + 2 טענות סף → לפחות 7 queries.
|
||||
|
||||
## משימה על-פי-דרישה: ניתוח פרוטוקול-הדיון
|
||||
|
||||
**זו אינה חלק מהזרימה הרגילה** — היא מגיעה כ-issue ייעודי כשחיים לוחץ "נתח את פרוטוקול הדיון" בפאנל **"מה קרה בדיון"** (טאב טיעונים-ועמדות). ה-issue יאמר במפורש להריץ `analyze_protocol`.
|
||||
|
||||
```
|
||||
mcp__legal-ai__analyze_protocol(case_number="<מספר-התיק>")
|
||||
mcp__legal-ai__analyze_protocol(case_number="<מספר-התיק>", document_id="<uuid>") # כשיש כמה פרוטוקולים
|
||||
```
|
||||
|
||||
הכלי משווה את **פרוטוקול דיון ועדת-הערר** מול **הטיעונים המאוגדים**, ומסווג כל שינוי ל-`strengthened` / `newly_raised` / `dropped`, עם `evidence_quote` מהפרוטוקול לכל שורה (INV-AH — אין שורה בלי ציטוט).
|
||||
|
||||
**שני תנאים מוקדמים — בדוק אותם לפני שאתה מריץ:**
|
||||
1. **פרוטוקול של ועדת-הערר.** הכלי בוחר אוטומטית פרוטוקול שה-`protocol_scope` שלו אינו `lower`; פרוטוקול של הוועדה **המקומית** מזין רקע (בלוק ו) בלבד ולא מוביל את ההשוואה. אם כל הפרוטוקולים בתיק הם `lower` — אין דיון-ערר להשוות אליו.
|
||||
2. **טיעונים מאוגדים.** הרץ `get_legal_arguments(case_number)`. אם ריק — הרץ קודם `aggregate_claims_to_arguments`.
|
||||
|
||||
אם תנאי חסר — **אל תעקוף**: כתוב comment בעברית שמפרט מה חסר, וסגור `blocked`.
|
||||
|
||||
`re-run` מחליף את הניתוח הקודם (idempotent). לקריאה בלבד, בלי ניתוח מחדש: `mcp__legal-ai__get_protocol_analysis(case_number, change_type="")`.
|
||||
|
||||
**דווח ב-comment בעברית**: כמה טענות התחזקו, כמה נטענו לראשונה, כמה ירדו — ומה החידוד המרכזי שעלה בדיון.
|
||||
|
||||
> ⚠️ **הרץ את הכלי בקדמה — לעולם לא ברקע.** שיגור ל-background וסיום התור מסיים את ה-run, וקבוצת-התהליכים נהרגת יחד איתו: הניתוח נקטע ולא נשמר דבר (נצפה ב-CMP-229, 2026-08-04). הכלי לוקח כמה דקות; זה תקין. חכה לו.
|
||||
|
||||
## שלב 6: בדיקת שלמות — לפני שמסיימים!
|
||||
|
||||
**לפני סיום, בצע את הבדיקות הבאות. אם בדיקה נכשלת — אל תסיים כ-"done".**
|
||||
@@ -323,7 +352,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
|
||||
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג.
|
||||
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
||||
|
||||
## מבנה הפלט המלא — analysis-and-research.md
|
||||
@@ -347,7 +376,18 @@ wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח מ
|
||||
1. ...
|
||||
|
||||
## 5. טענות סף
|
||||
[אם קיימות — כולל שאלות משפטיות + עמדת ועדת הערר לכל טענה]
|
||||
[אם אין — "לא זוהו טענות סף." אם יש — טענה אחת לכל H3, במבנה הזה:]
|
||||
|
||||
### ס-1 — [כותרת הטענה] ([מי מעלה])
|
||||
|
||||
**טענה (claim):** ...
|
||||
**תשובה (response):** ...
|
||||
**שאלה משפטית:** ...
|
||||
|
||||
**עמדת ועדת הערר:**
|
||||
[ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
### ס-2 — ...
|
||||
|
||||
**תקן ביקורת:** [שיקול דעת עצמאי / בחינת תקינות השומה / אחר]
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ tools:
|
||||
- mcp__legal-ai__halacha_corroboration
|
||||
- mcp__legal-ai__corroboration_rebuild
|
||||
- mcp__legal-ai__extract_appraiser_facts
|
||||
- mcp__legal-ai__get_appraiser_facts
|
||||
- mcp__legal-ai__extract_plans
|
||||
- mcp__legal-ai__plan_get
|
||||
- mcp__legal-ai__plan_search
|
||||
@@ -704,6 +705,8 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
```
|
||||
⚠️ אם מחזיר `status="sides_missing"` → דווח לחיים שאין תיוג `appraiser_side` במסמכי השומה (`document_update` עם `appraiser_side` בערכים `committee`/`appellant`/`deciding`). עצור עד שיתוקן.
|
||||
|
||||
אחרי החילוץ — הרץ `mcp__legal-ai__get_appraiser_facts(case_number="...")` כדי **לקרוא בחזרה** ולוודא שהעובדות אכן נשמרו. חילוץ שדיווח הצלחה אך לא נקרא בחזרה אינו ראיה שהנתונים שם.
|
||||
|
||||
אם הטבלה כבר מלאה — `write_interim_draft` ידלג על ההרצה אוטומטית, אז גם בלי הצעד הזה זה יעבוד.
|
||||
|
||||
3. **כתיבת 5 הבלוקים:**
|
||||
@@ -820,44 +823,38 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
|
||||
---
|
||||
|
||||
**תבנית issue לכותב ההחלטה — חובה בכל issue שמוקצה לכותב:**
|
||||
**מסמך-ההכוונה לכותב — הפק את התדריך שהיית רוצה לקבל, לא טופס למילוי:**
|
||||
|
||||
כל issue לכותב חייב לכלול את **כל** הסעיפים הבאים. אסור לשלוח issue עם משפט כמו "הועבר לכתיבה" — זה חסר תועלת. הכותב צריך הכל מוכן מראש.
|
||||
כשאתה מעביר תיק לכותב אתה מבצע את **פעולת-ההיסק המרכזית שלך**: להמיר את ניתוח-המנתח + הכרעות-היו"ר למסמך שמאפשר לכותב לנסח החלטה חדה בסגנון דפנה **בלי לחזור אליך**. אל תמלא טופס — הפעל שיפוט משפטי. תדריך טוב:
|
||||
|
||||
- **מוביל בהכרעה ובסוגיה המכריעה** — קבע איזו סוגיה נושאת את התוצאה ומה מייתר את מה, והצב אותה ראשונה.
|
||||
- **בונה כל סוגיה כסילוגיזם** (כלל → עובדות → מסקנה) עם התקדים והמסמך הספציפיים.
|
||||
- **מזהה אדנים עצמאיים** — אם יותר מנימוק אחד מספיק לבדו לתוצאה, אמור זאת מפורשות, כך שנפילת אדן בערעור לא תפיל את ההחלטה.
|
||||
- **בודק עקביות פנימית** — אם שתי הכרעות עלולות להיראות סותרות (למשל דחיית טענה פרשנית אחת וקבלת אחרת), סמן את המתח והסבר את האבחנה לפני שעורך-דין יטען לו.
|
||||
- **עונה לנקודה החזקה של הצד המפסיד** — לא מתעלם ממנה.
|
||||
- **משקלל את הכרעות-היו"ר** ומעביר אותן מילולית.
|
||||
|
||||
**מה התדריך חייב להכיל** (החוזה מול הכותב — אל תשמיט אף רכיב; אל תשלח issue עם "הועבר לכתיבה"):
|
||||
|
||||
```markdown
|
||||
## הנחיות כתיבה — ערר {case_number}
|
||||
|
||||
### 1. תוצאה ומצב
|
||||
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה}
|
||||
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא לקובץ + הנחיה "קרא את הטיוטה, השתמש בה כבסיס, אל תכתוב מאפס"
|
||||
- **הוראות עריכה מתוך הטיוטה:** {רשימה מדויקת של מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}
|
||||
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה} — עם נימוק קצר ומהי הראיה הניצחת.
|
||||
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא + "קרא, השתמש כבסיס, אל תכתוב מאפס".
|
||||
- **הוראות עריכה מהטיוטה:** {מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}.
|
||||
|
||||
### 2. סדר סוגיות + מבנה סילוגיסטי
|
||||
לכל סוגיה שצריך לכתוב/לערוך — מבנה סילוגיסטי מלא:
|
||||
|
||||
**סוגיה N: {כותרת}**
|
||||
- סוג ניתוח: {כלל ברור / איזון אינטרסים / מידתיות / שיקול דעת}
|
||||
- כלל (הנחה עליונה): {הוראת תכנית / סעיף חוק / הלכה — ציטוט מדויק}
|
||||
- עובדות (הנחה תחתונה): {העובדות הספציפיות שצריך להחיל — הפנייה למסמך מקור ספציפי}
|
||||
- מסקנה: {מה נובע מהחלת הכלל על העובדות}
|
||||
- תקדימים: {שם פסק דין + מה הוא קובע + למה רלוונטי}
|
||||
- מסמכי מקור: {שמות קבצים ספציפיים ב-data/cases/{case_number}/documents/originals/}
|
||||
### 2. סוגיות — סדר סילוגיסטי, המכריעה מובילה
|
||||
לכל סוגיה: סוג-ניתוח (כלל ברור / איזון / מידתיות / שיקול-דעת) · כלל (ציטוט מדויק של הוראת-תכנית/חוק/הלכה) · עובדות (בהפניה למסמך-מקור ספציפי) · מסקנה · תקדימים (שם + מה קובע + רלוונטיות) · מסמכי-מקור (ב-data/cases/{case_number}/documents/originals/). סמן אדנים עצמאיים, מוקשי-עקביות ומענה לצד המפסיד היכן שהם קיימים.
|
||||
|
||||
### 3. טיפול בטענות
|
||||
| # | טענה | טיפול | סוגיה |
|
||||
|---|------|-------|-------|
|
||||
| 1 | {טענה} | דיון מלא / קיבוץ / דילוג | {באיזו סוגיה} |
|
||||
...
|
||||
טבלה: # | טענה | טיפול (דיון מלא / קיבוץ / דילוג) | סוגיה.
|
||||
|
||||
### 4. chair directions
|
||||
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions")
|
||||
### 4. הנחיות-היו"ר
|
||||
העתק מילולי של עמדות-הוועדה מ-analysis-and-research.md (או "קרא get_chair_directions"), **עטוף ב-`<chair_directions>…</chair_directions>`** — טקסט מילולי בלבד בלי פרפרזה, כדי שהכותב לא ידרוס אותן.
|
||||
|
||||
### 5. הנחיות סגנון
|
||||
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
|
||||
- ללא כפילות: בלוק י מפנה לבלוקים קודמים
|
||||
- טענות מקוריות: בלוק ז = כתבי טענות מקוריים
|
||||
- אורך מינימלי לדיון: 1,500 מילים לבלוק י
|
||||
- פסיקה: חובה לצטט לפחות 3 תקדימים בדיון
|
||||
ניטרליות (בלוק ו = עובדות בלבד, בלי ציטוטי-צדדים) · ללא כפילות (בלוק י מפנה לקודמים) · טענות מקוריות (בלוק ז) · דיון ≥ 1,500 מילים · ≥ 3 תקדימים בדיון.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -96,7 +96,7 @@ tools:
|
||||
"id": "r1",
|
||||
"type": "insert_after", // או insert_before, replace, delete
|
||||
"anchor_bookmark": "block-yod", // מ-list_bookmarks
|
||||
"content": "וכך נפסק בעניין פלוני. בבג\"ץ 1234/21 קבע השופט...",
|
||||
"content": "<תוכן ההוספה — טקסט עברי נקי, בלי ציטוט מומצא>",
|
||||
"style": "body", // או heading, quote
|
||||
"reason": "הוספת פסק הלכה שחסר לפי בקשת יו\"ר"
|
||||
}
|
||||
@@ -128,7 +128,7 @@ tools:
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
|
||||
@@ -96,5 +96,4 @@ tools:
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל / markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked` (`blocked` אם markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
@@ -17,6 +17,7 @@ tools:
|
||||
- mcp__legal-ai__search_precedent_library
|
||||
- mcp__legal-ai__search_internal_decisions
|
||||
- mcp__legal-ai__precedent_library_get
|
||||
- mcp__legal-ai__precedent_library_list
|
||||
- mcp__legal-ai__precedent_list
|
||||
- mcp__legal-ai__halacha_review
|
||||
---
|
||||
@@ -56,6 +57,8 @@ tools:
|
||||
|
||||
## 7 בדיקות
|
||||
|
||||
> **סף-הקבלה המשותף — `docs/qa-acceptance-rubric.md`:** 7 הבדיקות למטה הן האכיפה המפורטת שלו (מקור-אמת אחד; הכותב מכוון לאותו rubric). בדיווח — מפה כל `fail` לחוסם/אזהרה שם.
|
||||
|
||||
### 1. שלמות מבנית (structural_integrity)
|
||||
- כל בלוקי חובה קיימים (ה עד יא)
|
||||
- מספור רציף ללא קפיצות
|
||||
@@ -232,14 +235,24 @@ new → processing → documents_ready → analyst_verified → research_complet
|
||||
6. האם יש אנלוגיה לתקדים ללא הסבר מדיניות (למה הדמיון רלוונטי)?
|
||||
|
||||
### שלב 3: דיווח — חובה!
|
||||
פרסם comment ב-Paperclip עם:
|
||||
- תוצאת כל בדיקה (pass/fail)
|
||||
- רשימת שגיאות מפורטת (אם יש)
|
||||
- האם מותר לייצא (כל הקריטיים pass?)
|
||||
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
|
||||
פרסם comment ב-Paperclip **במבנה קבוע** (markdown — נקרא ע"י היו"ר וע"י ה-CEO), כדי שהפסיקה תהיה עקבית וניתנת-לפרסינג. מלא שורה לכל אחת מ-7 הבדיקות:
|
||||
|
||||
```markdown
|
||||
## דוח בדיקת איכות — ערר {case_number}
|
||||
|
||||
| בדיקה | תוצאה | חומרה | ממצא (אם fail) |
|
||||
|-------|:-----:|:-----:|-----------------|
|
||||
| {שם הבדיקה} | pass / fail | critical / major / minor | {פירוט קצר} |
|
||||
| ... שורה לכל 7 הבדיקות ... | | | |
|
||||
|
||||
**מותר לייצא:** כן / לא ← "לא" אם ולו בדיקה **critical** אחת ב-fail
|
||||
**סטטוס חדש:** drafted (עבר) / qa_review (נכשל)
|
||||
```
|
||||
|
||||
- עדכן סטטוס בהתאם: `drafted` (אם עבר) או `qa_review` (אם נכשל).
|
||||
|
||||
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
@@ -410,7 +410,7 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
## כללים
|
||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||
|
||||
@@ -53,6 +53,8 @@ tools:
|
||||
|
||||
## לפני שאתה מתחיל — קרא!
|
||||
|
||||
> **המטרה שלך — `docs/qa-acceptance-rubric.md`:** זהו סף-הקבלה שה-QA יאכוף. כתוב כדי לעבור אותו מהפעם הראשונה, ולפני מסירה ל-CEO עבור על החוסמים ואשר שכל אחד מתקיים.
|
||||
|
||||
### חובה לפני כל כתיבה — נקודת ההתחלה:
|
||||
0. **עץ ההחלטה: `docs/daphna-decision-tree.md`** — **כלי הפעולה היומיומי**. מאחד את כל המסמכים לתהליך אנליטי קצר: מהי הראיה הניצחת? איזה ארכיטקטורה? איזה מוד פתיחה? איזה אורך? **תמיד להתחיל כאן** — המסמך מצביע איזה מסמך אחר לקרוא לפי השאלה.
|
||||
|
||||
@@ -122,9 +124,11 @@ tools:
|
||||
4. החזר את הטקסט ל-CEO (בקומנט או כ-return value) — **לא** שומר ב-DB
|
||||
5. CEO יקרא ל-`revise_draft` עם הטקסט שלך
|
||||
|
||||
**דוגמה לפלט מצופה:**
|
||||
**דוגמה לפלט מצופה** (עטופה בתגית כדי שלא תתבלבל עם הוראה):
|
||||
|
||||
> בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
|
||||
<example_output>
|
||||
בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
|
||||
</example_output>
|
||||
|
||||
---
|
||||
|
||||
@@ -218,18 +222,13 @@ case_update(case_number, status="drafted")
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
||||
|
||||
## בלוק י — דיון (הבלוק החשוב ביותר)
|
||||
|
||||
**קריאת חובה לפני כתיבה (5 מסמכים)**:
|
||||
1. `docs/daphna-voice-fingerprint.md` — קבועים, פעלי "אנחנו", אנטי-דפוסים
|
||||
2. `docs/daphna-precedent-network.md` — לכל סוגיה משפטית, איזה תקדם
|
||||
3. `docs/daphna-architecture-by-outcome.md` — מבנה לפי תוצאה + עץ החלטה
|
||||
4. `docs/daphna-acceptance-architecture.md` — **חובה אם תוצאה צפויה: קבלה (לא חלקית).** חמש תבניות מובחנות
|
||||
5. `docs/voice-1130-25.md` — דוגמה עמוקה
|
||||
> קרא קודם את **"חובה לפני בלוק י (חמישיית הקול)"** שלמעלה — חמשת מסמכי-הקול.
|
||||
|
||||
**עץ החלטה לבחירת ארכיטקטורה**:
|
||||
1. מה התוצאה?
|
||||
@@ -373,6 +372,8 @@ case_update(case_number, status="drafted")
|
||||
|
||||
### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions`
|
||||
|
||||
> **מקור העמדות:** בדרך-כלל מ-`get_chair_directions` (שלב 1ג). אם ה-CEO **העתיק** עמדות-יו"ר לתוך ה-issue שלך, הן מגיעות עטופות ב-`<chair_directions>…</chair_directions>` — טקסט מילולי זה **מחייב אותך בדיוק כמו** `chair_ruling` מהכלי: אל תדרוס, אל תפרפרז, נסח בעדו.
|
||||
|
||||
עבור **כל טענת סף** ו**כל סוגיה** ב-direction_doc שבנית בשלב 1ג:
|
||||
|
||||
1. **פתח את הדיון במסקנה של דפנה** — למשל "**טענת הסף הראשונה נדחית**"
|
||||
|
||||
25
.gitea/workflows/agent-tool-grants.yaml
Normal file
25
.gitea/workflows/agent-tool-grants.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: INV-AG3 Agent Tool Grants
|
||||
|
||||
# Hard gate for INV-AG3 (docs/spec/X4-agents.md §2א): a subagent's `tools:`
|
||||
# frontmatter is a CLOSED allow-list, so any MCP tool an agent is TOLD to run —
|
||||
# by the backend delegation in web/, or by its own instructions — must appear
|
||||
# there. Built after analyze_protocol shipped without a grant (2026-06-30) and
|
||||
# the analyst was handed an issue instructing it to run a tool it could not
|
||||
# call (CMP-229, 2026-08-04). Pure-stdlib check (no venv) — fast, runs on every
|
||||
# PR and on push to main.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
agent-tool-grants:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: INV-AG3 — agent tool-grant guard
|
||||
run: python3 scripts/agent_tool_grants_guard.py
|
||||
40
CLAUDE.md
40
CLAUDE.md
@@ -1,6 +1,6 @@
|
||||
# עוזר משפטי — Legal Decision Assistant
|
||||
|
||||
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, TaskMaster מלא) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
|
||||
> **אינדקס דק.** הכללים הקריטיים נמצאים כאן; העומק התפעולי (Deploy, Paperclip-ops, adapters, מבנה-תיקיות, Chair-Feedback, עבודה מול Gitea Issues) הוצא ל-[`docs/operations-runbook.md`](docs/operations-runbook.md) כדי לרזות את ההקשר הנטען בכל סשן.
|
||||
|
||||
## רקע הפרויקט
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
| [`.claude/agents/HEARTBEAT.md`](.claude/agents/HEARTBEAT.md) | checklist הפעלת סוכן — routing, company filtering, quirks, wakeup עם UUID נכון | **לפני כל עבודה על סוכנים** |
|
||||
| [`skills/dafna-decision-template/SKILL.md`](skills/dafna-decision-template/SKILL.md) | export DOCX לפי styles של תבנית Word של דפנה — line classification, dash policy, placeholder handling | לפני export DOCX |
|
||||
| [`docs/corpus-graph.md`](docs/corpus-graph.md) | **מפת הקורפוס** (`/graph`) — גרף ציטוטים אינטראקטיבי נייטיב; 6 שכבות (פסיקה/נושא/תחום/הלכות/חוסרי‑מחקר/יומונים), אנליטיקה (PageRank/אשכולות), endpoints, ואיך מוסיפים שכבה | לפני עבודה על דף `/graph` או `web/graph_api.py` |
|
||||
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, TaskMaster | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
|
||||
| [`docs/taskmaster-to-gitea-migration.md`](docs/taskmaster-to-gitea-migration.md) | **מיגרציית ניהול-המשימות** — TaskMaster→Gitea Issues (2026-08-05), טבלת מיפוי מלאה, ומלכודת המספור בהפניות `(#N)` ישנות | כשנתקלים בהפניה `(#N)` בקומיט ישן, או כשמחפשים משימה שהייתה ב-TaskMaster |
|
||||
| [`docs/operations-runbook.md`](docs/operations-runbook.md) | **עומק תפעולי** — Deploy (Coolify/pm2), Paperclip-ops מלא (wakeup, sync, webhook, scheduled jobs, adapters), מבנה-תיקיות, Chair-Feedback, Gitea Issues API | לפני עבודה על Deploy / אינטגרציית-Paperclip / adapters |
|
||||
|
||||
---
|
||||
|
||||
@@ -114,7 +115,7 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
|
||||
6. **אל תיגע** בשינויים לא-מתויקים שאינם שלך בעץ הראשי — הם של סשן אחר. אם העץ הראשי על ענף זר — אל תתייק עליו.
|
||||
|
||||
> **בידוד-DB:** ה-worktree מבודד-קבצים בלבד — לא בידוד-repo ולא בידוד-DB. **אל תריץ migrations מ-2 worktrees במקביל** על Postgres המשותף (`localhost:5433`) — סכמה שאף סשן לא מצפה לה ([Run agents in parallel](https://code.claude.com/docs/en/agents)).
|
||||
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
|
||||
> **סוכני Paperclip — אינם מבודדים (אומת 2026-06-06):** 14 מתוך 16 הסוכנים רצים על אדפטר `claude_local` הרשמי, שמריץ `claude -p` ב-`adapter_config.cwd=/home/chaim/legal-ai` **המשותף** — אין לו אופציית `worktreeMode`/`-w`. כלומר **כל סוכני Paperclip חולקים את עץ-העבודה הראשי**. הסיכון ממותן ע"י כלל הסשנים נתמך-הסביבה למעלה + תזמור סדרתי ע"י ה-CEO — **לא** ע"י בידוד-worktree per-agent. ניתוח מלא: ארכיון TaskMaster `legal-ai` #104 (נסגר cancelled — "לתעד, לא לבדד").
|
||||
|
||||
---
|
||||
|
||||
@@ -144,9 +145,36 @@ cd ~/legal-ai && claude --worktree <slug> # או, בתוך סשן: "עבוד
|
||||
## כלל: עדכון `scripts/SCRIPTS.md`
|
||||
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** (תפקיד, סטטוס, החלפה).
|
||||
|
||||
## ניהול משימות — TaskMaster AI
|
||||
**תמיד** TaskMaster (לא TASKS.md ידני). קובץ קנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (tags: `master`, `legal-ai`). פקודות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`.
|
||||
> **⚠️ מלכוד cwd ב-CLI:** `--tag` בוחר קבוצה *בתוך* הקובץ — לא לאיזה קובץ לכתוב (ה-CLI מאתר לפי cwd). תמיד `cd ~/legal-ai` לפני כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את הקובץ ישירות. פירוט: [`docs/operations-runbook.md`](docs/operations-runbook.md).
|
||||
## ניהול משימות — Gitea Issues ⚠️
|
||||
|
||||
> **מקור-האמת לעבודה הוא [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues) — לא TaskMaster, לא הקובץ הזה, לא זיכרון.**
|
||||
> **TaskMaster הוצא משימוש ב-2026-08-05.** 26 המשימות הפתוחות הועברו ל-issues #433–#458 (מיפוי מלא: [`docs/taskmaster-to-gitea-migration.md`](docs/taskmaster-to-gitea-migration.md)). `.taskmaster/tasks/tasks.json` נשמר לקריאה-בלבד כארכיון היסטורי — **אין לכתוב אליו ואין ליצור בו משימות חדשות.**
|
||||
|
||||
המוסכמות מגיעות מ-[`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template) — אותה מערכת תיוג ואותו workflow כמו שאר פרויקטי המשרד.
|
||||
|
||||
### Session-Start Protocol
|
||||
כשנפתח סשן עם "איפה אנחנו עומדים / מה נשאר / בוא נמשיך" — **אל תשחזר מצב מזיכרון.** קרא את ה-issues החיים קודם:
|
||||
1. `GET /api/v1/repos/ezer-mishpati/legal-ai/issues?state=open` (טוקן מ-`~/.git-credentials` או Infisical `/apps/legal-ai` `GITEA_TOKEN`).
|
||||
2. הבא בתור = ה-issue בעל העדיפות הגבוהה ביותר (`p0-blocker` → `p1-high` → `p2-normal` → `p3-low`; בתוך עדיפות — `type:bug` קודם).
|
||||
3. **דלג** על `status:in-progress` (מישהו עובד), `status:in-review` (יש PR), `status:needs-decision` (ממתין להכרעת חיים/דפנה), `status:blocked`, `status:deferred`.
|
||||
4. רק **אחרי** קריאת ה-issues — הצע את הצעד הבא.
|
||||
|
||||
### Issue-First
|
||||
- **שום קוד בלי issue.** כל תקלה/צורך/משימה → issue קודם.
|
||||
- כל issue חייב: **What & Why** + **Acceptance Criteria** + הקשר. issue ללא AC אינו `status:ready` ואינו נלקח.
|
||||
- **Branch naming:** `<type>/<issue>-<slug>` (`feat`/`fix`/`chore`/`refactor`/`docs`/`test`) — יצירת branch = בלעדיות על ה-issue.
|
||||
- ה-PR מפנה ל-issue (`Closes #N`) ומצהיר invariants (ראה [`.gitea/PULL_REQUEST_TEMPLATE.md`](.gitea/PULL_REQUEST_TEMPLATE.md)).
|
||||
|
||||
### מערכת התיוג
|
||||
| שכבה | ערכים |
|
||||
|------|-------|
|
||||
| `type:` | `bug` · `feature` · `chore` · `spike` |
|
||||
| `priority:` | `p0-blocker` · `p1-high` · `p2-normal` · `p3-low` |
|
||||
| `status:` | `ready` · `in-progress` · `in-review` · `needs-decision` · `blocked` · `deferred` · `good-first-issue` |
|
||||
| `size:` | `xs` (<חצי יום) · `s` (~יום) · `m` (2-3 ימים) · `l` (שבוע+ — לשקול פיצול) |
|
||||
| `area:` | `agents` · `retrieval` · `extraction` · `writing` · `backend` · `frontend` · `mcp` · `infra` · `docs` · `ci` · `security` |
|
||||
|
||||
> ⚠️ **הפניות `(#N)` בהודעות-commit מלפני 2026-08-05 מתייחסות למזהי TaskMaster, לא למספרי issue.** מספרי ה-issue של legal-ai מתחילים ב-#433; מתחת לזה המספרים שייכים ל-PRs ולשני issues ישנים.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
|
||||
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
|
||||
| legal-chat-service | גשר claude CLI לטאב הצ'אט ב-/training (pm2, loopback) | `127.0.0.1:8770` |
|
||||
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
|
||||
| Infisical | ניהול סודות — פרויקט **All Infrastructure** (`2c462576-b125-4279-b0ec-7220dbf51ccf`), env `main`. סודות המערכת מפוזרים על שלוש תיקיות-אחיות: `/apps/legal-ai` · `/apps/paperclip` · `/apps/paperclip-hermes` | `secret.marcus-law.co.il` |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,9 +195,33 @@ CEO שמתעורר מ-`weekly-feedback-job` כותב לקובץ בלבד — **
|
||||
|
||||
---
|
||||
|
||||
## ניהול משימות — TaskMaster AI (פירוט)
|
||||
## ניהול משימות — Gitea Issues (פירוט)
|
||||
|
||||
- קובץ המשימות הקנוני: `~/legal-ai/.taskmaster/tasks/tasks.json` (יחסי ל-project root, **לא** `~/.taskmaster/tasks/tasks.json`). מכיל את כל ה-tags של legal-ai (`master`, `legal-ai`).
|
||||
- פקודות עיקריות: `get_tasks`, `next_task`, `add_task`, `update_task`, `expand_task`
|
||||
- לפני התחלת עבודה → `next_task`; אחרי סיום → `update_task` עם status=done; משימה מורכבת → `expand_task`
|
||||
- **⚠️ מלכוד cwd ב-CLI:** הדגל `--tag` בוחר קבוצה לוגית *בתוך* הקובץ — הוא **לא** בוחר לאיזה `tasks.json` לכתוב. ה-CLI מאתר את הקובץ לפי ה-cwd. תמיד `cd ~/legal-ai` לפני `task-master add-task` או כל פקודה משנה, ואז אמת ב-MCP `get_tasks`. כשלא בטוחים — לערוך את `~/legal-ai/.taskmaster/tasks/tasks.json` ישירות.
|
||||
> **TaskMaster AI הוצא משימוש ב-2026-08-05.** מקור-האמת לעבודה הוא
|
||||
> [Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues).
|
||||
> ה-workflow, מערכת התיוג ו-Session-Start Protocol מתועדים ב-[`CLAUDE.md`](../CLAUDE.md);
|
||||
> טבלת המיפוי TaskMaster→issue ב-[`taskmaster-to-gitea-migration.md`](taskmaster-to-gitea-migration.md).
|
||||
|
||||
### עבודה מול ה-API
|
||||
הטוקן: `~/.git-credentials` (שורת `gitea.nautilus`) או Infisical `/apps/legal-ai` `GITEA_TOKEN`.
|
||||
|
||||
```bash
|
||||
API=https://gitea.nautilus.marcusgroup.org/api/v1/repos/ezer-mishpati/legal-ai
|
||||
T=$(grep -m1 'gitea.nautilus' ~/.git-credentials | sed 's|.*://[^:]*:\([^@]*\)@.*|\1|')
|
||||
|
||||
# מה הבא בתור (דילוג על in-progress/in-review/blocked/deferred/needs-decision)
|
||||
curl -s -H "Authorization: token $T" "$API/issues?state=open&labels=priority:p1-high"
|
||||
|
||||
# issue חדש
|
||||
curl -s -X POST -H "Authorization: token $T" -H "Content-Type: application/json" \
|
||||
"$API/issues" -d '{"title":"...","body":"## What & Why\n...\n## Acceptance Criteria\n...","labels":[<ids>]}'
|
||||
|
||||
# תוויות (id ← שם)
|
||||
curl -s -H "Authorization: token $T" "$API/labels?limit=100"
|
||||
```
|
||||
|
||||
### ארכיון TaskMaster
|
||||
`~/legal-ai/.taskmaster/tasks/tasks.json` **מוקפא לקריאה-בלבד** — 166 משימות שהושלמו,
|
||||
4 שבוטלו, ו-26 הפתוחות שהועברו. שימושי לחפירה היסטורית ("למה עשינו X"), אבל
|
||||
**אין לכתוב אליו**. אם צריך הקשר ממשימה ישנה — לחפש בקובץ הזה, ולפתוח issue חדש
|
||||
אם נדרשת עבודה.
|
||||
|
||||
@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
|
||||
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
||||
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
||||
- TaskMaster: `legal-ai` #149.
|
||||
|
||||
---
|
||||
|
||||
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
|
||||
|
||||
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
|
||||
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
|
||||
"כל התורים ריקים — אין עבודה".
|
||||
|
||||
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
|
||||
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
|
||||
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
|
||||
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
|
||||
ולכן זה נראה כאילו משהו נמסר.
|
||||
|
||||
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
|
||||
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
|
||||
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
|
||||
|
||||
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
|
||||
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
|
||||
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
|
||||
שאומת כמגיע לסוכן.
|
||||
|
||||
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
|
||||
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
|
||||
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
|
||||
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
|
||||
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
|
||||
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
|
||||
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
|
||||
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
|
||||
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
|
||||
|
||||
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
|
||||
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
|
||||
הרצת-סרק. TaskMaster `legal-ai` #228.
|
||||
|
||||
57
docs/qa-acceptance-rubric.md
Normal file
57
docs/qa-acceptance-rubric.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Rubric קבלה של החלטה — מקור-אמת אחד
|
||||
|
||||
מסמך זה הוא **הגדרת ה"מוכן"** של טיוטת החלטה: סף-הקבלה שהכותב **מכוון אליו מראש**
|
||||
וה-QA **אוכף** כשער. שני הסוכנים מפנים לכאן — אין לשכפל את הקריטריונים בקבצי-הסוכן.
|
||||
|
||||
**זהו סף-הקבלה, לא הוראת-הכתיבה.** ה"איך" המלא חי במסמכי-הקול המופנים בכל שורה
|
||||
(`daphna-voice-fingerprint.md`, `daphna-block-zayin-claims.md`,
|
||||
`daphna-architecture-by-outcome.md`, `daphna-acceptance-architecture.md`,
|
||||
`decision-methodology.md`, `anti-hallucination-gate.md`). כאן — רק המבחן.
|
||||
|
||||
> **לכותב:** לפני מסירה ל-CEO, עבור על החוסמים למטה ואשר שכל אחד מתקיים. טיוטה
|
||||
> שלא עוברת חוסם אחד — תוחזר ב-`needs_revision`. כתוב כדי לעבור מהפעם הראשונה.
|
||||
>
|
||||
> **ל-QA:** אלה הקריטריונים ש-7 הבדיקות שלך אוכפות. `fail` על חוסם = `qa_review`;
|
||||
> `fail` על אזהרה = דיווח לחיים, לא חוסם.
|
||||
|
||||
---
|
||||
|
||||
## חוסמי ייצוא (critical — כל אחד מכשיל את הטיוטה)
|
||||
|
||||
1. **שלמות מבנית** — בלוקים ה–יא קיימים; מספור רציף ללא קפיצות/כפילות; "להלן" בשימוש ראשון.
|
||||
2. **רקע ניטרלי (בלוק ו)** — עובדות בלבד; **אפס** ציטוטים מצדדים; **אפס** מילות-שיפוט
|
||||
("חריג", "בעייתי", "מגוחך", "פגום", "שערורייתי").
|
||||
3. **כיסוי טענות** — כל טענה מהותית מבלוק ז קיבלה מענה בבלוק י (ישיר / קיבוץ / ציון שנבחנה).
|
||||
טענות `[skip]` לא נספרות; `[bundle]` נבדקות כקבוצה. טענה מהותית לא-מסומנת שלא נענתה = כשל.
|
||||
4. **מתודולוגיה** — לכל סוגיה מבנה סילוגיסטי (כלל+עובדות+מסקנה); ממצא עובדתי מופרד ממסקנה
|
||||
משפטית; טענת הצד המפסיד הוצגה בחוזקתה (Steel-Man); איזון מפורש כשנדרש; בלי "נוסחאות ריקות";
|
||||
ציטוטים בסנדוויץ' (הקדמה→ציטוט→ניתוח).
|
||||
5. **קול דפנה** —
|
||||
- בלוק ז: כותרת **"תמצית טענות הצדדים"**; כותרת-משנה לכל צד; בלי רשימה ממוספרת בפסקה;
|
||||
בלי מילות-הערכה; בלי גילוי-מסקנה; קול פעיל ("העורר טוען").
|
||||
- בלוק י: כותרת **"דיון והכרעה"**; קול "אנחנו" תפקידי ("מצאנו", לא "הוועדה מוצאת");
|
||||
דפוס **"אכן... אולם"** לטענות שנדחות; בלי מספור-פסקאות סדרתי; ציטוטי פסיקה במלואם (4–15 שורות);
|
||||
"למעלה מן הצורך" לטיעונים מרכזיים; בלי תוצאת הכל-או-לא-כלום כשיש טענות מהותיות משני הצדדים.
|
||||
- 8xxx עם הכרעה שמאית → ציטוט **בר"מ 3644/13** קיים.
|
||||
6. **עיגון-מקור (INV-AH)** — כל אזכור פסיקה/חוק/הלכה/מספר מעוגן-מקור עם ציטוט; ציטוט חיצוני
|
||||
תואם להלכה מאושרת בקורפוס (`search_precedent_library`). אזכור לא-מעוגן = הסר או הורד ל-`[ספקולציה]`.
|
||||
7. **צירוף פסיקה ל-DB** — כל ציטוט בבלוק י (חיצוני/internal) רשום ב-`case_precedents` דרך
|
||||
`precedent_attach`. ציטוט שקיים רק בטקסט ולא ב-DB = חוסם (ה-exporter ו-Hermes קוראים מה-DB).
|
||||
8. **log שאילתות-קורפוס** — `analysis-and-research.md` §7א ו-`precedent-research.md` §ז קיימים,
|
||||
עם מינימום queries לקורפוס הסמכותי (`analyst ≥ טענות-סף + סוגיות`; `researcher ≥ סוגיות`)
|
||||
ותיעוד negative-evidence (גם 0-results).
|
||||
|
||||
## אזהרות (warning — מדווח לחיים, לא חוסם)
|
||||
- **משקלות בטווח:** ו 15–40% · ז 20–40% · י 32–50% · יא 2–9%.
|
||||
- **בלי כפילות:** בלוק י מפנה לבלוקים קודמים ("כאמור"), לא חוזר על עובדות/טענות.
|
||||
- **מראה-מקום מלא בציטוטים:** מספר-תיק מלא + ערכאה + תאריך/`פורסם בנבו` (+ `page_reference` לציטוט ארוך).
|
||||
|
||||
## אם תוצאה = קבלה (מ-`daphna-acceptance-architecture.md`)
|
||||
- התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבת-הקבלה, ופורמט-הסיום תואם לתבנית
|
||||
(A: "מתבטלת" + הודאת-צד-נגדי; B: "תיקבע לדיון"; C: "בכפוף לתיקונים" + הכרה בוועדה;
|
||||
D: "דרישת התשלום בטלה"; E: "השומה תושב לתיקון").
|
||||
|
||||
---
|
||||
|
||||
> **מקור-אמת:** חומרת-הבדיקות המחייבת חיה ב-`.claude/agents/legal-qa.md` ("7 בדיקות" + טבלת-חומרה)
|
||||
> וב-`docs/spec/05-qa-review.md`. Rubric זה הוא התמצית המשותפת — כשמשתנה שער QA, עדכן שם ואת התמצית כאן.
|
||||
@@ -146,9 +146,29 @@ another company`, [X2 §2](X2-multi-company.md)).
|
||||
**כלל:** ה-frontmatter `tools:` של כל סוכן מעניק **בדיוק** את הכלים שהוראותיו דורשות — כל כלי שההוראות
|
||||
מצריכות מוענק, וכלי שמוענק-ולא-בשימוש נבחן. מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||
(שערים מוגדרים) ו-[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים); מקביל ל-[X9 INV-TOOL6](X9-mcp-tool-contract.md).
|
||||
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/). (פרויקטלי-תפעולי.)
|
||||
**אכיפה:** בדיקת-עקביות tools↔instructions (FU-13 ✅ 2026-06-06). אכיפה אוטומטית עתידית — בתת-פרויקט 5 (spec-guardian).
|
||||
**הפרה ידועה:** — (טופל ב-FU-13: legal-analyst קיבל `aggregate_claims_to_arguments`; researcher כבר היה תקין; `extract_references`/`extract_internal_citations` הם מטלת-researcher, לא analyst — ראה §2א).
|
||||
|
||||
> **ה-frontmatter הוא allow-list סגורה.** כלי הרשום בשרת-ה-MCP אך חסר מהרשימה **אינו ניתן לקריאה**
|
||||
> ע"י הסוכן — גם כשהשרת מחובר לחלוטין. הסוכן חווה זאת כ"הכלים לא נחשפים לסשן", ובלי הבנת המנגנון
|
||||
> הוא נוטה **לעקוף** (SQL ישיר, סקריפט מקומי) במקום לדווח — וזה מסלול מקביל, כלומר הפרת G2.
|
||||
> **"מורים להריץ" כולל את ה-backend:** טקסט של issue שנוצר ב-`web/` ומכיל `mcp__legal-ai__X` הוא
|
||||
> הוראה לכל דבר, ולכן מחייב הענקה.
|
||||
|
||||
**מקור-סמכות:** frontmatter `tools:` מול ה-instructions בקבצי-[.claude/agents/](../../.claude/agents/)
|
||||
**ומול הוראות-ה-backend** ב-`web/`. (פרויקטלי-תפעולי.)
|
||||
**אכיפה:** ✅ **אוטומטית מ-2026-08-04** — [`scripts/agent_tool_grants_guard.py`](../../scripts/agent_tool_grants_guard.py),
|
||||
שער-CI קשיח ([`.gitea/workflows/agent-tool-grants.yaml`](../../.gitea/workflows/agent-tool-grants.yaml)),
|
||||
בדפוס [`leak_guard.py`](../../scripts/leak_guard.py) של G12. ארבעה כללים: (1) כל `mcp__legal-ai__X`
|
||||
ב-`web/` מוענק לסוכן כלשהו · (2) כל `mcp__legal-ai__X` בגוף קובץ-סוכן מוענק **באותו** קובץ ·
|
||||
(3) אין הענקה לכלי שאינו רשום בשרת · (4) שם-כלי בגרשיים-הפוכים ללא תחילית — מוענק, או מסווג
|
||||
מפורשות ב-`CONTRASTIVE_OK` (אזכור ניגודי / מטלת-סוכן-אחר / שם-עמודה מתנגש). לא-סוכנים ולכן
|
||||
מוחרגים: `hermes-curator.md` ו-`legal-analyst-gemini-critique.md` (בלי frontmatter בכוונה —
|
||||
האדפטר שולח פרומפט גולמי) ו-`HEARTBEAT.md` (checklist משותף).
|
||||
**הפרה ידועה:** — (היסטוריה: FU-13 ✅ 2026-06-06 — `aggregate_claims_to_arguments` ל-analyst;
|
||||
`extract_references`/`extract_internal_citations` הם מטלת-researcher, ראה §2א. **הישנות 2026-08-04**
|
||||
— האכיפה הידנית לא החזיקה: `analyze_protocol` (24e3e2f, 2026-06-30) נרשם בשרת בלי הענקה, ו-#226
|
||||
הוסיף delegation שמורה למנתח להריץ אותו → CMP-229 נשרף בשתי הרצות ועקף ל-SQL ידני. נסגר יחד עם
|
||||
`get_protocol_analysis`/`get_legal_arguments`/`get_appraiser_facts` ו-`precedent_library_list` ל-QA,
|
||||
והאכיפה הועברה ל-CI כדי שלא תישען שוב על משמעת ידנית.)
|
||||
|
||||
### INV-AG4: שער שטן-מליץ — red-team לידים לא-סמכותיים תחת אישור-יו"ר
|
||||
**כלל:** אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הפעלת הכותב, ה-CEO מפעיל
|
||||
|
||||
61
docs/taskmaster-to-gitea-migration.md
Normal file
61
docs/taskmaster-to-gitea-migration.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# מיגרציה: TaskMaster → Gitea Issues
|
||||
|
||||
**תאריך: 2026-08-05.** ניהול המשימות של legal-ai עבר מ-TaskMaster AI ל-[Gitea Issues](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues), לפי המוסכמות של [`infra/project-template`](https://gitea.marcus-law.co.il/infra/project-template).
|
||||
|
||||
## מה השתנה
|
||||
|
||||
- **מקור-האמת לעבודה = Gitea Issues.** לא TaskMaster, לא `CLAUDE.md`, לא זיכרון.
|
||||
- **`.taskmaster/tasks/tasks.json` הוקפא** — נשמר לקריאה-בלבד כארכיון היסטורי (166 משימות שהושלמו + 4 שבוטלו). אין לכתוב אליו ואין ליצור בו משימות חדשות.
|
||||
- **26 המשימות הפתוחות** הועברו ל-issues **#433–#458**, כל אחת עם `type` / `priority` / `area` / `status`, וגוף במבנה *What & Why → הקשר → Acceptance Criteria*.
|
||||
- **30 תוויות** נוצרו בריפו: 5 שכבות (`type`/`priority`/`status`/`size`/`area`). שכבות `type`/`priority`/`status`/`size` זהות לתבנית; שכבת `area` הותאמה לתחומי legal-ai.
|
||||
|
||||
## ⚠️ מלכודת מספור
|
||||
|
||||
ב-Gitea, issues ו-PRs חולקים מרחב-מספרים אחד. מספרי ה-issue של המיגרציה מתחילים ב-**#433** כי #1–#432 כבר תפוסים (2 issues ישנים + כל ה-PRs).
|
||||
|
||||
**לכן: כל הפניה `(#N)` בהודעת-commit מלפני 2026-08-05 מתייחסת למזהה TaskMaster — לא למספר issue.** למשל `fix(retrieval): ... (#232)` מפנה ל-TaskMaster #232, בעוד #232 ב-Gitea הוא PR ישן. הטבלה למטה היא הגשר.
|
||||
|
||||
## טבלת המיפוי
|
||||
|
||||
| TaskMaster | Gitea | תוויות | כותרת |
|
||||
|---|---|---|---|
|
||||
| #14 | [#433](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/433) | type:chore · priority:p3-low · status:deferred | Upgrade: speed up halacha+metadata extraction |
|
||||
| #116 | [#434](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/434) | type:chore · priority:p3-low · status:deferred | P2 (אופציונלי) — resume תוך-פאנל: פירוק הפאנל ל-map per-item |
|
||||
| #121 | [#435](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/435) | type:spike · priority:p1-high · status:needs-decision | מיפוי כל שערי-האישור-האנושי (חיים/דפנה) והכרעה פר-מקום: להחליף ב"קונצנזוס תלת-מודלי ללא-אדם"? |
|
||||
| #124 | [#436](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/436) | type:feature · priority:p3-low · status:ready | קטלוג עלון חודשי "עו"ד על נדל"ן" — פיצול ל-N רשומות digest (design-open) |
|
||||
| #125 | [#437](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/437) | type:chore · priority:p1-high · status:in-progress | אופרציונליזציה של לולאת רכישת-הסגנון — סגירת ממצאי אודיט #122 |
|
||||
| #126 | [#438](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/438) | type:chore · priority:p2-normal · status:in-progress | ניקוי persona "Hermes" → Curator + כיבוי self-learning אינרטי בפרופילי ה-curator |
|
||||
| #128 | [#439](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/439) | type:chore · priority:p2-normal · status:ready | X14 finalize: ניקוי-דיסק אחרי soak + replication off-site מתוזמן (SNSD) |
|
||||
| #129 | [#440](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/440) | type:chore · priority:p2-normal · status:ready | INV-STG1 Phase 2: read-wire ה-pipeline ל-ensure_local → כתיבת-בלוב ל-S3 בלבד (ללא דיסק) |
|
||||
| #147 | [#441](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/441) | type:bug · priority:p3-low | חילוץ-הלכות: אטומיות-מלאה ל-force re-extract (extract-then-swap / staging_run_id) |
|
||||
| #150 | [#442](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/442) | type:feature · priority:p2-normal | משיכת פרטי-תב"ע אוטומטית למרשם-התכניות (V38) + עיון בתכניות-מאושרות |
|
||||
| #154 | [#443](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/443) | type:feature · priority:p1-high | X11 Phase 2 — חיווט סמכות-תקדים מודעת-טיפול לסוכני-המחקר |
|
||||
| #155 | [#444](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/444) | type:bug · priority:p1-high | באג עמידות: ריצת-heartbeat כפולה על אותו issue דורסת טיוטת-קובץ תוך כדי כתיבה |
|
||||
| #163 | [#445](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/445) | type:bug · priority:p2-normal | חקירת אנומליית chunking — פסיקות עליון ארוכות מקבלות chunk יחיד → 0 הלכות |
|
||||
| #209 | [#446](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/446) | type:bug · priority:p3-low | Plugin status-sync מכוון ל-issue-ילד ומחזיר done→in_progress |
|
||||
| #217 | [#447](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/447) | type:chore · priority:p2-normal | בלוק-י: צמצום-הקשר ממוקד-רלוונטיות (איכות + מניעת-דחיסה) |
|
||||
| #218 | [#448](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/448) | type:feature · priority:p1-high · status:in-progress | Escalation primitive מדורג-חומרה בשער-הפלטפורמה (השראת Gastown gt escalate) |
|
||||
| #219 | [#449](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/449) | type:feature · priority:p1-high · status:in-progress | טלמטריה מובנית (OpenTelemetry) על שכבת-הסוכנים (השראת Gastown OTel) |
|
||||
| #220 | [#450](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/450) | type:feature · priority:p2-normal · status:in-progress | Seance — תשאול סשן-קודם לצמצום re-work של blind-heartbeats (השראת Gastown gt seance) |
|
||||
| #221 | [#451](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/451) | type:feature · priority:p2-normal · status:deferred | Molecules — פייפליין 12-הבלוקים כתבנית הצהרתית עם checkpoint-recovery (השראת Gastown formulas) |
|
||||
| #222 | [#452](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/452) | type:feature · priority:p2-normal · status:in-progress | טקסונומיית-בריאות מפורשת לסוכן ב-dashboard (Zombie/Stalled/Working/Idle — השראת Gastown gt feed --problems) |
|
||||
| #225 | [#453](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/453) | type:spike · priority:p2-normal · status:needs-decision | בית-אב לדוקטרינה-משפטית של דפנה (chair legal-doctrine home) — תכנון בלבד |
|
||||
| #228 | [#454](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/454) | type:bug · priority:p1-high | תשובת-interaction/הערה על issue בבעלות-אדם לא מעירה את ה-CEO — לנתב דרך פרימיטיב CEO-child (#227) |
|
||||
| #229 | [#455](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/455) | type:bug · priority:p1-high | block_writer שומר טקסט-שיחה של המודל לתוך תוכן הבלוק — נדרש שער-ניקוי בכתיבה |
|
||||
| #230 | [#456](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/456) | type:bug · priority:p2-normal | extractor._text_quality_ok לא מזהה סדר-קריאה RTL משובש ב-PDF דיגיטלי |
|
||||
| #231 | [#457](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/457) | type:bug · priority:p1-high · status:ready | סוכני Paperclip מנותקים משרת ה-MCP legal-ai — ACP הפך לברירת-מחדל ב-claude_local |
|
||||
| #233 | [#458](https://gitea.nautilus.marcusgroup.org/ezer-mishpati/legal-ai/issues/458) | type:bug · priority:p1-high · status:ready | צבירת-הטיעונים מוחקת צד שלם בשקט — 310 טענות עוררים → 0 טיעונים, status ok |
|
||||
|
||||
## משימות שנסגרו בזמן המיגרציה (לא הועברו)
|
||||
|
||||
| TaskMaster | למה נסגרה |
|
||||
|---|---|
|
||||
| #223 | PR #393 מוזג — בחירת פרוטוקול לפי `protocol_scope`. אומת ב-1069-04-26. |
|
||||
| #224 | PRs #392 + #403 מוזגו — `claims.party_name` במקור + פיצול משיבים. אומת ב-1069-04-26. |
|
||||
| #232 | PR #431 מוזג — 104 החלטות ועדות-ערר היו מסוננות מהחיפוש (סינון בברירת-מחדל). מלכודת 3 נסגרה ב-PR #432. |
|
||||
|
||||
## מה עדיין דורש עבודה ידנית
|
||||
|
||||
- **`size:` לא הוקצה** לאף issue — TaskMaster לא החזיק הערכות-גודל, ולא רציתי להמציא אותן. לפי כלל התבנית, issue ללא AC+size אינו `status:ready`.
|
||||
- **21 מתוך 26 issues ללא Acceptance Criteria** — הן הועברו מ-TaskMaster בלי `testStrategy`, וגוף ה-issue מסמן זאת במפורש. לפני לקיחת issue כזה — להגדיר AC, להקצות `size`, ולסמן `status:ready`.
|
||||
- 5 שכן קיבלו `status:ready` (היה להן `testStrategy`): #436, #439, #440, #457, #458.
|
||||
@@ -6,6 +6,7 @@ Run with: python -m legal_mcp.server
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -41,11 +42,56 @@ async def lifespan(server: FastMCP) -> AsyncIterator[None]:
|
||||
logger.info("Ezer Mishpati MCP server stopped")
|
||||
|
||||
|
||||
# HTTP listener address, used only when MCP_TRANSPORT selects an HTTP transport.
|
||||
#
|
||||
# These MUST be passed to the constructor rather than left to FastMCP's own
|
||||
# FASTMCP_HOST / FASTMCP_PORT environment settings: FastMCP.__init__ declares
|
||||
# `host: str = "127.0.0.1"` and `port: int = 8000` as explicit keyword defaults
|
||||
# and forwards them into Settings(**settings). In pydantic-settings, explicit
|
||||
# init kwargs outrank environment variables — so FASTMCP_PORT is silently
|
||||
# ignored and the server binds 8000 regardless (verified 2026-08-05; on this
|
||||
# host 8000 is already taken, so it failed loudly by luck rather than design).
|
||||
#
|
||||
# Default to loopback. The bearer gate below is the real protection; the narrow
|
||||
# bind is defence in depth, not a substitute for it.
|
||||
MCP_HTTP_HOST = os.environ.get("MCP_HTTP_HOST", "127.0.0.1")
|
||||
MCP_HTTP_PORT = int(os.environ.get("MCP_HTTP_PORT", "8790"))
|
||||
|
||||
# Bearer gate — wired only when an HTTP transport is actually selected (#231.2).
|
||||
#
|
||||
# stdio must never require a token: the pipe is the boundary there, and every
|
||||
# interactive session reaches us that way. Demanding a token on stdio would
|
||||
# break all of them for no security gain.
|
||||
#
|
||||
# Building the verifier at import time (rather than inside main()) is deliberate:
|
||||
# FastMCP takes `token_verifier` and `auth` as constructor arguments, so a
|
||||
# missing token has to fail here — before the listener exists — not after it is
|
||||
# already accepting connections.
|
||||
_http_transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() in ("sse", "streamable-http")
|
||||
_auth_kwargs: dict = {}
|
||||
if _http_transport:
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
|
||||
from legal_mcp.services.http_auth import StaticTokenVerifier, load_token
|
||||
|
||||
_base_url = f"http://{MCP_HTTP_HOST}:{MCP_HTTP_PORT}"
|
||||
_auth_kwargs = {
|
||||
"token_verifier": StaticTokenVerifier(load_token()),
|
||||
# AuthSettings is what switches on the SDK's BearerAuthBackend. We are a
|
||||
# resource server with a pre-shared token, not an OAuth client, so both
|
||||
# URLs simply point at ourselves — they exist to satisfy the protected-
|
||||
# resource metadata contract, and nothing issues tokens from them.
|
||||
"auth": AuthSettings(issuer_url=_base_url, resource_server_url=_base_url),
|
||||
}
|
||||
|
||||
# Create MCP server
|
||||
mcp = FastMCP(
|
||||
"Ezer Mishpati - עוזר משפטי",
|
||||
instructions="מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר",
|
||||
lifespan=lifespan,
|
||||
host=MCP_HTTP_HOST,
|
||||
port=MCP_HTTP_PORT,
|
||||
**_auth_kwargs,
|
||||
)
|
||||
|
||||
# ── Import and register tools ───────────────────────────────────────
|
||||
@@ -1260,7 +1306,44 @@ async def corroboration_rebuild(case_law_id: str = "") -> dict:
|
||||
|
||||
|
||||
def main():
|
||||
mcp.run(transport="stdio")
|
||||
"""Run the server on the transport named by ``MCP_TRANSPORT`` (default stdio).
|
||||
|
||||
ONE server, two transports — deliberately not a second implementation (G2).
|
||||
The tool registry, services and DB pool above are shared verbatim; only the
|
||||
wire protocol differs, so a tool can never exist on one transport and not
|
||||
the other.
|
||||
|
||||
- ``stdio`` (default) — the historical path. Every interactive Claude Code
|
||||
session reaches us this way via the ``legal-ai`` entry in ``~/.claude.json``.
|
||||
Changing this default would break them all, so it stays the default.
|
||||
- ``streamable-http`` — required by the agent-platform port. Agents driven
|
||||
over the Agent Client Protocol receive their MCP servers from the *client*
|
||||
at session start, and that channel accepts HTTP servers only: each is
|
||||
injected as ``{type:"http", name, url, headers:[Bearer]}``. A stdio server
|
||||
has no path into such a session at all, which is why platform-driven agents
|
||||
lost all 108 tools when their execution engine changed — TaskMaster #231.
|
||||
Platform-side wiring lives behind the port (``web/agent_platform_port.py``),
|
||||
not here; this module only has to be reachable over HTTP.
|
||||
|
||||
Host/port come from FastMCP's own ``FASTMCP_HOST`` / ``FASTMCP_PORT`` settings.
|
||||
Bind to loopback only: this transport carries no authentication yet, and the
|
||||
registry includes destructive tools (``case_delete``, ``precedent_library_delete``).
|
||||
The Bearer gate is #231.2 and MUST land before this is reachable off-host.
|
||||
"""
|
||||
transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() or "stdio"
|
||||
valid = ("stdio", "sse", "streamable-http")
|
||||
if transport not in valid:
|
||||
# Fail loudly — a typo must not silently fall back to stdio and leave
|
||||
# the HTTP listener absent while everything "looks" fine (§6).
|
||||
raise SystemExit(
|
||||
f"MCP_TRANSPORT={transport!r} is not one of {valid}",
|
||||
)
|
||||
if transport != "stdio":
|
||||
logger.info(
|
||||
"Serving MCP over %s on %s:%s",
|
||||
transport, mcp.settings.host, mcp.settings.port,
|
||||
)
|
||||
mcp.run(transport=transport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -147,6 +147,38 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
class AggregationFailed(RuntimeError):
|
||||
"""One side's aggregation call failed. Never swallowed — see #233."""
|
||||
|
||||
|
||||
#: Largest number of propositions sent to Claude in a single aggregation call.
|
||||
#:
|
||||
#: Above roughly this size the model stops returning a JSON array and the whole
|
||||
#: side is lost. The per-brief split added for the respondent side (#224) hid
|
||||
#: this by accident — it kept those calls small — but appellant and committee
|
||||
#: speak with a single voice and are never split by brief, so a big appeal goes
|
||||
#: out in one oversized call. 1069-04-26 sent 310 propositions and got nothing
|
||||
#: back (#233).
|
||||
#:
|
||||
#: Chunking is a real trade-off, not a free win: each chunk is grouped in
|
||||
#: isolation, so a side split across chunks can end up with more (and slightly
|
||||
#: overlapping) arguments than a single pass would have produced. Losing an
|
||||
#: entire litigant is worse, and the alternative — a smaller prompt per
|
||||
#: proposition — would degrade every case to fix the large ones.
|
||||
MAX_PROPS_PER_CALL = 80
|
||||
|
||||
|
||||
def _chunk(propositions: list[dict], size: int) -> list[list[dict]]:
|
||||
"""Split propositions into calls of at most ``size``, keeping claim order.
|
||||
|
||||
Order matters: claims arrive sorted by ``claim_index``, so neighbouring
|
||||
propositions usually belong to the same head of argument. Slicing in order
|
||||
keeps related material together instead of scattering one argument across
|
||||
chunks.
|
||||
"""
|
||||
return [propositions[i:i + size] for i in range(0, len(propositions), size)]
|
||||
|
||||
|
||||
async def _aggregate_party(
|
||||
party: str, propositions: list[dict], party_name: str = "",
|
||||
) -> list[dict]:
|
||||
@@ -155,9 +187,27 @@ async def _aggregate_party(
|
||||
``party_name`` names the specific pleading when this is a split side
|
||||
(respondent / permit_applicant brief), so the prompt scopes to that
|
||||
litigant's position only (#224).
|
||||
|
||||
Sides larger than ``MAX_PROPS_PER_CALL`` are aggregated in several calls and
|
||||
concatenated; a failure in any chunk raises rather than returning a partial
|
||||
side quietly.
|
||||
"""
|
||||
if not propositions:
|
||||
return []
|
||||
|
||||
if len(propositions) > MAX_PROPS_PER_CALL:
|
||||
chunks = _chunk(propositions, MAX_PROPS_PER_CALL)
|
||||
logger.info(
|
||||
"argument_aggregator: party '%s'%s has %d propositions — "
|
||||
"aggregating in %d calls of up to %d",
|
||||
party, f" ({party_name})" if party_name else "",
|
||||
len(propositions), len(chunks), MAX_PROPS_PER_CALL,
|
||||
)
|
||||
out: list[dict] = []
|
||||
for chunk in chunks:
|
||||
out.extend(await _aggregate_party(party, chunk, party_name=party_name))
|
||||
return out
|
||||
|
||||
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||
|
||||
try:
|
||||
@@ -171,11 +221,18 @@ async def _aggregate_party(
|
||||
) from e
|
||||
|
||||
if not isinstance(raw_result, list):
|
||||
logger.warning(
|
||||
"argument_aggregator: Claude returned non-list (%s) for party '%s'",
|
||||
type(raw_result).__name__, party,
|
||||
# NOT a silent []: returning empty here is indistinguishable from "this
|
||||
# side genuinely has no arguments", and the caller would then report
|
||||
# status=completed while a whole litigant vanished. That is exactly what
|
||||
# happened to the appellant side of 1069-04-26 — 310 claims in, 0
|
||||
# arguments out, "completed" (#233). Raise so the caller records it in
|
||||
# ``errors`` and the status degrades to completed_with_errors (§6).
|
||||
raise AggregationFailed(
|
||||
f"Claude returned {type(raw_result).__name__}, not a list, for party "
|
||||
f"'{party}'{f' ({party_name})' if party_name else ''} with "
|
||||
f"{len(propositions)} propositions — the side would otherwise be "
|
||||
f"dropped without trace",
|
||||
)
|
||||
return []
|
||||
|
||||
out: list[dict] = []
|
||||
for entry in raw_result:
|
||||
@@ -221,7 +278,7 @@ async def aggregate_claims_to_arguments(
|
||||
|
||||
# Pull all claims for this case, grouped by party.
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, party_role, claim_text, claim_index, source_document
|
||||
"""SELECT id, party_role, claim_text, claim_index, source_document, party_name
|
||||
FROM claims
|
||||
WHERE case_id = $1
|
||||
ORDER BY party_role, claim_index""",
|
||||
@@ -249,10 +306,13 @@ async def aggregate_claims_to_arguments(
|
||||
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
||||
if party not in ALLOWED_PARTIES:
|
||||
party = "unknown"
|
||||
party_name = (
|
||||
(r["source_document"] or "").strip()
|
||||
if party in SPLIT_PARTIES else ""
|
||||
)
|
||||
# Prefer the stored party_name (stamped by the extractor, #224); fall
|
||||
# back to source_document for legacy claims predating the stamping.
|
||||
# Single-voice sides (appellant/committee) stay ''.
|
||||
if party in SPLIT_PARTIES:
|
||||
party_name = (r["party_name"] or "").strip() or (r["source_document"] or "").strip()
|
||||
else:
|
||||
party_name = ""
|
||||
by_group.setdefault((party, party_name), []).append(dict(r))
|
||||
|
||||
# Valid claim_ids for this case == the ids of the claims we just fetched.
|
||||
@@ -273,6 +333,12 @@ async def aggregate_claims_to_arguments(
|
||||
group_key = f"{party}·{party_name}" if party_name else party
|
||||
try:
|
||||
arguments = await _aggregate_party(party, props, party_name=party_name)
|
||||
except AggregationFailed as e:
|
||||
# A side that failed is NOT a side with no arguments. Record it so
|
||||
# the status degrades and the caller can see which litigant is
|
||||
# missing (#233).
|
||||
errors.append(f"{group_key}: {e}")
|
||||
continue
|
||||
except RuntimeError as e:
|
||||
# Most likely cause: Claude CLI not installed (running from
|
||||
# the container). Don't crash — record the gap and continue.
|
||||
|
||||
@@ -23,8 +23,10 @@ from pathlib import Path
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, embeddings, claude_session, audit, storage
|
||||
from legal_mcp.services.lessons import (
|
||||
ANTI_PATTERNS as _ANTI_PATTERNS,
|
||||
OUTCOME_LABELS_HE,
|
||||
PRACTICE_AREA_OVERRIDES,
|
||||
anti_pattern_directive,
|
||||
canonical_outcome,
|
||||
get_content_checklist,
|
||||
get_methodology_summary,
|
||||
@@ -192,16 +194,16 @@ BLOCK_PROMPTS = {
|
||||
## כללים קריטיים:
|
||||
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
|
||||
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
|
||||
- **מבנה קבוע עם 3 חלקים:**
|
||||
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
|
||||
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
|
||||
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
|
||||
- **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
|
||||
- "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
|
||||
- **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
|
||||
- "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
|
||||
- כותרת: "תמצית טענות הצדדים"
|
||||
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
|
||||
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
|
||||
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
|
||||
- מספור רציף
|
||||
- **יעד אורך: 800-1500 מילים**
|
||||
- **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
|
||||
|
||||
## טענות שחולצו (קבץ טענות דומות לנושאים):
|
||||
{claims_context}
|
||||
@@ -284,6 +286,7 @@ BLOCK_PROMPTS = {
|
||||
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
|
||||
- **מספור רציף** — המשך מספור מהבלוק הקודם
|
||||
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
|
||||
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
|
||||
|
||||
## כיוון מאושר (חובה):
|
||||
{direction_context}
|
||||
@@ -368,6 +371,7 @@ async def write_block(
|
||||
block_id: str,
|
||||
instructions: str = "",
|
||||
effort_override: str | None = None,
|
||||
model_override: str | None = None,
|
||||
) -> dict:
|
||||
"""כתיבת בלוק יחיד בהחלטה.
|
||||
|
||||
@@ -380,6 +384,12 @@ async def write_block(
|
||||
THIS call only — used by the #208 model/effort calibration harness
|
||||
to A/B efforts without mutating the pinned defaults. Production
|
||||
callers leave it None and get the deterministic per-block effort.
|
||||
model_override: optional per-call generation model id (e.g.
|
||||
"claude-opus-5"). Same contract as effort_override — the #208
|
||||
harness A/Bs MODELS without mutating the pinned GENERATION_MODEL.
|
||||
Pass the BASE id only: the 1M-context escalation (#216) is applied
|
||||
on top automatically for large prompts, so an override never
|
||||
silently loses the 1M window. Production callers leave it None.
|
||||
|
||||
Returns:
|
||||
dict עם content, word_count, block_id, generation_type
|
||||
@@ -467,6 +477,12 @@ async def write_block(
|
||||
if instructions:
|
||||
prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
|
||||
# LAST in the prompt, deliberately (see lessons.anti_pattern_directive): the
|
||||
# same canonical rule already appears inside style_context, but ~47K chars
|
||||
# deep, where it measurably fails to bind. Restating it here is the only
|
||||
# change the A/B isolated as effective — so nothing may be appended after it.
|
||||
prompt += "\n\n" + anti_pattern_directive()
|
||||
|
||||
# Block י requires approved direction
|
||||
if block_id == "block-yod":
|
||||
dir_doc = (decision or {}).get("direction_doc") or {}
|
||||
@@ -477,7 +493,12 @@ async def write_block(
|
||||
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
|
||||
# block-yod legitimately carries the whole case as source-context. The 400K
|
||||
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
|
||||
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
|
||||
# model_override (#208 harness) swaps the BASE id only — the 1M decision below
|
||||
# still applies, so an A/B'd model keeps the same context-window behaviour as
|
||||
# the pinned default instead of silently falling back to the 200K build.
|
||||
_base_model = model_override or GENERATION_MODEL
|
||||
_model_1m = GENERATION_MODEL_1M if _base_model == GENERATION_MODEL else f"{_base_model}[1m]"
|
||||
gen_model = _model_1m if len(prompt) > _CTX_1M_THRESHOLD_CHARS else _base_model
|
||||
|
||||
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
|
||||
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
|
||||
@@ -629,18 +650,49 @@ async def _build_claims_context(case_id: UUID) -> str:
|
||||
# Fallback to all non-block-zayin claims if no source claims exist.
|
||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
||||
|
||||
lines = []
|
||||
current_role = ""
|
||||
# Group by (party_role, brief) so a multi-litigant side (respondent /
|
||||
# permit_applicant) whose claims come from distinct pleadings reads as
|
||||
# SEPARATE positions — e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6" — instead
|
||||
# of one merged "טענות המשיבים" (#224). The brief label is the source
|
||||
# pleading, and which sides split is SPLIT_PARTIES — the SAME rule the
|
||||
# argument aggregator uses, so block ז and the arguments panel agree (G2).
|
||||
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
|
||||
|
||||
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
|
||||
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
|
||||
claim_num = 0
|
||||
role_order = ["appellant", "committee", "respondent", "permit_applicant"]
|
||||
|
||||
groups: dict[tuple[str, str], list[dict]] = {}
|
||||
for c in source_claims:
|
||||
if c["party_role"] != current_role:
|
||||
current_role = c["party_role"]
|
||||
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
|
||||
claim_num += 1
|
||||
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
||||
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
|
||||
role = c.get("party_role", "") or ""
|
||||
# Prefer the stored party_name (stamped by the extractor, #224); fall
|
||||
# back to source_document for legacy claims predating the stamping.
|
||||
if role in SPLIT_PARTIES:
|
||||
brief = (c.get("party_name") or "").strip() or (c.get("source_document") or "").strip()
|
||||
else:
|
||||
brief = ""
|
||||
groups.setdefault((role, brief), []).append(c)
|
||||
|
||||
def _sort_key(k: tuple[str, str]) -> tuple[int, str]:
|
||||
role, brief = k
|
||||
idx = role_order.index(role) if role in role_order else len(role_order)
|
||||
return (idx, brief)
|
||||
|
||||
lines: list[str] = []
|
||||
claim_num = 0
|
||||
for role, brief in sorted(groups.keys(), key=_sort_key):
|
||||
header = role_heb.get(role, role or "טענות נוספות")
|
||||
if brief:
|
||||
header = f"{header} — {brief}"
|
||||
lines.append(f"\n### {header}")
|
||||
for c in groups[(role, brief)]:
|
||||
claim_num += 1
|
||||
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
||||
lines.append(
|
||||
f"\n**סה\"כ {claim_num} טענות. שמור על ההפרדה בין הצדדים לעיל — "
|
||||
f"לכל צד/כתב-תשובה עמדה נפרדת (אל תמזג משיבים שונים). ענה על כל טענה "
|
||||
f"מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -1075,6 +1127,16 @@ async def _build_style_context(practice_area: str = "") -> str:
|
||||
# ── למידה מצטברת (T15) — עריכות היו"ר ב-/methodology + לקחי /training ──
|
||||
# גובר על ברירות-המחדל לעיל. כך כל מה שלמדנו עד היום מגיע לכותב.
|
||||
learned: list[str] = []
|
||||
# The canonical anti-patterns are rendered UNCONDITIONALLY, before any DB
|
||||
# call. They used to be produced inside the overrides loop below — so a
|
||||
# failure on an EARLIER category (e.g. golden_ratios) aborted the loop and
|
||||
# dropped the style invariants from the prompt silently, with only a generic
|
||||
# "overrides not loaded" warning to show for it (§6). A chair-override
|
||||
# outage must not be able to un-teach Dafna's structural style.
|
||||
learned.append("\n**אנטי-דפוסים (להימנע) — כתוב נרטיב משפטי רציף; הימנע מ:**")
|
||||
for ap in _ANTI_PATTERNS:
|
||||
learned.append(f"- {ap['note']}")
|
||||
|
||||
try:
|
||||
for cat, label in (
|
||||
("golden_ratios", "יחסי-זהב (אחוזי-סעיפים)"),
|
||||
@@ -1093,10 +1155,8 @@ async def _build_style_context(practice_area: str = "") -> str:
|
||||
# corrects them, and drafts keep emitting them (the gap that left
|
||||
# 8137 with 28 hits). Chair additions layer on top; they never
|
||||
# remove the canonical ones.
|
||||
from legal_mcp.services.lessons import ANTI_PATTERNS as _ANTI
|
||||
learned.append(f"\n**{label} — כתוב נרטיב משפטי רציף; הימנע מ:**")
|
||||
for ap in _ANTI:
|
||||
learned.append(f"- {ap['note']}")
|
||||
# The canonical list is already rendered above, outside this try —
|
||||
# here we only layer the chair's ADDITIONS on top of it.
|
||||
for k, v in (ov or {}).items():
|
||||
learned.append(f"- (יו\"ר) {k}: {json.dumps(v, ensure_ascii=False)}")
|
||||
continue
|
||||
@@ -1232,6 +1292,12 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
||||
if instructions:
|
||||
formatted_prompt += f"\n\n## הנחיות נוספות:\n{instructions}"
|
||||
|
||||
# Same closing directive, same position, same canonical source as write_block.
|
||||
# This is the EXTERNAL-writer path (legal-writer agent) — if the rule were
|
||||
# applied only in write_block, agent-written blocks would keep emitting the
|
||||
# anti-patterns and the two writers would drift apart (G2).
|
||||
formatted_prompt += "\n\n" + anti_pattern_directive()
|
||||
|
||||
# Block י requires approved direction
|
||||
if block_id == "block-yod":
|
||||
dir_doc = (decision or {}).get("direction_doc") or {}
|
||||
|
||||
@@ -34,6 +34,45 @@ logger = logging.getLogger(__name__)
|
||||
_SUGGEST_PER_ISSUE = 4
|
||||
_SUGGEST_FLOOR = 0.45
|
||||
|
||||
#: Concurrent per-argument retrievals. The fan-out used to be unbounded — one
|
||||
#: task per legal argument — which is self-defeating, not merely risky: each
|
||||
#: task opens a Voyage embed call, and past ~8 in flight Voyage throttles, so
|
||||
#: every request stalls together. Measured on this corpus (32 arguments):
|
||||
#:
|
||||
#: one search alone ...... 1.2s
|
||||
#: 32 unbounded .......... 30.4s ← 25× a single search
|
||||
#: 32 at 8 concurrent .... 22.1s ← bounded is FASTER
|
||||
#:
|
||||
#: A 69-argument case (1069-04-26) therefore blew past the 30s proxy timeout and
|
||||
#: returned 500, and overlapping page reloads piled contention into Postgres
|
||||
#: deadlocks. Bounding the fan-out both fixes the failure and speeds it up.
|
||||
_MAX_CONCURRENT_LOOKUPS = 8
|
||||
|
||||
def _passes_floor(hit: dict) -> bool:
|
||||
"""Is this hit similar enough to the argument to suggest to the chair?
|
||||
|
||||
Reads ``relevance`` (always a cosine similarity), NOT ``score`` — ``score``
|
||||
becomes a rank-fusion value (~0.008-0.02) as soon as the lexical leg returns
|
||||
rows, and comparing that against a cosine-calibrated floor rejected every
|
||||
hit. That is why this tab showed no supporting precedent for any argument.
|
||||
|
||||
``relevance is None`` means the row came from the lexical leg only, so no
|
||||
cosine was ever computed. It is KEPT: it earned its place by BM25 rank
|
||||
inside an already-tiny top-k, and dropping it would silently hide exact
|
||||
phrase/docket matches — the very hits a chair searches for by name.
|
||||
"""
|
||||
rel = hit.get("relevance")
|
||||
if rel is None:
|
||||
return True
|
||||
return float(rel) >= _SUGGEST_FLOOR
|
||||
|
||||
|
||||
#: Wall-clock ceiling for the whole retrieval phase. The proxy gives up at 30s;
|
||||
#: cutting ourselves off earlier lets us return the suggestions that DID land
|
||||
#: instead of a 500 that shows the chair nothing. Partial results are labelled
|
||||
#: (``retrieval_complete: false``) rather than passed off as the full picture.
|
||||
_RETRIEVAL_BUDGET_S = 22.0
|
||||
|
||||
|
||||
async def build_view(case_number: str) -> dict:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
@@ -83,13 +122,46 @@ async def build_view(case_number: str) -> dict:
|
||||
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
||||
# Resolve the authority breakdown for the hit set in one batched query.
|
||||
clids = [UUID(str(h["case_law_id"])) for h in hits
|
||||
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
|
||||
if h.get("case_law_id") and _passes_floor(h)]
|
||||
authority = await db.citation_authority(clids) if clids else {}
|
||||
return hits, authority
|
||||
|
||||
# Fan out the expensive per-argument retrieval concurrently — was a sequential
|
||||
# waterfall (N args × Voyage embed + vector search each). gather preserves order.
|
||||
fetched = await asyncio.gather(*(_fetch(a) for a in args)) if args else []
|
||||
# Fan out the expensive per-argument retrieval, but BOUNDED — see
|
||||
# _MAX_CONCURRENT_LOOKUPS. gather preserves order, so the zip below still
|
||||
# pairs each argument with its own result.
|
||||
_sem = asyncio.Semaphore(_MAX_CONCURRENT_LOOKUPS)
|
||||
|
||||
async def _fetch_bounded(a: dict) -> tuple[list[dict], dict]:
|
||||
async with _sem:
|
||||
return await _fetch(a)
|
||||
|
||||
retrieval_complete = True
|
||||
fetched: list[tuple[list[dict], dict]] = []
|
||||
if args:
|
||||
# Harvest whatever finished inside the budget, per argument. Deliberately
|
||||
# NOT wait_for(gather(...)): that cancels every task on timeout, so one
|
||||
# slow lookup would throw away the 30 that already succeeded and the page
|
||||
# would show nothing at all.
|
||||
tasks = [asyncio.ensure_future(_fetch_bounded(a)) for a in args]
|
||||
done, pending = await asyncio.wait(tasks, timeout=_RETRIEVAL_BUDGET_S)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if pending:
|
||||
# Let the cancellations settle before the caller's DB pool unwinds —
|
||||
# a task cancelled mid-query otherwise surfaces as a stray
|
||||
# "connection_lost" future with no owner.
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
retrieval_complete = False
|
||||
logger.warning(
|
||||
"citation_verification: retrieval budget of %.0fs exhausted for %s "
|
||||
"— %d of %d arguments returned suggestions, the rest are empty",
|
||||
_RETRIEVAL_BUDGET_S, case_number, len(done), len(args),
|
||||
)
|
||||
for t in tasks:
|
||||
if t in done and not t.cancelled() and t.exception() is None:
|
||||
fetched.append(t.result())
|
||||
else:
|
||||
fetched.append(([], {}))
|
||||
|
||||
out_args: list[dict] = []
|
||||
n_verified = 0
|
||||
@@ -104,7 +176,7 @@ async def build_view(case_number: str) -> dict:
|
||||
clid = str(h.get("case_law_id") or "")
|
||||
if not clid or clid in seen:
|
||||
continue
|
||||
if float(h.get("score", 0) or 0) < _SUGGEST_FLOOR:
|
||||
if not _passes_floor(h):
|
||||
continue
|
||||
seen.add(clid)
|
||||
att = attached_by_arg.get(aid, {}).get(clid)
|
||||
@@ -137,6 +209,11 @@ async def build_view(case_number: str) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"case_number": case_number,
|
||||
# False when the retrieval budget ran out: the attached/verified rows and
|
||||
# the radar are complete, but the corpus SUGGESTIONS are missing. The UI
|
||||
# must say so — an empty suggestion list otherwise reads as "no precedent
|
||||
# in the corpus supports this argument", which is a different claim.
|
||||
"retrieval_complete": retrieval_complete,
|
||||
"arguments": out_args,
|
||||
"summary": {
|
||||
"arguments_total": len(out_args),
|
||||
|
||||
@@ -368,8 +368,15 @@ async def extract_and_store_claims(
|
||||
|
||||
# Determine claim_type from document type and title
|
||||
claim_type = _infer_claim_type(doc_type, source_name)
|
||||
# Stamp party_name at the source (#224). For a multi-litigant side
|
||||
# (respondent / permit_applicant) the brief label IS the source pleading, so
|
||||
# opposing briefs (משיבות 2-3 vs משיבים 4-6) stay distinct downstream without
|
||||
# the aggregator/block-writer having to re-derive it. SPLIT_PARTIES is the
|
||||
# single rule shared with the aggregator (G2); single-voice sides stay ''.
|
||||
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
|
||||
for c in claims:
|
||||
c["claim_type"] = claim_type
|
||||
c["party_name"] = source_name if c.get("party_role") in SPLIT_PARTIES else ""
|
||||
|
||||
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
||||
# Mark this document analysed (WS2 / #201). store_claims already replaced
|
||||
|
||||
@@ -1918,6 +1918,20 @@ ALTER TABLE cases ADD COLUMN IF NOT EXISTS hearing_attendees JSONB NOT NULL DEFA
|
||||
"""
|
||||
|
||||
|
||||
# V52 (#224): backfill claims.party_name for existing rows. The extractor now
|
||||
# stamps party_name = source_document for the multi-litigant sides at write time
|
||||
# (claims_extractor), but claims stored before that shipped have party_name=''.
|
||||
# For those, the brief label is the source pleading — same rule the aggregator
|
||||
# uses (respondent / permit_applicant split). Idempotent: only fills empties, so
|
||||
# re-running is a no-op. Single-voice sides (appellant/committee) stay ''.
|
||||
SCHEMA_V52_SQL = """
|
||||
UPDATE claims SET party_name = source_document
|
||||
WHERE party_role IN ('respondent', 'permit_applicant')
|
||||
AND COALESCE(party_name, '') = ''
|
||||
AND COALESCE(source_document, '') <> '';
|
||||
"""
|
||||
|
||||
|
||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||
@@ -1991,6 +2005,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(SCHEMA_V49_SQL)
|
||||
await conn.execute(SCHEMA_V50_SQL)
|
||||
await conn.execute(SCHEMA_V51_SQL)
|
||||
await conn.execute(SCHEMA_V52_SQL)
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -5309,21 +5324,19 @@ async def list_external_case_law(
|
||||
search: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
) -> list[dict]:
|
||||
"""List chair-uploaded precedents, with simple filters.
|
||||
|
||||
source_kind="" (default) = the whole corpus — court rulings *and*
|
||||
appeals-committee decisions. The old ``external_upload`` default hid
|
||||
every committee decision from a plain listing (#232).
|
||||
source_kind="all_committees" expands to: source_kind='internal_committee'
|
||||
OR (source_kind='external_upload' AND source_type='appeals_committee').
|
||||
"""
|
||||
pool = await get_pool()
|
||||
if source_kind == "all_committees":
|
||||
conditions = [
|
||||
"(source_kind = 'internal_committee' OR "
|
||||
"(source_kind = 'external_upload' AND source_type = 'appeals_committee'))"
|
||||
]
|
||||
else:
|
||||
conditions = [f"source_kind = '{source_kind}'"]
|
||||
sk_clause = _source_kind_clause(source_kind)
|
||||
conditions = [sk_clause] if sk_clause else []
|
||||
params: list = []
|
||||
idx = 1
|
||||
if practice_area:
|
||||
@@ -5343,13 +5356,19 @@ async def list_external_case_law(
|
||||
params.append(source_type)
|
||||
idx += 1
|
||||
if search:
|
||||
# Case-number separator normalisation (#232, trap 2): committee numbers
|
||||
# are stored hyphenated ("83-16") while people write them with a slash
|
||||
# ("83/16"). Fold both sides to '/' so either form finds the row —
|
||||
# matching at the point of comparison rather than asking every caller
|
||||
# to guess the stored form.
|
||||
conditions.append(
|
||||
f"(case_number ILIKE ${idx} OR case_name ILIKE ${idx} "
|
||||
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx})"
|
||||
f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx} "
|
||||
f"OR replace(case_number, '-', '/') ILIKE replace(${idx}, '-', '/'))"
|
||||
)
|
||||
params.append(f"%{search}%")
|
||||
idx += 1
|
||||
where_sql = " AND ".join(conditions)
|
||||
where_sql = " AND ".join(conditions) if conditions else "TRUE"
|
||||
params.extend([limit, offset])
|
||||
sql = f"""
|
||||
SELECT id, case_number, case_name, court, date, practice_area,
|
||||
@@ -7760,6 +7779,45 @@ async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
#: Accepted ``source_kind`` selectors. ``""``/``"all"`` mean *no filter* —
|
||||
#: the whole corpus, court rulings and appeals-committee decisions alike.
|
||||
_SOURCE_KIND_SELECTORS = frozenset(
|
||||
{"", "all", "all_committees", "external_upload", "internal_committee", "cited_only"}
|
||||
)
|
||||
|
||||
|
||||
def _source_kind_clause(source_kind: str, column_prefix: str = "") -> str:
|
||||
"""Return the SQL predicate for a ``source_kind`` selector, or "" for none.
|
||||
|
||||
Single definition for every caller (G2) — the selector vocabulary was
|
||||
previously restated at each search/list site, which is how the
|
||||
``external_upload`` default silently hid 104 appeals-committee
|
||||
decisions from ``search_precedent_library`` (#232).
|
||||
|
||||
``column_prefix`` is the table alias plus dot (e.g. ``"cl."``) or "" when
|
||||
the query selects from ``case_law`` directly.
|
||||
|
||||
Raises ValueError on an unknown selector rather than interpolating it —
|
||||
the value reaches SQL by f-string, so the whitelist is also what keeps
|
||||
that safe.
|
||||
"""
|
||||
sk = (source_kind or "").strip()
|
||||
if sk not in _SOURCE_KIND_SELECTORS:
|
||||
raise ValueError(
|
||||
f"source_kind לא מוכר: {source_kind!r}. "
|
||||
f"ערכים חוקיים: {sorted(_SOURCE_KIND_SELECTORS - {''})} או '' (הכל)"
|
||||
)
|
||||
if sk in ("", "all"):
|
||||
return ""
|
||||
p = column_prefix
|
||||
if sk == "all_committees":
|
||||
return (
|
||||
f"({p}source_kind = 'internal_committee' OR "
|
||||
f"({p}source_kind = 'external_upload' AND {p}source_type = 'appeals_committee'))"
|
||||
)
|
||||
return f"{p}source_kind = '{sk}'"
|
||||
|
||||
|
||||
async def search_precedent_library_semantic(
|
||||
query_embedding: list[float],
|
||||
practice_area: str = "",
|
||||
@@ -7770,14 +7828,15 @@ async def search_precedent_library_semantic(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
) -> list[dict]:
|
||||
"""Semantic search over precedents filtered by source_kind.
|
||||
"""Semantic search over precedents, optionally filtered by source_kind.
|
||||
|
||||
source_kind='external_upload' → court rulings (default)
|
||||
source_kind='internal_committee' → appeals-committee decisions
|
||||
source_kind='' → the whole corpus (default, #232)
|
||||
source_kind='external_upload' → court rulings only
|
||||
source_kind='internal_committee' → appeals-committee decisions only
|
||||
|
||||
Returns merged halachot + chunks. Halachot are pre-distilled rules, so
|
||||
they get a small score boost. Only ``approved`` / ``published`` halachot
|
||||
@@ -7785,12 +7844,15 @@ async def search_precedent_library_semantic(
|
||||
of halacha review status.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
sk_clause = _source_kind_clause(source_kind, "cl.")
|
||||
halacha_filters = [
|
||||
"h.review_status <> 'rejected'", # #153: include background; rank verified higher
|
||||
f"cl.source_kind = '{source_kind}'",
|
||||
"cl.searchable = true",
|
||||
]
|
||||
chunk_filters = [f"cl.source_kind = '{source_kind}'", "cl.searchable = true"]
|
||||
chunk_filters = ["cl.searchable = true"]
|
||||
if sk_clause:
|
||||
halacha_filters.append(sk_clause)
|
||||
chunk_filters.append(sk_clause)
|
||||
h_params: list = [query_embedding, limit]
|
||||
c_params: list = [query_embedding, limit]
|
||||
h_idx = 3
|
||||
@@ -7917,6 +7979,14 @@ async def search_precedent_library_semantic(
|
||||
# Calibrated so the average (≈0.85) stays at +0.05 (legacy value).
|
||||
_conf = float(d.get("confidence") or 0.0)
|
||||
d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0)
|
||||
# Stable cosine-scale relevance, carried alongside ``score``.
|
||||
# ``score`` is the RANKING signal and downstream fusion overwrites it
|
||||
# with an RRF value (~0.008-0.02) whenever the lexical leg returns
|
||||
# rows — a different scale entirely. Anything that THRESHOLDS must
|
||||
# read ``relevance`` instead, which always means "cosine similarity
|
||||
# to the query" no matter which fusion stages ran. See
|
||||
# hybrid_search._merge_sem_lex.
|
||||
d["relevance"] = d["score"]
|
||||
d["type"] = "halacha"
|
||||
# authority is DERIVED from the source, never stored (INV-DM7)
|
||||
d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
|
||||
@@ -7928,6 +7998,7 @@ async def search_precedent_library_semantic(
|
||||
if d.get("decision_date") is not None:
|
||||
d["decision_date"] = d["decision_date"].isoformat()
|
||||
d["score"] = float(d["score"])
|
||||
d["relevance"] = d["score"] # cosine anchor — see the halacha branch above
|
||||
d["type"] = "passage"
|
||||
_maybe_swap_parent(d)
|
||||
results.append(d)
|
||||
@@ -8002,7 +8073,7 @@ async def search_precedent_library_lexical(
|
||||
appeal_subtype: str = "",
|
||||
is_binding: bool | None = None,
|
||||
subject_tag: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
limit: int = 30,
|
||||
@@ -8028,12 +8099,15 @@ async def search_precedent_library_lexical(
|
||||
return []
|
||||
|
||||
pool = await get_pool()
|
||||
sk_clause = _source_kind_clause(source_kind, "cl.")
|
||||
halacha_filters = [
|
||||
"h.review_status <> 'rejected'", # #153: include background; rank verified higher
|
||||
f"cl.source_kind = '{source_kind}'",
|
||||
"cl.searchable = true",
|
||||
]
|
||||
chunk_filters = [f"cl.source_kind = '{source_kind}'", "cl.searchable = true"]
|
||||
chunk_filters = ["cl.searchable = true"]
|
||||
if sk_clause:
|
||||
halacha_filters.append(sk_clause)
|
||||
chunk_filters.append(sk_clause)
|
||||
# $1 = query, $2 = limit. Filters append starting at $3.
|
||||
h_params: list = [query, limit]
|
||||
c_params: list = [query, limit]
|
||||
|
||||
106
mcp-server/src/legal_mcp/services/http_auth.py
Normal file
106
mcp-server/src/legal_mcp/services/http_auth.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Bearer-token gate for the HTTP transport of the MCP server (#231.2).
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
Over ``stdio`` the protection is the pipe itself: only a process that already
|
||||
runs as this user can speak to the server. ``streamable-http`` removes that
|
||||
property entirely — anything that can reach the socket can call any of the 108
|
||||
registered tools, and the registry includes ``case_delete``,
|
||||
``precedent_library_delete``, ``document_upload`` and every block-writing tool.
|
||||
An unauthenticated listener is therefore a delete-any-case endpoint.
|
||||
|
||||
The agent platform already speaks this dialect: it injects each runtime MCP
|
||||
server as ``{type:"http", name, url, headers:[{name:"Authorization",
|
||||
value:"Bearer <token>"}]}``. So a static bearer token is exactly the shape the
|
||||
caller will present — no negotiation, no OAuth dance.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
- We implement the SDK's own ``TokenVerifier`` protocol and let
|
||||
``BearerAuthBackend`` do the enforcement, rather than adding bespoke
|
||||
middleware. One auth path, the framework's (G2).
|
||||
- The token is read from the environment, which the service unit populates from
|
||||
Infisical. It is never defaulted, never logged, and never embedded here.
|
||||
- Comparison is constant-time: a naive ``==`` leaks the token byte-by-byte to a
|
||||
caller who can time responses.
|
||||
- ``verify_token`` returns ``None`` (not an exception) on mismatch — that is the
|
||||
protocol's "reject" signal and yields a clean 401 instead of a 500 that would
|
||||
read as a server fault.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
logger = logging.getLogger("legal_mcp.http_auth")
|
||||
|
||||
#: Environment variable carrying the shared bearer token.
|
||||
#:
|
||||
#: Name matches the Infisical key exactly — All Infrastructure / env `main` /
|
||||
#: `/apps/legal-ai` / ``MCP_HTTP_SHARED_SECRET``, tagged ``credentials``. Keeping
|
||||
#: the two identical means nobody has to hold a mapping in their head, and it
|
||||
#: follows the two bridge tokens already in that folder
|
||||
#: (``COURT_FETCH_SHARED_SECRET``, ``LEGAL_CHAT_SHARED_SECRET``).
|
||||
TOKEN_ENV = "MCP_HTTP_SHARED_SECRET"
|
||||
|
||||
#: Minimum acceptable token length. Short tokens are brute-forceable; refusing
|
||||
#: them at startup is cheaper than discovering it from an access log.
|
||||
MIN_TOKEN_LEN = 32
|
||||
|
||||
#: Reported as the authenticated principal. Single shared token today, so this
|
||||
#: is a constant rather than a real identity — kept explicit so that audit rows
|
||||
#: never imply per-agent attribution we cannot actually make.
|
||||
CLIENT_ID = "mcp-http-shared"
|
||||
|
||||
|
||||
class MissingTokenError(RuntimeError):
|
||||
"""Raised when the HTTP transport is requested without a usable token.
|
||||
|
||||
Deliberately fatal. The tempting alternative — start anyway and log a
|
||||
warning — produces a listener that looks healthy and answers every
|
||||
destructive tool call. Refusing to boot is the safe failure (§6: never
|
||||
swallow, never degrade silently).
|
||||
"""
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
"""Return the configured bearer token, or raise if it is unusable."""
|
||||
token = (os.environ.get(TOKEN_ENV) or "").strip()
|
||||
if not token:
|
||||
raise MissingTokenError(
|
||||
f"{TOKEN_ENV} is not set. The HTTP transport exposes destructive "
|
||||
f"tools and will not start without a bearer token. Set it from "
|
||||
f"Infisical, or use MCP_TRANSPORT=stdio.",
|
||||
)
|
||||
if len(token) < MIN_TOKEN_LEN:
|
||||
raise MissingTokenError(
|
||||
f"{TOKEN_ENV} is shorter than {MIN_TOKEN_LEN} characters — refusing "
|
||||
f"to start. Generate a long random token.",
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
class StaticTokenVerifier(TokenVerifier):
|
||||
"""Verifies the single shared bearer token presented by the platform.
|
||||
|
||||
Not an identity system: it answers "may this caller in at all", not "who is
|
||||
it". Per-agent attribution would need per-agent tokens, which is a later
|
||||
step once profiles exist (#231.4).
|
||||
"""
|
||||
|
||||
def __init__(self, expected: str) -> None:
|
||||
self._expected = expected
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
# compare_digest over bytes; it tolerates unequal lengths without
|
||||
# short-circuiting, which is the whole point.
|
||||
if not hmac.compare_digest(token.encode("utf-8"),
|
||||
self._expected.encode("utf-8")):
|
||||
# No token material in the log line — only the fact of a rejection.
|
||||
logger.warning("Rejected MCP HTTP request: bearer token mismatch")
|
||||
return None
|
||||
return AccessToken(token=token, client_id=CLIENT_ID, scopes=[])
|
||||
@@ -98,15 +98,16 @@ async def search_precedent_library_hybrid(
|
||||
is_binding: bool | None = None,
|
||||
subject_tag: str = "",
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
district: str = "",
|
||||
chair_name: str = "",
|
||||
max_per_case_law: int = 2,
|
||||
) -> list[dict]:
|
||||
"""Hybrid wrapper for precedent-library search.
|
||||
|
||||
source_kind='external_upload' → court rulings (default)
|
||||
source_kind='internal_committee' → appeals-committee decisions
|
||||
source_kind='' → the whole corpus (default, #232)
|
||||
source_kind='external_upload' → court rulings only
|
||||
source_kind='internal_committee' → appeals-committee decisions only
|
||||
max_per_case_law: MMR-style diversity cap — at most N hits per
|
||||
case_law_id in the final ranked list (default 2). Prevents a
|
||||
single precedent from monopolizing the result list when many of
|
||||
@@ -302,6 +303,21 @@ def _merge_sem_lex(
|
||||
if key in lex_row_by_key else 0.0
|
||||
d["lex_rank"] = lex_rank or 0
|
||||
d["score"] = sem_term + lex_term
|
||||
# ``score`` is now an RRF value (~0.008-0.02), NOT a cosine. Carry the
|
||||
# cosine forward under ``relevance`` so thresholding callers keep a
|
||||
# stable scale — without it, a caller comparing score >= 0.45 silently
|
||||
# drops every result the moment the lexical leg returns anything, which
|
||||
# is exactly how the citation-verification tab ended up showing no
|
||||
# supporting precedent for any argument.
|
||||
#
|
||||
# A lexical-only row has no cosine at all. It gets ``relevance = None``
|
||||
# rather than 0.0: "we did not measure this" is not "measured as
|
||||
# irrelevant", and the row earned its place by BM25 rank. Callers decide
|
||||
# (see case_citation_verification) — but they must decide knowingly.
|
||||
if key in sem_row_by_key:
|
||||
d["relevance"] = float(sem_row_by_key[key].get("relevance", d["sem_score"]))
|
||||
else:
|
||||
d["relevance"] = None
|
||||
merged.append(d)
|
||||
|
||||
merged.sort(key=lambda x: -float(x["score"]))
|
||||
|
||||
@@ -59,6 +59,28 @@ ANTI_PATTERNS: list[dict] = [
|
||||
"note": "רשימות תבליטים באנליזה — דפנה כותבת נרטיב רציף"},
|
||||
]
|
||||
|
||||
|
||||
def anti_pattern_directive() -> str:
|
||||
"""The closing style directive, rendered from ANTI_PATTERNS (the same list
|
||||
style_distance scores against — one source, two renderings, not two rules).
|
||||
|
||||
WHY THIS EXISTS SEPARATELY FROM the style-context rendering: the rule was
|
||||
already reaching the writer, buried ~47K chars deep inside style_context,
|
||||
and drafts kept emitting the very patterns it forbids. A measured A/B over
|
||||
the signed finals (9 cases, 60 generations, 2026-07-28) showed that the SAME
|
||||
rule restated at the END of the assembled prompt cuts anti-pattern hits by
|
||||
72–93% on both blocks and both models:
|
||||
|
||||
block-vav opus-4-8 1.75 → 0.12 | opus-5 2.25 → 0.62
|
||||
block-zayin opus-4-8 4.57 → 0.43 | opus-5 4.43 → 0.43
|
||||
|
||||
So this is a POSITION fix, not a new instruction. Keep it last in the prompt.
|
||||
"""
|
||||
lines = ["## כלל-סגנון מחייב (גובר על כל דוגמה בהקשר שלמעלה)",
|
||||
"כתוב נרטיב משפטי רציף בלבד — פסקאות שלמות. אסור:"]
|
||||
lines += [f"- {ap['note']}" for ap in ANTI_PATTERNS]
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Paragraph length guidance (word counts) ────────────────────────
|
||||
|
||||
PARAGRAPH_LENGTHS = {
|
||||
|
||||
@@ -477,7 +477,7 @@ async def list_precedents(
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
@@ -503,12 +503,18 @@ async def search_library(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "",
|
||||
) -> list[dict]:
|
||||
"""Semantic search merging halachot (rule-level) and chunks (passage-level).
|
||||
|
||||
Only ``approved`` / ``published`` halachot are returned, per chair-review
|
||||
policy. Chunks are returned regardless of halacha review status.
|
||||
|
||||
``source_kind=""`` (default) covers the whole corpus — court rulings and
|
||||
appeals-committee decisions together. It used to be hard-wired to
|
||||
``external_upload`` here, which made 104 committee decisions
|
||||
unreachable through this entry point (#232).
|
||||
|
||||
When ``VOYAGE_RERANK_ENABLED`` is set, results are passed through
|
||||
voyage rerank-2 (cross-encoder). The +0.05 halacha boost from
|
||||
``search_precedent_library_semantic`` is preserved before rerank
|
||||
@@ -529,4 +535,5 @@ async def search_library(
|
||||
is_binding=is_binding,
|
||||
subject_tag=subject_tag,
|
||||
include_halachot=include_halachot,
|
||||
source_kind=source_kind,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date as date_type
|
||||
from uuid import UUID
|
||||
|
||||
@@ -72,7 +73,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
|
||||
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
|
||||
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY' → 'ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר' → 'בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
|
||||
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
|
||||
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
|
||||
"case_number_clean": "מספר הערר/תיק **בדיוק כפי שמופיע בכותרת** — רק ספרות ומפרידים. שתי צורות קיימות ושתיהן חוקיות: דו-חלקית ('1062/24', '8031/21') ותלת-חלקית של ועדות ערר ('1094-09-19', '85074-09-24' — סידורי-חודש-שנה). **העתק את הספרות מהמסמך; אל תשלים, אל תנחש ואל תתקן ספרה.** ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. **אם המספר אינו מופיע בטקסט — מחרוזת ריקה** (הקוד דוחה ממילא מספר שאינו מעוגן בטקסט).",
|
||||
"chair_name": "שם יו\\\"ר ההרכב של **ההחלטה הזו** — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. כמעט תמיד מופיע — בשני מקומות: (א) בכותרת/רובריקה בראש המסמך, ליד 'בפני:' / 'בהרכב:' / רשימת חברי הוועדה; (ב) בבלוק-החתימה בסוף ההחלטה, אחרי 'ההחלטה ניתנה' — שם מופיעים זה-לצד-זה מזכיר/ת הוועדה והיו\\\"ר (למשל בשתי עמודות: בצד אחד 'פלוני, עו\\\"ד / מזכיר ועדת הערר' ובצד השני 'אלמוני, עו\\\"ד / יו\\\"ר ועדת הערר'). **קח את השם שמעליו/לצדו כתוב 'יו\\\"ר' — לא את המזכיר/ה.** השאר שם פרטי+משפחה בלבד, בלי תוארים ('עו\\\"ד', 'אדריכל', 'עו\\\"ד דפנה תמיר'→'דפנה תמיר'). **אזהרה קריטית:** אל תיקח שם יו\\\"ר של פסק/החלטה אחרים ש**מצוטטים** בגוף ההחלטה (למשל 'כפי שנקבע ברשותה של יו\\\"ר פלונית בערר אחר...') — אלה תקדימים מצוטטים, לא היו\\\"ר של ההחלטה הנוכחית. אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
|
||||
"parties": "שמות הצדדים בשורה אחת בצורה 'עורר נ\\' משיב' — בדיוק כפי שמופיעים בכותרת/רובריקה. בלי הדגשה, בלי מספר-תיק, בלי תוארים מיותרים. למשל 'ישיבת חברת אהבת שלום נ\\' תאיה' או 'ראם חיים נ\\' הוועדה המקומית לתכנון ובניה ירושלים'. אם הצדדים אינם מופיעים בטקסט (למשל החלטה שמתחילה בגוף בלי רובריקה) — מחרוזת ריקה. **אל תמציא שמות.**",
|
||||
@@ -242,6 +243,37 @@ def _is_clean_docket(s: str) -> bool:
|
||||
return bool(_DOCKET_RE.fullmatch((s or "").strip()))
|
||||
|
||||
|
||||
def _strip_invisibles(s: str) -> str:
|
||||
"""Drop Unicode format chars (category Cf) — RLM/LRM/ZWJ and friends.
|
||||
|
||||
Hebrew legal PDFs carry bidi marks *inside* docket numbers, so a plain
|
||||
substring test against the raw text misses a docket that is plainly there.
|
||||
"""
|
||||
return "".join(ch for ch in (s or "") if unicodedata.category(ch) != "Cf")
|
||||
|
||||
|
||||
def _docket_grounded(docket: str, *sources: str) -> bool:
|
||||
"""True when every digit group of ``docket`` appears, in order, in a source.
|
||||
|
||||
INV-AH (quote-or-retract) applied to the identity field. ``_is_clean_docket``
|
||||
only checks the *shape*, so a model that misreads one digit produces a
|
||||
perfectly well-formed but wrong docket — which is exactly how ערר 1094-09-19
|
||||
(פדילה) was stored as ``1094-09-14`` while all five case documents said
|
||||
...-19 (#232, trap 3). Shape validation cannot catch that; grounding can.
|
||||
|
||||
Tolerant of the separator (``-`` vs ``/``), of whitespace around it, and of
|
||||
bidi marks, so a real docket in the source still matches. Sources are the
|
||||
document text and the value being replaced — never the LLM's own output,
|
||||
which would make the check circular.
|
||||
"""
|
||||
parts = re.split(r"[-/]", (docket or "").strip())
|
||||
if not parts or not all(p.isdigit() for p in parts):
|
||||
return False
|
||||
pattern = r"\s*[-/]\s*".join(re.escape(p) for p in parts)
|
||||
haystack = _strip_invisibles("\n".join(s or "" for s in sources))
|
||||
return re.search(pattern, haystack) is not None
|
||||
|
||||
|
||||
def _source_type_for_level(level: str) -> str:
|
||||
"""Derive source_type from precedent_level — the library section is driven by
|
||||
source_type, so the two MUST agree (an LLM slip pairing
|
||||
@@ -400,6 +432,23 @@ async def apply_to_record(
|
||||
"already owned by another non-internal row (likely duplicate)",
|
||||
cur_cn, cn_clean,
|
||||
)
|
||||
elif not _docket_grounded(
|
||||
cn_clean,
|
||||
record.get("full_text") or "",
|
||||
cur_cn,
|
||||
record.get("citation_formatted") or "",
|
||||
):
|
||||
# The docket is well-formed but appears nowhere in the decision text
|
||||
# or in the value it would replace — i.e. the model produced digits
|
||||
# it cannot point at. case_number is the identity field; a wrong one
|
||||
# silently detaches the row from every reference to the real case.
|
||||
# Refuse the write and say so (§6) rather than trust the shape.
|
||||
logger.warning(
|
||||
"metadata_extractor: case_number normalization %r→%r REFUSED — the "
|
||||
"docket does not appear in the decision text or in the current "
|
||||
"value (ungrounded extraction, INV-AH). Keeping %r.",
|
||||
cur_cn, cn_clean, cur_cn,
|
||||
)
|
||||
else:
|
||||
fields_to_update["case_number"] = cn_clean
|
||||
|
||||
|
||||
@@ -43,8 +43,18 @@ SUBSECTION_RE = re.compile(r"^###\s+(.+?)$", re.MULTILINE)
|
||||
# Matches "**LABEL:**" field markers — handles both inline and block variants:
|
||||
# "**עמדת המבקשת:** Some text on same line"
|
||||
# "**שאלות משפטיות:**\n1. First question"
|
||||
# and both the bare and the list-item form, because the analyst agent writes
|
||||
# threshold claims as a bullet list ("- **עמדת ועדת הערר:**") while it writes
|
||||
# issues bare. Group 1 is the list marker (or None), group 2 is the label.
|
||||
# The label itself must not contain ** or newlines.
|
||||
FIELD_LABEL_RE = re.compile(r"^\*\*([^\n*]+?):\*\*[ \t]*", re.MULTILINE)
|
||||
FIELD_LABEL_RE = re.compile(
|
||||
r"^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)?\*\*([^\n*]+?):\*\*[ \t]*",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# Terminators that end a field's content even without a following field label:
|
||||
# a heading, or a horizontal rule closing the subsection.
|
||||
FIELD_TERMINATOR_RE = re.compile(r"^(?:#{2,}[ \t]|[ \t]*---[ \t]*$)", re.MULTILINE)
|
||||
|
||||
# Matches the case number in the H1
|
||||
CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILINE)
|
||||
@@ -53,6 +63,17 @@ CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILIN
|
||||
DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
def _is_chair_label(label: str) -> bool:
|
||||
"""Is this field label the chair-position field?
|
||||
|
||||
Matches on prefix, not equality, because the analyst sometimes decorates the
|
||||
label with a parenthetical — "עמדת ועדת הערר (הכוונת יו"ר 24.6)". Requiring
|
||||
equality made the reader treat those as ordinary fields and the writer append
|
||||
a second, duplicate block instead of updating the existing one.
|
||||
"""
|
||||
return label.strip().startswith(CHAIR_POSITION_LABEL)
|
||||
|
||||
|
||||
def _is_placeholder(text: str) -> bool:
|
||||
"""Check if a field value is one of the placeholder strings (empty)."""
|
||||
stripped = text.strip()
|
||||
@@ -135,7 +156,7 @@ def _extract_fields(text: str) -> list[dict]:
|
||||
|
||||
fields = []
|
||||
for i, m in enumerate(matches):
|
||||
label = m.group(1).strip()
|
||||
label = m.group(2).strip()
|
||||
content_start = m.end()
|
||||
content_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
content = text[content_start:content_end].strip()
|
||||
@@ -164,11 +185,17 @@ def _build_subsection_dict(
|
||||
parts = title.split(": ", 1)
|
||||
display_title = parts[1] if len(parts) > 1 else title
|
||||
|
||||
# Only the *first* chair-position field is the editable one — the same one
|
||||
# update_chair_position writes to. A subsection that carries more than one
|
||||
# (e.g. an H3 covering "סוגיות 4–6") keeps the extras as ordinary fields
|
||||
# rather than silently reading back a different field than the one saved.
|
||||
chair_position = ""
|
||||
chair_seen = False
|
||||
regular_fields = []
|
||||
for f in fields:
|
||||
if f["label"] == CHAIR_POSITION_LABEL:
|
||||
if not chair_seen and _is_chair_label(f["label"]):
|
||||
chair_position = _normalize_chair_position(f["content"])
|
||||
chair_seen = True
|
||||
else:
|
||||
regular_fields.append(f)
|
||||
|
||||
@@ -311,6 +338,46 @@ def _find_subsection_by_id(
|
||||
return None
|
||||
|
||||
|
||||
def _split_trailing_rule(body: str) -> tuple[str, str]:
|
||||
"""Split a subsection body into (content, trailing "---" separator).
|
||||
|
||||
Returns ("<body>", "") when the subsection has no closing rule.
|
||||
"""
|
||||
m = re.search(r"\n[ \t]*---[ \t]*\s*\Z", body)
|
||||
if not m:
|
||||
return body, ""
|
||||
return body[: m.start()], body[m.start() :]
|
||||
|
||||
|
||||
def _chair_field_span(body: str) -> tuple[int, int] | None:
|
||||
"""Locate the chair-position field's *content* range in a subsection body.
|
||||
|
||||
Returns (content_start, content_end) — the slice the chair's text occupies,
|
||||
excluding the "**LABEL:**" marker itself so the marker line (and any list
|
||||
bullet in front of it) survives an update untouched. Returns None when the
|
||||
subsection has no chair-position field yet.
|
||||
|
||||
Field boundaries come from FIELD_LABEL_RE — the same definition the reader
|
||||
uses — so what update writes is exactly what parse reads back (G2: one
|
||||
definition of a field, not two that can drift).
|
||||
"""
|
||||
matches = list(FIELD_LABEL_RE.finditer(body))
|
||||
for i, m in enumerate(matches):
|
||||
if not _is_chair_label(m.group(2)):
|
||||
continue
|
||||
content_start = m.end()
|
||||
if i + 1 < len(matches):
|
||||
content_end = matches[i + 1].start()
|
||||
else:
|
||||
content_end = len(body)
|
||||
# A heading or closing rule ends the field even without a next label.
|
||||
term = FIELD_TERMINATOR_RE.search(body, content_start, content_end)
|
||||
if term:
|
||||
content_end = term.start()
|
||||
return content_start, content_end
|
||||
return None
|
||||
|
||||
|
||||
def update_chair_position(
|
||||
file_path: Path, section_id: str, new_text: str
|
||||
) -> dict[str, Any]:
|
||||
@@ -330,40 +397,62 @@ def update_chair_position(
|
||||
|
||||
_abs_start, _abs_end, subsection_body = found
|
||||
|
||||
# Find the "**עמדת ועדת הערר:**" label within this subsection
|
||||
label_pattern = re.compile(
|
||||
r"(\*\*" + re.escape(CHAIR_POSITION_LABEL) + r":\*\*)\s*\n?([^*]*?)(?=\n\*\*|\n##|\n---|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
m = label_pattern.search(subsection_body)
|
||||
if not m:
|
||||
# Label not present — append it at the end of the subsection
|
||||
# (just before the trailing --- if any)
|
||||
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{new_text.strip()}\n"
|
||||
new_subsection = subsection_body.rstrip() + new_block
|
||||
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
|
||||
span = _chair_field_span(subsection_body)
|
||||
body_text = new_text.strip() or CHAIR_POSITION_PLACEHOLDERS[0]
|
||||
|
||||
if span is None:
|
||||
# Label not present — append it at the end of the subsection,
|
||||
# before a trailing horizontal rule if there is one.
|
||||
head, tail = _split_trailing_rule(subsection_body)
|
||||
new_block = f"\n\n**{CHAIR_POSITION_LABEL}:**\n{body_text}\n"
|
||||
new_subsection = head.rstrip() + new_block + tail
|
||||
else:
|
||||
# Replace the existing content of the chair_position field
|
||||
replacement = f"{m.group(1)}\n{new_text.strip() if new_text.strip() else CHAIR_POSITION_PLACEHOLDERS[0]}\n"
|
||||
# Replace the existing content of the chair_position field, keeping the
|
||||
# label line exactly as written (including any list marker) so the file
|
||||
# structure the analyst produced is preserved.
|
||||
content_start, content_end = span
|
||||
new_subsection = (
|
||||
subsection_body[: m.start()] + replacement + subsection_body[m.end():]
|
||||
subsection_body[:content_start].rstrip("\r\n \t")
|
||||
+ f"\n{body_text}\n"
|
||||
+ subsection_body[content_end:]
|
||||
)
|
||||
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
|
||||
|
||||
new_content = content[:_abs_start] + new_subsection + content[_abs_end:]
|
||||
|
||||
# Atomic write
|
||||
tmp_path = file_path.with_suffix(file_path.suffix + ".tmp")
|
||||
tmp_path.write_text(new_content, encoding="utf-8") # noqa: STG1 — atomic .tmp; in-place edit, S3 re-sync in Phase-2 read-wiring
|
||||
os.replace(tmp_path, file_path)
|
||||
|
||||
preview = new_text.strip()[:120]
|
||||
# Read-after-write: a position the parser cannot read back is not saved,
|
||||
# however cleanly the write itself succeeded. Reporting success here is what
|
||||
# let a whole class of format drift hide behind a green "נשמר" in the UI.
|
||||
stored = _stored_chair_position(file_path, section_id)
|
||||
expected = _normalize_chair_position(new_text)
|
||||
if stored != expected:
|
||||
raise RuntimeError(
|
||||
f"העמדה נכתבה ל-{file_path.name} אך לא נקראה בחזרה עבור {section_id} "
|
||||
f"— ככל הנראה מבנה השדה בקובץ חורג מהתבנית הצפויה"
|
||||
)
|
||||
|
||||
return {
|
||||
"saved": True,
|
||||
"section_id": section_id,
|
||||
"preview": preview,
|
||||
"position": stored,
|
||||
"preview": stored[:120],
|
||||
"timestamp": datetime.now(IL_TZ).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _stored_chair_position(file_path: Path, section_id: str) -> str:
|
||||
"""Re-parse the file and return the chair position now stored for a section."""
|
||||
parsed = parse(file_path)
|
||||
for item in parsed.get("threshold_claims", []) + parsed.get("issues", []):
|
||||
if item["id"] == section_id:
|
||||
return item.get("chair_position", "") or ""
|
||||
return ""
|
||||
|
||||
|
||||
# ── Chair directions extraction (for downstream agents) ─────────
|
||||
|
||||
|
||||
|
||||
@@ -176,7 +176,13 @@ def block_distance_to_final(
|
||||
outcome = canonical_outcome(outcome)
|
||||
diff = compute_diff_stats(regenerated_text or "", final_section_text or "")
|
||||
change_percent = diff["change_percent"]
|
||||
anti_total = count_anti_patterns(regenerated_text or "")["total"]
|
||||
anti = count_anti_patterns(regenerated_text or "")
|
||||
anti_total = anti["total"]
|
||||
# Per-pattern breakdown, not just the total: a calibration run that only
|
||||
# reports "anti=4" cannot tell you WHICH rule was broken, so it cannot say
|
||||
# what to fix. (Diagnosing the 2026-07-28 model A/B needed exactly this and
|
||||
# had to fall back on inference.)
|
||||
anti_by_pattern = {name: h["count"] for name, h in anti["by_pattern"].items()}
|
||||
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
regen_words = len((regenerated_text or "").split())
|
||||
@@ -205,6 +211,7 @@ def block_distance_to_final(
|
||||
"final_words": final_words,
|
||||
"change_percent": change_percent,
|
||||
"anti_pattern_total": anti_total,
|
||||
"anti_by_pattern": anti_by_pattern,
|
||||
"golden_ratio_deviation_pp": ratio_dev,
|
||||
"distance": distance,
|
||||
}
|
||||
|
||||
@@ -96,10 +96,13 @@ async def precedent_library_list(
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים."""
|
||||
"""רשימה של פסיקה בקורפוס הסמכותי, עם פילטרים.
|
||||
|
||||
source_kind ריק (ברירת מחדל) = כל הקורפוס, כולל החלטות ועדות ערר.
|
||||
"""
|
||||
rows = await precedent_library.list_precedents(
|
||||
practice_area=practice_area,
|
||||
court=court,
|
||||
@@ -266,8 +269,9 @@ async def search_precedent_library(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "",
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית.
|
||||
"""חיפוש סמנטי בקורפוס הפסיקה הסמכותית — פסקי דין **והחלטות ועדות ערר**.
|
||||
|
||||
מחזיר תוצאות מעורבות: הלכות (rule-level, מאושרות בלבד) + קטעי טקסט
|
||||
(passage-level). הלכות מקבלות boost קל בדירוג כי הן מזוקקות מראש.
|
||||
@@ -282,6 +286,9 @@ async def search_precedent_library(
|
||||
subject_tag: סינון לפי תגית נושא (לדוגמה "מועד_קביעת_שומה").
|
||||
limit: מספר תוצאות מקסימלי.
|
||||
include_halachot: האם לכלול הלכות (ברירת מחדל: כן).
|
||||
source_kind: ריק (ברירת מחדל) = כל הקורפוס — פסקי דין והחלטות ועדות
|
||||
ערר יחד. "external_upload" = פסקי בתי משפט בלבד;
|
||||
"internal_committee" = החלטות ועדות ערר בלבד.
|
||||
|
||||
Returns: רשימה מדורגת. כל פריט הוא {"type": "halacha"|"passage", "score", ...}.
|
||||
"""
|
||||
@@ -299,6 +306,7 @@ async def search_precedent_library(
|
||||
subject_tag=subject_tag,
|
||||
limit=limit,
|
||||
include_halachot=include_halachot,
|
||||
source_kind=source_kind,
|
||||
)
|
||||
# X11 Phase 2 (#154): attach the incoming-citation authority breakdown so the
|
||||
# research agent can WEIGH and ARGUE authority ("הלכה שאומצה ב-N החלטות ועדת-ערר")
|
||||
|
||||
60
mcp-server/tests/test_anti_pattern_directive.py
Normal file
60
mcp-server/tests/test_anti_pattern_directive.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""The style invariants must actually REACH the writer.
|
||||
|
||||
Both tests here cover defects found by the 2026-07-28 model×prompt A/B over the
|
||||
signed finals: the canonical anti-patterns were present in the prompt but buried
|
||||
~47K chars into style_context (where they measurably failed to bind), and they
|
||||
were rendered inside a try/except that an unrelated DB failure could abort.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import block_writer
|
||||
from legal_mcp.services.lessons import ANTI_PATTERNS, anti_pattern_directive
|
||||
|
||||
|
||||
def test_directive_renders_every_canonical_anti_pattern():
|
||||
"""One source, two renderings — the directive may not drift from the list
|
||||
style_distance scores against."""
|
||||
text = anti_pattern_directive()
|
||||
for ap in ANTI_PATTERNS:
|
||||
assert ap["note"] in text, f"missing anti-pattern in directive: {ap['name']}"
|
||||
|
||||
|
||||
def test_both_writer_paths_append_the_directive_last():
|
||||
"""write_block (in-process) and get_block_context (legal-writer agent) must
|
||||
both close with the directive — otherwise the two writers drift (G2)."""
|
||||
import inspect
|
||||
src = inspect.getsource(block_writer)
|
||||
for fn in ("async def write_block(", "async def get_block_context("):
|
||||
start = src.index(fn)
|
||||
# bound the search to this function: up to the next top-level def
|
||||
rest = src[start + len(fn):]
|
||||
nxt = rest.find("\nasync def ")
|
||||
body = rest[: nxt if nxt != -1 else len(rest)]
|
||||
assert "anti_pattern_directive()" in body, f"{fn} does not append the style directive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_style_context_keeps_anti_patterns_when_overrides_fail(monkeypatch):
|
||||
"""A chair-override outage must not silently un-teach the structural style.
|
||||
|
||||
Regression: the canonical list used to be emitted inside the overrides loop,
|
||||
so a throw on an EARLIER category (golden_ratios) dropped it entirely.
|
||||
"""
|
||||
async def _boom(*a, **k):
|
||||
raise RuntimeError("methodology table unavailable")
|
||||
|
||||
async def _empty(*a, **k):
|
||||
return []
|
||||
|
||||
# Every DB accessor this function touches is stubbed — the test must not open
|
||||
# a real connection (a live pool here leaks across the shared event loop and
|
||||
# breaks unrelated tests later in the run).
|
||||
monkeypatch.setattr(block_writer.db, "get_style_patterns", _empty)
|
||||
monkeypatch.setattr(block_writer.db, "get_methodology_overrides", _boom)
|
||||
monkeypatch.setattr(block_writer.db, "get_recent_decision_lessons", _empty)
|
||||
|
||||
ctx = await block_writer._build_style_context("היטל השבחה")
|
||||
|
||||
assert "נרטיב משפטי רציף" in ctx
|
||||
for ap in ANTI_PATTERNS:
|
||||
assert ap["note"] in ctx, f"anti-pattern dropped on override failure: {ap['name']}"
|
||||
54
mcp-server/tests/test_argument_aggregator.py
Normal file
54
mcp-server/tests/test_argument_aggregator.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Regression tests for argument aggregation (#233).
|
||||
|
||||
Both tests cover the same 2026-08-05 incident from different angles: the
|
||||
appellant side of 1069-04-26 sent 310 propositions in one Claude call, the call
|
||||
came back as something other than a JSON array, and the code logged a warning
|
||||
and returned ``[]``. The caller could not tell that apart from "this side has no
|
||||
arguments", so ``aggregate_claims_to_arguments`` reported ``completed`` with the
|
||||
central litigant of the appeal missing entirely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services.argument_aggregator import (
|
||||
MAX_PROPS_PER_CALL,
|
||||
AggregationFailed,
|
||||
_aggregate_party,
|
||||
_chunk,
|
||||
)
|
||||
|
||||
|
||||
def test_chunk_preserves_every_proposition_and_their_order():
|
||||
"""Chunking must not drop or reorder — losing claims here is invisible."""
|
||||
props = [{"i": i} for i in range(310)]
|
||||
|
||||
chunks = _chunk(props, MAX_PROPS_PER_CALL)
|
||||
|
||||
assert sum(len(c) for c in chunks) == 310, "propositions were lost"
|
||||
assert [p for c in chunks for p in c] == props, "order changed"
|
||||
assert all(len(c) <= MAX_PROPS_PER_CALL for c in chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_list_reply_raises_instead_of_dropping_the_side(monkeypatch):
|
||||
"""A malformed reply must surface, never look like an empty side.
|
||||
|
||||
This is the exact 1069-04-26 failure. If this test ever goes back to
|
||||
asserting ``== []``, the silent-drop bug has been reintroduced.
|
||||
"""
|
||||
async def _query_json(prompt, tools=""): # noqa: ARG001
|
||||
return {"error": "not a list"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"legal_mcp.services.argument_aggregator.claude_session.query_json",
|
||||
_query_json,
|
||||
)
|
||||
|
||||
with pytest.raises(AggregationFailed) as excinfo:
|
||||
await _aggregate_party("appellant", [{"id": "x", "claim_text": "t"}])
|
||||
|
||||
# The message has to name the side, or an operator reading
|
||||
# completed_with_errors cannot tell which litigant went missing.
|
||||
assert "appellant" in str(excinfo.value)
|
||||
81
mcp-server/tests/test_citation_view_fanout.py
Normal file
81
mcp-server/tests/test_citation_view_fanout.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""The citation-verification view must not fan out without a bound.
|
||||
|
||||
`build_view` used to spawn one retrieval task per legal argument with a bare
|
||||
`asyncio.gather`. Each task opens a Voyage embed call, and past ~8 in flight
|
||||
Voyage throttles — so the unbounded version was *slower* than a bounded one
|
||||
(32 arguments: 30.4s unbounded vs 22.1s at 8), and a 69-argument case blew past
|
||||
the 30s proxy timeout and returned 500 while overlapping reloads piled
|
||||
contention into Postgres deadlocks.
|
||||
|
||||
These tests pin the two properties that fix gave us: the fan-out is bounded,
|
||||
and a timeout yields the results that DID land instead of nothing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import case_citation_verification as ccv
|
||||
|
||||
|
||||
def test_concurrency_bound_is_set_and_modest():
|
||||
assert 1 <= ccv._MAX_CONCURRENT_LOOKUPS <= 16, (
|
||||
"the bound exists to stay under Voyage's throttle point — a large value "
|
||||
"reintroduces the stall this was added to fix"
|
||||
)
|
||||
|
||||
|
||||
def test_retrieval_budget_leaves_room_under_the_proxy_timeout():
|
||||
"""The proxy gives up at 30s; we must cut ourselves off before that."""
|
||||
assert 0 < ccv._RETRIEVAL_BUDGET_S < 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semaphore_actually_caps_in_flight_work():
|
||||
"""A semaphore of N never lets N+1 coroutines run the body at once."""
|
||||
limit = ccv._MAX_CONCURRENT_LOOKUPS
|
||||
sem = asyncio.Semaphore(limit)
|
||||
in_flight = 0
|
||||
peak = 0
|
||||
|
||||
async def worker():
|
||||
nonlocal in_flight, peak
|
||||
async with sem:
|
||||
in_flight += 1
|
||||
peak = max(peak, in_flight)
|
||||
await asyncio.sleep(0.01)
|
||||
in_flight -= 1
|
||||
|
||||
await asyncio.gather(*(worker() for _ in range(limit * 4)))
|
||||
assert peak <= limit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_harvests_finished_work_instead_of_discarding_it():
|
||||
"""The regression: wait_for(gather(...)) cancels everything on timeout, so
|
||||
one slow lookup threw away every result that had already succeeded. The
|
||||
harvest pattern must keep them."""
|
||||
|
||||
async def quick(i):
|
||||
await asyncio.sleep(0.01)
|
||||
return i
|
||||
|
||||
async def never():
|
||||
await asyncio.sleep(30)
|
||||
return "unreachable"
|
||||
|
||||
tasks = [asyncio.ensure_future(quick(i)) for i in range(5)]
|
||||
tasks.append(asyncio.ensure_future(never()))
|
||||
|
||||
done, pending = await asyncio.wait(tasks, timeout=0.3)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
harvested = [
|
||||
t.result() if (t in done and not t.cancelled() and t.exception() is None) else None
|
||||
for t in tasks
|
||||
]
|
||||
assert harvested[:5] == [0, 1, 2, 3, 4], "finished work must survive the timeout"
|
||||
assert harvested[5] is None, "the unfinished one is empty, not fabricated"
|
||||
assert len(pending) == 1
|
||||
65
mcp-server/tests/test_docket_grounding.py
Normal file
65
mcp-server/tests/test_docket_grounding.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""#232 trap 3 — a well-formed docket is not necessarily the right docket.
|
||||
|
||||
ערר (מרכז) 1094-09-19 (פדילה) was stored as ``1094-09-14``: shape-valid, so
|
||||
``_is_clean_docket`` waved it through, but wrong — and case_number is the
|
||||
identity field, so the row detached from every reference to the real case.
|
||||
Grounding the digits in the source text is what shape validation cannot do.
|
||||
"""
|
||||
|
||||
from legal_mcp.services.precedent_metadata_extractor import (
|
||||
_docket_grounded,
|
||||
_is_clean_docket,
|
||||
_strip_invisibles,
|
||||
)
|
||||
|
||||
|
||||
HEADER = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה אברהים נ' הוועדה המקומית"
|
||||
|
||||
|
||||
def test_the_regression_shape_valid_but_wrong_digit():
|
||||
"""Both pass the shape check; only the real one is grounded."""
|
||||
assert _is_clean_docket("1094-09-14")
|
||||
assert _is_clean_docket("1094-09-19")
|
||||
assert _docket_grounded("1094-09-19", HEADER)
|
||||
assert not _docket_grounded("1094-09-14", HEADER)
|
||||
|
||||
|
||||
def test_separator_and_spacing_are_tolerated():
|
||||
"""A real docket must still match when the source writes it differently."""
|
||||
assert _docket_grounded("1094-09-19", "בערר 1094/09/19 נקבע")
|
||||
assert _docket_grounded("4768/22", "עת\"מ 4768-22 פלוני")
|
||||
assert _docket_grounded("1132-09-24", "תיק 1132 - 09 - 24")
|
||||
|
||||
|
||||
def test_bidi_marks_inside_the_number_do_not_defeat_grounding():
|
||||
"""Hebrew legal PDFs embed RLM/LRM between digits and separators."""
|
||||
noisy = "ערר (מרכז) 1094-09-19 פדילה"
|
||||
assert _strip_invisibles(noisy).count("") == 0
|
||||
assert _docket_grounded("1094-09-19", noisy)
|
||||
|
||||
|
||||
def test_grounding_accepts_the_value_being_replaced():
|
||||
"""Normalising an uploader's citation string into a clean docket is the
|
||||
whole point of the rewrite — the digits come from there, not the text."""
|
||||
citation = "ערר (ועדות ערר - תכנון ובנייה מרכז) 1094-09-19 פדילה נ' טירה (נבו 4.12.2019)"
|
||||
assert _docket_grounded("1094-09-19", "", citation)
|
||||
assert not _docket_grounded("1094-09-14", "", citation)
|
||||
|
||||
|
||||
def test_two_and_three_part_dockets_both_ground():
|
||||
assert _docket_grounded("8031/21", "בהיטל השבחה 8031/21 נדון")
|
||||
assert _docket_grounded("85074-09-24", "בל\"מ 85074-09-24")
|
||||
|
||||
|
||||
def test_non_numeric_or_empty_never_grounds():
|
||||
assert not _docket_grounded("", HEADER)
|
||||
assert not _docket_grounded("ערר 1094", HEADER)
|
||||
assert not _docket_grounded("abc-de", HEADER)
|
||||
|
||||
|
||||
def test_absent_from_every_source_is_refused():
|
||||
"""The פדילה failure mode: text has no docket at all, so anything the
|
||||
model offers is ungrounded and must not reach the identity field."""
|
||||
body = "בפני: יו\"ר הוועדה: רונית אלפר, עו\"ד\nהעוררים: 1. פדילה אברהים"
|
||||
assert not _docket_grounded("1094-09-14", body)
|
||||
assert not _docket_grounded("1094-09-19", body)
|
||||
68
mcp-server/tests/test_relevance_scale.py
Normal file
68
mcp-server/tests/test_relevance_scale.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""`score` and `relevance` are different things — thresholds must use `relevance`.
|
||||
|
||||
Retrieval returns cosine similarities (~0.4-0.75) until the lexical leg returns
|
||||
rows; then `_merge_sem_lex` replaces `score` with an RRF value (~0.008-0.02).
|
||||
Both are legitimate *ranking* signals, but they are not on the same scale, so a
|
||||
caller comparing `score >= 0.45` rejected every hit the moment BM25 matched
|
||||
anything. That is how the citation-verification tab came to show no supporting
|
||||
precedent for a single argument, on every case.
|
||||
|
||||
`relevance` is the fix: always a cosine, or None when the row came from the
|
||||
lexical leg alone and no cosine was ever computed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services.case_citation_verification import _SUGGEST_FLOOR, _passes_floor
|
||||
from legal_mcp.services.hybrid_search import _merge_sem_lex
|
||||
|
||||
|
||||
def _sem(key: str, score: float) -> dict:
|
||||
return {"chunk_id": key, "case_law_id": "c1", "score": score, "relevance": score}
|
||||
|
||||
|
||||
def _lex(key: str, score: float) -> dict:
|
||||
# The lexical leg emits ts_rank_cd, never a cosine — so no `relevance`.
|
||||
return {"chunk_id": key, "case_law_id": "c1", "score": score}
|
||||
|
||||
|
||||
def test_fusion_replaces_score_but_preserves_the_cosine():
|
||||
"""The regression in one assertion."""
|
||||
merged = _merge_sem_lex([_sem("a", 0.73)], [_lex("a", 0.31)], limit=10)
|
||||
row = merged[0]
|
||||
assert row["score"] < 0.1, "fused score is an RRF value, not a cosine"
|
||||
assert row["relevance"] == pytest.approx(0.73), "the cosine must survive fusion"
|
||||
|
||||
|
||||
def test_lexical_only_row_has_no_fabricated_cosine():
|
||||
"""None means 'not measured' — not 'measured as irrelevant'."""
|
||||
merged = _merge_sem_lex([], [_lex("b", 0.31)], limit=10)
|
||||
assert merged[0]["relevance"] is None
|
||||
|
||||
|
||||
def test_semantic_only_row_keeps_its_cosine():
|
||||
merged = _merge_sem_lex([_sem("c", 0.62)], [], limit=10)
|
||||
assert merged[0]["relevance"] == pytest.approx(0.62)
|
||||
|
||||
|
||||
def test_floor_would_have_rejected_everything_on_the_fused_score():
|
||||
"""Guards the exact production symptom: fused scores are ~0.008, the floor
|
||||
is 0.45, so score-based filtering wipes the result set."""
|
||||
merged = _merge_sem_lex(
|
||||
[_sem(k, 0.70) for k in "abcd"], [_lex(k, 0.30) for k in "abcd"], limit=10)
|
||||
assert all(r["score"] < _SUGGEST_FLOOR for r in merged) # the bug
|
||||
assert all(_passes_floor(r) for r in merged) # the fix
|
||||
|
||||
|
||||
def test_floor_still_rejects_genuinely_weak_hits():
|
||||
"""The fix must not become 'accept everything'."""
|
||||
assert not _passes_floor({"relevance": 0.10})
|
||||
assert not _passes_floor({"relevance": _SUGGEST_FLOOR - 0.01})
|
||||
assert _passes_floor({"relevance": _SUGGEST_FLOOR})
|
||||
|
||||
|
||||
def test_lexical_only_hits_are_kept_deliberately():
|
||||
"""An exact docket/phrase match reaches the top by BM25 rank with no cosine.
|
||||
Dropping it would hide precisely what a chair searches for by name."""
|
||||
assert _passes_floor({"relevance": None})
|
||||
assert _passes_floor({}) # missing key behaves the same as None
|
||||
237
mcp-server/tests/test_research_md_chair_position.py
Normal file
237
mcp-server/tests/test_research_md_chair_position.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Chair-position round-trip in analysis-and-research.md.
|
||||
|
||||
Regression cover for the reader/writer asymmetry that made "עמדת ועדת הערר"
|
||||
appear to save on threshold claims and then vanish on refresh: the writer
|
||||
matched the label anywhere on a line, the reader only at the start of one, and
|
||||
the analyst agent writes threshold claims as a bullet list
|
||||
("- **עמדת ועדת הערר:**") while it writes issues flush-left.
|
||||
|
||||
The invariant these tests pin down: whatever update_chair_position writes,
|
||||
parse() must read back — for every label form that appears in the corpus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import research_md
|
||||
|
||||
|
||||
BULLETED = """# ניתוח משפטי — ערר 1069-04-26
|
||||
תאריך: 2026-08-02
|
||||
|
||||
## 5. טענות סף
|
||||
|
||||
### ס-1 — אי-מילוי תנאי ההפקדה
|
||||
- **טענה (claim):** התכנית אושרה מבלי שמולאו תנאי ההפקדה.
|
||||
- **שאלה משפטית:** האם הסטת התנאים פוגמת בחוקיות?
|
||||
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
### ס-2 — זכות עמידה
|
||||
- **טענה (claim):** לעוררים אין אינטרס מוגן.
|
||||
- **עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
## 6. סוגיות להכרעה
|
||||
|
||||
### סוגיה 1: סמכות לפי 62א(א)(11)
|
||||
|
||||
**ניתוח:**
|
||||
- הכלל החל: ...
|
||||
|
||||
**עמדת ועדת הערר:** [ימולא ע"י יו"ר הוועדה]
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def analysis_file(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "analysis-and-research.md"
|
||||
path.write_text(BULLETED, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _positions(path: Path) -> dict[str, str]:
|
||||
parsed = research_md.parse(path)
|
||||
items = parsed["threshold_claims"] + parsed["issues"]
|
||||
return {item["id"]: item["chair_position"] for item in items}
|
||||
|
||||
|
||||
# ── the reported bug ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bulleted_threshold_position_survives_a_reload(analysis_file: Path) -> None:
|
||||
"""The bug as chaim hit it: saved on a threshold claim, gone on refresh."""
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדתנו היא שהטענה נדחית.")
|
||||
|
||||
assert _positions(analysis_file)["threshold_1"] == "עמדתנו היא שהטענה נדחית."
|
||||
|
||||
|
||||
def test_flush_left_issue_position_still_round_trips(analysis_file: Path) -> None:
|
||||
"""The surface that already worked must keep working."""
|
||||
research_md.update_chair_position(analysis_file, "issue_1", "יש לקבל את הערר בנקודה זו.")
|
||||
|
||||
assert _positions(analysis_file)["issue_1"] == "יש לקבל את הערר בנקודה זו."
|
||||
|
||||
|
||||
def test_bulleted_threshold_fields_are_parsed_at_all(analysis_file: Path) -> None:
|
||||
"""Bulleted labels were invisible to the reader — the whole claim looked empty."""
|
||||
threshold = research_md.parse(analysis_file)["threshold_claims"]
|
||||
|
||||
labels = [f["label"] for f in threshold[0]["fields"]]
|
||||
assert "טענה (claim)" in labels
|
||||
assert "שאלה משפטית" in labels
|
||||
# the chair field is surfaced separately, never as a regular field
|
||||
assert not any(research_md._is_chair_label(label) for label in labels)
|
||||
|
||||
|
||||
# ── writer/reader agreement on content boundaries ───────────────
|
||||
|
||||
|
||||
def test_position_containing_markdown_is_not_truncated(analysis_file: Path) -> None:
|
||||
"""The old writer stopped the field at the first '*' and swallowed the rest."""
|
||||
text = 'העמדה כוללת **הדגשה** וגם קו --- באמצע\nושורה שנייה.'
|
||||
research_md.update_chair_position(analysis_file, "issue_1", text)
|
||||
|
||||
assert _positions(analysis_file)["issue_1"] == text
|
||||
|
||||
|
||||
def test_update_does_not_touch_sibling_subsections(analysis_file: Path) -> None:
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "ראשונה")
|
||||
research_md.update_chair_position(analysis_file, "threshold_2", "שנייה")
|
||||
|
||||
positions = _positions(analysis_file)
|
||||
assert positions["threshold_1"] == "ראשונה"
|
||||
assert positions["threshold_2"] == "שנייה"
|
||||
assert positions["issue_1"] == ""
|
||||
|
||||
|
||||
def test_closing_rule_and_bullet_marker_are_preserved(analysis_file: Path) -> None:
|
||||
"""An update must not restructure the file the analyst produced."""
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||||
research_md.update_chair_position(analysis_file, "issue_1", "עמדה")
|
||||
content = analysis_file.read_text(encoding="utf-8")
|
||||
|
||||
assert "- **עמדת ועדת הערר:**" in content
|
||||
assert content.rstrip().endswith("---")
|
||||
|
||||
|
||||
def test_clearing_a_position_restores_the_placeholder(analysis_file: Path) -> None:
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "")
|
||||
|
||||
assert _positions(analysis_file)["threshold_1"] == ""
|
||||
assert research_md.CHAIR_POSITION_PLACEHOLDERS[0] in analysis_file.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_repeated_saves_do_not_accumulate_blocks(analysis_file: Path) -> None:
|
||||
for text in ("ראשון", "שני", "שלישי"):
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", text)
|
||||
|
||||
content = analysis_file.read_text(encoding="utf-8")
|
||||
assert content.count(f"**{research_md.CHAIR_POSITION_LABEL}:**") == 3
|
||||
assert _positions(analysis_file)["threshold_1"] == "שלישי"
|
||||
|
||||
|
||||
# ── decorated and duplicated labels ─────────────────────────────
|
||||
|
||||
|
||||
def test_decorated_label_is_updated_not_duplicated(tmp_path: Path) -> None:
|
||||
"""'עמדת ועדת הערר (הכוונת יו"ר 24.6)' appears in the corpus (8125-09-24)."""
|
||||
path = tmp_path / "a.md"
|
||||
path.write_text(
|
||||
"## 6. סוגיות להכרעה\n\n"
|
||||
"### סוגיה 1: כותרת\n\n"
|
||||
'**עמדת ועדת הערר (הכוונת יו"ר 24.6):** [ימולא ע"י יו"ר הוועדה]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
research_md.update_chair_position(path, "issue_1", "עמדה מעודכנת")
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert content.count(research_md.CHAIR_POSITION_LABEL) == 1
|
||||
assert _positions(path)["issue_1"] == "עמדה מעודכנת"
|
||||
|
||||
|
||||
def test_duplicate_labels_read_back_the_one_that_was_written(tmp_path: Path) -> None:
|
||||
"""An H3 covering several issues (1033-02-25) must not read a sibling's text."""
|
||||
path = tmp_path / "a.md"
|
||||
path.write_text(
|
||||
"## 6. סוגיות להכרעה\n\n"
|
||||
"### סוגיות 4–6: מקובצות\n\n"
|
||||
"**עמדת ועדת הערר:** ראשונה\n\n"
|
||||
"**עמדת ועדת הערר:** אחרונה\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
research_md.update_chair_position(path, "issue_1", "העמדה הנכונה")
|
||||
|
||||
assert _positions(path)["issue_1"] == "העמדה הנכונה"
|
||||
|
||||
|
||||
# ── read-after-write guard ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_returns_the_persisted_value(analysis_file: Path) -> None:
|
||||
result = research_md.update_chair_position(analysis_file, "threshold_1", " עמדה ")
|
||||
|
||||
assert result["saved"] is True
|
||||
assert result["position"] == "עמדה"
|
||||
|
||||
|
||||
def test_a_write_the_parser_cannot_read_back_is_reported_as_failure(
|
||||
analysis_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Regression guard: the bug hid behind a success response for months."""
|
||||
monkeypatch.setattr(
|
||||
research_md, "_stored_chair_position", lambda *_args: "משהו אחר"
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="לא נקראה בחזרה"):
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||||
|
||||
|
||||
# ── downstream consumers see the recovered positions ────────────
|
||||
|
||||
|
||||
def test_chair_directions_count_bulleted_positions(analysis_file: Path) -> None:
|
||||
"""extract_chair_directions feeds legal-writer; it under-reported before."""
|
||||
research_md.update_chair_position(analysis_file, "threshold_1", "עמדה")
|
||||
|
||||
directions = research_md.extract_chair_directions(analysis_file)
|
||||
assert directions["filled_count"] == 1
|
||||
assert directions["status"] == "partial"
|
||||
filled = [t for t in directions["threshold_claims"] if t["direction"]]
|
||||
assert [t["id"] for t in filled] == ["threshold_1"]
|
||||
|
||||
|
||||
# ── the real corpus ─────────────────────────────────────────────
|
||||
|
||||
|
||||
CORPUS = sorted(
|
||||
Path("/home/chaim/legal-ai/data/cases").glob(
|
||||
"*/documents/research/analysis-and-research.md"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not CORPUS, reason="case corpus not present on this host")
|
||||
@pytest.mark.parametrize("source", CORPUS, ids=lambda p: p.parts[-4])
|
||||
def test_every_corpus_subsection_round_trips(source: Path, tmp_path: Path) -> None:
|
||||
sentinel = "עמדת-בדיקה — **הדגשה** ו---קו\nושורה שנייה."
|
||||
before = _positions(source)
|
||||
|
||||
for section_id in before:
|
||||
working = tmp_path / f"{section_id}.md"
|
||||
working.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
research_md.update_chair_position(working, section_id, sentinel)
|
||||
after = _positions(working)
|
||||
|
||||
assert after[section_id] == sentinel, f"{source.parts[-4]} {section_id}"
|
||||
untouched = {k: v for k, v in after.items() if k != section_id}
|
||||
assert untouched == {k: v for k, v in before.items() if k != section_id}
|
||||
67
mcp-server/tests/test_source_kind_selector.py
Normal file
67
mcp-server/tests/test_source_kind_selector.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""#232 — the source_kind selector must not silently hide a corpus.
|
||||
|
||||
`search_precedent_library` defaulted to source_kind='external_upload' all the
|
||||
way down the stack, so 104 appeals-committee decisions (29% of the corpus)
|
||||
were unreachable through the entry point the writing agents actually call.
|
||||
These tests pin the selector semantics; the end-to-end retrieval check lives
|
||||
in scripts/test_retrieval_by_name.py (needs a live DB).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services.db import _source_kind_clause
|
||||
|
||||
|
||||
def test_empty_selector_means_no_filter():
|
||||
"""'' and 'all' must produce no predicate — the whole corpus."""
|
||||
assert _source_kind_clause("") == ""
|
||||
assert _source_kind_clause("all") == ""
|
||||
assert _source_kind_clause(" ") == ""
|
||||
|
||||
|
||||
def test_named_kinds_produce_equality_predicate():
|
||||
assert _source_kind_clause("internal_committee", "cl.") == (
|
||||
"cl.source_kind = 'internal_committee'"
|
||||
)
|
||||
assert _source_kind_clause("external_upload") == "source_kind = 'external_upload'"
|
||||
|
||||
|
||||
def test_all_committees_expands_to_both_shapes():
|
||||
"""Committee decisions live under two historical shapes — cover both."""
|
||||
clause = _source_kind_clause("all_committees", "cl.")
|
||||
assert "cl.source_kind = 'internal_committee'" in clause
|
||||
assert "cl.source_type = 'appeals_committee'" in clause
|
||||
assert clause.startswith("(") and clause.endswith(")")
|
||||
|
||||
|
||||
def test_unknown_selector_raises_rather_than_reaching_sql():
|
||||
"""The value is f-string-interpolated, so the whitelist is the guard."""
|
||||
with pytest.raises(ValueError, match="source_kind"):
|
||||
_source_kind_clause("'; DROP TABLE case_law; --")
|
||||
with pytest.raises(ValueError):
|
||||
_source_kind_clause("internal")
|
||||
|
||||
|
||||
def test_default_of_the_search_entry_points_is_whole_corpus():
|
||||
"""A regression guard on the defaults themselves — this is the bug."""
|
||||
import inspect
|
||||
|
||||
from legal_mcp.services import hybrid_search, precedent_library
|
||||
from legal_mcp.services import db as db_mod
|
||||
from legal_mcp.tools import precedent_library as plib_tool
|
||||
|
||||
for fn in (
|
||||
db_mod.search_precedent_library_semantic,
|
||||
db_mod.search_precedent_library_lexical,
|
||||
db_mod.list_external_case_law,
|
||||
hybrid_search.search_precedent_library_hybrid,
|
||||
precedent_library.search_library,
|
||||
precedent_library.list_precedents,
|
||||
plib_tool.search_precedent_library,
|
||||
plib_tool.precedent_library_list,
|
||||
):
|
||||
default = inspect.signature(fn).parameters["source_kind"].default
|
||||
assert default == "", (
|
||||
f"{fn.__module__}.{fn.__qualname__} defaults source_kind to "
|
||||
f"{default!r} — that hides a corpus from every caller (#232)"
|
||||
)
|
||||
@@ -26,6 +26,7 @@
|
||||
| `test_retrieval_by_name.py` | python | בדיקת אחזור-לפי-שם (#52/RC-A) — מאמת ש`search_precedent_library`/`search_internal_decisions` מדרגים את ההחלטה עצמה (אגסי) מעל מי שמצטט אותה, + רגרסיות לשאילתות מהותיות. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/test_retrieval_by_name.py` (exit 0 = עבר). | ידני אחרי שינוי שכבת חיפוש |
|
||||
| `eval_gold_bootstrap.py` | python | **FU-5 (GAP-11) — bootstrap ל-gold-set** של הערכת-אחזור ל-`data/eval/gold-set.jsonl`. שני מקורות: `--source citations` (cited==relevant מ-`search_relevance_feedback`; ריק עד שייצברו ציטוטים) ו-`--source known_item` (query=שם-תיק → relevant=עצמו; אות אמיתי היום). Idempotent — שומר שורות `source=chair`, מחדש `bootstrap_*`. דורש POSTGRES. | לפני eval; חוזר כשנצבר ground-truth |
|
||||
| `eval_retrieval.py` | python | **FU-5 (GAP-11, INV-RET4/G8) — harness הערכת-אחזור** — מריץ את מסלול-האחזור בייצור (`search_library`/`search_internal`) על ה-gold-set, מחשב precision@k/recall@k/MRR/nDCG@k (k=5,10), מצרף overall+per-corpus+per-PA ל-`data/eval/eval-report-<ts>.{json,md}` + delta מול `data/eval/baseline.json` (מתעד retrieval_config). `--self-test` בודק את המטריקות offline; `--update-baseline` מאמץ snapshot. **שער-CI במשמעת:** הרץ לפני/אחרי כל שינוי בשכבת-האחזור באותו קונפיג. דורש POSTGRES+VOYAGE_API_KEY. | לפני/אחרי שינוי RRF/k/embedder/rerank |
|
||||
| `legal-mcp-http.config.cjs` | pm2/js | **שרת ה-MCP חשוף ב-streamable-http** (#231) — `python -m legal_mcp.server` עם `MCP_TRANSPORT=streamable-http`, bound **`127.0.0.1:8790`**, Bearer `MCP_HTTP_SHARED_SECRET` מ-`~/.legal-mcp-http.env` (מקור-אמת: Infisical → All Infrastructure / `main` / `/apps/legal-ai`, תג `credentials`). **למה:** סוכנים המונעים דרך Agent Client Protocol מקבלים את שרתי-ה-MCP שלהם מהלקוח בפתיחת הסשן, והערוץ הזה נושא שרתי HTTP בלבד — ל-stdio אין מסלול לשם, ומכאן שהסוכנים נותרו בלי 108 הכלים. **אינו מחליף את stdio:** כל סשן אינטראקטיבי ממשיך דרך `~/.claude.json`; אותו קוד, אותו מרשם-כלים, שתי תחבורות (G2). **אבטחה:** loopback בלבד (צר יותר מ-`10.0.1.1` של legal-chat-service — שום קונטיינר לא צריך MCP), והשרת **מסרב לעלות בלי טוקן** (`services/http_auth.py`), כך שתקלת-הגדרה לא יכולה לייצר מאזין לא-מאומת. מראָה לדפוס `legal-chat-service.config.cjs`. התקנה: `pm2 start scripts/legal-mcp-http.config.cjs && pm2 save`. בדיקה: POST ל-`/mcp` → 401 בלי טוקן, 200 עם. | pm2 (host-side) |
|
||||
| `legal-court-fetch-service.config.cjs` | pm2/js | **שירות-מארח Tier-1 לאחזור פסקי-דין מנט המשפט (X13)** — 2 apps: (א) `legal-court-fetch-xvfb` (Xvfb :99, צג-וירטואלי ל-Camoufox); (ב) `legal-court-fetch-service` (`python -m legal_mcp.court_fetch_service.server`, bound `10.0.1.1:8771`, Bearer `COURT_FETCH_SHARED_SECRET` מ-`~/.legal-court-fetch-service.env`, `DISPLAY=:99`). מריץ Camoufox דרך חבילת-הפייתון (in-process) כי הקונטיינר לא יכול דפדפן. תלות: `pip install -e "mcp-server[court-fetch]" && python -m camoufox fetch`. אחזור = ניווט→צופה→`GetImages`(X-Requested-With)→PDF, ללא CAPTCHA; כשל→`ok:false`→orchestrator מסלים ל-fallback אנושי. **אומת על עת"מ 46111-12-22 (34 עמ').** מראָה לדפוס `legal-chat-service.config.cjs`. ספ: `docs/spec/X13-court-fetch.md`. התקנה: `pm2 start scripts/legal-court-fetch-service.config.cjs && pm2 save`. בריאות: `curl http://10.0.1.1:8771/health`. | pm2 (host-side) |
|
||||
| `drain_court_fetch.py` | python | **ריקון תור-אחזור הפסיקה (X13)** — קורא ל-`court_fetch_orchestrator.drain_pending(limit)` שמוריד+קולט כל job ממתין שהיומונים מילאו, וקושר חזרה ליומון. מקומי בלבד (ingest = claude CLI). no-op מהיר כשהתור ריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_court_fetch.py [limit]`. | דרך `legal-court-fetch-drain.config.cjs` (pm2 cron) |
|
||||
| `legal-court-fetch-drain.config.cjs` | pm2/js | **תזמון שעתי של `drain_court_fetch.py`** (cron `17 * * * *`, `COURT_FETCH_DRAIN_CRON` לעקיפה) — הופך את לולאת יומון→אחזור→קליטה ל-fully-autonomous. `autorestart:false` (one-shot per tick). דורש `legal-court-fetch-service` רץ. התקנה: `pm2 start scripts/legal-court-fetch-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||
@@ -98,6 +99,7 @@
|
||||
|--------|------|---------|-----------|
|
||||
| `spec-guard.sh` | bash | **PreToolUse hook לאכיפת "פרוטוקול כתיבת-קוד"** (CLAUDE.md §פרוטוקול כתיבת-קוד) — בכל Edit/Write/MultiEdit על נתיב-קוד (`web/`, `mcp-server/`, `web-ui/src/`, `scripts/`, `adapters/`) מזריק תזכורת ל-Claude לקרוא את `docs/spec/00-constitution.md`+ספ-התחום ולוודא קיום G1–G12 — לפני שכותבים. **+ leak-guard בזמן-אמת (G12):** על כתיבה ל-`mcp-server/src/*` בודק את התוכן-הנכתב (`new_string`/`content`) ומזהיר אם מוזרק מונח-Paperclip לשכבת-האינטליגנציה (לא-deduped). המקבילה האינטראקטיבית ל-INV-AG1. קלט JSON ב-stdin, פלט `hookSpecificOutput.additionalContext` (non-blocking, exit 0). Dedup פעם-בסשן לתזכורת-הספ. רשום ב-`.claude/settings.json`. | נקרא אוטומטית ע"י Claude Code (hook) |
|
||||
| `leak_guard.py` | python | **המאכף הקנוני של INV-G12 (שער-הפלטפורמה / docs/spec/X15 §4 / R4).** שני כללים קשיחים: (1) `mcp-server/src` ללא סמלי-Paperclip (allowlist מנומק לפי substring); (2) רק `web/agent_platform_port.py` (+ קבצי-המעטפת) מייבאים את לקוח-Paperclip. stdlib-בלבד (אין venv). `leak_guard.py` = סריקת-repo (exit 1 על הפרה); `leak_guard.py <file>...` = קבצים נתונים (ל-hook). משותף ל-spec-guard.sh (hook), ל-CI (`.gitea/workflows/leak-guard.yaml`) ול-`mcp-server/tests/test_platform_port_leak_guard.py`. | CI + hook + pytest |
|
||||
| `agent_tool_grants_guard.py` | python | **המאכף הקנוני של INV-AG3 (מפת-הרשאות הסוכנים / docs/spec/X4-agents.md §2א).** ה-frontmatter `tools:` של סוכן הוא **allow-list סגורה** — כלי הרשום בשרת-ה-MCP אך חסר ממנה אינו ניתן לקריאה, גם כשהשרת מחובר. ארבעה כללים קשיחים: (1) כל `mcp__legal-ai__X` המופיע ב-`web/` (delegation שיוצר issue לסוכן) מוענק לסוכן כלשהו; (2) כל `mcp__legal-ai__X` בגוף קובץ-סוכן מוענק ב-frontmatter של **אותו** קובץ; (3) אין הענקה לכלי שאינו רשום ב-`@mcp.tool`; (4) שם-כלי בגרשיים-הפוכים ללא תחילית — מוענק, או מסווג ב-`CONTRASTIVE_OK` עם נימוק (כולל בדיקת-התיישנות לסיווגים). מחריג קבצים שאינם סוכני-claude_local: `hermes-curator.md`, `legal-analyst-gemini-critique.md`, `HEARTBEAT.md`. **כלל 5 (host-only, `--check-bindings`):** allow-list נאכפת רק כשה-runtime בוחר את הסוכן (`--agent <name>`); בלעדיו אותו קובץ נמסר כ-`--append-system-prompt-file` — פרוזה, לא שער — וכל הכלים נשארים נגישים. הקשירה יושבת ב-DB של הפלטפורמה ולכן ה-CI לא רואה אותה; בלי הדגל השער **אומר זאת במפורש** במקום לרמוז על אכיפה שלא אימת. stdlib-בלבד. נבנה אחרי CMP-229 (2026-08-04) — `analyze_protocol` נרשם בשרת ב-2026-06-30 בלי הענקה, ו-#226 הורה למנתח להריץ אותו. CI: `.gitea/workflows/agent-tool-grants.yaml`. | CI |
|
||||
| `check_undefined_names.py` | python | **CI gate ל-undefined names (מחלקת ה-NameError).** מריץ pyflakes על `web`, `mcp-server/src`, `scripts` ומפיל build (exit 1) רק על "undefined name"/"may be undefined" — לא על imports-לא-בשימוש/f-strings (רעש). זו בדיוק מחלקת-הבאג של PR #249 (שינוי-שם תיק → 500): שם שמופנה אך לא מיובא/מוגדר, חבוי בתוך `background_tasks` עד זמן-ריצה. דורש pyflakes (ה-workflow מתקין ל-venv זמני). משותף ל-CI (`.gitea/workflows/lint.yaml`). | CI |
|
||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||
| `host_sync.sh` | bash | מסנכרן את עץ-המארח `~/legal-ai` ל-origin/main (ff-only) כדי שקוד-המארח (כותב/פאנלים/MCP שרצים מהעץ, לא בקונטיינר) יתעדכן אחרי merge; restart מדויק ל-chat/court-fetch/reaper רק כשקבציהם משתנים. בטוח: אף-פעם לא force; tasks.json הדירטי נשמר. סוגר את פער-פריסת-המארח (TaskMaster #160) | `* * * * *` (cron, flock) |
|
||||
|
||||
319
scripts/agent_tool_grants_guard.py
Executable file
319
scripts/agent_tool_grants_guard.py
Executable file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""INV-AG3 guard — every MCP tool an agent is TOLD to run must be GRANTED to it.
|
||||
|
||||
The canonical checker for INV-AG3 (docs/spec/X4-agents.md §2א): a Claude-Code
|
||||
subagent's ``tools:`` frontmatter is a CLOSED allow-list. A tool that is
|
||||
registered on the MCP server but absent from that list is *not callable* by the
|
||||
agent, however well the server is connected.
|
||||
|
||||
Why this exists — the failure it is built to catch (2026-08-04):
|
||||
``analyze_protocol`` shipped on 2026-06-30 (24e3e2f) touching 9 files, none of
|
||||
them ``.claude/agents/*``. Later ``wake_analyst_for_protocol_analysis``
|
||||
(web/paperclip_client.py, #226) started writing "הרץ
|
||||
``mcp__legal-ai__analyze_protocol(...)``" straight into the analyst's issue.
|
||||
The analyst therefore received an explicit instruction to run a tool it was
|
||||
never granted, reported "tools exist on the connected server but aren't exposed
|
||||
as callable in this session", and burned two runs working around it via raw
|
||||
psql + a hand-written script. INV-AG3 already covered this on paper; its
|
||||
enforcement was deferred ("אכיפה אוטומטית עתידית"), so the drift went unnoticed
|
||||
for five weeks. This script is that deferred enforcement.
|
||||
|
||||
Three HARD rules:
|
||||
|
||||
1. **Backend delegation.** Every ``mcp__legal-ai__X`` named inside ``web/``
|
||||
(the backend telling an agent what to run) must be granted to at least one
|
||||
agent. This is the rule that catches the 2026-08-04 failure.
|
||||
|
||||
2. **Per-agent instructions.** Every ``mcp__legal-ai__X`` in an agent file's
|
||||
BODY must be granted in that same file's frontmatter. Prefixed mentions are
|
||||
imperative by convention ("הרץ `mcp__legal-ai__…`").
|
||||
|
||||
3. **No phantom grants.** Every granted tool must actually be registered on
|
||||
the MCP server — catches typos and tools deleted out from under an agent.
|
||||
|
||||
Plus one reviewed-exception rule:
|
||||
|
||||
4. **Bare tool names.** An agent body may name a tool in backticks without the
|
||||
``mcp__legal-ai__`` prefix (```get_legal_arguments```). Those are
|
||||
ambiguous: some are real requirements, others are deliberately contrastive
|
||||
("**לא** דרך `precedent_library_upload`"), a pointer at *another* agent's
|
||||
job, or a DB column that merely shares a tool's name. Each is classified
|
||||
once in ``CONTRASTIVE_OK`` below; anything new fails until reviewed.
|
||||
|
||||
And one host-only rule, opt-in via ``--check-bindings``:
|
||||
|
||||
5. **Bindings.** Rules 1–4 compare files to files, which says nothing about
|
||||
whether an allow-list is *enforced*. It is only enforced when the runtime
|
||||
selects that agent (``--agent <name>``); without the flag the same file is
|
||||
delivered as ``--append-system-prompt-file`` — prose, not a gate — and every
|
||||
tool stays reachable. Found on 2026-08-05: one agent declared 41 grants with
|
||||
no ``--agent`` flag, so the largest allow-list in the system was inert while
|
||||
this guard reported OK. The binding lives in the platform DB, so CI cannot
|
||||
see it; without the flag the guard now says so out loud instead of implying
|
||||
enforcement it never verified.
|
||||
|
||||
NOT AGENTS (no frontmatter by design — the adapter sends the file as a raw
|
||||
prompt, so YAML would leak into it): ``hermes-curator.md`` (deepseek_local),
|
||||
``legal-analyst-gemini-critique.md`` (gemini_local). ``HEARTBEAT.md`` is a
|
||||
shared checklist, not an agent. All three are skipped.
|
||||
|
||||
Usage:
|
||||
agent_tool_grants_guard.py # exit 1 on any violation
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
AGENTS_DIR = REPO / ".claude" / "agents"
|
||||
MCP_SRC = REPO / "mcp-server" / "src"
|
||||
BACKEND_DIR = REPO / "web"
|
||||
|
||||
# Files under .claude/agents/ that are not claude_local subagent definitions.
|
||||
NOT_AGENTS = {
|
||||
"HEARTBEAT.md",
|
||||
"hermes-curator.md",
|
||||
"legal-analyst-gemini-critique.md",
|
||||
}
|
||||
|
||||
# Bare (unprefixed) tool names in an agent body that are NOT requirements.
|
||||
# Each entry is (agent file, tool, why) — reviewed 2026-08-04. Adding to this
|
||||
# map is a deliberate act: it asserts "the agent is not being told to call this".
|
||||
CONTRASTIVE_OK = {
|
||||
("legal-analyst.md", "case_create"): "prose about the cases.practice_area CHECK constraint, not a call",
|
||||
("legal-analyst.md", "search_internal_decisions"): "names the filter surface when contrasting Axis A/B",
|
||||
("legal-ceo.md", "search_decisions"): "contrast — 'search_decisions = only Dafna' vs the granted search_internal_decisions",
|
||||
("legal-ceo.md", "precedent_library_upload"): "explicitly the forbidden path ('לא דרך …', citation guard rejects)",
|
||||
("legal-ceo.md", "document_update"): "describes the tagging chaim must fix, not a CEO call",
|
||||
("legal-proofreader.md", "extraction_status"): "the documents.extraction_status DB column — name collides with a tool",
|
||||
("legal-qa.md", "precedent_attach"): "explicitly the researcher's job ('דרך precedent_attach של ה-researcher')",
|
||||
("legal-writer.md", "revise_draft"): "the CEO calls it ('CEO יקרא ל-revise_draft'), not the writer",
|
||||
("legal-writer.md", "search_case_precedents"): "a do-not-confuse disambiguation note ('שונה! … לא לבלבל')",
|
||||
}
|
||||
|
||||
TOOL_RE = re.compile(r"mcp__legal-ai__(\w+)")
|
||||
REGISTER_RE = re.compile(r"@mcp\.tool\([^)]*\)\s*(?:async\s+)?def\s+(\w+)")
|
||||
# `tool_name(` or `tool_name` inside backticks.
|
||||
BARE_RE = re.compile(r"`(\w+)[(`]")
|
||||
|
||||
|
||||
def server_tools() -> set[str]:
|
||||
"""Tool names registered on the MCP server."""
|
||||
out: set[str] = set()
|
||||
for path in MCP_SRC.rglob("*.py"):
|
||||
out |= set(REGISTER_RE.findall(path.read_text(encoding="utf-8", errors="ignore")))
|
||||
return out
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[str, str]:
|
||||
"""Return (frontmatter, body). Empty frontmatter when the file has none."""
|
||||
if not text.startswith("---"):
|
||||
return "", text
|
||||
parts = text.split("---")
|
||||
if len(parts) < 3:
|
||||
return "", text
|
||||
return parts[1], "---".join(parts[2:])
|
||||
|
||||
|
||||
def agent_files() -> list[Path]:
|
||||
return sorted(p for p in AGENTS_DIR.glob("*.md") if p.name not in NOT_AGENTS)
|
||||
|
||||
|
||||
def check_bindings() -> list[tuple[str, str]]:
|
||||
"""Return [(agent file, why)] for agents whose allow-list nothing enforces.
|
||||
|
||||
A ``tools:`` list is only an allow-list when the runtime is told which agent
|
||||
to be. The local adapter enforces it under ``--agent <name>``; without that
|
||||
flag the very same file is delivered as ``--append-system-prompt-file``, i.e.
|
||||
prose the model may follow or ignore, and every tool stays reachable.
|
||||
|
||||
Found the hard way on 2026-08-05: one agent carried 41 grants and no
|
||||
``--agent`` flag, so the largest allow-list in the system was inert — and
|
||||
this guard had been reporting OK on it, because Rules 1–4 only ever compare
|
||||
files to files.
|
||||
|
||||
Host-only. The binding lives in the platform's database, which CI cannot
|
||||
reach, so this shells out to ``psql`` rather than adding a driver dependency
|
||||
that would break the stdlib-only property the CI path relies on. Returns []
|
||||
when the database is unreachable — an unreachable DB is "not checked", not
|
||||
"no violations", and the caller prints that distinction.
|
||||
"""
|
||||
|
||||
sql = (
|
||||
"select adapter_config->>'instructionsEntryFile', "
|
||||
"coalesce(adapter_config->>'extraArgs','') "
|
||||
"from agents where adapter_type='claude_local' "
|
||||
"and adapter_config->>'instructionsEntryFile' is not null;"
|
||||
)
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["psql", "-h", "localhost", "-p", "54329", "-U", "paperclip",
|
||||
"-d", "paperclip", "-X", "-A", "-t", "-F", "\t", "-c", sql],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
env={**os.environ, "PGPASSWORD": os.environ.get("PGPASSWORD", "paperclip")},
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
if out.returncode != 0:
|
||||
return []
|
||||
|
||||
bad: dict[str, str] = {}
|
||||
for line in out.stdout.splitlines():
|
||||
if "\t" not in line:
|
||||
continue
|
||||
entry_file, extra = line.split("\t", 1)
|
||||
entry_file = entry_file.strip()
|
||||
if not entry_file or entry_file in NOT_AGENTS:
|
||||
continue
|
||||
want = entry_file[:-3] if entry_file.endswith(".md") else entry_file
|
||||
try:
|
||||
args = json.loads(extra) if extra.strip() else []
|
||||
except json.JSONDecodeError:
|
||||
args = []
|
||||
# Only a literal ["--agent", "<name>"] pair binds the allow-list.
|
||||
ok = any(
|
||||
a == "--agent" and i + 1 < len(args) and args[i + 1] == want
|
||||
for i, a in enumerate(args)
|
||||
)
|
||||
if not ok:
|
||||
bad[entry_file] = (
|
||||
"extraArgs is empty" if not args
|
||||
else f"extraArgs={extra.strip()} does not select '{want}'"
|
||||
)
|
||||
|
||||
# Only report agents that actually declare grants — an agent with no tools:
|
||||
# list has nothing to enforce and is not a finding.
|
||||
result = []
|
||||
for path in agent_files():
|
||||
if path.name in bad:
|
||||
fm, _ = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
|
||||
if TOOL_RE.findall(fm):
|
||||
result.append((path.name, bad[path.name]))
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
registered = server_tools()
|
||||
if not registered:
|
||||
print("agent-tool-grants: FAIL — no @mcp.tool registrations found; is the tree complete?")
|
||||
return 1
|
||||
|
||||
grants: dict[str, set[str]] = {}
|
||||
bodies: dict[str, str] = {}
|
||||
for path in agent_files():
|
||||
fm, body = split_frontmatter(path.read_text(encoding="utf-8", errors="ignore"))
|
||||
grants[path.name] = set(TOOL_RE.findall(fm))
|
||||
bodies[path.name] = body
|
||||
|
||||
all_granted: set[str] = set().union(*grants.values()) if grants else set()
|
||||
violations: list[str] = []
|
||||
|
||||
# Rule 1 — backend delegation must land on a granted tool.
|
||||
for path in sorted(BACKEND_DIR.rglob("*.py")):
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for tool in sorted(set(TOOL_RE.findall(text))):
|
||||
if tool not in all_granted:
|
||||
rel = path.relative_to(REPO)
|
||||
violations.append(
|
||||
f"[1 backend] {rel} instructs an agent to run "
|
||||
f"mcp__legal-ai__{tool}, but NO agent grants it.\n"
|
||||
f" fix: add `- mcp__legal-ai__{tool}` to the tools: "
|
||||
f"frontmatter of the agent that receives that issue."
|
||||
)
|
||||
|
||||
# Rule 2 — a prefixed mention in an agent body is an instruction to that agent.
|
||||
for name, body in bodies.items():
|
||||
for tool in sorted(set(TOOL_RE.findall(body))):
|
||||
if tool not in grants[name]:
|
||||
violations.append(
|
||||
f"[2 instructions] .claude/agents/{name} tells the agent to run "
|
||||
f"mcp__legal-ai__{tool}, which its own tools: list omits.\n"
|
||||
f" fix: add `- mcp__legal-ai__{tool}` to that frontmatter."
|
||||
)
|
||||
|
||||
# Rule 3 — no grant may point at a tool the server does not register.
|
||||
for name, granted in grants.items():
|
||||
for tool in sorted(granted - registered):
|
||||
violations.append(
|
||||
f"[3 phantom] .claude/agents/{name} grants mcp__legal-ai__{tool}, "
|
||||
f"which is not registered on the MCP server.\n"
|
||||
f" fix: correct the name, or drop the grant if the tool was removed."
|
||||
)
|
||||
|
||||
# Rule 4 — every bare tool name is either granted or classified as contrastive.
|
||||
for name, body in bodies.items():
|
||||
bare = {m for m in BARE_RE.findall(body) if m in registered}
|
||||
for tool in sorted(bare - grants[name]):
|
||||
if (name, tool) in CONTRASTIVE_OK:
|
||||
continue
|
||||
violations.append(
|
||||
f"[4 bare name] .claude/agents/{name} mentions `{tool}` — a real MCP "
|
||||
f"tool it is not granted.\n"
|
||||
f" fix: grant it if the agent must call it, otherwise add "
|
||||
f"(\"{name}\", \"{tool}\") to CONTRASTIVE_OK with the reason."
|
||||
)
|
||||
|
||||
# Stale exceptions: a classification that no longer matches the text is noise.
|
||||
for (name, tool), _why in sorted(CONTRASTIVE_OK.items()):
|
||||
if name not in bodies:
|
||||
violations.append(
|
||||
f"[4 stale] CONTRASTIVE_OK names {name}, which is not an agent file."
|
||||
)
|
||||
elif tool not in {m for m in BARE_RE.findall(bodies[name])}:
|
||||
violations.append(
|
||||
f"[4 stale] CONTRASTIVE_OK ({name}, {tool}) no longer appears in that "
|
||||
f"file — drop the exception."
|
||||
)
|
||||
|
||||
# Rule 5 — a grant list only binds if the runtime actually selects that agent.
|
||||
unenforced = check_bindings() if "--check-bindings" in sys.argv else None
|
||||
if unenforced:
|
||||
for name, detail in unenforced:
|
||||
violations.append(
|
||||
f"[5 binding] .claude/agents/{name} declares a tools: allow-list, but "
|
||||
f"the runtime does not select that agent — {detail}.\n"
|
||||
f" The list is inert: it is delivered as prompt text only, so "
|
||||
f"every tool remains callable.\n"
|
||||
f" fix: set adapter_config.extraArgs to "
|
||||
f'["--agent", "{name[:-3]}"], or drop tools: and document the agent as '
|
||||
f"unrestricted. Not both."
|
||||
)
|
||||
|
||||
if violations:
|
||||
print(f"INV-AG3 agent-tool-grants guard: {len(violations)} violation(s)\n")
|
||||
for v in violations:
|
||||
print(f" ✗ {v}")
|
||||
print(
|
||||
"\ndocs/spec/X4-agents.md §2א INV-AG3 — the frontmatter tools: list is a "
|
||||
"CLOSED allow-list.\nA tool missing from it is not callable, no matter that "
|
||||
"the MCP server is connected."
|
||||
)
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"INV-AG3 agent-tool-grants guard: OK "
|
||||
f"({len(agent_files())} agents, {len(all_granted)} distinct grants, "
|
||||
f"{len(registered)} tools registered)"
|
||||
)
|
||||
if unenforced is None:
|
||||
# Say plainly what was NOT checked. A guard that prints a bare "OK" invites
|
||||
# the reader to conclude the allow-lists are enforced; this one has only
|
||||
# compared files to files. Enforcement is a runtime property (see Rule 5),
|
||||
# and on 2026-08-05 exactly one agent was found declaring 41 grants that
|
||||
# nothing enforces — while this guard reported OK.
|
||||
print(
|
||||
" note: file-level only. Whether each allow-list is actually ENFORCED "
|
||||
"depends on the\n runtime passing --agent <name>, which needs the platform "
|
||||
"DB — re-run with --check-bindings\n on the host to verify."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -166,17 +166,33 @@ def aggregate_cell(per_run: list[dict]) -> dict:
|
||||
"""Mean each metric across repeated generations of the same (case, block, effort)."""
|
||||
if not per_run:
|
||||
return {"distance": 1.0, "anti_pattern_total": 0.0, "change_percent": 100.0,
|
||||
"golden_ratio_deviation_pp": None, "n": 0}
|
||||
"golden_ratio_deviation_pp": None, "anti_by_pattern": {}, "n": 0}
|
||||
ratios = [r["golden_ratio_deviation_pp"] for r in per_run if r.get("golden_ratio_deviation_pp") is not None]
|
||||
return {
|
||||
"distance": round(mean(r["distance"] for r in per_run), 4),
|
||||
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in per_run), 2),
|
||||
"change_percent": round(mean(r["change_percent"] for r in per_run), 2),
|
||||
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
|
||||
"anti_by_pattern": _mean_by_pattern(per_run),
|
||||
"n": len(per_run),
|
||||
}
|
||||
|
||||
|
||||
def _mean_by_pattern(per_run: list[dict]) -> dict:
|
||||
"""Mean hits PER anti-pattern name across runs — the 'which rule broke' view.
|
||||
|
||||
A pattern absent from a run counts as 0 (count_anti_patterns omits zero-hit
|
||||
keys), so the mean is over ALL runs, not only the ones that tripped it.
|
||||
"""
|
||||
names: set[str] = set()
|
||||
for r in per_run:
|
||||
names |= set((r.get("anti_by_pattern") or {}).keys())
|
||||
return {
|
||||
name: round(mean((r.get("anti_by_pattern") or {}).get(name, 0) for r in per_run), 2)
|
||||
for name in sorted(names)
|
||||
}
|
||||
|
||||
|
||||
def _current_default(block_id: str) -> str | None:
|
||||
from legal_mcp.services.block_writer import BLOCK_CONFIG, DEFAULT_EFFORT
|
||||
cfg = BLOCK_CONFIG.get(block_id, {})
|
||||
@@ -420,13 +436,30 @@ async def _finals_for_calibration(case_filter: str | None) -> list[dict]:
|
||||
|
||||
|
||||
async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
|
||||
final_total_words: int, outcome: str, repeats: int) -> dict:
|
||||
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section."""
|
||||
final_total_words: int, outcome: str, repeats: int,
|
||||
model: str | None = None, instructions: str = "") -> dict:
|
||||
"""Generate `block_id` at `effort` `repeats` times; score each vs the final section.
|
||||
|
||||
`model` (optional) A/Bs the generation model via write_block(model_override=…).
|
||||
None ⇒ the pinned GENERATION_MODEL, i.e. the production path unchanged.
|
||||
|
||||
`instructions` (optional) is appended to the block prompt for EVERY cell in
|
||||
the run — a prompt-variant A/B (e.g. an explicit formatting rule). It is
|
||||
applied to all models so the comparison stays a model comparison rather
|
||||
than silently becoming a prompt comparison.
|
||||
"""
|
||||
from legal_mcp.services import block_writer
|
||||
from legal_mcp.services.style_distance import block_distance_to_final
|
||||
runs: list[dict] = []
|
||||
models_used: list[str] = []
|
||||
for _ in range(repeats):
|
||||
res = await block_writer.write_block(case_id, block_id, effort_override=effort)
|
||||
res = await block_writer.write_block(
|
||||
case_id, block_id, instructions=instructions,
|
||||
effort_override=effort, model_override=model,
|
||||
)
|
||||
# Record what the CLI was actually asked to run, so a silent fallback to
|
||||
# a different build is visible in the report rather than mis-attributed.
|
||||
models_used.append(res.get("model_used") or "?")
|
||||
scored = block_distance_to_final(
|
||||
block_id, res.get("content", ""), final_section, outcome,
|
||||
section_target_total_words=final_total_words,
|
||||
@@ -434,6 +467,8 @@ async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
|
||||
runs.append(scored)
|
||||
agg = aggregate_cell(runs)
|
||||
agg["effort"] = effort
|
||||
agg["model"] = model
|
||||
agg["models_used"] = sorted(set(models_used))
|
||||
agg["runs"] = runs
|
||||
return agg
|
||||
|
||||
@@ -446,6 +481,7 @@ async def _run(args, ts: str) -> dict:
|
||||
|
||||
efforts = args.efforts
|
||||
blocks = args.blocks
|
||||
models = args.models
|
||||
finals = await _finals_for_calibration(args.case)
|
||||
|
||||
cases_meta = []
|
||||
@@ -468,11 +504,15 @@ async def _run(args, ts: str) -> dict:
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
plan[block_id] = [c for c in cases_meta if section and c["sections"].get(section)]
|
||||
|
||||
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats
|
||||
total_cells = sum(len(plan[b]) for b in blocks) * len(efforts) * args.repeats * len(models)
|
||||
grid_summary = {
|
||||
"n_finals": len(cases_meta),
|
||||
"finals": [c["case_number"] for c in cases_meta],
|
||||
"blocks": blocks, "efforts": efforts, "repeats": args.repeats,
|
||||
"models": models,
|
||||
# Provenance: a prompt-variant run is NOT comparable to a baseline run,
|
||||
# so the instruction text is recorded in the report, not just the shell.
|
||||
"instructions": getattr(args, "instructions", "") or "",
|
||||
"total_generations": total_cells,
|
||||
"per_block_n": {b: len(plan[b]) for b in blocks},
|
||||
}
|
||||
@@ -480,6 +520,27 @@ async def _run(args, ts: str) -> dict:
|
||||
if args.dry_run:
|
||||
return {"dry_run": True, "grid": grid_summary, "by_block": {}}
|
||||
|
||||
by_model: dict[str, dict] = {}
|
||||
for model in models:
|
||||
by_block = await _run_blocks_for_model(
|
||||
model, blocks, efforts, plan, args, ts, grid_summary, by_model, _BLOCK_TO_SECTION,
|
||||
)
|
||||
by_model[model] = by_block
|
||||
|
||||
# `by_block` stays the single-model shape (first model) so --rerank and the
|
||||
# existing per-block report path keep working unchanged (G2 — no second
|
||||
# result schema); multi-model runs additionally carry by_model.
|
||||
out = {"dry_run": False, "grid": grid_summary, "by_block": by_model[models[0]]}
|
||||
if len(models) > 1:
|
||||
out["by_model"] = by_model
|
||||
return out
|
||||
|
||||
|
||||
async def _run_blocks_for_model(model, blocks, efforts, plan, args, ts, grid_summary,
|
||||
by_model_so_far, _BLOCK_TO_SECTION) -> dict:
|
||||
"""The per-block × per-effort grid for ONE generation model."""
|
||||
from uuid import UUID
|
||||
|
||||
by_block: dict[str, dict] = {}
|
||||
for block_id in blocks:
|
||||
section = _BLOCK_TO_SECTION.get(block_id)
|
||||
@@ -497,11 +558,12 @@ async def _run(args, ts: str) -> dict:
|
||||
cell = await _score_cell(
|
||||
UUID(c["case_id"]), block_id, effort, final_section,
|
||||
c["final_total_words"], c["outcome"], args.repeats,
|
||||
model=model, instructions=getattr(args, "instructions", "") or "",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
|
||||
logger.warning(
|
||||
"calibration cell skipped: case=%s block=%s effort=%s — %s",
|
||||
c["case_number"], block_id, effort, exc,
|
||||
"calibration cell skipped: case=%s block=%s effort=%s model=%s — %s",
|
||||
c["case_number"], block_id, effort, model, exc,
|
||||
)
|
||||
continue
|
||||
per_effort_runs[effort].append(cell)
|
||||
@@ -523,6 +585,7 @@ async def _run(args, ts: str) -> dict:
|
||||
"anti_pattern_total": round(mean(r["anti_pattern_total"] for r in rows), 2),
|
||||
"change_percent": round(mean(r["change_percent"] for r in rows), 2),
|
||||
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
|
||||
"anti_by_pattern": _mean_by_pattern(rows),
|
||||
"n": len(rows),
|
||||
})
|
||||
rec = recommend_effort(effort_rows)
|
||||
@@ -532,6 +595,11 @@ async def _run(args, ts: str) -> dict:
|
||||
"recommended": rec["effort"] if rec else None,
|
||||
"confidence": rec["confidence"] if rec else None,
|
||||
"confidence_margin": rec.get("confidence_margin") if rec else None,
|
||||
"model": model,
|
||||
# Model builds the CLI actually reported across this block's cells —
|
||||
# a mismatch vs `model` means a silent fallback, not a real A/B.
|
||||
"models_used": sorted({m for e in per_effort_runs.values()
|
||||
for cell in e for m in cell.get("models_used", [])}),
|
||||
"efforts": effort_rows,
|
||||
"per_case": per_case,
|
||||
}
|
||||
@@ -541,11 +609,15 @@ async def _run(args, ts: str) -> dict:
|
||||
# Blocks not yet done are simply absent from by_block; _write_report tolerates
|
||||
# partial results. main() does the final flush once the loop finishes.
|
||||
try:
|
||||
_write_report({"dry_run": False, "grid": grid_summary, "by_block": by_block}, ts)
|
||||
snap = {"dry_run": False, "grid": grid_summary, "by_block": by_block}
|
||||
if by_model_so_far or len(grid_summary.get("models", [])) > 1:
|
||||
snap["by_model"] = {**by_model_so_far, model: by_block}
|
||||
_write_report(snap, ts)
|
||||
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
|
||||
logger.warning("incremental report write failed after block=%s — %s", block_id, exc)
|
||||
logger.warning("incremental report write failed after block=%s model=%s — %s",
|
||||
block_id, model, exc)
|
||||
|
||||
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
|
||||
return by_block
|
||||
|
||||
|
||||
IL_TZ = ZoneInfo("Asia/Jerusalem")
|
||||
@@ -578,7 +650,10 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
|
||||
f"- בלוקים: {', '.join(g['blocks'])}",
|
||||
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
|
||||
f"- models: {', '.join(m or 'pinned-default' for m in g.get('models', [None]))}",
|
||||
f"- סך ייצורי-מודל: {g['total_generations']}",
|
||||
(f"- ⚠️ **וריאנט-פרומפט** (לא בר-השוואה לריצת-בסיס): `{g['instructions']}`"
|
||||
if g.get("instructions") else "- וריאנט-פרומפט: — (פרומפט ייצור כפי-שהוא)"),
|
||||
"",
|
||||
]
|
||||
if result.get("dry_run"):
|
||||
@@ -613,6 +688,54 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||
lines.append("")
|
||||
by_model = result.get("by_model") or {}
|
||||
if len(by_model) > 1:
|
||||
lines += ["## השוואת-מודלים (אותו block, אותו effort, אותם סופיים)\n",
|
||||
"| block | effort | model | anti_total | change% | ratioΔpp | distance | n |",
|
||||
"|---|---|---|---|---|---|---|---|"]
|
||||
for b in g["blocks"]:
|
||||
for eff in g["efforts"]:
|
||||
rows = []
|
||||
for m, bb in by_model.items():
|
||||
for r in (bb.get(b) or {}).get("efforts", []):
|
||||
if r["effort"] == eff:
|
||||
rows.append((m, r))
|
||||
if len(rows) < 2:
|
||||
continue # nothing to compare for this cell — don't fake a row
|
||||
best = min(rows, key=lambda mr: (mr[1]["anti_pattern_total"],
|
||||
mr[1]["golden_ratio_deviation_pp"] or 0,
|
||||
mr[1]["distance"]))[0]
|
||||
for m, r in rows:
|
||||
ratio = r["golden_ratio_deviation_pp"]
|
||||
star = " ⭐" if m == best else ""
|
||||
lines.append(
|
||||
f"| {b} | {eff} | {m}{star} | {r['anti_pattern_total']} | "
|
||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | "
|
||||
f"{r['distance']:.4f} | {r['n']} |")
|
||||
lines.append("")
|
||||
|
||||
# WHICH rule broke — a total alone can't tell you what to fix.
|
||||
bd_rows = [(b, eff, m, r) for b in g["blocks"] for eff in g["efforts"]
|
||||
for m, bb in by_model.items()
|
||||
for r in (bb.get(b) or {}).get("efforts", []) if r["effort"] == eff]
|
||||
if any(r.get("anti_by_pattern") for *_, r in bd_rows):
|
||||
names = sorted({n for *_, r in bd_rows for n in (r.get("anti_by_pattern") or {})})
|
||||
lines += ["### פילוח אנטי-דפוסים (איזה כלל הופר)\n",
|
||||
"| block | effort | model | " + " | ".join(names) + " |",
|
||||
"|---|---|---|" + "---|" * len(names)]
|
||||
for b, eff, m, r in bd_rows:
|
||||
cells = " | ".join(str((r.get("anti_by_pattern") or {}).get(n, 0)) for n in names)
|
||||
lines.append(f"| {b} | {eff} | {m} | {cells} |")
|
||||
lines.append("")
|
||||
# A silent CLI fallback would make the whole comparison meaningless — surface it.
|
||||
for m, bb in by_model.items():
|
||||
for b, bd in bb.items():
|
||||
used = bd.get("models_used") or []
|
||||
if used and any(not u.startswith(str(m)) for u in used):
|
||||
lines.append(f"> ⚠️ **{b} / {m}**: ה-CLI דיווח `{', '.join(used)}` — "
|
||||
"ייתכן fallback שקט; ההשוואה לתא זה אינה תקפה.\n")
|
||||
lines.append("")
|
||||
|
||||
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
|
||||
"**change% מדווח-לא-מדורג** — מערבב סגנון עם שלמות-תוכן (07-learning §0.7), "
|
||||
"anti_total הוא הסיגנל הנקי-לסגנון. confidence=⚠️weak ⇒ הבחירה בתוך-הרעש "
|
||||
@@ -633,6 +756,12 @@ async def main() -> int:
|
||||
help="comma block ids to calibrate")
|
||||
ap.add_argument("--case", default=None, help="restrict to a single case_number")
|
||||
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
|
||||
ap.add_argument("--models", default="",
|
||||
help="comma generation-model ids to A/B (e.g. claude-opus-4-8,claude-opus-5). "
|
||||
"Empty (default) = the pinned GENERATION_MODEL, i.e. production unchanged.")
|
||||
ap.add_argument("--instructions", default="",
|
||||
help="extra prompt instruction appended to EVERY cell (prompt-variant A/B). "
|
||||
"Applied to all models — the run stays a model comparison. Recorded in the report.")
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
@@ -653,6 +782,9 @@ async def main() -> int:
|
||||
if bad_b:
|
||||
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
|
||||
return 2
|
||||
# [None] = "use the pinned GENERATION_MODEL" — keeps the default run byte-identical
|
||||
# to the pre-#models behaviour instead of hard-coding the id in a second place (G2).
|
||||
args.models = [m.strip() for m in args.models.split(",") if m.strip()] or [None]
|
||||
|
||||
ts = _ts()
|
||||
result = await _run(args, ts)
|
||||
|
||||
96
scripts/legal-mcp-http.config.cjs
Normal file
96
scripts/legal-mcp-http.config.cjs
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* pm2 ecosystem entry for legal-mcp-http — the legal-ai MCP server exposed over
|
||||
* streamable-http (TaskMaster #231).
|
||||
*
|
||||
* Why it exists
|
||||
* Agents driven over the Agent Client Protocol get their MCP servers from the
|
||||
* *client* at session start, and that channel carries HTTP servers only. A
|
||||
* stdio server has no path into such a session, which is how platform-driven
|
||||
* agents ended up with none of the 108 tools. This service is the HTTP end
|
||||
* they can actually be pointed at.
|
||||
*
|
||||
* It does NOT replace the stdio path. Every interactive Claude Code session
|
||||
* still reaches the same server through the `legal-ai` entry in
|
||||
* ~/.claude.json, spawned per session. Same code, same tool registry, two
|
||||
* transports (G2) — this is a second *door*, not a second server.
|
||||
*
|
||||
* Security
|
||||
* The registry includes case_delete, precedent_library_delete, document_upload
|
||||
* and every block-writing tool, so an open port here is a delete-any-case
|
||||
* endpoint. Two defences, both required:
|
||||
* 1. Bind 127.0.0.1 — the platform runs on this host, so loopback suffices.
|
||||
* Deliberately narrower than legal-chat-service's 10.0.1.1: nothing in a
|
||||
* container needs to call MCP.
|
||||
* 2. Bearer token — MCP_HTTP_SHARED_SECRET, loaded below. The server
|
||||
* REFUSES TO START without it (services/http_auth.py), so a
|
||||
* misconfiguration cannot silently produce an unauthenticated listener.
|
||||
*
|
||||
* Secret
|
||||
* Source of truth: Infisical, project "All Infrastructure", env `main`,
|
||||
* /apps/legal-ai/MCP_HTTP_SHARED_SECRET (tag: credentials). The file read
|
||||
* below is a chmod-600 runtime copy, same arrangement as
|
||||
* legal-chat-service.config.cjs. Rotate in Infisical first, then refresh the
|
||||
* file and `pm2 restart legal-mcp-http`.
|
||||
*
|
||||
* Install (once):
|
||||
* pm2 start /home/chaim/legal-ai/scripts/legal-mcp-http.config.cjs
|
||||
* pm2 save
|
||||
*
|
||||
* Smoke test — expect 401 without the token, 200 with it:
|
||||
* curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8790/mcp \
|
||||
* -H 'Content-Type: application/json' \
|
||||
* -H 'Accept: application/json, text/event-stream' \
|
||||
* -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'
|
||||
*
|
||||
* Update: pm2 restart legal-mcp-http --update-env
|
||||
* Stop: pm2 stop legal-mcp-http
|
||||
*/
|
||||
const fs = require("fs");
|
||||
|
||||
const ENV_FILE = "/home/chaim/.legal-mcp-http.env";
|
||||
const env = {
|
||||
HOME: "/home/chaim",
|
||||
PATH: "/home/chaim/.local/bin:/usr/local/bin:/usr/bin:/bin",
|
||||
PYTHONUNBUFFERED: "1",
|
||||
// Same DB/data wiring the stdio server gets from ~/.claude.json, so both
|
||||
// transports read exactly the same corpus.
|
||||
DOTENV_PATH: "/home/chaim/.env",
|
||||
DATA_DIR: "/home/chaim/legal-ai/data",
|
||||
MCP_TRANSPORT: "streamable-http",
|
||||
MCP_HTTP_HOST: "127.0.0.1",
|
||||
MCP_HTTP_PORT: "8790",
|
||||
};
|
||||
|
||||
try {
|
||||
const text = fs.readFileSync(ENV_FILE, "utf8");
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line || line.trim().startsWith("#")) continue;
|
||||
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m) env[m[1]] = m[2];
|
||||
}
|
||||
} catch (e) {
|
||||
// Warn, but do not fabricate a token. The server's own gate turns a missing
|
||||
// secret into a refusal to boot, which is the outcome we want — pm2 will
|
||||
// surface it as a crash loop rather than serve unauthenticated traffic.
|
||||
console.error(`legal-mcp-http: failed to load ${ENV_FILE}: ${e.message}`);
|
||||
console.error("Service will refuse to start without MCP_HTTP_SHARED_SECRET.");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "legal-mcp-http",
|
||||
cwd: "/home/chaim/legal-ai/mcp-server",
|
||||
script: "/home/chaim/legal-ai/mcp-server/.venv/bin/python",
|
||||
args: "-m legal_mcp.server",
|
||||
env,
|
||||
restart_delay: 5000,
|
||||
// Low ceiling on purpose: if the token is missing the process exits
|
||||
// immediately, and we want pm2 to stop retrying and leave an obvious
|
||||
// errored entry rather than loop forever on a config mistake.
|
||||
max_restarts: 10,
|
||||
autorestart: true,
|
||||
max_memory_restart: "800M",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { CaseHeader } from "@/components/cases/case-header";
|
||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||
@@ -243,12 +244,17 @@ export default function CaseDetailPage({
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-0">
|
||||
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-0">
|
||||
|
||||
@@ -29,12 +29,15 @@ import {
|
||||
useRunLog,
|
||||
useCancelRun,
|
||||
useResetAgentSession,
|
||||
useAgentHealth,
|
||||
useRecentEscalations,
|
||||
type OpsService,
|
||||
type OperationsSnapshot,
|
||||
type PipelineStats,
|
||||
type AgentRun,
|
||||
type SubscriptionUsage,
|
||||
type UsageWindow,
|
||||
type AgentHealthState,
|
||||
} from "@/lib/api/operations";
|
||||
import { formatTime, israelParts } from "@/lib/format-date";
|
||||
|
||||
@@ -802,6 +805,154 @@ function LiveAgentsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||
const HEALTH_LABEL: Record<AgentHealthState, string> = {
|
||||
zombie: "זומבי",
|
||||
stalled: "תקוע",
|
||||
working: "עובד",
|
||||
idle: "רגוע",
|
||||
};
|
||||
|
||||
function AgentHealthPanel() {
|
||||
const { data, isLoading } = useAgentHealth();
|
||||
const { data: esc } = useRecentEscalations();
|
||||
|
||||
const counts = data?.counts;
|
||||
// Only zombies/stalled are "problems" — the backend already sorts worst-first.
|
||||
// An escalated issue is chair-owned, so it leaves this list; the escalations
|
||||
// card below is the handled-loop history.
|
||||
const problems = (data?.items ?? []).filter(
|
||||
(i) => i.health === "zombie" || i.health === "stalled",
|
||||
);
|
||||
const healthyWorking = counts?.working ?? 0;
|
||||
const healthyIdle = counts?.idle ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<StatTile
|
||||
label="זומבי"
|
||||
value={counts?.zombie ?? 0}
|
||||
tone="red"
|
||||
title="לולאת-recovery — משויך+פעיל-כביכול אך אין ריצה חיה. מוסלם אוטומטית."
|
||||
/>
|
||||
<StatTile
|
||||
label="תקוע"
|
||||
value={counts?.stalled ?? 0}
|
||||
tone="amber"
|
||||
title="ננער חוזר בלי התקדמות"
|
||||
/>
|
||||
<StatTile label="עובד" value={counts?.working ?? 0} tone="green" />
|
||||
<StatTile label="רגוע" value={counts?.idle ?? 0} tone="muted" />
|
||||
</div>
|
||||
|
||||
{problems.length === 0 ? (
|
||||
<p className="text-sm text-emerald-600">
|
||||
אין סוכן תקוע כרגע — כל הסוכנים תקינים.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{problems.map((p) => (
|
||||
<div
|
||||
key={p.issue_id}
|
||||
className={
|
||||
p.health === "zombie"
|
||||
? "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
|
||||
: "flex items-center justify-between gap-3 rounded-md border border-gold/40 bg-gold-wash px-3 py-2"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<Badge
|
||||
variant={p.health === "zombie" ? "destructive" : "outline"}
|
||||
className={
|
||||
p.health === "stalled"
|
||||
? "border-warn/50 text-warn font-normal"
|
||||
: "font-normal"
|
||||
}
|
||||
>
|
||||
{HEALTH_LABEL[p.health]}
|
||||
</Badge>
|
||||
<span dir="ltr" className="font-mono text-[0.82rem] text-navy font-semibold">
|
||||
{p.identifier}
|
||||
</span>
|
||||
<span className="text-[0.82rem] text-navy font-semibold">
|
||||
{p.agent_name}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-ink-muted">
|
||||
{p.recovery_wakeups > 0
|
||||
? `${p.recovery_wakeups} יקיצות-שחזור`
|
||||
: `${p.wakeups} יקיצות`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.7rem] text-ink-muted shrink-0">
|
||||
{p.health === "zombie" ? "יוסלם אוטומטית" : "מנוטר"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-[0.78rem] text-ink-muted">
|
||||
<b className="text-navy">{healthyWorking + healthyIdle} סוכנים תקינים</b> —{" "}
|
||||
{healthyWorking} עובדים, {healthyIdle} רגועים. מוצגות רק בעיות.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* handled-loop history — the watchdog's escalations, gold-wash like a gate */}
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-sm font-semibold mb-0.5">הסלמות אוטומטיות אחרונות</h3>
|
||||
<p className="text-ink-muted text-[0.72rem] mb-3">
|
||||
כל זומבי מתמשך (לולאת-recovery ≥2 יקיצות-שחזור) משויך אליך אוטומטית עם הערת-מערכת
|
||||
— בלי להעיר סוכן מחדש.
|
||||
</p>
|
||||
{!esc?.items?.length ? (
|
||||
<p className="text-sm text-ink-muted">אין הסלמות אחרונות.</p>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{esc.items.map((e) => (
|
||||
<div
|
||||
key={`${e.issue_id}-${e.created_at ?? ""}`}
|
||||
className="flex items-start gap-2.5 border-t border-gold/15 first:border-0 pt-2 first:pt-0"
|
||||
>
|
||||
<Badge
|
||||
variant={e.severity === "medium" ? "outline" : "destructive"}
|
||||
className={
|
||||
e.severity === "medium"
|
||||
? "border-gold/50 text-gold-deep font-normal shrink-0"
|
||||
: "font-normal shrink-0"
|
||||
}
|
||||
>
|
||||
{e.severity}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span dir="ltr" className="font-mono text-[0.8rem] text-navy font-semibold">
|
||||
{e.identifier}
|
||||
</span>
|
||||
<div className="text-[0.78rem] text-ink-soft">{e.reason}</div>
|
||||
</div>
|
||||
<span className="text-[0.68rem] text-ink-muted shrink-0">
|
||||
{e.created_at ? ago(Date.parse(e.created_at)) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { data, isLoading, error } = useOperations();
|
||||
|
||||
@@ -840,6 +991,9 @@ export default function OperationsPage() {
|
||||
<SectionHeader>סוכנים פעילים</SectionHeader>
|
||||
<LiveAgentsPanel />
|
||||
|
||||
<SectionHeader>בריאות-הסוכנים</SectionHeader>
|
||||
<AgentHealthPanel />
|
||||
|
||||
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
||||
<AgentAdaptersPanel />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useSendComment,
|
||||
useSubmitInteraction,
|
||||
useDismissInteraction,
|
||||
useSetIssueStatus,
|
||||
} from "@/lib/api/agents";
|
||||
import type {
|
||||
Interaction,
|
||||
@@ -34,6 +35,9 @@ import {
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
Ban,
|
||||
Target,
|
||||
Plus,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── Role → color mapping ────────────────────────────────────── */
|
||||
@@ -769,31 +773,96 @@ function IssueGroup({
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const setStatus = useSetIssueStatus(caseNumber);
|
||||
const closed = issue.status === "done" || issue.status === "cancelled";
|
||||
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
|
||||
// the live CEO root) so the hierarchy is visible, not flattened.
|
||||
const parentIdentifier = issue.parent_id
|
||||
? issueMap.get(issue.parent_id)
|
||||
: undefined;
|
||||
|
||||
const close = (status: "done" | "cancelled") =>
|
||||
setStatus.mutate(
|
||||
{ issue_id: issue.id, status },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
|
||||
),
|
||||
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b border-rule-soft last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
|
||||
>
|
||||
<Badge
|
||||
variant={closed ? "secondary" : "default"}
|
||||
className="text-[10px] font-mono shrink-0"
|
||||
<div className="border-b-2 border-rule last:border-b-0">
|
||||
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex-1 flex items-center gap-2 text-start min-w-0"
|
||||
>
|
||||
{issue.identifier}
|
||||
</Badge>
|
||||
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
||||
{shortIssueTitle(issue.title)}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||||
{issueStatusLabel(issue.status)} · {items.length}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
<Badge
|
||||
variant={closed ? "secondary" : "default"}
|
||||
className="text-[10px] font-mono shrink-0"
|
||||
>
|
||||
{issue.identifier}
|
||||
</Badge>
|
||||
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
||||
{shortIssueTitle(issue.title)}
|
||||
</span>
|
||||
{parentIdentifier && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parentIdentifier}`}
|
||||
>
|
||||
↳ {parentIdentifier}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||||
{issueStatusLabel(issue.status)} · {items.length}
|
||||
</span>
|
||||
</button>
|
||||
{!closed && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => close("done")}
|
||||
disabled={setStatus.isPending}
|
||||
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
|
||||
title="סמן את המשימה כהושלמה"
|
||||
>
|
||||
{setStatus.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
הושלם
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => close("cancelled")}
|
||||
disabled={setStatus.isPending}
|
||||
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
|
||||
title="בטל את המשימה"
|
||||
>
|
||||
בטל
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={open ? "כווץ" : "הרחב"}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="pb-1">
|
||||
{items.map((item) =>
|
||||
@@ -818,6 +887,341 @@ 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));
|
||||
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
|
||||
const idToIdentifier = useMemo(
|
||||
() => new Map(issues.map((i) => [i.id, i.identifier])),
|
||||
[issues],
|
||||
);
|
||||
const parentTag = (i: PaperclipIssue) =>
|
||||
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
|
||||
|
||||
// The issue the pill currently represents (explicit pick, or the auto default).
|
||||
const effectiveIssue =
|
||||
target.kind === "issue"
|
||||
? issues.find((i) => i.id === target.id) ?? null
|
||||
: target.kind === "auto"
|
||||
? defaultIssue
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 bg-sand-soft/60 border border-rule rounded-lg px-2.5 py-1.5 hover:border-gold/60 min-w-0 max-w-full"
|
||||
>
|
||||
<Target className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||
{target.kind === "new_run" ? (
|
||||
<span className="text-xs font-semibold text-navy">פתח הרצה חדשה</span>
|
||||
) : effectiveIssue ? (
|
||||
<>
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{effectiveIssue.identifier}
|
||||
</Badge>
|
||||
<span className="text-xs font-medium text-navy truncate">
|
||||
{shortIssueTitle(effectiveIssue.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(effectiveIssue.status)}`}
|
||||
>
|
||||
{issueStatusLabel(effectiveIssue.status)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-ink-muted">בחר יעד</span>
|
||||
)}
|
||||
<ChevronDown className="w-3.5 h-3.5 text-ink-faint shrink-0 ms-1" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
|
||||
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
|
||||
visible without scrolling to the bottom of a long task list. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ kind: "new_run" });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
|
||||
>
|
||||
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-semibold text-navy">
|
||||
פתח הרצה חדשה
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-muted">
|
||||
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||
|
||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||
משימות פעילות
|
||||
</div>
|
||||
{activeIssues.map((i) => {
|
||||
const selected =
|
||||
(target.kind === "issue" && target.id === i.id) ||
|
||||
(target.kind === "auto" && defaultIssue?.id === i.id);
|
||||
const parent = parentTag(i);
|
||||
return (
|
||||
<button
|
||||
key={i.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ kind: "issue", id: i.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{i.identifier}
|
||||
</Badge>
|
||||
{parent && (
|
||||
<span
|
||||
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parent}`}
|
||||
>
|
||||
↳ {parent}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ink-soft truncate flex-1">
|
||||
{shortIssueTitle(i.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||
>
|
||||
{issueStatusLabel(i.status)}
|
||||
</span>
|
||||
{selected && (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{closedIssues.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||
משימות סגורות · שליחה אליהן לא תעיר סוכן
|
||||
</div>
|
||||
{closedIssues.map((i) => {
|
||||
const parent = parentTag(i);
|
||||
return (
|
||||
<div
|
||||
key={i.id}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
|
||||
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{i.identifier}
|
||||
</Badge>
|
||||
{parent && (
|
||||
<span
|
||||
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parent}`}
|
||||
>
|
||||
↳ {parent}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ink-faint truncate flex-1">
|
||||
{shortIssueTitle(i.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||
>
|
||||
{issueStatusLabel(i.status)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({
|
||||
caseNumber,
|
||||
issues,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
issues: PaperclipIssue[];
|
||||
}) {
|
||||
const sendComment = useSendComment(caseNumber);
|
||||
const [body, setBody] = useState("");
|
||||
const [target, setTarget] = useState<Target>({ kind: "auto" });
|
||||
|
||||
const selectedIssue =
|
||||
target.kind === "issue" ? issues.find((i) => i.id === target.id) ?? null : null;
|
||||
const closedTarget = !!selectedIssue && !isOpenStatus(selectedIssue.status);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!body.trim() || closedTarget) return;
|
||||
const vars =
|
||||
target.kind === "new_run"
|
||||
? { body: body.trim(), new_run: true }
|
||||
: target.kind === "issue"
|
||||
? { body: body.trim(), issue_id: target.id }
|
||||
: { body: body.trim() };
|
||||
sendComment.mutate(vars, {
|
||||
onSuccess: (res) => {
|
||||
setBody("");
|
||||
setTarget({ kind: "auto" });
|
||||
toast.success(
|
||||
res.new_run
|
||||
? `נפתחה הרצה חדשה — ${res.issue_identifier}`
|
||||
: `נשלח ל-${res.issue_identifier}`,
|
||||
);
|
||||
},
|
||||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-rule rounded-xl shadow-sm p-3.5">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<MessageSquare className="w-4 h-4 text-gold-deep" />
|
||||
<h3 className="text-sm font-bold text-navy">הוראה לסוכנים</h3>
|
||||
<span className="text-[11px] text-ink-faint ms-auto">
|
||||
Ctrl+Enter לשליחה
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-[11px] font-semibold text-ink-muted shrink-0">
|
||||
שליחה אל:
|
||||
</span>
|
||||
<TargetSelector issues={issues} target={target} onChange={setTarget} />
|
||||
</div>
|
||||
|
||||
{closedTarget && (
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-2 text-[11.5px] text-red-700 leading-snug">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<b className="font-bold">יעד סגור.</b> שליחה למשימה סגורה לא תעיר סוכן —
|
||||
ההוראה לא תטופל. בחר משימה פעילה או{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold underline"
|
||||
onClick={() => setTarget({ kind: "new_run" })}
|
||||
>
|
||||
פתח הרצה חדשה
|
||||
</button>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="כתוב הוראה לסוכנים..."
|
||||
aria-label="הוראה לסוכנים"
|
||||
className="min-h-[60px] resize-none text-sm"
|
||||
dir="rtl"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center mt-2">
|
||||
<span className="text-[11px] text-ink-faint">
|
||||
ההודעה תנותב דרך סוכן ה-CEO · היעד ניתן לשינוי למעלה
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ms-auto"
|
||||
onClick={handleSend}
|
||||
disabled={!body.trim() || closedTarget || sendComment.isPending}
|
||||
>
|
||||
{sendComment.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4 me-1" />
|
||||
)}
|
||||
שלח
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||
|
||||
export function AgentActivityFeed({
|
||||
@@ -826,9 +1230,6 @@ export function AgentActivityFeed({
|
||||
caseNumber: string;
|
||||
}) {
|
||||
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
||||
const sendComment = useSendComment(caseNumber);
|
||||
const [body, setBody] = useState("");
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
||||
// and a fresh Map each render would defeat the child cards' memoization).
|
||||
@@ -838,27 +1239,6 @@ export function AgentActivityFeed({
|
||||
return m;
|
||||
}, [data?.issues]);
|
||||
|
||||
// Auto-scroll on new comments or interactions
|
||||
const commentCount = data?.comments?.length ?? 0;
|
||||
const interactionCount = data?.interactions?.length ?? 0;
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [commentCount, interactionCount]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!body.trim()) return;
|
||||
sendComment.mutate(
|
||||
{ body: body.trim() },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setBody("");
|
||||
toast.success(`נשלח ל-${res.issue_identifier}`);
|
||||
},
|
||||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// ── Empty / loading states ──
|
||||
|
||||
if (isLoading) {
|
||||
@@ -939,14 +1319,18 @@ export function AgentActivityFeed({
|
||||
);
|
||||
|
||||
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) */}
|
||||
<AgentRoster agents={data.agents ?? []} />
|
||||
|
||||
{/* pending interactions surfaced to the top — each an accordion, first
|
||||
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
|
||||
{/* pending interactions surfaced to the top — each an accordion, all
|
||||
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
|
||||
{pending.length > 0 && (
|
||||
<div className="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">
|
||||
<Clock className="w-4 h-4" />
|
||||
ממתין לתשובתך
|
||||
@@ -954,27 +1338,30 @@ export function AgentActivityFeed({
|
||||
{pending.length}
|
||||
</span>
|
||||
</div>
|
||||
{pending.map((i, idx) => (
|
||||
{pending.map((i) => (
|
||||
<InteractionCard
|
||||
key={`pending-${i.id}`}
|
||||
interaction={i}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={idx === 0}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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.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} הודעות
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Per-issue accordions */}
|
||||
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||
{totalFeedItems === 0 ? (
|
||||
hasActiveIssue ? (
|
||||
@@ -989,56 +1376,17 @@ export function AgentActivityFeed({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
issueGroups.map(({ iss, items }, idx) => (
|
||||
issueGroups.map(({ iss, items }) => (
|
||||
<IssueGroup
|
||||
key={iss.id}
|
||||
issue={iss}
|
||||
items={items}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={
|
||||
idx === 0 ||
|
||||
(iss.status !== "done" && iss.status !== "cancelled")
|
||||
}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
{/* Comment input */}
|
||||
<div className="border-t border-rule pt-3 mt-3 space-y-2">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="כתוב הוראה לסוכנים..."
|
||||
aria-label="הוראה לסוכנים"
|
||||
className="min-h-[60px] resize-none text-sm"
|
||||
dir="rtl"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-ink-faint">
|
||||
ההודעה תנותב דרך סוכן ה-CEO · 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>
|
||||
);
|
||||
|
||||
304
web-ui/src/components/cases/case-files-browser.tsx
Normal file
304
web-ui/src/components/cases/case-files-browser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,13 @@ import { useCasePrecedents } from "@/lib/api/precedents";
|
||||
* the main case page (X17 #3). Lives inside the "טיעונים ועמדות" tab above the
|
||||
* collapsible by-party aggregated arguments. The 12-block editor + citation
|
||||
* verification moved to their own top-level tabs; /compose was deleted.
|
||||
*
|
||||
* The threshold-claim / issue cards deliberately stack in a SINGLE column.
|
||||
* They were a two-column CSS grid, but grid rows share a height: expanding one
|
||||
* card grew its row and shoved every card below it down — in *both* columns.
|
||||
* A single column keeps the jump local to what sits underneath, and gives the
|
||||
* expanded body (fields + chair editor + supporting precedents) full width
|
||||
* instead of half. Do not reintroduce `lg:grid-cols-2` here (chair, 2026-08-04).
|
||||
*/
|
||||
|
||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
@@ -203,7 +210,7 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
|
||||
{analysis.data.threshold_claims.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2 items-start">
|
||||
<div className="space-y-3">
|
||||
{analysis.data.threshold_claims.map((tc) => (
|
||||
<SubsectionCard
|
||||
key={tc.id}
|
||||
@@ -225,7 +232,7 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
|
||||
{analysis.data.issues.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2 items-start">
|
||||
<div className="space-y-3">
|
||||
{analysis.data.issues.map((iss) => (
|
||||
<SubsectionCard
|
||||
key={iss.id}
|
||||
|
||||
@@ -45,8 +45,10 @@ export function ChairEditor({
|
||||
if (trimmed === lastSaved.current.trim()) return;
|
||||
setState({ kind: "saving" });
|
||||
try {
|
||||
await mutate.mutateAsync({ sectionId, position: trimmed });
|
||||
lastSaved.current = trimmed;
|
||||
const res = await mutate.mutateAsync({ sectionId, position: trimmed });
|
||||
/* Track what the backend read back, not what we sent — "✓ נשמר" must
|
||||
mean "persisted and re-readable", or a later blur skips the save. */
|
||||
lastSaved.current = res?.position ?? trimmed;
|
||||
setState({ kind: "saved", at: new Date() });
|
||||
} catch (e) {
|
||||
setState({
|
||||
|
||||
@@ -26,6 +26,8 @@ export type PaperclipIssue = {
|
||||
completed_at: string | null;
|
||||
created_at: string | null;
|
||||
company_id: string;
|
||||
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
|
||||
parent_id: string | null;
|
||||
};
|
||||
|
||||
export type PaperclipComment = {
|
||||
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
|
||||
export function useSendComment(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
||||
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
||||
mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
|
||||
apiRequest<{
|
||||
comment_id: string | null;
|
||||
issue_id: string;
|
||||
issue_identifier: string;
|
||||
issue_status?: string;
|
||||
new_run?: boolean;
|
||||
}>(
|
||||
`/api/cases/${caseNumber}/agents/comment`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
@@ -200,6 +208,25 @@ export function useDismissInteraction(caseNumber: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Manually close a board issue to done/cancelled — the chair tidying the agents
|
||||
* board (e.g. superseded runs left in in_review). Backend does a loop-safe
|
||||
* direct close and issues no wakeup; only the two terminal statuses are allowed. */
|
||||
export function useSetIssueStatus(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { issue_id: string; status: "done" | "cancelled" }) =>
|
||||
apiRequest<{ ok: boolean; id: string; identifier: string; status: string }>(
|
||||
`/api/cases/${caseNumber}/agents/issue-status`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
onSuccess: () => {
|
||||
if (caseNumber) {
|
||||
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type AgentResetResult = {
|
||||
ok: boolean;
|
||||
reassigned_issues: { id: string; identifier: string }[];
|
||||
|
||||
66
web-ui/src/lib/api/case-files.ts
Normal file
66
web-ui/src/lib/api/case-files.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -190,6 +190,61 @@ export function useAgentRuns() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||
export type AgentHealthState = "zombie" | "stalled" | "working" | "idle";
|
||||
|
||||
export type AgentHealthItem = {
|
||||
issue_id: string;
|
||||
identifier: string;
|
||||
status: string;
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
health: AgentHealthState;
|
||||
wakeups: number;
|
||||
recovery_wakeups: number;
|
||||
};
|
||||
|
||||
export type AgentHealthResponse = {
|
||||
ok: boolean;
|
||||
items: AgentHealthItem[]; // worst-first (zombie → stalled → working → idle)
|
||||
counts: Record<AgentHealthState, number>;
|
||||
};
|
||||
|
||||
/** Per-issue agent health — surfaces recovery-loop zombies automatically. */
|
||||
export function useAgentHealth() {
|
||||
return useQuery({
|
||||
queryKey: ["operations", "agents", "health"],
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<AgentHealthResponse>("/api/operations/agents/health", { signal }),
|
||||
refetchInterval: 5000, // live alongside the running-agents view
|
||||
staleTime: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
export type EscalationItem = {
|
||||
issue_id: string;
|
||||
identifier: string;
|
||||
severity: "critical" | "high" | "medium";
|
||||
reason: string;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type RecentEscalationsResponse = {
|
||||
ok: boolean;
|
||||
items: EscalationItem[]; // newest-first
|
||||
};
|
||||
|
||||
/** Recent chair escalations (watchdog + manual) — the handled-loop history. */
|
||||
export function useRecentEscalations() {
|
||||
return useQuery({
|
||||
queryKey: ["operations", "agents", "escalations"],
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<RecentEscalationsResponse>("/api/operations/agents/escalations", { signal }),
|
||||
refetchInterval: 15000, // escalations change slowly (watchdog runs every 15m)
|
||||
staleTime: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Full output log of one run — fetched on demand (drawer open). */
|
||||
export function useRunLog(runId: string | null) {
|
||||
return useQuery({
|
||||
|
||||
@@ -59,20 +59,31 @@ export function useResearchAnalysis(caseNumber: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export type SaveChairPositionResult = {
|
||||
saved: boolean;
|
||||
section_id: string;
|
||||
/** What the backend read back out of the file after writing — the truth. */
|
||||
position: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export function useSaveChairPosition(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (vars: { sectionId: string; position: string }) =>
|
||||
apiRequest<unknown>(
|
||||
apiRequest<SaveChairPositionResult>(
|
||||
`/api/cases/${caseNumber}/research/analysis/chair-position`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: { section_id: vars.sectionId, position: vars.position },
|
||||
},
|
||||
),
|
||||
onSuccess: (_res, vars) => {
|
||||
onSuccess: (res, vars) => {
|
||||
/* Locally patch the cached analysis so other consumers stay in sync
|
||||
without an immediate refetch that would steal focus from the editor. */
|
||||
without an immediate refetch that would steal focus from the editor.
|
||||
Cache the value the server read back, never the value we sent — the
|
||||
two diverged silently while the parser could not see bulleted fields. */
|
||||
const persisted = res?.position ?? vars.position;
|
||||
qc.setQueryData<ResearchAnalysis | undefined>(
|
||||
researchKeys.analysis(caseNumber ?? ""),
|
||||
(prev) => {
|
||||
@@ -80,7 +91,7 @@ export function useSaveChairPosition(caseNumber: string | undefined) {
|
||||
const patch = (arr?: ResearchSubsection[]) =>
|
||||
arr?.map((s) =>
|
||||
s.id === vars.sectionId
|
||||
? { ...s, chair_position: vars.position }
|
||||
? { ...s, chair_position: persisted }
|
||||
: s,
|
||||
);
|
||||
return {
|
||||
|
||||
110
web/agent_health.py
Normal file
110
web/agent_health.py
Normal 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
|
||||
@@ -33,42 +33,118 @@ from web.paperclip_api import (
|
||||
pc_request,
|
||||
require_paperclip_db_url,
|
||||
)
|
||||
from web.agent_telemetry import instrument
|
||||
from web.paperclip_client import (
|
||||
COMPANIES as PAPERCLIP_COMPANIES,
|
||||
accept_interaction as pc_accept_interaction,
|
||||
archive_project as pc_archive_project,
|
||||
cancel_interaction as pc_cancel_interaction,
|
||||
cancel_run as pc_cancel_run,
|
||||
create_project as pc_create_project,
|
||||
create_workflow_issue as pc_create_workflow_issue,
|
||||
get_agent_health as pc_get_agent_health,
|
||||
get_recent_escalations as pc_get_recent_escalations,
|
||||
get_predecessor_context as pc_get_predecessor_context,
|
||||
get_predecessor_for_case as pc_get_predecessor_for_case,
|
||||
get_agents_for_case as pc_get_agents_for_case,
|
||||
get_agents_for_company as pc_get_agents,
|
||||
get_case_issues as pc_get_case_issues,
|
||||
pick_default_comment_target as pc_pick_default_comment_target,
|
||||
get_issue_comments as pc_get_issue_comments,
|
||||
get_issue_interactions as pc_get_issue_interactions,
|
||||
get_project_url,
|
||||
get_run_events as pc_get_run_events,
|
||||
get_run_log as pc_get_run_log,
|
||||
list_live_runs as pc_list_live_runs,
|
||||
post_comment as pc_post_comment,
|
||||
reap_stale_interactions as pc_reap_stale_interactions,
|
||||
reject_interaction as pc_reject_interaction,
|
||||
reset_agent_session as pc_reset_agent_session,
|
||||
reset_case_agents as pc_reset_case_agents,
|
||||
respond_to_interaction as pc_respond_to_interaction,
|
||||
restore_project as pc_restore_project,
|
||||
update_project_name as pc_update_project_name,
|
||||
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
||||
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
|
||||
wake_analyst_for_protocol_analysis as pc_wake_analyst_for_protocol_analysis,
|
||||
get_generation_run_status as pc_get_generation_run_status,
|
||||
wake_ceo_agent as pc_wake_ceo,
|
||||
wake_ceo_for_action as pc_wake_ceo_for_action,
|
||||
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
||||
wake_curator_for_final as pc_wake_curator_for_final,
|
||||
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
||||
# ── raw imports: wrapped with telemetry below (state-affecting ops, #219) ──
|
||||
cancel_interaction as _cancel_interaction,
|
||||
cancel_run as _cancel_run,
|
||||
escalate_issue as _escalate_issue,
|
||||
mark_comment_routed as _mark_comment_routed,
|
||||
post_comment as _post_comment,
|
||||
reap_stale_interactions as _reap_stale_interactions,
|
||||
reset_agent_session as _reset_agent_session,
|
||||
reset_case_agents as _reset_case_agents,
|
||||
set_issue_status as _set_issue_status,
|
||||
wake_analyst_for_appraiser_facts as _wake_analyst_for_appraiser_facts,
|
||||
wake_analyst_for_argument_aggregation as _wake_analyst_for_argument_aggregation,
|
||||
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
||||
wake_ceo_agent as _wake_ceo,
|
||||
wake_ceo_for_action as _wake_ceo_for_action,
|
||||
open_ceo_run as _open_ceo_run,
|
||||
wake_ceo_for_feedback_fold as _wake_ceo_for_feedback_fold,
|
||||
wake_curator_for_final as _wake_curator_for_final,
|
||||
wake_for_precedent_extraction as _wake_for_precedent_extraction,
|
||||
)
|
||||
|
||||
# ── telemetry-wrapped platform ops (#219 / docs/spec/X15) ───────────────────
|
||||
# Every state-affecting platform op the Port exposes emits one structured event
|
||||
# through web.agent_telemetry, so recovery-loop behaviour (repeated wakeups,
|
||||
# stranded dispositions) is observable in real time rather than reconstructed
|
||||
# from the Paperclip DB. The wrappers preserve the public ``pc_*`` names and
|
||||
# signatures — call sites in app.py are unchanged. Read-only observability ops
|
||||
# (list_live_runs / get_run_log / get_issue_comments …) are intentionally NOT
|
||||
# instrumented: they neither wake agents nor change disposition state.
|
||||
pc_wake_ceo = instrument("agent.wakeup", role="ceo")(_wake_ceo)
|
||||
pc_wake_ceo_for_action = instrument(
|
||||
"agent.wakeup", role="ceo", keys=("case_number", "company_id", "action"),
|
||||
)(_wake_ceo_for_action)
|
||||
pc_open_ceo_run = instrument(
|
||||
"agent.wakeup", role="ceo", keys=("case_number", "company_id"),
|
||||
)(_open_ceo_run)
|
||||
pc_wake_ceo_for_feedback_fold = instrument(
|
||||
"agent.wakeup", role="ceo", keys=("feedback_id", "category", "block_id"),
|
||||
)(_wake_ceo_for_feedback_fold)
|
||||
pc_wake_curator_for_final = instrument(
|
||||
"agent.wakeup", role="curator", keys=("case_number", "company_id", "task"),
|
||||
)(_wake_curator_for_final)
|
||||
pc_wake_for_precedent_extraction = instrument(
|
||||
"agent.wakeup", role="ceo", keys=("case_law_id", "citation", "practice_area"),
|
||||
)(_wake_for_precedent_extraction)
|
||||
pc_wake_analyst_for_appraiser_facts = instrument(
|
||||
"agent.wakeup", role="analyst",
|
||||
)(_wake_analyst_for_appraiser_facts)
|
||||
pc_wake_analyst_for_argument_aggregation = instrument(
|
||||
"agent.wakeup", role="analyst",
|
||||
)(_wake_analyst_for_argument_aggregation)
|
||||
pc_wake_analyst_for_protocol_analysis = instrument(
|
||||
"agent.wakeup", role="analyst", keys=("case_number", "company_id", "document_id"),
|
||||
)(_wake_analyst_for_protocol_analysis)
|
||||
pc_post_comment = instrument(
|
||||
"agent.comment", keys=("issue_id", "company_id"),
|
||||
)(_post_comment)
|
||||
pc_mark_comment_routed = instrument(
|
||||
"agent.comment", keys=("issue_id", "comment_id"),
|
||||
)(_mark_comment_routed)
|
||||
pc_cancel_interaction = instrument(
|
||||
"interaction.cancelled", keys=("issue_id", "interaction_id"),
|
||||
)(_cancel_interaction)
|
||||
# First-class escalation-to-chair (#218): the loop-safe alternative to leaving an
|
||||
# issue agent-owned+blocked. Emits a severity-tagged event so escalations are
|
||||
# countable per case in the same stream as the wakeups they replace.
|
||||
pc_escalate_issue = instrument(
|
||||
"agent.escalated", keys=("issue_id", "severity", "company_id", "reason"),
|
||||
)(_escalate_issue)
|
||||
# Manual chair close of a board issue (done/cancelled) — the loop-safe primitive
|
||||
# behind the agents-board "סמן כהושלם / בטל" actions. Countable per case in the
|
||||
# same telemetry stream as the escalations/wakeups it sits beside.
|
||||
pc_set_issue_status = instrument(
|
||||
"issue.status_set", keys=("issue_id", "status", "company_id"),
|
||||
)(_set_issue_status)
|
||||
pc_reap_stale_interactions = instrument(
|
||||
"interaction.reaped", result_keys=("cancelled",),
|
||||
)(_reap_stale_interactions)
|
||||
pc_cancel_run = instrument("run.cancelled", keys=("run_id",))(_cancel_run)
|
||||
pc_reset_agent_session = instrument(
|
||||
"agent.session_reset", keys=("agent_id",),
|
||||
)(_reset_agent_session)
|
||||
pc_reset_case_agents = instrument(
|
||||
"agent.session_reset", keys=("case_number",),
|
||||
)(_reset_case_agents)
|
||||
|
||||
# ── domain-named lifecycle aliases (preferred for new call sites) ───────────
|
||||
archive_case_project = pc_archive_project
|
||||
restore_case_project = pc_restore_project
|
||||
@@ -98,11 +174,13 @@ __all__ = [
|
||||
# issues / workflow
|
||||
"pc_create_workflow_issue",
|
||||
"pc_get_case_issues",
|
||||
"pc_pick_default_comment_target",
|
||||
# agents / wakeups
|
||||
"pc_get_agents_for_case",
|
||||
"pc_get_agents",
|
||||
"pc_wake_ceo",
|
||||
"pc_wake_ceo_for_action",
|
||||
"pc_open_ceo_run",
|
||||
"pc_wake_ceo_for_feedback_fold",
|
||||
"pc_wake_curator_for_final",
|
||||
"pc_wake_for_precedent_extraction",
|
||||
@@ -112,6 +190,7 @@ __all__ = [
|
||||
"pc_get_generation_run_status",
|
||||
# comments / interactions
|
||||
"pc_post_comment",
|
||||
"pc_mark_comment_routed",
|
||||
"pc_get_issue_comments",
|
||||
"pc_get_issue_interactions",
|
||||
"pc_accept_interaction",
|
||||
@@ -119,7 +198,13 @@ __all__ = [
|
||||
"pc_respond_to_interaction",
|
||||
"pc_cancel_interaction",
|
||||
"pc_reap_stale_interactions",
|
||||
"pc_escalate_issue",
|
||||
"pc_set_issue_status",
|
||||
# agent-run observability + control (live view + smart management)
|
||||
"pc_get_agent_health",
|
||||
"pc_get_recent_escalations",
|
||||
"pc_get_predecessor_context",
|
||||
"pc_get_predecessor_for_case",
|
||||
"pc_list_live_runs",
|
||||
"pc_get_run_log",
|
||||
"pc_get_run_events",
|
||||
|
||||
157
web/agent_telemetry.py
Normal file
157
web/agent_telemetry.py
Normal 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
|
||||
360
web/app.py
360
web/app.py
@@ -61,9 +61,15 @@ from web.agent_platform_port import (
|
||||
pc_cancel_run,
|
||||
pc_create_project,
|
||||
pc_create_workflow_issue,
|
||||
pc_escalate_issue,
|
||||
pc_get_agent_health,
|
||||
pc_get_recent_escalations,
|
||||
pc_get_predecessor_context,
|
||||
pc_get_predecessor_for_case,
|
||||
pc_get_agents,
|
||||
pc_get_agents_for_case,
|
||||
pc_get_case_issues,
|
||||
pc_pick_default_comment_target,
|
||||
pc_get_issue_comments,
|
||||
pc_get_issue_interactions,
|
||||
pc_get_run_events,
|
||||
@@ -76,6 +82,7 @@ from web.agent_platform_port import (
|
||||
pc_reset_agent_session,
|
||||
pc_reset_case_agents,
|
||||
pc_respond_to_interaction,
|
||||
pc_set_issue_status,
|
||||
pc_restore_project,
|
||||
pc_wake_analyst_for_appraiser_facts,
|
||||
pc_wake_analyst_for_argument_aggregation,
|
||||
@@ -84,6 +91,8 @@ from web.agent_platform_port import (
|
||||
rename_case_project,
|
||||
pc_wake_ceo,
|
||||
pc_wake_ceo_for_action,
|
||||
pc_open_ceo_run,
|
||||
pc_mark_comment_routed,
|
||||
pc_wake_ceo_for_feedback_fold,
|
||||
pc_wake_curator_for_final,
|
||||
pc_wake_for_precedent_extraction,
|
||||
@@ -121,18 +130,66 @@ async def _interaction_reaper_loop():
|
||||
logger.warning("interaction reaper sweep failed", exc_info=True)
|
||||
|
||||
|
||||
# Watchdog kill-switch — set AGENT_ZOMBIE_AUTO_ESCALATE=0 to observe health
|
||||
# without acting (escalation stays available manually via the endpoint).
|
||||
_ZOMBIE_AUTO_ESCALATE = os.environ.get("AGENT_ZOMBIE_AUTO_ESCALATE", "1") != "0"
|
||||
|
||||
|
||||
async def _zombie_escalation_loop():
|
||||
"""Watchdog: auto-escalate persistent recovery-loop zombies to the chair (#218+#222).
|
||||
|
||||
Every 15 min, derive per-issue health (#222) and hand any persistent zombie
|
||||
(a recovery loop, ``>=`` ``ZOMBIE_ESCALATE_MIN_RECOVERY`` recovery wakeups)
|
||||
to the human via the loop-safe escalate primitive (#218) — closing the loop
|
||||
that today burns budget until a human notices. Server-side on purpose: an
|
||||
agent stuck in a recovery loop cannot reliably self-escalate. Idempotent —
|
||||
an escalated issue is agent-unowned next sweep, so it is not re-escalated.
|
||||
"""
|
||||
from web.agent_health import zombie_escalation
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(900)
|
||||
if not _ZOMBIE_AUTO_ESCALATE:
|
||||
continue
|
||||
health = await pc_get_agent_health()
|
||||
for item in health.get("items", []):
|
||||
decision = zombie_escalation(item)
|
||||
if decision is None:
|
||||
continue
|
||||
severity, reason = decision
|
||||
result = await pc_escalate_issue(item["issue_id"], severity, reason)
|
||||
if result.get("ok"):
|
||||
logger.warning(
|
||||
"watchdog auto-escalated %s (%s, %d recovery wakeups) to chair [%s]",
|
||||
item.get("identifier"), item.get("agent_name"),
|
||||
item.get("recovery_wakeups", 0), severity,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"watchdog escalation failed for %s: %s",
|
||||
item.get("identifier"), result.get("error"),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.warning("zombie escalation sweep failed", exc_info=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
await db.init_schema()
|
||||
sync_task = asyncio.create_task(git_sync.sweep_loop())
|
||||
reaper_task = asyncio.create_task(_interaction_reaper_loop())
|
||||
watchdog_task = asyncio.create_task(_zombie_escalation_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sync_task.cancel()
|
||||
reaper_task.cancel()
|
||||
for _t in (sync_task, reaper_task):
|
||||
watchdog_task.cancel()
|
||||
for _t in (sync_task, reaper_task, watchdog_task):
|
||||
try:
|
||||
await _t
|
||||
except asyncio.CancelledError:
|
||||
@@ -3014,42 +3071,82 @@ async def api_learn(case_number: str):
|
||||
# ── Local files API — research, drafts, proofread ──
|
||||
|
||||
|
||||
# Directories under a case that are infrastructure/cache, not chair-facing
|
||||
# content. Hidden dirs (``.git``, ``.claude`` …) are skipped separately.
|
||||
_CASE_FILE_EXCLUDE_DIRS = {"thumbnails"}
|
||||
|
||||
|
||||
def _is_content_dir(p: Path) -> bool:
|
||||
return p.is_dir() and not p.name.startswith(".") and p.name not in _CASE_FILE_EXCLUDE_DIRS
|
||||
|
||||
|
||||
def _case_file_folders(case_dir: Path) -> list[tuple[str, Path]]:
|
||||
"""Enumerate every content subdirectory under a case dir as (key, path).
|
||||
|
||||
Covers *all* subfolders (not just research) — recursing one level into
|
||||
``documents/`` — while skipping hidden (``.git``/``.claude``…) and cache
|
||||
dirs. The ``key`` is the case-relative path with ``/`` encoded as ``__`` so
|
||||
it survives a single URL path segment; ``_resolve_case_file`` re-validates it
|
||||
against this same enumeration, so the key set is the serve allowlist (no path
|
||||
traversal).
|
||||
"""
|
||||
folders: list[tuple[str, Path]] = []
|
||||
if not case_dir.exists():
|
||||
return folders
|
||||
for child in sorted(case_dir.iterdir()):
|
||||
if not _is_content_dir(child):
|
||||
continue
|
||||
if child.name == "documents":
|
||||
folders.extend(
|
||||
(f"documents__{sub.name}", sub)
|
||||
for sub in sorted(child.iterdir())
|
||||
if _is_content_dir(sub)
|
||||
)
|
||||
else:
|
||||
folders.append((child.name, child))
|
||||
return folders
|
||||
|
||||
|
||||
def _resolve_case_file(case_dir: Path, folder_key: str, filename: str) -> Path | None:
|
||||
"""Safely resolve (folder_key, filename) to a file inside the case dir.
|
||||
|
||||
Returns None unless ``folder_key`` is one of the enumerated content folders
|
||||
AND ``filename`` resolves to a regular file directly inside it (no traversal).
|
||||
"""
|
||||
base = dict(_case_file_folders(case_dir)).get(folder_key)
|
||||
if base is None:
|
||||
return None
|
||||
target = (base / filename).resolve()
|
||||
if target.parent != base.resolve() or not target.is_file():
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files")
|
||||
async def api_local_files(case_number: str):
|
||||
"""List local files from case subdirectories (research, drafts, proofread)."""
|
||||
"""List local files across ALL content subfolders under a case (not just
|
||||
research) — so the chair can reach any agent-produced file (drafts, OCR text,
|
||||
originals…) from the UI instead of only over SSH. Empty folders are returned
|
||||
too (with an empty ``files`` list) so the UI can show them as such."""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
result = {}
|
||||
for folder in ("research", "proofread"):
|
||||
folder_path = case_dir / "documents" / folder
|
||||
if folder_path.exists():
|
||||
files = []
|
||||
for f in sorted(folder_path.iterdir()):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
"folder": folder,
|
||||
})
|
||||
if files:
|
||||
result[folder] = files
|
||||
# Drafts are at case level, not under documents
|
||||
drafts_path = case_dir / "drafts"
|
||||
if drafts_path.exists():
|
||||
folders = []
|
||||
for key, path in _case_file_folders(case_dir):
|
||||
files = []
|
||||
for f in sorted(drafts_path.iterdir()):
|
||||
for f in sorted(path.iterdir()):
|
||||
if f.is_file() and not f.name.startswith("."):
|
||||
stat = f.stat()
|
||||
files.append({
|
||||
"filename": f.name,
|
||||
"size": stat.st_size,
|
||||
"modified_at": stat.st_mtime,
|
||||
"folder": "drafts",
|
||||
})
|
||||
if files:
|
||||
result["drafts"] = files
|
||||
return result
|
||||
folders.append({
|
||||
"key": key,
|
||||
"name": path.name,
|
||||
"path": str(path.relative_to(case_dir)),
|
||||
"files": files,
|
||||
})
|
||||
return {"folders": folders}
|
||||
|
||||
|
||||
async def serve_blob(
|
||||
@@ -3119,17 +3216,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non
|
||||
|
||||
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
||||
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||
"""Read contents of a local case file."""
|
||||
if folder not in ("research", "proofread", "drafts"):
|
||||
raise HTTPException(400, "Invalid folder")
|
||||
"""Serve a single local case file from any content subfolder.
|
||||
|
||||
``folder`` is a key from ``/local-files`` (``documents__research``, ``drafts``…);
|
||||
``_resolve_case_file`` validates it against the folder allowlist and blocks
|
||||
path traversal before the bytes are served.
|
||||
"""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if folder == "drafts":
|
||||
path = case_dir / "drafts" / filename
|
||||
else:
|
||||
path = case_dir / "documents" / folder / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
path = _resolve_case_file(case_dir, folder, filename)
|
||||
if path is None:
|
||||
raise HTTPException(404, "קובץ לא נמצא")
|
||||
return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename)
|
||||
media_type = _local_file_media_type(filename)
|
||||
return await serve_blob(path, media_type=media_type, filename=filename)
|
||||
|
||||
|
||||
def _local_file_media_type(filename: str) -> str:
|
||||
"""Best-effort content type for a served case file (text renders inline,
|
||||
binaries download)."""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
return {
|
||||
"md": "text/markdown; charset=utf-8",
|
||||
"txt": "text/plain; charset=utf-8",
|
||||
"json": "application/json; charset=utf-8",
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"doc": "application/msword",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
||||
@@ -4550,14 +4662,39 @@ async def api_case_agents(case_number: str):
|
||||
class AgentCommentRequest(BaseModel):
|
||||
body: str
|
||||
issue_id: str | None = None
|
||||
# "פתח הרצה חדשה" — skip the comment entirely and only open a CEO run. Kept for
|
||||
# the agents-tab target selector; the default path below now opens a run too, so
|
||||
# this only differs in *not* recording the instruction on the parent thread.
|
||||
new_run: bool = False
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/agents/comment")
|
||||
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||
"""Post a comment on a Paperclip issue linked to a case.
|
||||
"""Post a chair instruction to a Paperclip issue linked to a case.
|
||||
|
||||
If issue_id is omitted, the most recent non-done issue is used.
|
||||
Routing (agents-tab target selector):
|
||||
- ``new_run=True`` → open a fresh CEO-owned run (child issue assigned to the
|
||||
CEO + wakeup), sidestepping the ``issue_assignee_changed`` cancellation on
|
||||
a human-owned parent. Overrides ``issue_id``.
|
||||
- ``issue_id`` given → post to that issue (explicit chair choice).
|
||||
- neither → prefer the live CEO main issue, never a closed one
|
||||
(``pick_default_comment_target``).
|
||||
"""
|
||||
if req.new_run:
|
||||
result = await pc_open_ceo_run(case_number, req.body)
|
||||
if result.get("status") != "ok":
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"פתיחת הרצה חדשה נכשלה: {result.get('reason', 'unknown')}",
|
||||
)
|
||||
return {
|
||||
"comment_id": None,
|
||||
"issue_id": result["issue_id"],
|
||||
"issue_identifier": result.get("identifier", ""),
|
||||
"issue_status": "in_progress",
|
||||
"new_run": True,
|
||||
}
|
||||
|
||||
issues = await pc_get_case_issues(case_number)
|
||||
if not issues:
|
||||
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
||||
@@ -4567,14 +4704,35 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||
if not target:
|
||||
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||
else:
|
||||
# Pick the most recent non-done issue, or the last one
|
||||
active = [i for i in issues if i["status"] != "done"]
|
||||
target = active[-1] if active else issues[-1]
|
||||
target = pc_pick_default_comment_target(issues)
|
||||
|
||||
result = await pc_post_comment(target["id"], target["company_id"], req.body)
|
||||
|
||||
# Find the identifier for the response
|
||||
# The comment alone reaches nobody: a case issue awaiting the chair is
|
||||
# `in_review` and owned by a human, so Paperclip cancels any wakeup on it
|
||||
# (`issue_assignee_changed` → "wake the new owner" → the owner is a person).
|
||||
# The plugin sweep that was meant to catch this delivers the instruction as
|
||||
# `payload.prompt`, which the runner drops. So the comment is recorded for the
|
||||
# thread, and the instruction is carried by a CEO-owned child run — the one
|
||||
# path verified to reach the agent (TaskMaster #228; see paperclip-quirks.md).
|
||||
run = await pc_open_ceo_run(case_number, req.body)
|
||||
if run.get("status") != "ok":
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"ההערה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא יטפל בה: "
|
||||
f"{run.get('reason', 'unknown')}",
|
||||
)
|
||||
# Claim the comment so the sweep does not re-route it: the CEO answers on the
|
||||
# child issue, leaving the parent with no agent reply, which the sweep reads
|
||||
# as "still pending" forever.
|
||||
await pc_mark_comment_routed(target["id"], result.get("comment_id", ""))
|
||||
|
||||
# Echo the resolved target so the UI can show where it landed and flag it
|
||||
# when the chair explicitly targeted a closed issue (whose wakeup is skipped).
|
||||
result["issue_identifier"] = target.get("identifier", "")
|
||||
result["issue_status"] = target.get("status", "")
|
||||
result["run_issue_id"] = run["issue_id"]
|
||||
result["run_issue_identifier"] = run.get("identifier", "")
|
||||
return result
|
||||
|
||||
|
||||
@@ -4591,8 +4749,10 @@ async def api_post_interaction_response(
|
||||
):
|
||||
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
||||
|
||||
Routes to /respond | /accept | /reject based on `action`. Paperclip
|
||||
auto-wakes the issue assignee after a successful submission.
|
||||
Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
|
||||
carrying the answer. Paperclip's own `wake_assignee` cannot deliver it: the
|
||||
issue is owned by the chair while it waits for her, so the "assignee" it wakes
|
||||
is a person and no agent ever runs (TaskMaster #228).
|
||||
"""
|
||||
issues = await pc_get_case_issues(case_number)
|
||||
if not any(i["id"] == req.issue_id for i in issues):
|
||||
@@ -4604,7 +4764,7 @@ async def api_post_interaction_response(
|
||||
"reject": pc_reject_interaction,
|
||||
}
|
||||
try:
|
||||
return await handlers[req.action](
|
||||
result = await handlers[req.action](
|
||||
req.issue_id, req.interaction_id, req.payload,
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -4613,6 +4773,23 @@ async def api_post_interaction_response(
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||
|
||||
answer = json.dumps(req.payload, ensure_ascii=False)
|
||||
run = await pc_open_ceo_run(
|
||||
case_number,
|
||||
f'תשובת היו"ר לשאלה שהוצגה לה ({req.action}):\n\n{answer}\n\n'
|
||||
f"קרא את ה-interaction המקורי על ה-issue והמשך משם.",
|
||||
)
|
||||
if run.get("status") != "ok":
|
||||
raise HTTPException(
|
||||
502,
|
||||
f"התשובה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא ימשיך: "
|
||||
f"{run.get('reason', 'unknown')}",
|
||||
)
|
||||
if isinstance(result, dict):
|
||||
result["run_issue_id"] = run["issue_id"]
|
||||
result["run_issue_identifier"] = run.get("identifier", "")
|
||||
return result
|
||||
|
||||
|
||||
class InteractionDismissRequest(BaseModel):
|
||||
issue_id: str
|
||||
@@ -4651,6 +4828,53 @@ async def api_reset_case_agents(case_number: str):
|
||||
return result
|
||||
|
||||
|
||||
class EscalateRequest(BaseModel):
|
||||
issue_id: str
|
||||
severity: Literal["critical", "high", "medium"]
|
||||
reason: str
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/agents/escalate")
|
||||
async def api_escalate_issue(case_number: str, req: EscalateRequest):
|
||||
"""Escalate a single stuck issue to the chair (#218).
|
||||
|
||||
The loop-safe alternative to leaving an issue agent-owned+blocked: hands the
|
||||
issue to the human in one atomic transition (in_review + assignee_user_id)
|
||||
and records a severity note, without re-waking any agent. Emits an
|
||||
``agent.escalated`` telemetry event via the Port.
|
||||
"""
|
||||
issues = await pc_get_case_issues(case_number)
|
||||
if not any(i["id"] == req.issue_id for i in issues):
|
||||
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||
result = await pc_escalate_issue(req.issue_id, req.severity, req.reason)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(400, result.get("error", "ההסלמה נכשלה"))
|
||||
return result
|
||||
|
||||
|
||||
class IssueStatusRequest(BaseModel):
|
||||
issue_id: str
|
||||
status: Literal["done", "cancelled"]
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/agents/issue-status")
|
||||
async def api_set_issue_status(case_number: str, req: IssueStatusRequest):
|
||||
"""Manually close a board issue to done/cancelled from the chair UI.
|
||||
|
||||
Lets the chair tidy the agents board by hand — superseded "הרצה חדשה" runs
|
||||
left in ``in_review`` had no manual close control and piled up. Only the two
|
||||
terminal statuses are accepted; the loop-safe direct-DB close lives behind the
|
||||
Port (``pc_set_issue_status``) and issues no wakeup.
|
||||
"""
|
||||
issues = await pc_get_case_issues(case_number)
|
||||
if not any(i["id"] == req.issue_id for i in issues):
|
||||
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||
result = await pc_set_issue_status(req.issue_id, req.status)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(400, result.get("error", "עדכון הסטטוס נכשל"))
|
||||
return result
|
||||
|
||||
|
||||
# ── Settings: MCP Server Configuration ────────────────────────────
|
||||
#
|
||||
# Source of truth for legal-ai env vars is Coolify (see memory:
|
||||
@@ -6768,7 +6992,7 @@ async def precedent_library_list(
|
||||
precedent_level: str = "",
|
||||
source_type: str = "",
|
||||
search: str = "",
|
||||
source_kind: str = "external_upload",
|
||||
source_kind: str = "",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
):
|
||||
@@ -6796,6 +7020,7 @@ async def precedent_library_search(
|
||||
subject_tag: str = "",
|
||||
limit: int = 10,
|
||||
include_halachot: bool = True,
|
||||
source_kind: str = "",
|
||||
):
|
||||
if not q or len(q.strip()) < 2:
|
||||
return {"items": [], "count": 0}
|
||||
@@ -6808,6 +7033,7 @@ async def precedent_library_search(
|
||||
subject_tag=subject_tag,
|
||||
limit=limit,
|
||||
include_halachot=include_halachot,
|
||||
source_kind=source_kind,
|
||||
)
|
||||
return {"items": results, "count": len(results)}
|
||||
|
||||
@@ -7506,6 +7732,50 @@ async def operations_agents():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/operations/agents/health")
|
||||
async def operations_agent_health():
|
||||
"""Per-issue agent health taxonomy (#222): zombie / stalled / working / idle.
|
||||
|
||||
Read-only. Surfaces the stranded-child / recovery-loop cases (``zombie``)
|
||||
automatically — the ``gt feed --problems`` idea grounded in our failure
|
||||
modes — so they no longer need hand-querying the Paperclip DB. Consumed by
|
||||
the /operations dashboard (UI is design-gated, follow-up).
|
||||
"""
|
||||
return await pc_get_agent_health()
|
||||
|
||||
|
||||
@app.get("/api/operations/agents/escalations")
|
||||
async def operations_agent_escalations(limit: int = 10, hours: int = 24):
|
||||
"""Recent chair escalations — watchdog + manual (#218/#222).
|
||||
|
||||
Read-only history of what was handed to the chair, for the ops health panel.
|
||||
"""
|
||||
return await pc_get_recent_escalations(limit, hours)
|
||||
|
||||
|
||||
@app.get("/api/operations/issues/{issue_id}/predecessor")
|
||||
async def operations_issue_predecessor(issue_id: str, limit: int = 3):
|
||||
"""Recent finished runs on an issue for session continuation (#220, "seance").
|
||||
|
||||
Read-only. Lets a fresh heartbeat read what its predecessors on the same
|
||||
issue concluded (the run ``summary`` each leaves) instead of rediscovering
|
||||
context from scratch. Agent-facing wiring (MCP tool + HEARTBEAT) is a
|
||||
follow-up; this is the backend it will call.
|
||||
"""
|
||||
return await pc_get_predecessor_context(issue_id, limit)
|
||||
|
||||
|
||||
@app.get("/api/operations/cases/{case_number}/predecessor")
|
||||
async def operations_case_predecessor(case_number: str, limit: int = 5):
|
||||
"""Recent finished runs across a case's issues (#220, agent-facing "seance").
|
||||
|
||||
Case-scoped sibling of the by-issue endpoint — the plugin tool
|
||||
``legal_predecessor_context`` calls this so a resuming agent reads what prior
|
||||
sessions on the case concluded (agents key on case_number, not issue UUID).
|
||||
"""
|
||||
return await pc_get_predecessor_for_case(case_number, limit)
|
||||
|
||||
|
||||
@app.get("/api/operations/agents/runs/{run_id}/log")
|
||||
async def operations_agent_run_log(run_id: str):
|
||||
"""Full output log (NDJSON stream) of one heartbeat run."""
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
@@ -326,6 +327,40 @@ async def _create_issue(
|
||||
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:
|
||||
"""Store the legal-ai case number in plugin state, linked to the issue."""
|
||||
await conn.execute(
|
||||
@@ -483,7 +518,8 @@ async def get_case_issues(case_number: str) -> list[dict]:
|
||||
"""SELECT DISTINCT ON (i.id)
|
||||
i.id, i.title, i.status, i.identifier, i.priority,
|
||||
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
|
||||
LEFT JOIN agents a ON i.assignee_agent_id = a.id
|
||||
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,
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
"company_id": str(r["company_id"]),
|
||||
"parent_id": str(r["parent_id"]) if r["parent_id"] else None,
|
||||
}
|
||||
for r in sorted_rows
|
||||
]
|
||||
@@ -516,6 +553,43 @@ async def get_case_issues(case_number: str) -> list[dict]:
|
||||
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]:
|
||||
"""Get all comments on a list of Paperclip issues, with agent metadata."""
|
||||
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:
|
||||
"""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).
|
||||
Falls back to direct DB insert + CEO wakeup if API fails.
|
||||
Delivering the instruction is the caller's job, via :func:`open_ceo_run`. This
|
||||
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
|
||||
if PAPERCLIP_BOARD_API_KEY:
|
||||
@@ -635,7 +713,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
except Exception:
|
||||
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())
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
@@ -648,23 +725,6 @@ async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||
finally:
|
||||
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"}
|
||||
|
||||
|
||||
@@ -856,6 +916,142 @@ 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 set_issue_status(
|
||||
issue_id: str, status: str, company_id: str = "",
|
||||
) -> dict:
|
||||
"""Manually close a case issue to ``done`` or ``cancelled`` from the chair UI.
|
||||
|
||||
The chair needs to tidy the agents board by hand: superseded "הרצה חדשה" runs
|
||||
that were replaced by a later run pile up in ``in_review`` with no way to close
|
||||
them — there was no manual control, so they had to be closed out-of-band. This
|
||||
is the loop-safe primitive behind the board's "סמן כהושלם / בטל" actions.
|
||||
|
||||
Only the two **terminal** statuses are accepted (``CLOSED_ISSUE_STATUSES``) —
|
||||
this tidies the board, it does not drive the workflow (agents own the
|
||||
todo/in_progress/in_review transitions). Direct-DB in one transaction,
|
||||
mirroring :func:`escalate_issue`: bypasses Paperclip's disposition resolver
|
||||
(whose multi-PATCH/``issue.released`` behaviour *causes* the recovery loops),
|
||||
sets the matching close timestamp, clears any agent assignee so no recovery
|
||||
sweep re-acts on it, and records an ``author_type='system'`` audit note (inert
|
||||
w.r.t. the user-comment routing sweep, so it will not wake an agent). No wakeup.
|
||||
"""
|
||||
if status not in CLOSED_ISSUE_STATUSES:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": f"invalid status {status!r}; expected one of {tuple(CLOSED_ISSUE_STATUSES)}",
|
||||
}
|
||||
|
||||
# status is validated against the frozenset above, so the interpolated column
|
||||
# name is one of exactly two literals — no injection surface.
|
||||
ts_col = "completed_at" if status == "done" else "cancelled_at"
|
||||
note = (
|
||||
"✓ סומן ידנית כהושלם ע\"י היו\"ר"
|
||||
if status == "done"
|
||||
else "✕ בוטל ידנית ע\"י היו\"ר"
|
||||
)
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
async with conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
f"""UPDATE issues
|
||||
SET status=$1, {ts_col}=now(),
|
||||
assignee_agent_id=null, updated_at=now()
|
||||
WHERE id=$2::uuid
|
||||
RETURNING id, identifier, company_id""",
|
||||
status, 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, note,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
logger.info("Chair set issue %s → %s", issue_id, status)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": str(row["id"]),
|
||||
"identifier": row["identifier"],
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
async def respond_to_interaction(
|
||||
issue_id: str, interaction_id: str, payload: dict,
|
||||
) -> dict:
|
||||
@@ -962,6 +1158,249 @@ async def reap_stale_interactions() -> dict:
|
||||
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
|
||||
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
||||
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
||||
@@ -1384,6 +1823,93 @@ async def wake_ceo_for_action(
|
||||
}
|
||||
|
||||
|
||||
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": "סיכום-מנהלים",
|
||||
|
||||
251
web/tests/test_agent_health.py
Normal file
251
web/tests/test_agent_health.py
Normal 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": []}
|
||||
104
web/tests/test_agent_telemetry.py
Normal file
104
web/tests/test_agent_telemetry.py
Normal 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")
|
||||
78
web/tests/test_comment_target.py
Normal file
78
web/tests/test_comment_target.py
Normal 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"
|
||||
118
web/tests/test_escalate_issue.py
Normal file
118
web/tests/test_escalate_issue.py
Normal 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
|
||||
84
web/tests/test_predecessor_context.py
Normal file
84
web/tests/test_predecessor_context.py
Normal 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": []}
|
||||
Reference in New Issue
Block a user