Compare commits
79 Commits
39aff8fd45
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 66cddd20f0 | |||
| 9e7ef4fced | |||
| a0a30092e8 | |||
| b296422d87 | |||
| 21ad541659 | |||
| 3462cb7320 | |||
| 17c23ab377 | |||
| 972935ceb5 | |||
| 538d80f6ec | |||
| fdb0c98650 | |||
| 0d0631fea5 | |||
| ab5d34a9d3 | |||
| 6414e5f94a | |||
| f4ce8332fe | |||
| e5c7455284 | |||
| 68c2be2a7b | |||
| acb887845c | |||
| 93cce0505e | |||
| cc48896a0e | |||
| cc8af903b7 | |||
| bc0a4bb895 | |||
| 9d88255050 | |||
| 2b68d6cbeb | |||
| 581a4ba36f | |||
| 88977b2f10 | |||
| 40a2032e0d | |||
| def823b89f | |||
| 3ea5d3b536 | |||
| 425305844a | |||
| 5867f7175f | |||
| c954dded66 | |||
| 5cf1ff65f4 | |||
| 91bd3ac88c | |||
| bb83ddc611 | |||
| d1ca1152b1 | |||
| 44f6915f0c | |||
| 3b0d3e3b2e | |||
| b560417dd7 | |||
| 0bf311041b | |||
| 148f303378 | |||
| e439fad1a9 | |||
| fa2fc9390c | |||
| 331c712762 | |||
| 130f7cff71 | |||
| abff00faec | |||
| c7156b575e | |||
| 4285288ba7 |
@@ -86,6 +86,8 @@
|
|||||||
|
|
||||||
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
|
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
|
||||||
|
|
||||||
|
**1.5א2. פעולות סטרוקטורליות (כפתורי-UI עתידיים, CEO בלבד).** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` — נתב דטרמיניסטית בלי פענוח-טקסט: `action == "interim_draft"` → טיוטת ביניים, `action == "party_claims_summary"` → סיכום מנהלים של טענות הצדדים. פירוט: `legal-ceo.md` שלב 0 + שלבים H/H2.
|
||||||
|
|
||||||
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
|
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -99,6 +101,8 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
|||||||
```
|
```
|
||||||
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||||
|
|
||||||
|
**1.5ד. המשך-עבודה על תיק — קרא predecessor תחילה (seance, #220).** אם אתה מתעורר-מחדש על תיק שכבר עבדת עליו (continuation — issue פתוח עם היסטוריית-ריצות), **לפני** שתגלה-מחדש את ההקשר מאפס, קרא את הכלי `legal_predecessor_context(case_number=<מספר-התיק>)`. הוא מחזיר את מסקנות הריצות-הקודמות על התיק (ה-`summary` שכל heartbeat השאיר, newest-first) — כך תמשיך מנקודת-העצירה במקום לחזור על עבודה שכבר בוצעה (מונע את ה-*blind heartbeat*). אם אין ריצות-קודמות עם מסקנות — המשך רגיל.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||||
@@ -220,18 +224,22 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §7. סטטוסי תיק תקפים (case status flow)
|
## §7. סטטוסי תיק (case status flow)
|
||||||
|
|
||||||
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
|
**מקור-האמת היחיד** למודל-הסטטוסים: `mcp-server/src/legal_mcp/case_status_model.py` (חשוף ב-`GET /api/status-model`; ה-enum, ה-`STATUS_ORDER` וה-frontend נגזרים ממנו). 12 הסטטוסים הקנוניים, לפי 5 השלבים:
|
||||||
|
|
||||||
```
|
```
|
||||||
new → proofread → documents_ready → analyst_verified → research_complete*
|
קליטה : new · processing
|
||||||
|
הכנה : documents_ready
|
||||||
|
ניתוח וכיוון : analyst_verified · research_complete · outcome_set · direction_approved
|
||||||
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
|
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
|
||||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
כתיבת טיוטה : qa_review · drafted
|
||||||
→ drafted → qa_passed / qa_failed → exported
|
סגירה : exported · reviewed · final
|
||||||
```
|
```
|
||||||
|
|
||||||
`research_complete` — **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
|
`analyst_verified` ו-`research_complete` הם **סטטוסים קנוניים מהמעלה הראשונה** (לא legacy) — המנתח/חוקר מציבים אותם, ומקומם בשלב "ניתוח וכיוון". `research_complete` מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח; ה-CEO מטפל בו כמו `analyst_verified`.
|
||||||
|
|
||||||
|
> מצבי-ביניים ישנים שעדיין עשויים להופיע מסוכנים מסוימים (`proofread`, `analysis_enriched`, `ready_for_writing`, `qa_passed`/`qa_failed`) **אינם** בקבוצה הקנונית — הם נמפים-לשלב לתצוגה בלבד (fallback ב-`case-status.ts`) ואינם ניתנים-לבחירה ידנית. אם נדרש לקבע אחד מהם — להוסיף ל-`case_status_model.py` (המקור-היחיד).
|
||||||
|
|
||||||
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.
|
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ tools:
|
|||||||
- mcp__legal-ai__plan_list
|
- mcp__legal-ai__plan_list
|
||||||
- mcp__legal-ai__write_interim_draft
|
- mcp__legal-ai__write_interim_draft
|
||||||
- mcp__legal-ai__export_interim_draft
|
- mcp__legal-ai__export_interim_draft
|
||||||
|
- mcp__legal-ai__summarize_party_claims
|
||||||
---
|
---
|
||||||
|
|
||||||
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
||||||
@@ -225,6 +226,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
### שלב 0: בדוק למה התעוררת
|
### שלב 0: בדוק למה התעוררת
|
||||||
|
|
||||||
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
||||||
|
- **פעולות סטרוקטורליות (כפתורי-UI, בלי פענוח-טקסט):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` → נתב דטרמיניסטית לפי הערך: `action == "interim_draft"` → **שלב H** (טיוטת ביניים); `action == "party_claims_summary"` → **שלב H2** (סיכום מנהלים). אלו side-quests — אל תסרוק תיקים אחרים, טפל רק בתיק שב-payload (`case_number`/`issueId`). ה-`issueId` שב-payload הוא **issue-ילד ייעודי שמשויך אליך** (נוצר ע"י המערכת כדי שהריצה לא תבוטל כשה-issue הראשי ממתין-ליו"ר; #227) — הרץ את הפעולה, פרסם comment עם התוצאה, ו**סגור את ה-issue-ילד הזה כ-done** בסיום. אל תיגע ב-issue הראשי של התיק.
|
||||||
- אם ה-reason מכיל `user_commented` **ו**-`$PAPERCLIP_WAKE_PAYLOAD_JSON` כולל `issueId` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"** עם ה-issue הזה. אל תסרוק תיקים אחרים. **טפל רק בתגובה.**
|
- אם ה-reason מכיל `user_commented` **ו**-`$PAPERCLIP_WAKE_PAYLOAD_JSON` כולל `issueId` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"** עם ה-issue הזה. אל תסרוק תיקים אחרים. **טפל רק בתגובה.**
|
||||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||||
@@ -684,7 +686,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
|
|
||||||
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
|
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
|
||||||
|
|
||||||
**מתי:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
|
**מתי — שתי דרכים:**
|
||||||
|
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
|
||||||
|
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "interim_draft"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור טיוטת ביניים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
|
||||||
|
|
||||||
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
|
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
|
||||||
|
|
||||||
@@ -715,13 +719,13 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
```
|
```
|
||||||
mcp__legal-ai__export_interim_draft(case_number="...")
|
mcp__legal-ai__export_interim_draft(case_number="...")
|
||||||
```
|
```
|
||||||
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
|
מייצר `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`, מעדכן `active_draft_path`.
|
||||||
|
|
||||||
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||||
```
|
```
|
||||||
## טיוטת ביניים מוכנה — ערר {case_number}
|
## טיוטת ביניים מוכנה — ערר {case_number}
|
||||||
|
|
||||||
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
|
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`
|
||||||
|
|
||||||
### מה כלול
|
### מה כלול
|
||||||
| בלוק | כותרת | מילים |
|
| בלוק | כותרת | מילים |
|
||||||
@@ -748,6 +752,49 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
|
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
|
||||||
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
|
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
|
||||||
|
|
||||||
|
### שלב H2: סיכום מנהלים של טענות הצדדים (לבקשת חיים, לפני דיון)
|
||||||
|
|
||||||
|
**מתי — שתי דרכים:**
|
||||||
|
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "סיכום מנהלים" / "סיכום טענות" / "סיכום לקראת דיון" / "executive summary". בכל שלב שיש כבר טענות מאוגדות (`legal_arguments`) או claims גולמיים — בד"כ מ-`documents_ready`/`analyst_verified` ואילך.
|
||||||
|
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "party_claims_summary"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור סיכום מנהלים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
|
||||||
|
|
||||||
|
**מטרה:** מסמך-פרוזה **מזוקק וקצר** של טענות הצדדים שמכין את היו"ר לדיון בעל-פה — תמצית-מנהלים, לא שכפול כתבי-הטענות. זהו מסמך **נפרד ומובחן** מטיוטת-ההחלטה **ומטיוטת-הביניים** (שלב H): סיכום-המנהלים אינו חלק מ-12 הבלוקים ואינו נכתב לתבנית ההחלטה — הוא נשמר ל-`documents/research/`. החלטת-יו"ר, #202.
|
||||||
|
|
||||||
|
**זה side-quest, לא חלק מהזרימה B-F.** אל תשנה `cases.status`. אל תייצר issues לסוכני משנה. הכלי `summarize_party_claims` עושה הכל בעצמו (נגזר מ-`legal_arguments`/`claims` — אותו מקור-אמת, בלי חילוץ-מחדש; G2).
|
||||||
|
|
||||||
|
**זרימה (~3-7 דקות):**
|
||||||
|
|
||||||
|
1. פרסם comment קצר: "מתחיל יצירת סיכום מנהלים של טענות הצדדים — אעדכן בסיום." עדכן את ה-issue הראשי ל-`status=in_progress`.
|
||||||
|
|
||||||
|
2. **הפקת הסיכום:**
|
||||||
|
```
|
||||||
|
mcp__legal-ai__summarize_party_claims(case_number="...", instructions="")
|
||||||
|
```
|
||||||
|
הכלי נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback, מ-`claims` הגולמיים — אותו מקור-אמת של שלב הניתוח, בלי חילוץ-מחדש. הייצור עובר `claude_session` (`claude -p`) נעוץ ל-Opus 4.8 + `effort=high`, מתוחם לטענות-התיק בלבד (anti-hallucination). נשמר ל-`data/cases/{case_number}/documents/research/party-claims-summary.md`.
|
||||||
|
|
||||||
|
3. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||||
|
```
|
||||||
|
## סיכום מנהלים מוכן — ערר {case_number}
|
||||||
|
|
||||||
|
📄 **קובץ:** `data/cases/{case_number}/documents/research/party-claims-summary.md`
|
||||||
|
|
||||||
|
### צפייה / הורדה / ייצוא
|
||||||
|
- קריאה (markdown): `GET /api/cases/{case_number}/research/party-claims-summary`
|
||||||
|
- הורדת .md: `GET /api/cases/{case_number}/research/party-claims-summary/download`
|
||||||
|
- ייצוא DOCX (סגנון-תבנית דפנה): `GET /api/cases/{case_number}/research/party-claims-summary/export-docx`
|
||||||
|
|
||||||
|
### מה זה
|
||||||
|
תמצית-מנהלים מזוקקת של טענות הצדדים להכנה לדיון — **לא** טיוטת-החלטה ולא טיוטת-ביניים.
|
||||||
|
כולל פרק "נקודות-המחלוקת המרכזיות" כשאלות פתוחות, בלי הכרעה.
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **סטטוס issue הראשי:** עדכן ל-`in_review` (חזרה להמתנה לחיים).
|
||||||
|
|
||||||
|
**אזהרות:**
|
||||||
|
- זהו מסמך **נפרד** מטיוטת-הביניים (שלב H). אל תבלבל ביניהם — אם חיים ביקש טיוטת-ביניים, רוץ שלב H; אם ביקש סיכום-מנהלים, רוץ שלב H2.
|
||||||
|
- אל תייצא DOCX סופי (`export_docx`) ואל תפעיל את שלב B במקביל — זה side-quest.
|
||||||
|
- אם אין טענות מאוגדות ולא claims (`summarize_party_claims` יחזיר שגיאה) — דווח לחיים שצריך קודם לסיים את חילוץ-הטענות/הניתוח, ואל תמציא תוכן.
|
||||||
|
|
||||||
## מפת סטטוסים
|
## מפת סטטוסים
|
||||||
|
|
||||||
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
||||||
@@ -759,7 +806,7 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
|
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
|
||||||
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||||
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
|
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
|
||||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||||
|
|||||||
@@ -179,10 +179,11 @@ tools:
|
|||||||
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
||||||
|
|
||||||
```
|
```
|
||||||
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
|
new → processing → documents_ready → analyst_verified → research_complete
|
||||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
|
||||||
→ drafted (אתה כאן!) → qa_passed / qa_failed → exported
|
→ exported → reviewed → final
|
||||||
```
|
```
|
||||||
|
(מקור-אמת יחיד: `case_status_model.py` / `GET /api/status-model`. `analyst_verified`+`research_complete` קנוניים, שלב "ניתוח וכיוון". מצבי-ביניים ישנים כמו `proofread`/`analysis_enriched`/`qa_passed` נמפים-לשלב לתצוגה בלבד.)
|
||||||
|
|
||||||
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
||||||
|
|
||||||
|
|||||||
@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
|
|||||||
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
||||||
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
||||||
- TaskMaster: `legal-ai` #149.
|
- TaskMaster: `legal-ai` #149.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
|
||||||
|
|
||||||
|
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
|
||||||
|
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
|
||||||
|
"כל התורים ריקים — אין עבודה".
|
||||||
|
|
||||||
|
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
|
||||||
|
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
|
||||||
|
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
|
||||||
|
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
|
||||||
|
ולכן זה נראה כאילו משהו נמסר.
|
||||||
|
|
||||||
|
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
|
||||||
|
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
|
||||||
|
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
|
||||||
|
|
||||||
|
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
|
||||||
|
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
|
||||||
|
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
|
||||||
|
שאומת כמגיע לסוכן.
|
||||||
|
|
||||||
|
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
|
||||||
|
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
|
||||||
|
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
|
||||||
|
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
|
||||||
|
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
|
||||||
|
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
|
||||||
|
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
|
||||||
|
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
|
||||||
|
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
|
||||||
|
|
||||||
|
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
|
||||||
|
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
|
||||||
|
הרצת-סרק. TaskMaster `legal-ai` #228.
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
|
|||||||
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
||||||
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
||||||
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
||||||
|
**חלון-הקשר (#216):** הבנייה הסטנדרטית רצה 200K-טוקן (~400K תווים עברית). פרומפט גדול
|
||||||
|
(בעיקר בלוק-י, שנושא את התיק המלא כ-source-context) מוסלם אוטומטית לבניית-ה-1M של *אותו*
|
||||||
|
מודל — `claude-opus-4-8[1m]` (1M-טוקן, מחיר רגיל; `claude -p` חושף אותה ישירות כ-model-id,
|
||||||
|
בלי beta-header). **עידון של אותו pin, לא מסלול-מודל מקביל (G2).** הסף ב-`block_writer`:
|
||||||
|
פרומפט > ~350K תווים → `[1m]`; אחרת הבנייה הסטנדרטית. תקרת-קשיחה: 1.5M תווים.
|
||||||
|
|
||||||
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
||||||
|
|
||||||
@@ -107,6 +112,12 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
|
|||||||
(git + S3, באותו מסלול-אחסון של `analysis-and-research.md`); ניתן-לייצוא ל-DOCX בסגנון-תבנית
|
(git + S3, באותו מסלול-אחסון של `analysis-and-research.md`); ניתן-לייצוא ל-DOCX בסגנון-תבנית
|
||||||
דפנה (`build_party_claims_summary_docx`). מימוש: `tools/drafting.py` ·
|
דפנה (`build_party_claims_summary_docx`). מימוש: `tools/drafting.py` ·
|
||||||
`services/party_claims_summary.py` · `services/analysis_docx_exporter.py`.
|
`services/party_claims_summary.py` · `services/analysis_docx_exporter.py`.
|
||||||
|
- **טריגר — side-quest מבוקש-יו"ר (G10), נפרד מטיוטת-הביניים:** ה-CEO מפיק את הסיכום כשלב-צד
|
||||||
|
(`legal-ceo.md` שלב H2) ב-**שתי דרכים**: (א) הערת-יו"ר חופשית ("סיכום מנהלים" / "סיכום טענות" /
|
||||||
|
"סיכום לקראת דיון" / "executive summary"); או (ב) **פעולה סטרוקטורלית** —
|
||||||
|
`$PAPERCLIP_WAKE_PAYLOAD_JSON` עם `action == "party_claims_summary"` (המסלול הדטרמיניסטי שכפתור-UI
|
||||||
|
עתידי יפעיל, בלי פענוח-טקסט). זהו side-quest: אינו משנה `cases.status` ואינו יוצר issues לסוכני-משנה,
|
||||||
|
ומובחן מטיוטת-הביניים (`write_interim_draft`, שלב H, `action == "interim_draft"`).
|
||||||
|
|
||||||
### 1.5 ניתוח-מחדש מאחד אחרי מסמך-עיקרי חדש (#201)
|
### 1.5 ניתוח-מחדש מאחד אחרי מסמך-עיקרי חדש (#201)
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@
|
|||||||
|
|
||||||
**הקריאה:** טבלת `style_distance_history` (append-only) + `GET /api/learning/style-distance-history`. ירידה ב-`anti_pattern_total`/`change_percent` ככל ש-N גדל = **הוכחה מתגלגלת שהלמידה מכלילה** (INV-LRN4 — זהו משטח-המגמה של "ניגוד-האמת"). **אזהרת-פרשנות:** `change_percent` מערבב סגנון עם שלמות-תוכן (לפעמים היו"ר מכפילה אורך כי חסרה מהות, לפעמים חותכת) → `anti_pattern_total` הוא הסיגנל הנקי-יותר לסגנון. שימוש-חוזר בשירות `style_distance` ובבריכת `appeal_type_rules` — אין מסלול-מדד מקביל (G2).
|
**הקריאה:** טבלת `style_distance_history` (append-only) + `GET /api/learning/style-distance-history`. ירידה ב-`anti_pattern_total`/`change_percent` ככל ש-N גדל = **הוכחה מתגלגלת שהלמידה מכלילה** (INV-LRN4 — זהו משטח-המגמה של "ניגוד-האמת"). **אזהרת-פרשנות:** `change_percent` מערבב סגנון עם שלמות-תוכן (לפעמים היו"ר מכפילה אורך כי חסרה מהות, לפעמים חותכת) → `anti_pattern_total` הוא הסיגנל הנקי-יותר לסגנון. שימוש-חוזר בשירות `style_distance` ובבריכת `appeal_type_rules` — אין מסלול-מדד מקביל (G2).
|
||||||
|
|
||||||
|
> **המלצת-הכיול style-ranked (#213):** `recommend_effort` ב-`scripts/calibrate_effort.py` מדרג efforts לפי `anti_pattern_total` **ראשי** (סיגנל-סגנון נקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש). `change_percent` **מדווח-לא-מדורג** מאותה אזהרת-פרשנות לעיל (מערבב סגנון עם שלמות-תוכן). ההמלצה נושאת דגל-`confidence` ("clear"/"weak") כך שבחירה שההובלה-ב-anti שלה בתוך-הרעש (epsilon=max(0.5, 0.20·spread)) לא מוצגת כוודאית. אין מסלול-מדד מקביל — שימוש-חוזר ב-`style_distance` (G2).
|
||||||
|
|
||||||
> **חלון נקי חד-פעמי:** תיק שכבר `lessons_folded` פספס את חלון ה-held-out שלו — אין backfill. הטבלה מתמלאת קדימה מהסופי הבא.
|
> **חלון נקי חד-פעמי:** תיק שכבר `lessons_folded` פספס את חלון ה-held-out שלו — אין backfill. הטבלה מתמלאת קדימה מהסופי הבא.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
118
mcp-server/src/legal_mcp/case_status_model.py
Normal file
118
mcp-server/src/legal_mcp/case_status_model.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Single source of truth for the case-status lifecycle (the *status model*).
|
||||||
|
|
||||||
|
Every consumer derives its order / labels / phase from this one registry:
|
||||||
|
• ``CaseStatus`` enum (models.py) — the type-level key set (kept consistent
|
||||||
|
by the assertion at the bottom of this module);
|
||||||
|
• the forward-only ``STATUS_ORDER`` guard in ``tools/cases.py``;
|
||||||
|
• the ``GET /api/status-model`` endpoint (web/app.py) that the frontend
|
||||||
|
mirror (``web-ui/src/lib/api/case-status.ts``) is generated against.
|
||||||
|
|
||||||
|
To make a status *do something* on entry (notify, kick a job, …), set its
|
||||||
|
``on_enter`` to an action key — the single place where a status's behaviour is
|
||||||
|
declared. ``tools/cases.py`` dispatches it when a case transitions into that
|
||||||
|
status (forward-only), so the behaviour lives next to the definition.
|
||||||
|
|
||||||
|
The five **phases** are the coarse pipeline the 12 statuses collapse into for the
|
||||||
|
header stepper. Intermediate analyst/research states (``analyst_verified``,
|
||||||
|
``research_complete``) are first-class canonical statuses here — the agents set
|
||||||
|
them, so they must be in the model rather than fall between the canonical set
|
||||||
|
and the legacy bucket (the bug that made the chip, stepper and manual-changer
|
||||||
|
disagree).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# Ordered 5-phase pipeline (key → Hebrew label) — coarse view of the lifecycle.
|
||||||
|
PHASES: list[tuple[str, str]] = [
|
||||||
|
("intake", "קליטה ועיבוד"),
|
||||||
|
("prep", "הכנת תיק"),
|
||||||
|
("thinking", "ניתוח וכיוון"),
|
||||||
|
("writing", "כתיבת טיוטה"),
|
||||||
|
("done", "סגירה"),
|
||||||
|
]
|
||||||
|
PHASE_LABELS: dict[str, str] = dict(PHASES)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StatusDef:
|
||||||
|
key: str
|
||||||
|
label: str # Hebrew label (chip / dropdown)
|
||||||
|
description: str # Hebrew one-liner (status guide)
|
||||||
|
phase: str # one of PHASES keys
|
||||||
|
selectable: bool = True # offered in the manual status-changer dropdown
|
||||||
|
terminal: bool = False
|
||||||
|
on_enter: str | None = None # future: action key dispatched on entry
|
||||||
|
|
||||||
|
|
||||||
|
# THE lifecycle — ordered. STATUS_ORDER, the enum and the frontend all derive
|
||||||
|
# from this list. Insert intermediates in workflow order.
|
||||||
|
STATUS_DEFS: list[StatusDef] = [
|
||||||
|
StatusDef("new", "חדש", "התיק נוצר וממתין להעלאת מסמכים", "intake"),
|
||||||
|
StatusDef("processing", "בעיבוד", "המערכת מעבדת ומנתחת את המסמכים", "intake"),
|
||||||
|
StatusDef("documents_ready", "מסמכים מוכנים", "כל המסמכים עובדו ומוכנים לעבודה", "prep"),
|
||||||
|
StatusDef("analyst_verified", "ניתוח אומת", "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה של היו״ר", "thinking"),
|
||||||
|
StatusDef("research_complete", "מחקר הושלם", "חקר התקדימים הושלם (מסלול נפרד מהמנתח)", "thinking"),
|
||||||
|
StatusDef("outcome_set", "תוצאה נקבעה", "נקבעה תוצאה צפויה לערר", "thinking"),
|
||||||
|
StatusDef("direction_approved", "כיוון אושר", "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה", "thinking"),
|
||||||
|
StatusDef("qa_review", "בדיקת איכות", "הטיוטה בבדיקת איכות אוטומטית", "writing"),
|
||||||
|
StatusDef("drafted", "טיוטה", "טיוטה מוכנה לעיון", "writing"),
|
||||||
|
StatusDef("exported", "יוצא", "ההחלטה יוצאה לקובץ DOCX", "done"),
|
||||||
|
StatusDef("reviewed", "נבדק", 'ההחלטה נבדקה ע"י היו"ר', "done"),
|
||||||
|
StatusDef("final", "סופי", "החלטה סופית — מוכנה להגשה", "done", terminal=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
BY_KEY: dict[str, StatusDef] = {d.key: d for d in STATUS_DEFS}
|
||||||
|
STATUS_ORDER: list[str] = [d.key for d in STATUS_DEFS]
|
||||||
|
|
||||||
|
|
||||||
|
def phase_of(status: str | None) -> str | None:
|
||||||
|
"""The pipeline phase a status belongs to (None for unknown values)."""
|
||||||
|
d = BY_KEY.get(status or "")
|
||||||
|
return d.phase if d else None
|
||||||
|
|
||||||
|
|
||||||
|
def label_of(status: str | None) -> str:
|
||||||
|
d = BY_KEY.get(status or "")
|
||||||
|
return d.label if d else (status or "")
|
||||||
|
|
||||||
|
|
||||||
|
def to_dict() -> dict:
|
||||||
|
"""Serialisable status model for ``GET /api/status-model`` (frontend SSoT)."""
|
||||||
|
return {
|
||||||
|
"statuses": [
|
||||||
|
{
|
||||||
|
"key": d.key,
|
||||||
|
"label": d.label,
|
||||||
|
"description": d.description,
|
||||||
|
"phase": d.phase,
|
||||||
|
"selectable": d.selectable,
|
||||||
|
"terminal": d.terminal,
|
||||||
|
"on_enter": d.on_enter,
|
||||||
|
}
|
||||||
|
for d in STATUS_DEFS
|
||||||
|
],
|
||||||
|
"phases": [{"key": k, "label": v} for k, v in PHASES],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Drift guard — the registry IS the canonical key set; the CaseStatus enum must
|
||||||
|
# match it exactly. Importing here (models.py never imports this module) is safe.
|
||||||
|
def _assert_consistent() -> None:
|
||||||
|
from legal_mcp.models import CaseStatus
|
||||||
|
|
||||||
|
registry = {d.key for d in STATUS_DEFS}
|
||||||
|
enum_keys = {s.value for s in CaseStatus}
|
||||||
|
if registry != enum_keys:
|
||||||
|
raise RuntimeError(
|
||||||
|
"case_status_model drift: registry vs CaseStatus enum differ — "
|
||||||
|
f"only-in-registry={registry - enum_keys}, only-in-enum={enum_keys - registry}"
|
||||||
|
)
|
||||||
|
phase_keys = {k for k, _ in PHASES}
|
||||||
|
bad = {d.key: d.phase for d in STATUS_DEFS if d.phase not in phase_keys}
|
||||||
|
if bad:
|
||||||
|
raise RuntimeError(f"case_status_model: statuses with unknown phase: {bad}")
|
||||||
|
|
||||||
|
|
||||||
|
_assert_consistent()
|
||||||
@@ -9,13 +9,18 @@ from uuid import UUID
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the
|
# Core case lifecycle. The canonical key set, order, labels, phases and (future)
|
||||||
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the
|
# per-status actions all live in ONE place — legal_mcp/case_status_model.py —
|
||||||
# decorative mid-stage markers that no pipeline code ever set were removed).
|
# which asserts this enum matches it. The frontend mirror is
|
||||||
|
# web-ui/src/lib/api/case-status.ts (generated against GET /api/status-model).
|
||||||
|
# The analyst/research intermediate states are first-class canonical statuses
|
||||||
|
# (the agents set them) — not legacy.
|
||||||
class CaseStatus(str, enum.Enum):
|
class CaseStatus(str, enum.Enum):
|
||||||
NEW = "new"
|
NEW = "new"
|
||||||
PROCESSING = "processing"
|
PROCESSING = "processing"
|
||||||
DOCUMENTS_READY = "documents_ready"
|
DOCUMENTS_READY = "documents_ready"
|
||||||
|
ANALYST_VERIFIED = "analyst_verified"
|
||||||
|
RESEARCH_COMPLETE = "research_complete"
|
||||||
OUTCOME_SET = "outcome_set"
|
OUTCOME_SET = "outcome_set"
|
||||||
DIRECTION_APPROVED = "direction_approved"
|
DIRECTION_APPROVED = "direction_approved"
|
||||||
QA_REVIEW = "qa_review"
|
QA_REVIEW = "qa_review"
|
||||||
|
|||||||
@@ -759,13 +759,15 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def analyze_protocol(case_number: str) -> str:
|
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
||||||
|
|
||||||
מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' +
|
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
|
||||||
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent).
|
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
|
||||||
|
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
|
||||||
|
re-run מחליף (idempotent).
|
||||||
"""
|
"""
|
||||||
return await drafting.analyze_protocol(case_number)
|
return await drafting.analyze_protocol(case_number, document_id=document_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Allowed enum values mirror the DB CHECK constraints.
|
# Allowed enum values mirror the DB CHECK constraints.
|
||||||
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
||||||
|
# Sides that may comprise multiple distinct litigants with potentially opposing
|
||||||
|
# positions — aggregated per source pleading (party_name) rather than collapsed
|
||||||
|
# into one bucket (#224). Appellant/committee speak with a single voice.
|
||||||
|
SPLIT_PARTIES = {"respondent", "permit_applicant"}
|
||||||
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
||||||
|
|
||||||
# Hebrew labels for the prompt (Claude needs context in the same
|
# Hebrew labels for the prompt (Claude needs context in the same
|
||||||
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(party: str, propositions: list[dict]) -> str:
|
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
|
||||||
"""Compose the per-party aggregation prompt."""
|
"""Compose the per-party aggregation prompt."""
|
||||||
n = len(propositions)
|
n = len(propositions)
|
||||||
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
||||||
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
|
|||||||
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
||||||
|
|
||||||
party_he = PARTY_LABELS_HE.get(party, party)
|
party_he = PARTY_LABELS_HE.get(party, party)
|
||||||
|
# For a split side (e.g. a specific respondent brief), name the brief so
|
||||||
|
# Claude aggregates only that litigant's position and does not conflate it
|
||||||
|
# with a co-respondent's separate — possibly opposing — pleading (#224).
|
||||||
|
if party_name:
|
||||||
|
party_he = f"{party_he} — {party_name}"
|
||||||
# Strip noise from propositions for the prompt — Claude only needs
|
# Strip noise from propositions for the prompt — Claude only needs
|
||||||
# the id and the text to do the grouping.
|
# the id and the text to do the grouping.
|
||||||
compact = [
|
compact = [
|
||||||
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _aggregate_party(
|
async def _aggregate_party(
|
||||||
party: str, propositions: list[dict],
|
party: str, propositions: list[dict], party_name: str = "",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Ask Claude to group one party's propositions; return normalized rows."""
|
"""Ask Claude to group one party's propositions; return normalized rows.
|
||||||
|
|
||||||
|
``party_name`` names the specific pleading when this is a split side
|
||||||
|
(respondent / permit_applicant brief), so the prompt scopes to that
|
||||||
|
litigant's position only (#224).
|
||||||
|
"""
|
||||||
if not propositions:
|
if not propositions:
|
||||||
return []
|
return []
|
||||||
prompt = _build_prompt(party, propositions)
|
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||||
@@ -207,7 +221,7 @@ async def aggregate_claims_to_arguments(
|
|||||||
|
|
||||||
# Pull all claims for this case, grouped by party.
|
# Pull all claims for this case, grouped by party.
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, party_role, claim_text, claim_index, source_document
|
"""SELECT id, party_role, claim_text, claim_index, source_document, party_name
|
||||||
FROM claims
|
FROM claims
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
ORDER BY party_role, claim_index""",
|
ORDER BY party_role, claim_index""",
|
||||||
@@ -221,14 +235,28 @@ async def aggregate_claims_to_arguments(
|
|||||||
"total": 0,
|
"total": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Group propositions by party.
|
# Group propositions by party — and, for the multi-party sides (respondent /
|
||||||
by_party: dict[str, list[dict]] = {}
|
# permit_applicant), further by their source pleading so opposing joint
|
||||||
|
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
|
||||||
|
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
|
||||||
|
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
|
||||||
|
# the large respondent set per-brief also keeps each Claude call small enough
|
||||||
|
# to succeed — a single 100+ proposition call previously returned non-JSON
|
||||||
|
# and silently dropped the whole side.
|
||||||
|
by_group: dict[tuple[str, str], list[dict]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
party = r["party_role"]
|
party = r["party_role"]
|
||||||
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
||||||
if party not in ALLOWED_PARTIES:
|
if party not in ALLOWED_PARTIES:
|
||||||
party = "unknown"
|
party = "unknown"
|
||||||
by_party.setdefault(party, []).append(dict(r))
|
# 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.
|
# Valid claim_ids for this case == the ids of the claims we just fetched.
|
||||||
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
|
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
|
||||||
@@ -243,9 +271,11 @@ async def aggregate_claims_to_arguments(
|
|||||||
inserted = 0
|
inserted = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
for party, props in by_party.items():
|
for (party, party_name), props in by_group.items():
|
||||||
|
# Display key for the per-side summary: keep opposing briefs distinct.
|
||||||
|
group_key = f"{party}·{party_name}" if party_name else party
|
||||||
try:
|
try:
|
||||||
arguments = await _aggregate_party(party, props)
|
arguments = await _aggregate_party(party, props, party_name=party_name)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
# Most likely cause: Claude CLI not installed (running from
|
# Most likely cause: Claude CLI not installed (running from
|
||||||
# the container). Don't crash — record the gap and continue.
|
# the container). Don't crash — record the gap and continue.
|
||||||
@@ -259,11 +289,11 @@ async def aggregate_claims_to_arguments(
|
|||||||
),
|
),
|
||||||
"total": 0,
|
"total": 0,
|
||||||
}
|
}
|
||||||
errors.append(f"{party}: {msg}")
|
errors.append(f"{group_key}: {msg}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not arguments:
|
if not arguments:
|
||||||
party_counts[party] = 0
|
party_counts[group_key] = 0
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -271,12 +301,13 @@ async def aggregate_claims_to_arguments(
|
|||||||
for idx, arg in enumerate(arguments):
|
for idx, arg in enumerate(arguments):
|
||||||
arg_id = await conn.fetchval(
|
arg_id = await conn.fetchval(
|
||||||
"""INSERT INTO legal_arguments
|
"""INSERT INTO legal_arguments
|
||||||
(case_id, party, argument_index, argument_title,
|
(case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority)
|
argument_title, argument_body, legal_topic, priority)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
case_id,
|
case_id,
|
||||||
party,
|
party,
|
||||||
|
party_name,
|
||||||
idx + 1,
|
idx + 1,
|
||||||
arg["title"],
|
arg["title"],
|
||||||
arg["body"],
|
arg["body"],
|
||||||
@@ -313,7 +344,7 @@ async def aggregate_claims_to_arguments(
|
|||||||
cid, arg_id, e,
|
cid, arg_id, e,
|
||||||
)
|
)
|
||||||
inserted += 1
|
inserted += 1
|
||||||
party_counts[party] = len(arguments)
|
party_counts[group_key] = len(arguments)
|
||||||
|
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -338,9 +369,9 @@ async def get_legal_arguments(
|
|||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
if party and party in ALLOWED_PARTIES:
|
if party and party in ALLOWED_PARTIES:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
"""SELECT id, case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority, cited_precedents,
|
argument_title, argument_body, legal_topic, priority,
|
||||||
created_at, updated_at
|
cited_precedents, created_at, updated_at
|
||||||
FROM legal_arguments
|
FROM legal_arguments
|
||||||
WHERE case_id = $1 AND party = $2
|
WHERE case_id = $1 AND party = $2
|
||||||
ORDER BY priority, argument_index""",
|
ORDER BY priority, argument_index""",
|
||||||
@@ -348,12 +379,12 @@ async def get_legal_arguments(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
"""SELECT id, case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority, cited_precedents,
|
argument_title, argument_body, legal_topic, priority,
|
||||||
created_at, updated_at
|
cited_precedents, created_at, updated_at
|
||||||
FROM legal_arguments
|
FROM legal_arguments
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
ORDER BY party, priority, argument_index""",
|
ORDER BY party, party_name, priority, argument_index""",
|
||||||
case_id,
|
case_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ logger = logging.getLogger(__name__)
|
|||||||
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
|
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
|
||||||
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
|
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
|
||||||
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
|
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
|
||||||
|
# 1M-context build of the SAME pinned model (#216). The default build runs a
|
||||||
|
# 200K-token context (~400K Hebrew chars at ~2 chars/token); large prompts —
|
||||||
|
# notably block-yod, which carries the full case as source-context — overflow it.
|
||||||
|
# Opus 4.8 offers a 1M-token window at standard pricing; `claude -p` exposes it
|
||||||
|
# via the `[1m]` model id directly (no beta header — verified CLI 2.1.196). We
|
||||||
|
# escalate to it only when the prompt is large, so small blocks stay on the
|
||||||
|
# cheaper/faster 200K build. NOT a parallel model (G2) — same model, wider window.
|
||||||
|
GENERATION_MODEL_1M = "claude-opus-4-8[1m]"
|
||||||
|
# Escalate to the 1M build above this prompt size. Set below the 200K-token wall
|
||||||
|
# (~400K chars) with headroom for output tokens (max_tokens up to 16K) + tokenizer
|
||||||
|
# variance, so we switch before the standard build can overflow.
|
||||||
|
_CTX_1M_THRESHOLD_CHARS = 350_000
|
||||||
|
|
||||||
BLOCK_CONFIG = {
|
BLOCK_CONFIG = {
|
||||||
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
|
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
|
||||||
@@ -180,16 +192,16 @@ BLOCK_PROMPTS = {
|
|||||||
## כללים קריטיים:
|
## כללים קריטיים:
|
||||||
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
|
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
|
||||||
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
|
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
|
||||||
- **מבנה קבוע עם 3 חלקים:**
|
- **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
|
||||||
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
|
- "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
|
||||||
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
|
- **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
|
||||||
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
|
- "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
|
||||||
- כותרת: "תמצית טענות הצדדים"
|
- כותרת: "תמצית טענות הצדדים"
|
||||||
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
|
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
|
||||||
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
|
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
|
||||||
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
|
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
|
||||||
- מספור רציף
|
- מספור רציף
|
||||||
- **יעד אורך: 800-1500 מילים**
|
- **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
|
||||||
|
|
||||||
## טענות שחולצו (קבץ טענות דומות לנושאים):
|
## טענות שחולצו (קבץ טענות דומות לנושאים):
|
||||||
{claims_context}
|
{claims_context}
|
||||||
@@ -272,6 +284,7 @@ BLOCK_PROMPTS = {
|
|||||||
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
|
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
|
||||||
- **מספור רציף** — המשך מספור מהבלוק הקודם
|
- **מספור רציף** — המשך מספור מהבלוק הקודם
|
||||||
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
|
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
|
||||||
|
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
|
||||||
|
|
||||||
## כיוון מאושר (חובה):
|
## כיוון מאושר (חובה):
|
||||||
{direction_context}
|
{direction_context}
|
||||||
@@ -461,17 +474,21 @@ async def write_block(
|
|||||||
if not dir_doc.get("approved"):
|
if not dir_doc.get("approved"):
|
||||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||||
|
|
||||||
# Guard against context overflow before calling claude -p.
|
# Pick the model build by prompt size (#216). Above the 200K-token wall we
|
||||||
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
|
||||||
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
# block-yod legitimately carries the whole case as source-context. The 400K
|
||||||
# that as a conservative ceiling (well below the token limit).
|
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
|
||||||
_MAX_PROMPT_CHARS = 400_000
|
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
|
||||||
|
|
||||||
|
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
|
||||||
|
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
|
||||||
|
_MAX_PROMPT_CHARS = 1_500_000
|
||||||
if len(prompt) > _MAX_PROMPT_CHARS:
|
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||||
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
f"(limit {_MAX_PROMPT_CHARS:,}, even on the 1M-context build). "
|
||||||
f"source_context: {len(source_context):,} chars. "
|
f"source_context: {len(source_context):,} chars. "
|
||||||
f"Reduce documents or call extract_appraiser_facts first."
|
f"Reduce source-context (summaries / appraiser_facts / focused RAG)."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
||||||
@@ -483,14 +500,14 @@ async def write_block(
|
|||||||
content = await claude_session.query(
|
content = await claude_session.query(
|
||||||
prompt,
|
prompt,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
model=GENERATION_MODEL,
|
model=gen_model,
|
||||||
effort=effort,
|
effort=effort,
|
||||||
tools="", # prose gen — no tool_use → no error_max_turns
|
tools="", # prose gen — no tool_use → no error_max_turns
|
||||||
)
|
)
|
||||||
|
|
||||||
sources = await _collect_block_sources(case_id, block_id)
|
sources = await _collect_block_sources(case_id, block_id)
|
||||||
sources["case_law_ids"] = _precedent_case_law_ids
|
sources["case_law_ids"] = _precedent_case_law_ids
|
||||||
result = _build_result(block_id, content, block_cfg)
|
result = _build_result(block_id, content, block_cfg, model_used=gen_model)
|
||||||
# Record the EFFECTIVE effort (override wins) so the harness can attribute
|
# Record the EFFECTIVE effort (override wins) so the harness can attribute
|
||||||
# the measured distance to the effort that actually produced the text.
|
# the measured distance to the effort that actually produced the text.
|
||||||
if result.get("effort") is not None:
|
if result.get("effort") is not None:
|
||||||
@@ -499,7 +516,8 @@ async def write_block(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
def _build_result(block_id: str, content: str, block_cfg: dict,
|
||||||
|
model_used: str = GENERATION_MODEL) -> dict:
|
||||||
word_count = len(content.split())
|
word_count = len(content.split())
|
||||||
is_ai = block_cfg["model"] == "ai"
|
is_ai = block_cfg["model"] == "ai"
|
||||||
return {
|
return {
|
||||||
@@ -509,8 +527,9 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
|||||||
"content": content,
|
"content": content,
|
||||||
"word_count": word_count,
|
"word_count": word_count,
|
||||||
"generation_type": block_cfg["gen_type"],
|
"generation_type": block_cfg["gen_type"],
|
||||||
# AI blocks record the pinned model; template blocks record "script".
|
# AI blocks record the model build actually used (200K or [1m], #216);
|
||||||
"model_used": GENERATION_MODEL if is_ai else block_cfg["model"],
|
# template blocks record "script".
|
||||||
|
"model_used": model_used if is_ai else block_cfg["model"],
|
||||||
# The real generation knob (#204). None for template/script blocks.
|
# The real generation knob (#204). None for template/script blocks.
|
||||||
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
|
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
|
||||||
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
|
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
|
||||||
@@ -611,18 +630,49 @@ async def _build_claims_context(case_id: UUID) -> str:
|
|||||||
# Fallback to all non-block-zayin claims if no source claims exist.
|
# Fallback to all non-block-zayin claims if no source claims exist.
|
||||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
||||||
|
|
||||||
lines = []
|
# Group by (party_role, brief) so a multi-litigant side (respondent /
|
||||||
current_role = ""
|
# permit_applicant) whose claims come from distinct pleadings reads as
|
||||||
|
# SEPARATE positions — e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6" — instead
|
||||||
|
# of one merged "טענות המשיבים" (#224). The brief label is the source
|
||||||
|
# pleading, and which sides split is SPLIT_PARTIES — the SAME rule the
|
||||||
|
# argument aggregator uses, so block ז and the arguments panel agree (G2).
|
||||||
|
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
|
||||||
|
|
||||||
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
|
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
|
||||||
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
|
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
|
||||||
claim_num = 0
|
role_order = ["appellant", "committee", "respondent", "permit_applicant"]
|
||||||
|
|
||||||
|
groups: dict[tuple[str, str], list[dict]] = {}
|
||||||
for c in source_claims:
|
for c in source_claims:
|
||||||
if c["party_role"] != current_role:
|
role = c.get("party_role", "") or ""
|
||||||
current_role = c["party_role"]
|
# Prefer the stored party_name (stamped by the extractor, #224); fall
|
||||||
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
|
# back to source_document for legacy claims predating the stamping.
|
||||||
claim_num += 1
|
if role in SPLIT_PARTIES:
|
||||||
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
brief = (c.get("party_name") or "").strip() or (c.get("source_document") or "").strip()
|
||||||
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
|
else:
|
||||||
|
brief = ""
|
||||||
|
groups.setdefault((role, brief), []).append(c)
|
||||||
|
|
||||||
|
def _sort_key(k: tuple[str, str]) -> tuple[int, str]:
|
||||||
|
role, brief = k
|
||||||
|
idx = role_order.index(role) if role in role_order else len(role_order)
|
||||||
|
return (idx, brief)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
claim_num = 0
|
||||||
|
for role, brief in sorted(groups.keys(), key=_sort_key):
|
||||||
|
header = role_heb.get(role, role or "טענות נוספות")
|
||||||
|
if brief:
|
||||||
|
header = f"{header} — {brief}"
|
||||||
|
lines.append(f"\n### {header}")
|
||||||
|
for c in groups[(role, brief)]:
|
||||||
|
claim_num += 1
|
||||||
|
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
||||||
|
lines.append(
|
||||||
|
f"\n**סה\"כ {claim_num} טענות. שמור על ההפרדה בין הצדדים לעיל — "
|
||||||
|
f"לכל צד/כתב-תשובה עמדה נפרדת (אל תמזג משיבים שונים). ענה על כל טענה "
|
||||||
|
f"מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**"
|
||||||
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -368,8 +368,15 @@ async def extract_and_store_claims(
|
|||||||
|
|
||||||
# Determine claim_type from document type and title
|
# Determine claim_type from document type and title
|
||||||
claim_type = _infer_claim_type(doc_type, source_name)
|
claim_type = _infer_claim_type(doc_type, source_name)
|
||||||
|
# Stamp party_name at the source (#224). For a multi-litigant side
|
||||||
|
# (respondent / permit_applicant) the brief label IS the source pleading, so
|
||||||
|
# opposing briefs (משיבות 2-3 vs משיבים 4-6) stay distinct downstream without
|
||||||
|
# the aggregator/block-writer having to re-derive it. SPLIT_PARTIES is the
|
||||||
|
# single rule shared with the aggregator (G2); single-voice sides stay ''.
|
||||||
|
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
|
||||||
for c in claims:
|
for c in claims:
|
||||||
c["claim_type"] = claim_type
|
c["claim_type"] = claim_type
|
||||||
|
c["party_name"] = source_name if c.get("party_role") in SPLIT_PARTIES else ""
|
||||||
|
|
||||||
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
||||||
# Mark this document analysed (WS2 / #201). store_claims already replaced
|
# Mark this document analysed (WS2 / #201). store_claims already replaced
|
||||||
|
|||||||
@@ -146,8 +146,15 @@ async def query(
|
|||||||
"""
|
"""
|
||||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||||
|
|
||||||
if len(full_prompt) > 150_000:
|
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
|
||||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
# chars/token). Above it callers should pass the 1M-context model id
|
||||||
|
# (`claude-opus-4-8[1m]`) — block_writer escalates automatically (#216).
|
||||||
|
if len(full_prompt) > 350_000 and "[1m]" not in (model or ""):
|
||||||
|
logger.warning(
|
||||||
|
"Large prompt: %d chars on a non-[1m] model (%s) — may overflow the "
|
||||||
|
"200K-token context. Pass claude-opus-4-8[1m] for the 1M window.",
|
||||||
|
len(full_prompt), model or "CLI-default",
|
||||||
|
)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"claude", "-p",
|
"claude", "-p",
|
||||||
|
|||||||
@@ -1888,6 +1888,50 @@ CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V50 (#224): per-respondent-brief separation. The aggregator groups respondent /
|
||||||
|
# permit_applicant claims by their source pleading — each joint response brief
|
||||||
|
# (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") is one coherent litigation
|
||||||
|
# position, so opposing briefs no longer collapse into a single "respondent"
|
||||||
|
# bucket. ``party_name`` carries the brief label; appellant/committee stay ''.
|
||||||
|
SCHEMA_V50_SQL = """
|
||||||
|
ALTER TABLE legal_arguments ADD COLUMN IF NOT EXISTS party_name TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name
|
||||||
|
ON legal_arguments(case_id, party, party_name);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V51 (#226): hearing-attendance provenance for the "מה קרה בדיון" panel. The
|
||||||
|
# ערר-hearing protocol names who actually appeared (עוררים/משיבים + their
|
||||||
|
# counsel) and, when stated, the presiding panel. ``_extract_header`` already
|
||||||
|
# extracts this feed but only ``hearing_date`` had a canonical home (on cases);
|
||||||
|
# the attendee lists were returned as provenance and dropped. This column gives
|
||||||
|
# them a home so the panel can display them.
|
||||||
|
#
|
||||||
|
# This is DISTINCT from ``decisions.panel_members`` (G2, not a parallel path):
|
||||||
|
# that column is the authoring tribunal recorded on a specific written-decision
|
||||||
|
# version (the DOCX signature block). ``hearing_attendees`` is a snapshot of who
|
||||||
|
# was present at the *hearing*, extracted from the protocol — hearing-event
|
||||||
|
# provenance, not decision-authorship. Shape:
|
||||||
|
# {"panel_members": [...], "appellants_present": [...], "respondents_present": [...]}
|
||||||
|
SCHEMA_V51_SQL = """
|
||||||
|
ALTER TABLE cases ADD COLUMN IF NOT EXISTS hearing_attendees JSONB NOT NULL DEFAULT '{}';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V52 (#224): backfill claims.party_name for existing rows. The extractor now
|
||||||
|
# stamps party_name = source_document for the multi-litigant sides at write time
|
||||||
|
# (claims_extractor), but claims stored before that shipped have party_name=''.
|
||||||
|
# For those, the brief label is the source pleading — same rule the aggregator
|
||||||
|
# uses (respondent / permit_applicant split). Idempotent: only fills empties, so
|
||||||
|
# re-running is a no-op. Single-voice sides (appellant/committee) stay ''.
|
||||||
|
SCHEMA_V52_SQL = """
|
||||||
|
UPDATE claims SET party_name = source_document
|
||||||
|
WHERE party_role IN ('respondent', 'permit_applicant')
|
||||||
|
AND COALESCE(party_name, '') = ''
|
||||||
|
AND COALESCE(source_document, '') <> '';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||||
@@ -1959,6 +2003,9 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V47_SQL)
|
await conn.execute(SCHEMA_V47_SQL)
|
||||||
await conn.execute(SCHEMA_V48_SQL)
|
await conn.execute(SCHEMA_V48_SQL)
|
||||||
await conn.execute(SCHEMA_V49_SQL)
|
await conn.execute(SCHEMA_V49_SQL)
|
||||||
|
await conn.execute(SCHEMA_V50_SQL)
|
||||||
|
await conn.execute(SCHEMA_V51_SQL)
|
||||||
|
await conn.execute(SCHEMA_V52_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -2181,7 +2228,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
|
|||||||
set_clauses = []
|
set_clauses = []
|
||||||
values = []
|
values = []
|
||||||
for i, (key, val) in enumerate(fields.items(), start=2):
|
for i, (key, val) in enumerate(fields.items(), start=2):
|
||||||
if key in ("appellants", "respondents", "tags"):
|
if key in ("appellants", "respondents", "tags", "hearing_attendees"):
|
||||||
val = json.dumps(val)
|
val = json.dumps(val)
|
||||||
set_clauses.append(f"{key} = ${i}")
|
set_clauses.append(f"{key} = ${i}")
|
||||||
values.append(val)
|
values.append(val)
|
||||||
@@ -2194,7 +2241,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
|
|||||||
|
|
||||||
def _row_to_case(row: asyncpg.Record) -> dict:
|
def _row_to_case(row: asyncpg.Record) -> dict:
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
for field in ("appellants", "respondents", "tags"):
|
for field in ("appellants", "respondents", "tags", "hearing_attendees"):
|
||||||
if isinstance(d.get(field), str):
|
if isinstance(d.get(field), str):
|
||||||
d[field] = json.loads(d[field])
|
d[field] = json.loads(d[field])
|
||||||
d["id"] = str(d["id"])
|
d["id"] = str(d["id"])
|
||||||
|
|||||||
@@ -399,8 +399,15 @@ _INTERIM_BLOCK_ORDER = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _draft_filename_prefix(mode: str) -> str:
|
def _draft_naming(mode: str) -> tuple[str, str]:
|
||||||
return "טיוטת-ביניים" if mode == "interim" else "טיוטה"
|
"""(filename prefix, version separator) per export mode.
|
||||||
|
|
||||||
|
interim → ``טיוטה-טענות_הצדדים_{N}.docx`` (chair-requested naming);
|
||||||
|
final → ``טיוטה-v{N}.docx``.
|
||||||
|
"""
|
||||||
|
if mode == "interim":
|
||||||
|
return "טיוטה-טענות_הצדדים", "_"
|
||||||
|
return "טיוטה", "-v"
|
||||||
|
|
||||||
|
|
||||||
async def export_decision(
|
async def export_decision(
|
||||||
@@ -485,16 +492,19 @@ async def export_decision(
|
|||||||
if not output_path:
|
if not output_path:
|
||||||
export_dir = config.find_case_dir(case["case_number"]) / "exports"
|
export_dir = config.find_case_dir(case["case_number"]) / "exports"
|
||||||
export_dir.mkdir(parents=True, exist_ok=True)
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
prefix = _draft_filename_prefix(mode)
|
prefix, sep = _draft_naming(mode)
|
||||||
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
existing = sorted(export_dir.glob(f"{prefix}{sep}*.docx"))
|
||||||
next_ver = 1
|
next_ver = 1
|
||||||
for p in existing:
|
for p in existing:
|
||||||
try:
|
try:
|
||||||
ver = int(p.stem.split("-v")[1])
|
# Version is the trailing integer after the separator. Using
|
||||||
|
# rsplit keeps this correct even when the prefix itself contains
|
||||||
|
# the separator char (e.g. "טיוטה-טענות_הצדדים" with sep="_").
|
||||||
|
ver = int(p.stem.rsplit(sep, 1)[1])
|
||||||
next_ver = max(next_ver, ver + 1)
|
next_ver = max(next_ver, ver + 1)
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
pass
|
pass
|
||||||
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
|
output_path = str(export_dir / f"{prefix}{sep}{next_ver}.docx")
|
||||||
|
|
||||||
# Persist through the storage layer (INV-STG1). Under the filesystem
|
# Persist through the storage layer (INV-STG1). Under the filesystem
|
||||||
# backend the bytes land at output_path exactly as before; a caller-
|
# backend the bytes land at output_path exactly as before; a caller-
|
||||||
|
|||||||
@@ -120,15 +120,47 @@ HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול די
|
|||||||
MAX_PROTOCOL_CHARS = 120_000
|
MAX_PROTOCOL_CHARS = 120_000
|
||||||
|
|
||||||
|
|
||||||
def _find_protocol(docs: list[dict]) -> dict | None:
|
def _protocol_scope(doc: dict) -> str:
|
||||||
"""The protocol document for the case, if present (doc_type or title)."""
|
"""Protocol scope from ``metadata.protocol_scope``.
|
||||||
for d in docs:
|
|
||||||
if d.get("doc_type") == "protocol":
|
Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
|
||||||
return d
|
document-type-editor convention where only the non-default ``'lower'``
|
||||||
for d in docs:
|
(ועדה מקומית/מחוזית) is persisted.
|
||||||
if "פרוטוקול" in (d.get("title") or ""):
|
"""
|
||||||
return d
|
meta = doc.get("metadata") or {}
|
||||||
return None
|
return (meta.get("protocol_scope") or "").strip() or "appeal"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_protocol(
|
||||||
|
docs: list[dict], document_id: UUID | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""The ועדת-הערר hearing protocol to compare against the pleadings.
|
||||||
|
|
||||||
|
- ``document_id`` given → return exactly that document (explicit target),
|
||||||
|
so a case with several protocols can be pointed at the right hearing.
|
||||||
|
- Otherwise prefer a protocol scoped to the appeals committee
|
||||||
|
(``scope != 'lower'``). A ``'lower'`` protocol is the local/district
|
||||||
|
committee's proceedings — it feeds background (block ו) only and must NOT
|
||||||
|
drive the hearing-vs-pleadings comparison (#223). If every protocol is
|
||||||
|
lower-scoped there is no ערר-hearing to compare → return None.
|
||||||
|
- Among appeal-scoped protocols the most recent (by ``created_at``) wins —
|
||||||
|
the operative hearing; ties fall back to list order.
|
||||||
|
"""
|
||||||
|
if document_id is not None:
|
||||||
|
return next(
|
||||||
|
(d for d in docs if str(d.get("id")) == str(document_id)), None,
|
||||||
|
)
|
||||||
|
|
||||||
|
protocols = [d for d in docs if d.get("doc_type") == "protocol"]
|
||||||
|
if not protocols:
|
||||||
|
# Legacy fallback: untyped docs whose title says "פרוטוקול".
|
||||||
|
protocols = [d for d in docs if "פרוטוקול" in (d.get("title") or "")]
|
||||||
|
|
||||||
|
appeal_scoped = [d for d in protocols if _protocol_scope(d) != "lower"]
|
||||||
|
if not appeal_scoped:
|
||||||
|
return None
|
||||||
|
appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True)
|
||||||
|
return appeal_scoped[0]
|
||||||
|
|
||||||
|
|
||||||
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
||||||
@@ -227,21 +259,39 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
logger.info("protocol header: unparseable hearing_date %r", hearing_date)
|
logger.info("protocol header: unparseable hearing_date %r", hearing_date)
|
||||||
|
|
||||||
|
# Attendees → cases.hearing_attendees (#226). Unlike hearing_date there is no
|
||||||
|
# chair-entry path to protect, and re-running to point at the correct ערר
|
||||||
|
# protocol (#223) must refresh who appeared — so write whenever the operative
|
||||||
|
# protocol yielded any names, but never clobber good data with an empty
|
||||||
|
# extraction (all-empty feed → leave the prior snapshot intact).
|
||||||
|
attendees = {
|
||||||
|
"panel_members": feed.get("panel_members") or [],
|
||||||
|
"appellants_present": feed.get("appellants_present") or [],
|
||||||
|
"respondents_present": feed.get("respondents_present") or [],
|
||||||
|
}
|
||||||
|
if any(attendees.values()):
|
||||||
|
await db.update_case(case_id, hearing_attendees=attendees)
|
||||||
|
applied["hearing_attendees"] = True
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"feed": {
|
"feed": {
|
||||||
"hearing_date": hearing_date,
|
"hearing_date": hearing_date,
|
||||||
"panel_members": feed.get("panel_members") or [],
|
**attendees,
|
||||||
"appellants_present": feed.get("appellants_present") or [],
|
|
||||||
"respondents_present": feed.get("respondents_present") or [],
|
|
||||||
},
|
},
|
||||||
"applied_to_case": applied,
|
"applied_to_case": applied,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def analyze_protocol(case_id: UUID) -> dict:
|
async def analyze_protocol(
|
||||||
|
case_id: UUID, target_document_id: UUID | None = None,
|
||||||
|
) -> dict:
|
||||||
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
|
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
|
||||||
|
|
||||||
|
``target_document_id`` pins the analysis to a specific protocol document —
|
||||||
|
required when a case holds several protocols (e.g. a lower-committee annex
|
||||||
|
plus the ועדת-הערר hearing) and the auto-pick would be ambiguous (#223).
|
||||||
|
|
||||||
1. Locates the protocol document.
|
1. Locates the protocol document.
|
||||||
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
|
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
|
||||||
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
|
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
|
||||||
@@ -252,11 +302,20 @@ async def analyze_protocol(case_id: UUID) -> dict:
|
|||||||
Returns a serializable summary dict.
|
Returns a serializable summary dict.
|
||||||
"""
|
"""
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
protocol = _find_protocol(docs)
|
protocol = _find_protocol(docs, document_id=target_document_id)
|
||||||
if not protocol:
|
if not protocol:
|
||||||
|
if target_document_id is not None:
|
||||||
|
return {
|
||||||
|
"status": "no_protocol",
|
||||||
|
"message": f"מסמך {target_document_id} לא נמצא בתיק.",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"status": "no_protocol",
|
"status": "no_protocol",
|
||||||
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.",
|
"message": (
|
||||||
|
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
|
||||||
|
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
|
||||||
|
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
document_id = UUID(protocol["id"])
|
document_id = UUID(protocol["id"])
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
|
from legal_mcp.case_status_model import STATUS_ORDER # status SSoT
|
||||||
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
||||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
@@ -341,16 +342,8 @@ async def case_update(
|
|||||||
"""
|
"""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
# Ordered core lifecycle — regression protection (forward-only).
|
# Ordered core lifecycle (forward-only regression guard). Single source of
|
||||||
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and
|
# truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
|
||||||
# models.CaseStatus. Trimmed from 17 → 10 (decorative statuses removed).
|
|
||||||
STATUS_ORDER = [
|
|
||||||
"new", "processing", "documents_ready",
|
|
||||||
"outcome_set", "direction_approved",
|
|
||||||
"qa_review", "drafted",
|
|
||||||
"exported", "reviewed", "final",
|
|
||||||
]
|
|
||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return err(f"תיק {case_number} לא נמצא.")
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
@@ -363,6 +356,9 @@ async def case_update(
|
|||||||
# Only update if advancing or status is unknown to the order
|
# Only update if advancing or status is unknown to the order
|
||||||
if new_idx >= cur_idx or new_idx == -1:
|
if new_idx >= cur_idx or new_idx == -1:
|
||||||
fields["status"] = status
|
fields["status"] = status
|
||||||
|
# per-status on_enter hook (case_status_model) — the single place a
|
||||||
|
# status's behaviour is declared. No dispatcher wired yet; when the
|
||||||
|
# first action is added, dispatch BY_KEY[status].on_enter here.
|
||||||
if title:
|
if title:
|
||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if subject:
|
if subject:
|
||||||
|
|||||||
@@ -433,17 +433,24 @@ ALLOWED_DOC_TYPES = {
|
|||||||
# Allowed appraiser_side values; '' (empty) clears the tag.
|
# Allowed appraiser_side values; '' (empty) clears the tag.
|
||||||
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
||||||
|
|
||||||
|
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
|
||||||
|
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
|
||||||
|
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||||
|
|
||||||
|
|
||||||
async def document_update(
|
async def document_update(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
doc_type: str = "",
|
doc_type: str = "",
|
||||||
appraiser_side: str = "",
|
appraiser_side: str = "",
|
||||||
|
is_post_hearing: bool | None = None,
|
||||||
|
protocol_scope: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
|
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
|
||||||
|
|
||||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
|
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
|
||||||
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
|
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
|
||||||
|
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר (לאישור שייכות)
|
case_number: מספר תיק הערר (לאישור שייכות)
|
||||||
@@ -452,6 +459,9 @@ async def document_update(
|
|||||||
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
||||||
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
||||||
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
||||||
|
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
|
||||||
|
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
|
||||||
|
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
|
||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
@@ -477,17 +487,37 @@ async def document_update(
|
|||||||
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
||||||
updates["doc_type"] = doc_type
|
updates["doc_type"] = doc_type
|
||||||
|
|
||||||
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
# appraiser_side / protocol_scope are optional. The MCP tool can't
|
||||||
# "set to empty string", so we use the convention: only update if non-empty.
|
# distinguish "skip" from "set to empty string", so we use the convention:
|
||||||
# To clear, the operator must edit metadata directly (rare).
|
# only update if non-empty. To clear, edit metadata directly (rare).
|
||||||
|
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
metadata_dirty = False
|
||||||
|
|
||||||
if appraiser_side:
|
if appraiser_side:
|
||||||
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||||
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
||||||
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
||||||
metadata = doc.get("metadata") or {}
|
|
||||||
if isinstance(metadata, str):
|
|
||||||
metadata = json.loads(metadata)
|
|
||||||
metadata["appraiser_side"] = appraiser_side
|
metadata["appraiser_side"] = appraiser_side
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if is_post_hearing is not None:
|
||||||
|
if is_post_hearing:
|
||||||
|
metadata["is_post_hearing"] = True
|
||||||
|
else:
|
||||||
|
metadata.pop("is_post_hearing", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if protocol_scope:
|
||||||
|
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
|
||||||
|
return err(f"protocol_scope לא תקין: {protocol_scope}",
|
||||||
|
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
|
||||||
|
metadata["protocol_scope"] = protocol_scope
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if metadata_dirty:
|
||||||
updates["metadata"] = metadata
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
return err(str(e))
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def analyze_protocol(case_number: str) -> str:
|
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
||||||
|
|
||||||
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
||||||
@@ -588,12 +588,17 @@ async def analyze_protocol(case_number: str) -> str:
|
|||||||
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
||||||
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
||||||
|
|
||||||
|
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
|
||||||
|
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
|
||||||
|
ציין document_id כדי לכוון לדיון המדויק (#223).
|
||||||
|
|
||||||
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
||||||
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
||||||
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
|
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
|
||||||
"""
|
"""
|
||||||
from legal_mcp.services import protocol_analyzer
|
from legal_mcp.services import protocol_analyzer
|
||||||
|
|
||||||
@@ -601,8 +606,16 @@ async def analyze_protocol(case_number: str) -> str:
|
|||||||
if not case:
|
if not case:
|
||||||
return err(f"תיק {case_number} לא נמצא.")
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
target_doc_id: UUID | None = None
|
||||||
|
if document_id.strip():
|
||||||
|
try:
|
||||||
|
target_doc_id = UUID(document_id.strip())
|
||||||
|
except ValueError:
|
||||||
|
return err(f"document_id לא תקין: {document_id}")
|
||||||
try:
|
try:
|
||||||
result = await protocol_analyzer.analyze_protocol(case_id)
|
result = await protocol_analyzer.analyze_protocol(
|
||||||
|
case_id, target_document_id=target_doc_id,
|
||||||
|
)
|
||||||
await audit.log_action_safe(
|
await audit.log_action_safe(
|
||||||
"analyze_protocol", case_id=case_id,
|
"analyze_protocol", case_id=case_id,
|
||||||
details={"status": result.get("status"), "total": result.get("total", 0)},
|
details={"status": result.get("status"), "total": result.get("total", 0)},
|
||||||
@@ -703,7 +716,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
|||||||
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
||||||
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
|
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
|
||||||
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
|
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
|
||||||
דיון/סיכום/חתימות. שם הקובץ: טיוטת-ביניים-v{N}.docx.
|
דיון/סיכום/חתימות. שם הקובץ: טיוטה-טענות_הצדדים_{N}.docx.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
|
|||||||
@@ -49,6 +49,65 @@ def test_find_protocol_none():
|
|||||||
assert pa._find_protocol(docs) is None
|
assert pa._find_protocol(docs) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_skips_lower_scope():
|
||||||
|
# A local/district-committee protocol (scope='lower') must NOT be picked for
|
||||||
|
# the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223).
|
||||||
|
docs = [
|
||||||
|
{"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית",
|
||||||
|
"metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"},
|
||||||
|
{"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26",
|
||||||
|
"metadata": {}, "created_at": "2026-06-23"},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(docs)["id"] == "appeal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_all_lower_returns_none():
|
||||||
|
# If every protocol is lower-scoped there is no ערר-hearing to compare.
|
||||||
|
docs = [
|
||||||
|
{"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי",
|
||||||
|
"metadata": {"protocol_scope": "lower"}},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(docs) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_prefers_most_recent_appeal():
|
||||||
|
docs = [
|
||||||
|
{"id": "old", "doc_type": "protocol", "title": "פרוטוקול א",
|
||||||
|
"metadata": {}, "created_at": "2026-03-01"},
|
||||||
|
{"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב",
|
||||||
|
"metadata": {}, "created_at": "2026-06-23"},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(docs)["id"] == "new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_explicit_document_id_overrides():
|
||||||
|
# Explicit target wins even over scope/recency heuristics — including the
|
||||||
|
# ability to point at a lower-scoped doc if the caller insists.
|
||||||
|
docs = [
|
||||||
|
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
|
||||||
|
"title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"},
|
||||||
|
{"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol",
|
||||||
|
"title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"},
|
||||||
|
"created_at": "2026-01-01"},
|
||||||
|
]
|
||||||
|
from uuid import UUID
|
||||||
|
picked = pa._find_protocol(
|
||||||
|
docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"),
|
||||||
|
)
|
||||||
|
assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_unknown_document_id_returns_none():
|
||||||
|
from uuid import UUID
|
||||||
|
docs = [
|
||||||
|
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
|
||||||
|
"title": "פרוטוקול", "metadata": {}},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(
|
||||||
|
docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"),
|
||||||
|
) is None
|
||||||
|
|
||||||
|
|
||||||
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
|
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
|
||||||
|
|
||||||
_AID = "11111111-1111-1111-1111-111111111111"
|
_AID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
|
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
|
||||||
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
|
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
|
||||||
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
|
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
|
||||||
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)` → `claude_session.query` → `claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק על ה-effort בעל ה-distance-הממוצע-הנמוך (לצד ברירת-המחדל מ-#204). מפיק `data/eval/effort-calibration-<ts>.{json,md}` ב**כתיבה אינקרמנטלית** — הדוח נכתב מחדש אחרי כל בלוק שמסתיים, כך שקריסה באמצע הגריד לא מאבדת בלוקים שכבר יוצרו (INV-G8). כל תא-(תיק,בלוק,effort) שנכשל (rate-limit חולף, או חריגה) נרשם כ-warning ומדולג — לא מפיל את הריצה. **`block-yod` (דיון) הוצא מברירות-המחדל** (`CALIBRATABLE_BLOCKS`) — דורש כיוון-מאושר (brainstorm→approve_direction) שתיקי-הכיול חסרים ולכן `write_block` זורק; נשאר ב-`VALID_BLOCKS` לכפייה ידנית (`--blocks block-yod`) על-אחריות-המשתמש, כשהשער-per-תא שומר אותו לא-פטאלי. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח את לוגיקת-המדידה) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב |
|
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)` → `claude_session.query` → `claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק (style-clean, #213) לפי `anti_pattern_total` **ראשי** (סיגנל-הסגנון הנקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש); **`change_percent` מדווח-לא-מדורג** (מערבב סגנון עם שלמות-תוכן, 07-learning §0.7). ההמלצה נושאת דגל-`confidence` ("clear"/"weak" — הובלת-anti < epsilon=max(0.5, 0.20·spread) ⇒ ⚠️weak בתוך-הרעש, לא מוצג כוודאי) לצד ברירת-המחדל מ-#204. מפיק `data/eval/effort-calibration-<ts>.{json,md}` ב**כתיבה אינקרמנטלית** — הדוח נכתב מחדש אחרי כל בלוק שמסתיים, כך שקריסה באמצע הגריד לא מאבדת בלוקים שכבר יוצרו (INV-G8). כל תא-(תיק,בלוק,effort) שנכשל (rate-limit חולף, או חריגה) נרשם כ-warning ומדולג — לא מפיל את הריצה. **`block-yod` (דיון) הוצא מברירות-המחדל** (`CALIBRATABLE_BLOCKS`) — דורש כיוון-מאושר (brainstorm→approve_direction) שתיקי-הכיול חסרים ולכן `write_block` זורק; נשאר ב-`VALID_BLOCKS` לכפייה ידנית (`--blocks block-yod`) על-אחריות-המשתמש, כשהשער-per-תא שומר אותו לא-פטאלי. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + דירוג-סגנון + confidence + rerank) · **`--rerank <report.json>`** (offline, אפס LLM/DB — מדרג-מחדש דוח-כיול שמור תחת המפתח החדש, מדפיס טבלה + כותב `*-reranked.md`) · `--dry-run` (תכנון-גריד) · `--efforts`/`--blocks`/`--case`/`--repeats`. **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצה: `POSTGRES_PASSWORD=… mcp-server/.venv/bin/python scripts/calibrate_effort.py`. | ידני — לכיול ברירות-effort של הכותב |
|
||||||
| `calibrate_block_yod.py` | python | **#212 (WS5/Q1, INV-G8 eval-harness) — כיול effort ל-בלוק י' (דיון והכרעה) בלבד.** ה-harness ש-#208 לא יכל לכייל: `write_block(block-yod)` זורק "ללא כיוון מאושר" כי בלוק-הדיון דורש `decisions.direction_doc.approved` שתיקי-הכיול חסרים. **התוספת על #208 — שכבת אספקת-כיוון read-only:** לפני ייצור בלוק-הדיון, ה-harness מזרים `direction_doc` שמיש דרך **overlay על קריאת-ה-DB** (monkeypatch של `db.get_decision_by_case`/`db.get_decision` לתהליך-הזה-בלבד) — **אפס כתיבה ל-DB**, מצב-תיק-הייצור לא משתנה (השער reverted ב-finally). **אסטרטגיית-אספקה per-תיק:** (1) **stored** (מועדף, נאמן) — כיוון-מאושר שמור (`approved`+`selected_direction`, או סכמת-8126 עם `direction_id`/`structure`); (2) **reconstructed** (נסיגה, עדות-כיוון-בלבד) — `direction_doc` מינימלי שנגזר מ-`decision.outcome` + סקשן-הדיון של הסופי **באותו תיק** (INV-LRN5: outcome+מצביע-לסופי-עצמי, אפס מהות חוצת-תיקים, מתויג `reconstructed`); (3) **skip** אם אין דיון/כיוון. מודד דרך אותו `style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2) ומשתמש-חוזר ב-`_score_cell`/`recommend_effort` מ-`calibrate_effort` (#208). **ממצא-קורפוס (2026-06-30):** מ-8 סופיים, 7 ניתנים-לכיול; רק 8126-03-25 הוא stored+scorable (8174-12-24 שמור-אך-חסר-סקשן-דיון) → `--allow-reconstructed false` נותן תא-יחיד; ברירת-המחדל (reconstructed פעיל) נותנת 7 → הנסיגה היא מה שמפעיל את ה-harness היום. כתיבה אינקרמנטלית per-תיק + שער-per-תא לא-פטאלי (INV-G8). ⚠️ **גודל-מדגם בראש הדוח — עדות-כיוון, לא רגרסיה.** `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + לוגיקת-אספקת-כיוון + overlay) · `--dry-run` (תכנון-גריד + מצב-אספקה per-תיק, צריך DB) · `--efforts`/`--case`/`--repeats`/`--allow-reconstructed`. ברירת-מחדל נוכחית: `xhigh` (`block_writer.py:72`, 'by feel'). **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצת-כיול-חי (לבצע ע"י היו"ר): `POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 mcp-server/.venv/bin/python scripts/calibrate_block_yod.py`. | ידני — לכיול ה-effort של בלוק-הדיון (היו"ר מריץ; long, צורך-מכסה) |
|
| `calibrate_block_yod.py` | python | **#212 (WS5/Q1, INV-G8 eval-harness) — כיול effort ל-בלוק י' (דיון והכרעה) בלבד.** ה-harness ש-#208 לא יכל לכייל: `write_block(block-yod)` זורק "ללא כיוון מאושר" כי בלוק-הדיון דורש `decisions.direction_doc.approved` שתיקי-הכיול חסרים. **התוספת על #208 — שכבת אספקת-כיוון read-only:** לפני ייצור בלוק-הדיון, ה-harness מזרים `direction_doc` שמיש דרך **overlay על קריאת-ה-DB** (monkeypatch של `db.get_decision_by_case`/`db.get_decision` לתהליך-הזה-בלבד) — **אפס כתיבה ל-DB**, מצב-תיק-הייצור לא משתנה (השער reverted ב-finally). **אסטרטגיית-אספקה per-תיק:** (1) **stored** (מועדף, נאמן) — כיוון-מאושר שמור (`approved`+`selected_direction`, או סכמת-8126 עם `direction_id`/`structure`); (2) **reconstructed** (נסיגה, עדות-כיוון-בלבד) — `direction_doc` מינימלי שנגזר מ-`decision.outcome` + סקשן-הדיון של הסופי **באותו תיק** (INV-LRN5: outcome+מצביע-לסופי-עצמי, אפס מהות חוצת-תיקים, מתויג `reconstructed`); (3) **skip** אם אין דיון/כיוון. מודד דרך אותו `style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2) ומשתמש-חוזר ב-`_score_cell`/`recommend_effort` מ-`calibrate_effort` (#208). **ממצא-קורפוס (2026-06-30):** מ-8 סופיים, 7 ניתנים-לכיול; רק 8126-03-25 הוא stored+scorable (8174-12-24 שמור-אך-חסר-סקשן-דיון) → `--allow-reconstructed false` נותן תא-יחיד; ברירת-המחדל (reconstructed פעיל) נותנת 7 → הנסיגה היא מה שמפעיל את ה-harness היום. כתיבה אינקרמנטלית per-תיק + שער-per-תא לא-פטאלי (INV-G8). ⚠️ **גודל-מדגם בראש הדוח — עדות-כיוון, לא רגרסיה.** `--self-test` (offline, אפס DB/CLI — מוכיח מדידה + לוגיקת-אספקת-כיוון + overlay) · `--dry-run` (תכנון-גריד + מצב-אספקה per-תיק, צריך DB) · `--efforts`/`--case`/`--repeats`/`--allow-reconstructed`. ברירת-מחדל נוכחית: `xhigh` (`block_writer.py:72`, 'by feel'). **חובה מקומי** (claude CLI; לא בקונטיינר/worktree-ללא-CLI). הרצת-כיול-חי (לבצע ע"י היו"ר): `POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 mcp-server/.venv/bin/python scripts/calibrate_block_yod.py`. | ידני — לכיול ה-effort של בלוק-הדיון (היו"ר מריץ; long, צורך-מכסה) |
|
||||||
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
|
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
|
||||||
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
|
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ Mapping (removed → kept):
|
|||||||
|
|
||||||
uploading → processing
|
uploading → processing
|
||||||
in_progress → outcome_set
|
in_progress → outcome_set
|
||||||
analyst_verified → documents_ready
|
|
||||||
research_complete → documents_ready
|
|
||||||
brainstorming → outcome_set
|
brainstorming → outcome_set
|
||||||
analysis_enriched → direction_approved
|
analysis_enriched → direction_approved
|
||||||
ready_for_writing → direction_approved
|
ready_for_writing → direction_approved
|
||||||
@@ -50,12 +48,14 @@ from legal_mcp.services import db # noqa: E402
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
log = logging.getLogger("status-trim")
|
log = logging.getLogger("status-trim")
|
||||||
|
|
||||||
# removed status → nearest preceding kept status
|
# removed status → nearest preceding kept status.
|
||||||
|
# NOTE (2026-06-30): analyst_verified + research_complete were RE-CANONICALISED
|
||||||
|
# into the status model (legal_mcp/case_status_model.py, "thinking" phase) — they
|
||||||
|
# are valid statuses again, so they were removed from this map. Do NOT downgrade
|
||||||
|
# them; only genuinely-removed markers remain below.
|
||||||
STATUS_MAP = {
|
STATUS_MAP = {
|
||||||
"uploading": "processing",
|
"uploading": "processing",
|
||||||
"in_progress": "outcome_set",
|
"in_progress": "outcome_set",
|
||||||
"analyst_verified": "documents_ready",
|
|
||||||
"research_complete": "documents_ready",
|
|
||||||
"brainstorming": "outcome_set",
|
"brainstorming": "outcome_set",
|
||||||
"analysis_enriched": "direction_approved",
|
"analysis_enriched": "direction_approved",
|
||||||
"ready_for_writing": "direction_approved",
|
"ready_for_writing": "direction_approved",
|
||||||
|
|||||||
@@ -19,9 +19,15 @@ WHAT IT MEASURES (per (case, block, effort) cell — the A/B grid):
|
|||||||
• distance — normalized composite (lower = closer to Dafna)
|
• distance — normalized composite (lower = closer to Dafna)
|
||||||
No parallel metric path: it reuses style_distance + learning_loop (G2).
|
No parallel metric path: it reuses style_distance + learning_loop (G2).
|
||||||
|
|
||||||
RECOMMENDATION: for each block, the effort with the lowest MEAN composite distance
|
RECOMMENDATION (#213 — STYLE-CLEAN, anti-primary): for each block, the effort is
|
||||||
across cases (ties → fewer anti-patterns → lower change_percent). Reported next to
|
ranked by `anti_pattern_total` FIRST (the clean Dafna-style-rule-violation count),
|
||||||
the #204 current default so a regression/improvement is visible.
|
then `golden_ratio_deviation_pp` (structural-style), and `distance` only as a faint
|
||||||
|
final tiebreak. `change_percent` is REPORTED-NOT-RANKED: per 07-learning §0.7 it
|
||||||
|
mixes style with content-completeness (a case's missing facts the model can't know)
|
||||||
|
→ noise for STYLE. The pick carries a `confidence` flag ("clear"/"weak"): a pick
|
||||||
|
whose anti-pattern lead over the runner-up is within noise is flagged "weak" so an
|
||||||
|
over-claimed pick (the old block-zayin "low") is never shown as confident. Reported
|
||||||
|
next to the #204 current default so a regression/improvement is visible.
|
||||||
|
|
||||||
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded final
|
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded final
|
||||||
(draft_final_pairs.final_text non-empty). The report prints n_finals PROMINENTLY and
|
(draft_final_pairs.final_text non-empty). The report prints n_finals PROMINENTLY and
|
||||||
@@ -94,20 +100,66 @@ VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
|
|||||||
|
|
||||||
|
|
||||||
# ── pure helpers (offline-testable) ──────────────────────────────────────────
|
# ── pure helpers (offline-testable) ──────────────────────────────────────────
|
||||||
def recommend_effort(cells: list[dict]) -> dict | None:
|
# Style-clean ranking epsilon (#213). Confidence in a pick = how far the best
|
||||||
"""Pick the best effort for ONE block from its scored cells.
|
# effort's anti_pattern_total beats the 2nd-best. We call a pick "clear" only when
|
||||||
|
# that margin clears a floor that is itself the LARGER of:
|
||||||
|
# • an absolute floor (≥0.5 avg anti-patterns — a half-violation per case), and
|
||||||
|
# • a fraction of the spread across efforts (best can't claim a lead that is mere
|
||||||
|
# noise on a block whose efforts barely differ).
|
||||||
|
# Below that → "weak" (within noise; nominal pick still reported, but flagged so a
|
||||||
|
# within-noise pick is never shown as confident). Documented in the docstring.
|
||||||
|
CONFIDENCE_ABS_FLOOR = 0.5 # avg anti-patterns (a half-violation per case)
|
||||||
|
CONFIDENCE_SPREAD_FRACTION = 0.20 # of the across-effort anti spread
|
||||||
|
|
||||||
cells: [{"effort","distance","anti_pattern_total","change_percent","n"}].
|
|
||||||
Lowest mean composite distance wins; ties broken by fewer anti-patterns,
|
def _anti_confidence(rows: list[dict]) -> tuple[str, float, float]:
|
||||||
then lower change_percent. Pure → unit-tested in --self-test.
|
"""Confidence that the anti-primary pick is real, not within-noise.
|
||||||
|
|
||||||
|
Returns (confidence, margin, epsilon) where confidence ∈ {"clear","weak"}.
|
||||||
|
margin = (2nd-best anti) − (best anti); epsilon = the bar margin must clear.
|
||||||
|
A single effort (no rival) is trivially "clear". Pure → unit-tested.
|
||||||
|
"""
|
||||||
|
if len(rows) < 2:
|
||||||
|
return "clear", float("inf"), 0.0
|
||||||
|
antis = sorted(r["anti_pattern_total"] for r in rows)
|
||||||
|
best, second = antis[0], antis[1]
|
||||||
|
margin = second - best
|
||||||
|
spread = antis[-1] - antis[0]
|
||||||
|
epsilon = max(CONFIDENCE_ABS_FLOOR, CONFIDENCE_SPREAD_FRACTION * spread)
|
||||||
|
return ("clear" if margin >= epsilon else "weak"), round(margin, 4), round(epsilon, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_effort(cells: list[dict]) -> dict | None:
|
||||||
|
"""Pick the STYLE-CLEANEST effort for ONE block from its scored cells (#213).
|
||||||
|
|
||||||
|
cells: [{"effort","distance","anti_pattern_total","change_percent",
|
||||||
|
"golden_ratio_deviation_pp","n"}].
|
||||||
|
|
||||||
|
Ranking key (style-clean, G8 / 07-learning §0.7) — change_percent is EXCLUDED
|
||||||
|
from the ranking (it mixes style with content-completeness; reported only):
|
||||||
|
1. PRIMARY anti_pattern_total (↑ fewer Dafna-style violations = better)
|
||||||
|
2. SECONDARY golden_ratio_deviation_pp (↑ structural-style; None → +inf worst)
|
||||||
|
3. TIEBREAK distance (↑ faint final tiebreak only)
|
||||||
|
|
||||||
|
Returns the winning row dict (so callers reading ["effort"] keep working) with
|
||||||
|
two added keys: ``confidence`` ∈ {"clear","weak"} and ``confidence_margin`` —
|
||||||
|
so a within-noise pick is never presented as confident. Pure → unit-tested.
|
||||||
"""
|
"""
|
||||||
if not cells:
|
if not cells:
|
||||||
return None
|
return None
|
||||||
ranked = sorted(
|
ranked = sorted(
|
||||||
cells,
|
cells,
|
||||||
key=lambda c: (c["distance"], c["anti_pattern_total"], c["change_percent"]),
|
key=lambda c: (
|
||||||
|
c["anti_pattern_total"],
|
||||||
|
c["golden_ratio_deviation_pp"] if c.get("golden_ratio_deviation_pp") is not None else float("inf"),
|
||||||
|
c["distance"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return ranked[0]
|
best = dict(ranked[0])
|
||||||
|
conf, margin, _eps = _anti_confidence(cells)
|
||||||
|
best["confidence"] = conf
|
||||||
|
best["confidence_margin"] = margin
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
def aggregate_cell(per_run: list[dict]) -> dict:
|
def aggregate_cell(per_run: list[dict]) -> dict:
|
||||||
@@ -133,6 +185,88 @@ def _current_default(block_id: str) -> str | None:
|
|||||||
return cfg.get("effort", DEFAULT_EFFORT)
|
return cfg.get("effort", DEFAULT_EFFORT)
|
||||||
|
|
||||||
|
|
||||||
|
# ── re-rank a saved report (offline — NO LLM, NO DB) ─────────────────────────
|
||||||
|
def rerank_saved(saved: dict) -> dict:
|
||||||
|
"""Re-apply the CURRENT (#213) recommend_effort to a saved calibration report.
|
||||||
|
|
||||||
|
Reads the per-(block,effort) rows that the run already persisted
|
||||||
|
(distance/anti/change%/ratioΔ/n) and recomputes recommended + confidence — no
|
||||||
|
regeneration, no model, no DB. Lets a past run be re-ranked under the new
|
||||||
|
style-clean key. Returns {"block_id": {old, new, confidence, confidence_margin,
|
||||||
|
current_default, efforts}}. Pure → unit-tested in --self-test.
|
||||||
|
"""
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for block_id, bd in (saved.get("by_block") or {}).items():
|
||||||
|
rows = bd.get("efforts") or []
|
||||||
|
rec = recommend_effort(rows)
|
||||||
|
out[block_id] = {
|
||||||
|
"section": bd.get("section"),
|
||||||
|
"current_default": bd.get("current_default"),
|
||||||
|
"old_recommended": bd.get("recommended"),
|
||||||
|
"new_recommended": rec["effort"] if rec else None,
|
||||||
|
"confidence": rec["confidence"] if rec else None,
|
||||||
|
"confidence_margin": rec.get("confidence_margin") if rec else None,
|
||||||
|
"efforts": rows,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _render_rerank_md(saved: dict, reranked: dict, src: Path) -> str:
|
||||||
|
g = saved.get("grid", {})
|
||||||
|
finals = g.get("finals", [])
|
||||||
|
lines = [
|
||||||
|
f"# #213 — re-rank של {src.name} (style-clean, anti-primary · OFFLINE)\n",
|
||||||
|
f"> מקור: `{src.name}` · {g.get('n_finals','?')} סופיים "
|
||||||
|
f"({', '.join(finals) or '—'}) · ללא LLM/DB — דירוג-מחדש בלבד.\n",
|
||||||
|
"## old pick → new pick (+confidence)\n",
|
||||||
|
"| block | section | current | old pick | **new pick** | confidence | n |",
|
||||||
|
"|---|---|---|---|---|---|---|",
|
||||||
|
]
|
||||||
|
for b, rd in reranked.items():
|
||||||
|
old = rd.get("old_recommended") or "—"
|
||||||
|
new = rd.get("new_recommended") or "—"
|
||||||
|
mark = "" if new == old else " ⬅︎"
|
||||||
|
conf = rd.get("confidence")
|
||||||
|
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "—")
|
||||||
|
n_b = rd["efforts"][0]["n"] if rd.get("efforts") else 0
|
||||||
|
lines.append(
|
||||||
|
f"| {b} | {rd.get('section') or '—'} | {rd.get('current_default') or '—'} | "
|
||||||
|
f"{old} | **{new}**{mark} | {conf_cell} | {n_b} |")
|
||||||
|
lines.append("")
|
||||||
|
for b, rd in reranked.items():
|
||||||
|
if not rd.get("efforts"):
|
||||||
|
continue
|
||||||
|
lines += [f"### {b} ({rd.get('section') or '—'})\n",
|
||||||
|
"| effort | distance | anti_total | change% (reported) | ratioΔpp | n |",
|
||||||
|
"|---|---|---|---|---|---|"]
|
||||||
|
for r in rd["efforts"]:
|
||||||
|
star = " ⭐" if r["effort"] == rd.get("new_recommended") else ""
|
||||||
|
ratio = r.get("golden_ratio_deviation_pp")
|
||||||
|
lines.append(
|
||||||
|
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("")
|
||||||
|
lines.append("> דירוג **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak); "
|
||||||
|
"**change% מדווח-לא-מדורג** (07-learning §0.7); "
|
||||||
|
"confidence=⚠️weak ⇒ הובלת-anti < epsilon = max(0.5, 0.20·spread).\n")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _do_rerank(path: Path) -> int:
|
||||||
|
"""--rerank entry: load a saved report, re-rank offline, print + write *-reranked.md."""
|
||||||
|
if not path.exists():
|
||||||
|
print(f"rerank: file not found: {path}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
reranked = rerank_saved(saved)
|
||||||
|
md = _render_rerank_md(saved, reranked, path)
|
||||||
|
print(md)
|
||||||
|
out = path.with_name(path.stem + "-reranked.md")
|
||||||
|
out.write_text(md, encoding="utf-8")
|
||||||
|
print(f" reranked report written: {out}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ── self-test (no DB, no model) ──────────────────────────────────────────────
|
# ── self-test (no DB, no model) ──────────────────────────────────────────────
|
||||||
def _self_test() -> int:
|
def _self_test() -> int:
|
||||||
ok = True
|
ok = True
|
||||||
@@ -180,16 +314,66 @@ def _self_test() -> int:
|
|||||||
{"background", "claims", "discussion", "summary"}))
|
{"background", "claims", "discussion", "summary"}))
|
||||||
chk("split found ≥1 section", len(secs) >= 1)
|
chk("split found ≥1 section", len(secs) >= 1)
|
||||||
|
|
||||||
# recommend_effort: lowest distance wins; tie → fewer anti-patterns.
|
# recommend_effort (#213, style-clean): anti_pattern_total is PRIMARY — the
|
||||||
|
# effort with the FEWEST style violations wins, even if its distance is higher.
|
||||||
rec = recommend_effort([
|
rec = recommend_effort([
|
||||||
{"effort": "low", "distance": 0.40, "anti_pattern_total": 5, "change_percent": 40, "n": 1},
|
{"effort": "low", "distance": 0.20, "anti_pattern_total": 5, "change_percent": 22,
|
||||||
{"effort": "high", "distance": 0.20, "anti_pattern_total": 2, "change_percent": 25, "n": 1},
|
"golden_ratio_deviation_pp": 3.0, "n": 1},
|
||||||
{"effort": "xhigh", "distance": 0.20, "anti_pattern_total": 1, "change_percent": 22, "n": 1},
|
{"effort": "high", "distance": 0.40, "anti_pattern_total": 2, "change_percent": 40,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||||
|
{"effort": "xhigh", "distance": 0.50, "anti_pattern_total": 1, "change_percent": 50,
|
||||||
|
"golden_ratio_deviation_pp": 9.0, "n": 1},
|
||||||
])
|
])
|
||||||
chk("recommend picks lowest distance", rec["distance"] == 0.20)
|
chk("recommend: anti-primary (not distance)", rec["effort"] == "xhigh")
|
||||||
chk("recommend tie → fewer anti-patterns", rec["effort"] == "xhigh")
|
chk("recommend: change_percent NOT ranked", rec["distance"] == 0.50) # worst distance still won
|
||||||
chk("recommend empty ⇒ None", recommend_effort([]) is None)
|
chk("recommend empty ⇒ None", recommend_effort([]) is None)
|
||||||
|
|
||||||
|
# SECONDARY = golden_ratio_deviation_pp when anti ties; distance only as last tiebreak.
|
||||||
|
rec_tie = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 10,
|
||||||
|
"golden_ratio_deviation_pp": 8.0, "n": 1},
|
||||||
|
{"effort": "high", "distance": 0.90, "anti_pattern_total": 3, "change_percent": 90,
|
||||||
|
"golden_ratio_deviation_pp": 2.0, "n": 1},
|
||||||
|
])
|
||||||
|
chk("recommend: ratio breaks anti-tie", rec_tie["effort"] == "high")
|
||||||
|
rec_ratio_tie = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.30, "anti_pattern_total": 3, "change_percent": 10,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||||
|
{"effort": "high", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 90,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||||
|
])
|
||||||
|
chk("recommend: distance breaks final tie", rec_ratio_tie["effort"] == "high")
|
||||||
|
# None ratio is treated as worst (never beats a real deviation on the secondary key).
|
||||||
|
rec_none = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.10, "anti_pattern_total": 2, "change_percent": 10,
|
||||||
|
"golden_ratio_deviation_pp": None, "n": 1},
|
||||||
|
{"effort": "high", "distance": 0.90, "anti_pattern_total": 2, "change_percent": 90,
|
||||||
|
"golden_ratio_deviation_pp": 4.0, "n": 1},
|
||||||
|
])
|
||||||
|
chk("recommend: None ratio is worst", rec_none["effort"] == "high")
|
||||||
|
|
||||||
|
# confidence flag (#213): a clear anti lead ⇒ "clear"; within-noise ⇒ "weak".
|
||||||
|
rec_clear = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.5, "anti_pattern_total": 1.0, "change_percent": 50,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 5},
|
||||||
|
{"effort": "high", "distance": 0.5, "anti_pattern_total": 4.0, "change_percent": 50,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 5},
|
||||||
|
])
|
||||||
|
chk("confidence: big anti lead ⇒ clear", rec_clear["confidence"] == "clear")
|
||||||
|
rec_weak = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.4, "anti_pattern_total": 3.6, "change_percent": 93,
|
||||||
|
"golden_ratio_deviation_pp": 6.7, "n": 5},
|
||||||
|
{"effort": "xhigh", "distance": 0.5, "anti_pattern_total": 3.6, "change_percent": 102,
|
||||||
|
"golden_ratio_deviation_pp": 6.6, "n": 5},
|
||||||
|
{"effort": "high", "distance": 0.53, "anti_pattern_total": 3.8, "change_percent": 104,
|
||||||
|
"golden_ratio_deviation_pp": 5.7, "n": 5},
|
||||||
|
])
|
||||||
|
chk("confidence: anti tie ⇒ weak", rec_weak["confidence"] == "weak")
|
||||||
|
rec_single = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.4, "anti_pattern_total": 2.0, "change_percent": 50,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 5}])
|
||||||
|
chk("confidence: single effort ⇒ clear", rec_single["confidence"] == "clear")
|
||||||
|
|
||||||
# aggregate_cell: means + ratio drops Nones, keeps n.
|
# aggregate_cell: means + ratio drops Nones, keeps n.
|
||||||
agg = aggregate_cell([
|
agg = aggregate_cell([
|
||||||
{"distance": 0.2, "anti_pattern_total": 2, "change_percent": 20, "golden_ratio_deviation_pp": 3.0},
|
{"distance": 0.2, "anti_pattern_total": 2, "change_percent": 20, "golden_ratio_deviation_pp": 3.0},
|
||||||
@@ -199,6 +383,23 @@ def _self_test() -> int:
|
|||||||
chk("aggregate n counted", agg["n"] == 2)
|
chk("aggregate n counted", agg["n"] == 2)
|
||||||
chk("aggregate ratio skips None", agg["golden_ratio_deviation_pp"] == 3.0)
|
chk("aggregate ratio skips None", agg["golden_ratio_deviation_pp"] == 3.0)
|
||||||
|
|
||||||
|
# rerank_saved: re-applies the new key to a saved report's stored rows, no I/O.
|
||||||
|
# Mimics the real 8-final block-vav rows: low has fewest anti by a clear margin.
|
||||||
|
saved = {"grid": {"n_finals": 7, "finals": ["x"]}, "by_block": {
|
||||||
|
"block-vav": {"section": "background", "current_default": "medium",
|
||||||
|
"recommended": "low", "efforts": [
|
||||||
|
{"effort": "low", "distance": 0.4454, "anti_pattern_total": 1.57, "change_percent": 87.39, "golden_ratio_deviation_pp": 6.33, "n": 7},
|
||||||
|
{"effort": "medium", "distance": 0.4749, "anti_pattern_total": 2.29, "change_percent": 87.76, "golden_ratio_deviation_pp": 6.37, "n": 7},
|
||||||
|
{"effort": "high", "distance": 0.5381, "anti_pattern_total": 4.14, "change_percent": 90.69, "golden_ratio_deviation_pp": 5.99, "n": 7},
|
||||||
|
{"effort": "xhigh", "distance": 0.5413, "anti_pattern_total": 4.57, "change_percent": 89.53, "golden_ratio_deviation_pp": 6.41, "n": 7},
|
||||||
|
]},
|
||||||
|
}}
|
||||||
|
rr = rerank_saved(saved)
|
||||||
|
chk("rerank: block-vav stays low", rr["block-vav"]["new_recommended"] == "low")
|
||||||
|
chk("rerank: block-vav clear (anti lead)", rr["block-vav"]["confidence"] == "clear")
|
||||||
|
chk("rerank: keeps old pick for diff", rr["block-vav"]["old_recommended"] == "low")
|
||||||
|
chk("rerank: renders markdown", "new pick" in _render_rerank_md(saved, rr, Path("x.json")))
|
||||||
|
|
||||||
print("ALL PASS" if ok else "*** FAILURES ***")
|
print("ALL PASS" if ok else "*** FAILURES ***")
|
||||||
return 0 if ok else 1
|
return 0 if ok else 1
|
||||||
|
|
||||||
@@ -329,6 +530,8 @@ async def _run(args, ts: str) -> dict:
|
|||||||
"section": section,
|
"section": section,
|
||||||
"current_default": _current_default(block_id),
|
"current_default": _current_default(block_id),
|
||||||
"recommended": rec["effort"] if rec else None,
|
"recommended": rec["effort"] if rec else None,
|
||||||
|
"confidence": rec["confidence"] if rec else None,
|
||||||
|
"confidence_margin": rec.get("confidence_margin") if rec else None,
|
||||||
"efforts": effort_rows,
|
"efforts": effort_rows,
|
||||||
"per_case": per_case,
|
"per_case": per_case,
|
||||||
}
|
}
|
||||||
@@ -386,13 +589,18 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
|||||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
return jp, mp
|
return jp, mp
|
||||||
|
|
||||||
lines += ["## המלצה per-בלוק (distance נמוך = קרוב יותר לדפנה)\n",
|
lines += ["## המלצה per-בלוק (style-clean: anti_pattern_total ראשי — #213)\n",
|
||||||
"| block | section | current | **recommended** | n |", "|---|---|---|---|---|"]
|
"| block | section | current | **recommended** | confidence | n |",
|
||||||
|
"|---|---|---|---|---|---|"]
|
||||||
for b, bd in result["by_block"].items():
|
for b, bd in result["by_block"].items():
|
||||||
rec = bd.get("recommended") or "—"
|
rec = bd.get("recommended") or "—"
|
||||||
mark = "" if rec == bd.get("current_default") else " ⬅︎"
|
mark = "" if rec == bd.get("current_default") else " ⬅︎"
|
||||||
|
conf = bd.get("confidence")
|
||||||
|
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "—")
|
||||||
n_b = bd["efforts"][0]["n"] if bd.get("efforts") else 0
|
n_b = bd["efforts"][0]["n"] if bd.get("efforts") else 0
|
||||||
lines.append(f"| {b} | {bd.get('section','')} | {bd.get('current_default') or '—'} | **{rec}**{mark} | {n_b} |")
|
lines.append(
|
||||||
|
f"| {b} | {bd.get('section','')} | {bd.get('current_default') or '—'} | "
|
||||||
|
f"**{rec}**{mark} | {conf_cell} | {n_b} |")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
for b, bd in result["by_block"].items():
|
for b, bd in result["by_block"].items():
|
||||||
lines += [f"### {b} ({bd.get('section','')})\n",
|
lines += [f"### {b} ({bd.get('section','')})\n",
|
||||||
@@ -405,8 +613,10 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
|||||||
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
||||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("> change% מערבב סגנון עם שלמות-תוכן (07-learning §0.7); "
|
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
|
||||||
"anti_total הוא הסיגנל הנקי-יותר לסגנון.\n")
|
"**change% מדווח-לא-מדורג** — מערבב סגנון עם שלמות-תוכן (07-learning §0.7), "
|
||||||
|
"anti_total הוא הסיגנל הנקי-לסגנון. confidence=⚠️weak ⇒ הבחירה בתוך-הרעש "
|
||||||
|
"(ההובלה ב-anti קטנה מ-epsilon = max(0.5, 0.20·spread)).\n")
|
||||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
return jp, mp
|
return jp, mp
|
||||||
|
|
||||||
@@ -414,6 +624,8 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
|||||||
async def main() -> int:
|
async def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description="#208 model/effort calibration harness")
|
ap = argparse.ArgumentParser(description="#208 model/effort calibration harness")
|
||||||
ap.add_argument("--self-test", action="store_true", help="offline measurement-logic proof (no DB/CLI)")
|
ap.add_argument("--self-test", action="store_true", help="offline measurement-logic proof (no DB/CLI)")
|
||||||
|
ap.add_argument("--rerank", metavar="REPORT.json", default=None,
|
||||||
|
help="re-rank a saved calibration JSON under the current style-clean key (no LLM/DB)")
|
||||||
ap.add_argument("--dry-run", action="store_true", help="plan the A/B grid over existing finals, no model calls")
|
ap.add_argument("--dry-run", action="store_true", help="plan the A/B grid over existing finals, no model calls")
|
||||||
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
|
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
|
||||||
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
|
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
|
||||||
@@ -428,6 +640,9 @@ async def main() -> int:
|
|||||||
if args.self_test:
|
if args.self_test:
|
||||||
return _self_test()
|
return _self_test()
|
||||||
|
|
||||||
|
if args.rerank:
|
||||||
|
return _do_rerank(Path(args.rerank))
|
||||||
|
|
||||||
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
|
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
|
||||||
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
|
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
|
||||||
if bad:
|
if bad:
|
||||||
@@ -452,7 +667,8 @@ async def main() -> int:
|
|||||||
else:
|
else:
|
||||||
for b, bd in result["by_block"].items():
|
for b, bd in result["by_block"].items():
|
||||||
print(f" {b:16} current={bd.get('current_default') or '—':6} "
|
print(f" {b:16} current={bd.get('current_default') or '—':6} "
|
||||||
f"→ recommended={bd.get('recommended') or '—'}")
|
f"→ recommended={bd.get('recommended') or '—':6} "
|
||||||
|
f"[{bd.get('confidence') or '—'}]")
|
||||||
print(f" report: {mp}")
|
print(f" report: {mp}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import { CaseHeader } from "@/components/cases/case-header";
|
|||||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||||
|
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
|
||||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||||
|
import { HearingChangesPanel } from "@/components/cases/hearing-changes-panel";
|
||||||
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
||||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||||
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
||||||
@@ -80,10 +82,12 @@ export default function CaseDetailPage({
|
|||||||
];
|
];
|
||||||
|
|
||||||
// V2 "segmented" tab strip (X17): each tab a pill, active = raised white card.
|
// V2 "segmented" tab strip (X17): each tab a pill, active = raised white card.
|
||||||
|
// Spread full-width via justify-between (chair request) — wide gaps with few
|
||||||
|
// tabs, tightening as more are added; gap-2 is the no-overlap floor.
|
||||||
const tabsList = (
|
const tabsList = (
|
||||||
<TabsList
|
<TabsList
|
||||||
variant="line"
|
variant="line"
|
||||||
className="!h-auto gap-1.5 p-0 flex-wrap justify-start"
|
className="!h-auto w-full gap-2 p-0 justify-between"
|
||||||
>
|
>
|
||||||
{tabDefs.map(([value, label, Icon]) => (
|
{tabDefs.map(([value, label, Icon]) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -102,12 +106,6 @@ export default function CaseDetailPage({
|
|||||||
<>
|
<>
|
||||||
{data && <CaseEditDialog data={data} />}
|
{data && <CaseEditDialog data={data} />}
|
||||||
<UploadSheet caseNumber={caseNumber} />
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
<Button
|
|
||||||
className="bg-gold text-white hover:bg-gold-deep border-transparent"
|
|
||||||
onClick={() => setTab("decision")}
|
|
||||||
>
|
|
||||||
פתח עורך החלטה
|
|
||||||
</Button>
|
|
||||||
{canStartWorkflow && (
|
{canStartWorkflow && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
||||||
@@ -160,11 +158,28 @@ export default function CaseDetailPage({
|
|||||||
issues, with the aggregated by-party arguments collapsible below
|
issues, with the aggregated by-party arguments collapsible below
|
||||||
(merged from the deleted /compose editor, mockup 18f). */}
|
(merged from the deleted /compose editor, mockup 18f). */}
|
||||||
<TabsContent value="arguments" className="mt-0 space-y-4">
|
<TabsContent value="arguments" className="mt-0 space-y-4">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
{/* עמדות וטענות — collapsed by default (chair preference #226),
|
||||||
<CardContent className="px-6 py-5">
|
mirroring the two accordions below. */}
|
||||||
<PositionsPanel caseNumber={caseNumber} />
|
<Accordion type="single" collapsible>
|
||||||
</CardContent>
|
<AccordionItem
|
||||||
</Card>
|
value="positions"
|
||||||
|
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||||
|
<span className="flex flex-1 flex-col items-start">
|
||||||
|
<span className="text-navy text-[0.95rem] font-bold">
|
||||||
|
עמדות וטענות
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted text-[0.78rem]">
|
||||||
|
סוגיות-המחלוקת ועמדות הצדדים · עורך עמדת-היו״ר מזין את בלוק י׳
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-5 pt-0">
|
||||||
|
<PositionsPanel caseNumber={caseNumber} />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
<Accordion type="single" collapsible>
|
<Accordion type="single" collapsible>
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
value="byparty"
|
value="byparty"
|
||||||
@@ -185,6 +200,30 @@ export default function CaseDetailPage({
|
|||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
{/* מה קרה בדיון — comparative analysis of the hearing protocol vs.
|
||||||
|
the written pleadings (#226). Sits below the aggregated arguments
|
||||||
|
because it speaks to those same arguments in their oral gloss.
|
||||||
|
Collapsed by default, mirroring the "by-party" accordion above. */}
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem
|
||||||
|
value="hearing"
|
||||||
|
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||||
|
<span className="flex flex-1 flex-col items-start">
|
||||||
|
<span className="text-navy text-[0.95rem] font-bold">
|
||||||
|
מה קרה בדיון
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted text-[0.78rem]">
|
||||||
|
השוואת הטענות בכתב מול הדיון בעל-פה — מה התחזק ומה נטען לראשונה
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-6 pb-5 pt-0">
|
||||||
|
<HearingChangesPanel caseNumber={caseNumber} />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),
|
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),
|
||||||
@@ -205,12 +244,17 @@ export default function CaseDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="drafts" className="mt-0">
|
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="agents" className="mt-0">
|
<TabsContent value="agents" className="mt-0">
|
||||||
|
|||||||
@@ -29,12 +29,15 @@ import {
|
|||||||
useRunLog,
|
useRunLog,
|
||||||
useCancelRun,
|
useCancelRun,
|
||||||
useResetAgentSession,
|
useResetAgentSession,
|
||||||
|
useAgentHealth,
|
||||||
|
useRecentEscalations,
|
||||||
type OpsService,
|
type OpsService,
|
||||||
type OperationsSnapshot,
|
type OperationsSnapshot,
|
||||||
type PipelineStats,
|
type PipelineStats,
|
||||||
type AgentRun,
|
type AgentRun,
|
||||||
type SubscriptionUsage,
|
type SubscriptionUsage,
|
||||||
type UsageWindow,
|
type UsageWindow,
|
||||||
|
type AgentHealthState,
|
||||||
} from "@/lib/api/operations";
|
} from "@/lib/api/operations";
|
||||||
import { formatTime, israelParts } from "@/lib/format-date";
|
import { formatTime, israelParts } from "@/lib/format-date";
|
||||||
|
|
||||||
@@ -802,6 +805,154 @@ function LiveAgentsPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||||
|
const HEALTH_LABEL: Record<AgentHealthState, string> = {
|
||||||
|
zombie: "זומבי",
|
||||||
|
stalled: "תקוע",
|
||||||
|
working: "עובד",
|
||||||
|
idle: "רגוע",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AgentHealthPanel() {
|
||||||
|
const { data, isLoading } = useAgentHealth();
|
||||||
|
const { data: esc } = useRecentEscalations();
|
||||||
|
|
||||||
|
const counts = data?.counts;
|
||||||
|
// Only zombies/stalled are "problems" — the backend already sorts worst-first.
|
||||||
|
// An escalated issue is chair-owned, so it leaves this list; the escalations
|
||||||
|
// card below is the handled-loop history.
|
||||||
|
const problems = (data?.items ?? []).filter(
|
||||||
|
(i) => i.health === "zombie" || i.health === "stalled",
|
||||||
|
);
|
||||||
|
const healthyWorking = counts?.working ?? 0;
|
||||||
|
const healthyIdle = counts?.idle ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5">
|
||||||
|
{isLoading || !data ? (
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
<StatTile
|
||||||
|
label="זומבי"
|
||||||
|
value={counts?.zombie ?? 0}
|
||||||
|
tone="red"
|
||||||
|
title="לולאת-recovery — משויך+פעיל-כביכול אך אין ריצה חיה. מוסלם אוטומטית."
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="תקוע"
|
||||||
|
value={counts?.stalled ?? 0}
|
||||||
|
tone="amber"
|
||||||
|
title="ננער חוזר בלי התקדמות"
|
||||||
|
/>
|
||||||
|
<StatTile label="עובד" value={counts?.working ?? 0} tone="green" />
|
||||||
|
<StatTile label="רגוע" value={counts?.idle ?? 0} tone="muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{problems.length === 0 ? (
|
||||||
|
<p className="text-sm text-emerald-600">
|
||||||
|
אין סוכן תקוע כרגע — כל הסוכנים תקינים.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{problems.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.issue_id}
|
||||||
|
className={
|
||||||
|
p.health === "zombie"
|
||||||
|
? "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
|
||||||
|
: "flex items-center justify-between gap-3 rounded-md border border-gold/40 bg-gold-wash px-3 py-2"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
|
<Badge
|
||||||
|
variant={p.health === "zombie" ? "destructive" : "outline"}
|
||||||
|
className={
|
||||||
|
p.health === "stalled"
|
||||||
|
? "border-warn/50 text-warn font-normal"
|
||||||
|
: "font-normal"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{HEALTH_LABEL[p.health]}
|
||||||
|
</Badge>
|
||||||
|
<span dir="ltr" className="font-mono text-[0.82rem] text-navy font-semibold">
|
||||||
|
{p.identifier}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.82rem] text-navy font-semibold">
|
||||||
|
{p.agent_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.7rem] text-ink-muted">
|
||||||
|
{p.recovery_wakeups > 0
|
||||||
|
? `${p.recovery_wakeups} יקיצות-שחזור`
|
||||||
|
: `${p.wakeups} יקיצות`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[0.7rem] text-ink-muted shrink-0">
|
||||||
|
{p.health === "zombie" ? "יוסלם אוטומטית" : "מנוטר"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-3 text-[0.78rem] text-ink-muted">
|
||||||
|
<b className="text-navy">{healthyWorking + healthyIdle} סוכנים תקינים</b> —{" "}
|
||||||
|
{healthyWorking} עובדים, {healthyIdle} רגועים. מוצגות רק בעיות.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* handled-loop history — the watchdog's escalations, gold-wash like a gate */}
|
||||||
|
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4">
|
||||||
|
<h3 className="text-navy text-sm font-semibold mb-0.5">הסלמות אוטומטיות אחרונות</h3>
|
||||||
|
<p className="text-ink-muted text-[0.72rem] mb-3">
|
||||||
|
כל זומבי מתמשך (לולאת-recovery ≥2 יקיצות-שחזור) משויך אליך אוטומטית עם הערת-מערכת
|
||||||
|
— בלי להעיר סוכן מחדש.
|
||||||
|
</p>
|
||||||
|
{!esc?.items?.length ? (
|
||||||
|
<p className="text-sm text-ink-muted">אין הסלמות אחרונות.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{esc.items.map((e) => (
|
||||||
|
<div
|
||||||
|
key={`${e.issue_id}-${e.created_at ?? ""}`}
|
||||||
|
className="flex items-start gap-2.5 border-t border-gold/15 first:border-0 pt-2 first:pt-0"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant={e.severity === "medium" ? "outline" : "destructive"}
|
||||||
|
className={
|
||||||
|
e.severity === "medium"
|
||||||
|
? "border-gold/50 text-gold-deep font-normal shrink-0"
|
||||||
|
: "font-normal shrink-0"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{e.severity}
|
||||||
|
</Badge>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span dir="ltr" className="font-mono text-[0.8rem] text-navy font-semibold">
|
||||||
|
{e.identifier}
|
||||||
|
</span>
|
||||||
|
<div className="text-[0.78rem] text-ink-soft">{e.reason}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[0.68rem] text-ink-muted shrink-0">
|
||||||
|
{e.created_at ? ago(Date.parse(e.created_at)) : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function OperationsPage() {
|
export default function OperationsPage() {
|
||||||
const { data, isLoading, error } = useOperations();
|
const { data, isLoading, error } = useOperations();
|
||||||
|
|
||||||
@@ -840,6 +991,9 @@ export default function OperationsPage() {
|
|||||||
<SectionHeader>סוכנים פעילים</SectionHeader>
|
<SectionHeader>סוכנים פעילים</SectionHeader>
|
||||||
<LiveAgentsPanel />
|
<LiveAgentsPanel />
|
||||||
|
|
||||||
|
<SectionHeader>בריאות-הסוכנים</SectionHeader>
|
||||||
|
<AgentHealthPanel />
|
||||||
|
|
||||||
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
||||||
<AgentAdaptersPanel />
|
<AgentAdaptersPanel />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
useAgentActivity,
|
useAgentActivity,
|
||||||
useSendComment,
|
useSendComment,
|
||||||
useSubmitInteraction,
|
useSubmitInteraction,
|
||||||
|
useDismissInteraction,
|
||||||
|
useSetIssueStatus,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
import type {
|
import type {
|
||||||
Interaction,
|
Interaction,
|
||||||
@@ -16,6 +18,8 @@ import type {
|
|||||||
InteractionQuestion,
|
InteractionQuestion,
|
||||||
InteractionTask,
|
InteractionTask,
|
||||||
PaperclipComment,
|
PaperclipComment,
|
||||||
|
PaperclipAgentStatus,
|
||||||
|
PaperclipIssue,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
import { formatRelative } from "@/lib/format-date";
|
import { formatRelative } from "@/lib/format-date";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -29,6 +33,11 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
ChevronDown,
|
||||||
|
Ban,
|
||||||
|
Target,
|
||||||
|
Plus,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ── Role → color mapping ────────────────────────────────────── */
|
/* ── Role → color mapping ────────────────────────────────────── */
|
||||||
@@ -81,6 +90,79 @@ function issueStatusLabel(status: string) {
|
|||||||
return ISSUE_STATUS_LABELS[status] ?? status;
|
return ISSUE_STATUS_LABELS[status] ?? status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Agent roster (mockup 18i) — who's doing what right now ────── */
|
||||||
|
|
||||||
|
const AGENT_STATUS_LABEL: Record<string, string> = {
|
||||||
|
active: "פעיל",
|
||||||
|
running: "פעיל",
|
||||||
|
idle: "ממתין",
|
||||||
|
error: "שגיאה",
|
||||||
|
};
|
||||||
|
|
||||||
|
function agentIsActive(s: string) {
|
||||||
|
return s === "active" || s === "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentStatusDot(s: string) {
|
||||||
|
return agentIsActive(s)
|
||||||
|
? "bg-success shadow-[0_0_6px_rgba(74,124,89,0.6)]"
|
||||||
|
: s === "error"
|
||||||
|
? "bg-danger"
|
||||||
|
: "bg-rule";
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentStatusTone(s: string) {
|
||||||
|
return agentIsActive(s)
|
||||||
|
? "bg-success-bg text-success"
|
||||||
|
: s === "error"
|
||||||
|
? "bg-danger-bg text-danger"
|
||||||
|
: "bg-rule-soft text-ink-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
|
||||||
|
if (!agents.length) return null;
|
||||||
|
|
||||||
|
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
|
<Bot className="w-4 h-4 text-gold-deep shrink-0" />
|
||||||
|
<h3 className="text-[0.82rem] font-bold text-navy">סוכני התיק</h3>
|
||||||
|
<span className="text-[0.72rem] text-ink-muted">
|
||||||
|
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Compact roster — all agents on one row (mockup 18i v2): status · name ·
|
||||||
|
role only. The per-agent "current activity" line was removed: it
|
||||||
|
matched comments by role-fallback, so agents sharing a role showed the
|
||||||
|
same text. */}
|
||||||
|
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
|
||||||
|
{agents.map((a) => {
|
||||||
|
const active = agentIsActive(a.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
||||||
|
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
|
||||||
|
<span
|
||||||
|
className={`inline-block mt-1 text-[0.55rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
|
||||||
|
>
|
||||||
|
{AGENT_STATUS_LABEL[a.status] ?? a.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Time formatting ─────────────────────────────────────────── */
|
/* ── Time formatting ─────────────────────────────────────────── */
|
||||||
|
|
||||||
function timeAgo(iso: string | null): string {
|
function timeAgo(iso: string | null): string {
|
||||||
@@ -506,15 +588,21 @@ function InteractionCard({
|
|||||||
interaction,
|
interaction,
|
||||||
caseNumber,
|
caseNumber,
|
||||||
issueMap,
|
issueMap,
|
||||||
|
defaultOpen = true,
|
||||||
}: {
|
}: {
|
||||||
interaction: Interaction;
|
interaction: Interaction;
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
issueMap: Map<string, string>;
|
issueMap: Map<string, string>;
|
||||||
|
defaultOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const submit = useSubmitInteraction(caseNumber);
|
const submit = useSubmitInteraction(caseNumber);
|
||||||
|
const dismiss = useDismissInteraction(caseNumber);
|
||||||
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
||||||
const isPending = interaction.status === "pending";
|
const isPending = interaction.status === "pending";
|
||||||
const summary = summaryAnswer(interaction);
|
const summary = summaryAnswer(interaction);
|
||||||
|
// Pending interactions collapse into an accordion (mockup 18i v2); resolved
|
||||||
|
// ones always render their one-line summary.
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
|
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
|
||||||
submit.mutate(
|
submit.mutate(
|
||||||
@@ -531,6 +619,16 @@ function InteractionCard({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
dismiss.mutate(
|
||||||
|
{ issue_id: interaction.issue_id, interaction_id: interaction.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("הבקשה בוטלה"),
|
||||||
|
onError: () => toast.error("שגיאה בביטול הבקשה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
||||||
@@ -553,15 +651,30 @@ function InteractionCard({
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<span className="text-sm font-semibold text-navy">
|
|
||||||
{interaction.title || "שאלה לסוכן"}
|
|
||||||
</span>
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
<button
|
||||||
ממתין לתשובה
|
type="button"
|
||||||
</span>
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-2 flex-wrap text-start min-w-0"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-navy">
|
||||||
|
{interaction.title || "שאלה לסוכן"}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
||||||
|
ממתין לתשובה
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<ResolvedBadge status={interaction.status} />
|
<>
|
||||||
|
<span className="text-sm font-semibold text-navy">
|
||||||
|
{interaction.title || "שאלה לסוכן"}
|
||||||
|
</span>
|
||||||
|
<ResolvedBadge status={interaction.status} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{identifier && (
|
{identifier && (
|
||||||
<Badge variant="outline" className="text-[10px] font-mono">
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
@@ -572,39 +685,58 @@ function InteractionCard({
|
|||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
{isPending && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
disabled={dismiss.isPending}
|
||||||
|
className="h-7 px-2 text-ink-muted hover:text-danger"
|
||||||
|
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
|
||||||
|
>
|
||||||
|
{dismiss.isPending ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Ban className="w-3.5 h-3.5 me-1" />
|
||||||
|
)}
|
||||||
|
התעלם
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{interaction.summary && (
|
{interaction.summary && (!isPending || open) && (
|
||||||
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
interaction.kind === "ask_user_questions" ? (
|
open ? (
|
||||||
<AskUserQuestionsForm
|
interaction.kind === "ask_user_questions" ? (
|
||||||
interaction={interaction}
|
<AskUserQuestionsForm
|
||||||
onSubmit={(answers) => send("respond", { answers })}
|
interaction={interaction}
|
||||||
pending={submit.isPending}
|
onSubmit={(answers) => send("respond", { answers })}
|
||||||
/>
|
pending={submit.isPending}
|
||||||
) : interaction.kind === "request_confirmation" ? (
|
/>
|
||||||
<RequestConfirmationForm
|
) : interaction.kind === "request_confirmation" ? (
|
||||||
interaction={interaction}
|
<RequestConfirmationForm
|
||||||
onAccept={() => send("accept", {})}
|
interaction={interaction}
|
||||||
onReject={(reason) =>
|
onAccept={() => send("accept", {})}
|
||||||
send("reject", reason ? { reason } : {})
|
onReject={(reason) =>
|
||||||
}
|
send("reject", reason ? { reason } : {})
|
||||||
pending={submit.isPending}
|
}
|
||||||
/>
|
pending={submit.isPending}
|
||||||
) : interaction.kind === "suggest_tasks" ? (
|
/>
|
||||||
<SuggestTasksForm
|
) : interaction.kind === "suggest_tasks" ? (
|
||||||
interaction={interaction}
|
<SuggestTasksForm
|
||||||
onAccept={(keys) =>
|
interaction={interaction}
|
||||||
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
onAccept={(keys) =>
|
||||||
}
|
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
||||||
onReject={(reason) =>
|
}
|
||||||
send("reject", reason ? { reason } : {})
|
onReject={(reason) =>
|
||||||
}
|
send("reject", reason ? { reason } : {})
|
||||||
pending={submit.isPending}
|
}
|
||||||
/>
|
pending={submit.isPending}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
) : null
|
) : null
|
||||||
) : summary ? (
|
) : summary ? (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
@@ -616,6 +748,480 @@ function InteractionCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Issue group (timeline grouped by issue, accordion) ──────── */
|
||||||
|
|
||||||
|
type FeedItem =
|
||||||
|
| { kind: "comment"; at: number; comment: PaperclipComment }
|
||||||
|
| { kind: "interaction"; at: number; interaction: Interaction };
|
||||||
|
|
||||||
|
/** Drop the "[ערר 1043-02-26] " prefix Paperclip puts on every issue title. */
|
||||||
|
function shortIssueTitle(title: string): string {
|
||||||
|
return title.replace(/^\s*\[[^\]]*\]\s*/, "").trim() || title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueGroup({
|
||||||
|
issue,
|
||||||
|
items,
|
||||||
|
caseNumber,
|
||||||
|
issueMap,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
issue: PaperclipIssue;
|
||||||
|
items: FeedItem[];
|
||||||
|
caseNumber: string;
|
||||||
|
issueMap: Map<string, string>;
|
||||||
|
defaultOpen: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const setStatus = useSetIssueStatus(caseNumber);
|
||||||
|
const closed = issue.status === "done" || issue.status === "cancelled";
|
||||||
|
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
|
||||||
|
// the live CEO root) so the hierarchy is visible, not flattened.
|
||||||
|
const parentIdentifier = issue.parent_id
|
||||||
|
? issueMap.get(issue.parent_id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const close = (status: "done" | "cancelled") =>
|
||||||
|
setStatus.mutate(
|
||||||
|
{ issue_id: issue.id, status },
|
||||||
|
{
|
||||||
|
onSuccess: () =>
|
||||||
|
toast.success(
|
||||||
|
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
|
||||||
|
),
|
||||||
|
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b-2 border-rule last:border-b-0">
|
||||||
|
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex-1 flex items-center gap-2 text-start min-w-0"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant={closed ? "secondary" : "default"}
|
||||||
|
className="text-[10px] font-mono shrink-0"
|
||||||
|
>
|
||||||
|
{issue.identifier}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
||||||
|
{shortIssueTitle(issue.title)}
|
||||||
|
</span>
|
||||||
|
{parentIdentifier && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||||
|
title={`תת-משימה של ${parentIdentifier}`}
|
||||||
|
>
|
||||||
|
↳ {parentIdentifier}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||||||
|
{issueStatusLabel(issue.status)} · {items.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!closed && (
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => close("done")}
|
||||||
|
disabled={setStatus.isPending}
|
||||||
|
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
|
||||||
|
title="סמן את המשימה כהושלמה"
|
||||||
|
>
|
||||||
|
{setStatus.isPending ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
|
||||||
|
)}
|
||||||
|
הושלם
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => close("cancelled")}
|
||||||
|
disabled={setStatus.isPending}
|
||||||
|
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
|
||||||
|
title="בטל את המשימה"
|
||||||
|
>
|
||||||
|
בטל
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-label={open ? "כווץ" : "הרחב"}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="pb-1">
|
||||||
|
{items.map((item) =>
|
||||||
|
item.kind === "comment" ? (
|
||||||
|
<CommentCard
|
||||||
|
key={`c-${item.comment.id}`}
|
||||||
|
comment={item.comment}
|
||||||
|
issueMap={issueMap}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InteractionCard
|
||||||
|
key={`i-${item.interaction.id}`}
|
||||||
|
interaction={item.interaction}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
issueMap={issueMap}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Compose: directive box + target selector (top of tab) ────── */
|
||||||
|
|
||||||
|
const isOpenStatus = (s: string) => s !== "done" && s !== "cancelled";
|
||||||
|
|
||||||
|
/** Client mirror of the server's pick_default_comment_target
|
||||||
|
* (web/paperclip_client.py): prefer the newest OPEN top-level issue (the live
|
||||||
|
* CEO main issue), then any open issue, then any top-level, else newest.
|
||||||
|
* `issues` arrives oldest→newest, so the last match is the newest. */
|
||||||
|
function pickDefaultTarget(issues: PaperclipIssue[]): PaperclipIssue | null {
|
||||||
|
if (!issues.length) return null;
|
||||||
|
const isTop = (i: PaperclipIssue) => i.parent_id == null;
|
||||||
|
const preds: ((i: PaperclipIssue) => boolean)[] = [
|
||||||
|
(i) => isOpenStatus(i.status) && isTop(i),
|
||||||
|
(i) => isOpenStatus(i.status),
|
||||||
|
isTop,
|
||||||
|
];
|
||||||
|
for (const p of preds) {
|
||||||
|
const m = issues.filter(p);
|
||||||
|
if (m.length) return m[m.length - 1];
|
||||||
|
}
|
||||||
|
return issues[issues.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, string> = {
|
||||||
|
in_progress: "bg-emerald-100 text-emerald-700",
|
||||||
|
in_review: "bg-amber-100 text-amber-700",
|
||||||
|
todo: "bg-emerald-100 text-emerald-700",
|
||||||
|
backlog: "bg-emerald-100 text-emerald-700",
|
||||||
|
blocked: "bg-red-100 text-red-700",
|
||||||
|
done: "bg-gray-100 text-gray-500",
|
||||||
|
cancelled: "bg-red-50 text-red-600",
|
||||||
|
};
|
||||||
|
function statusTone(s: string) {
|
||||||
|
return STATUS_TONE[s] ?? "bg-gray-100 text-gray-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a chair instruction is routed. `auto` lets the server pick the live
|
||||||
|
* CEO main issue; `issue` targets an explicit one; `new_run` opens a fresh
|
||||||
|
* CEO-owned run (bypasses the human-owned-issue cancellation). */
|
||||||
|
type Target =
|
||||||
|
| { kind: "auto" }
|
||||||
|
| { kind: "issue"; id: string }
|
||||||
|
| { kind: "new_run" };
|
||||||
|
|
||||||
|
function TargetSelector({
|
||||||
|
issues,
|
||||||
|
target,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
issues: PaperclipIssue[];
|
||||||
|
target: Target;
|
||||||
|
onChange: (t: Target) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
|
||||||
|
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
|
||||||
|
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
|
||||||
|
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
|
||||||
|
const idToIdentifier = useMemo(
|
||||||
|
() => new Map(issues.map((i) => [i.id, i.identifier])),
|
||||||
|
[issues],
|
||||||
|
);
|
||||||
|
const parentTag = (i: PaperclipIssue) =>
|
||||||
|
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
|
||||||
|
|
||||||
|
// The issue the pill currently represents (explicit pick, or the auto default).
|
||||||
|
const effectiveIssue =
|
||||||
|
target.kind === "issue"
|
||||||
|
? issues.find((i) => i.id === target.id) ?? null
|
||||||
|
: target.kind === "auto"
|
||||||
|
? defaultIssue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-2 bg-sand-soft/60 border border-rule rounded-lg px-2.5 py-1.5 hover:border-gold/60 min-w-0 max-w-full"
|
||||||
|
>
|
||||||
|
<Target className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||||
|
{target.kind === "new_run" ? (
|
||||||
|
<span className="text-xs font-semibold text-navy">פתח הרצה חדשה</span>
|
||||||
|
) : effectiveIssue ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||||
|
{effectiveIssue.identifier}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs font-medium text-navy truncate">
|
||||||
|
{shortIssueTitle(effectiveIssue.title)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(effectiveIssue.status)}`}
|
||||||
|
>
|
||||||
|
{issueStatusLabel(effectiveIssue.status)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-ink-muted">בחר יעד</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 text-ink-faint shrink-0 ms-1" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
|
||||||
|
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
|
||||||
|
visible without scrolling to the bottom of a long task list. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange({ kind: "new_run" });
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
|
||||||
|
>
|
||||||
|
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-xs font-semibold text-navy">
|
||||||
|
פתח הרצה חדשה
|
||||||
|
</span>
|
||||||
|
<span className="block text-[10px] text-ink-muted">
|
||||||
|
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||||
|
|
||||||
|
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||||
|
משימות פעילות
|
||||||
|
</div>
|
||||||
|
{activeIssues.map((i) => {
|
||||||
|
const selected =
|
||||||
|
(target.kind === "issue" && target.id === i.id) ||
|
||||||
|
(target.kind === "auto" && defaultIssue?.id === i.id);
|
||||||
|
const parent = parentTag(i);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange({ kind: "issue", id: i.id });
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
|
||||||
|
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||||
|
{i.identifier}
|
||||||
|
</Badge>
|
||||||
|
{parent && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||||
|
title={`תת-משימה של ${parent}`}
|
||||||
|
>
|
||||||
|
↳ {parent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-ink-soft truncate flex-1">
|
||||||
|
{shortIssueTitle(i.title)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||||
|
>
|
||||||
|
{issueStatusLabel(i.status)}
|
||||||
|
</span>
|
||||||
|
{selected && (
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{closedIssues.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||||
|
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||||
|
משימות סגורות · שליחה אליהן לא תעיר סוכן
|
||||||
|
</div>
|
||||||
|
{closedIssues.map((i) => {
|
||||||
|
const parent = parentTag(i);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i.id}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
|
||||||
|
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
|
||||||
|
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||||
|
{i.identifier}
|
||||||
|
</Badge>
|
||||||
|
{parent && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||||
|
title={`תת-משימה של ${parent}`}
|
||||||
|
>
|
||||||
|
↳ {parent}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-ink-faint truncate flex-1">
|
||||||
|
{shortIssueTitle(i.title)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||||
|
>
|
||||||
|
{issueStatusLabel(i.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Composer({
|
||||||
|
caseNumber,
|
||||||
|
issues,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
issues: PaperclipIssue[];
|
||||||
|
}) {
|
||||||
|
const sendComment = useSendComment(caseNumber);
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [target, setTarget] = useState<Target>({ kind: "auto" });
|
||||||
|
|
||||||
|
const selectedIssue =
|
||||||
|
target.kind === "issue" ? issues.find((i) => i.id === target.id) ?? null : null;
|
||||||
|
const closedTarget = !!selectedIssue && !isOpenStatus(selectedIssue.status);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!body.trim() || closedTarget) return;
|
||||||
|
const vars =
|
||||||
|
target.kind === "new_run"
|
||||||
|
? { body: body.trim(), new_run: true }
|
||||||
|
: target.kind === "issue"
|
||||||
|
? { body: body.trim(), issue_id: target.id }
|
||||||
|
: { body: body.trim() };
|
||||||
|
sendComment.mutate(vars, {
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setBody("");
|
||||||
|
setTarget({ kind: "auto" });
|
||||||
|
toast.success(
|
||||||
|
res.new_run
|
||||||
|
? `נפתחה הרצה חדשה — ${res.issue_identifier}`
|
||||||
|
: `נשלח ל-${res.issue_identifier}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-rule rounded-xl shadow-sm p-3.5">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
|
<MessageSquare className="w-4 h-4 text-gold-deep" />
|
||||||
|
<h3 className="text-sm font-bold text-navy">הוראה לסוכנים</h3>
|
||||||
|
<span className="text-[11px] text-ink-faint ms-auto">
|
||||||
|
Ctrl+Enter לשליחה
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-[11px] font-semibold text-ink-muted shrink-0">
|
||||||
|
שליחה אל:
|
||||||
|
</span>
|
||||||
|
<TargetSelector issues={issues} target={target} onChange={setTarget} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{closedTarget && (
|
||||||
|
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-2 text-[11.5px] text-red-700 leading-snug">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<b className="font-bold">יעד סגור.</b> שליחה למשימה סגורה לא תעיר סוכן —
|
||||||
|
ההוראה לא תטופל. בחר משימה פעילה או{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-bold underline"
|
||||||
|
onClick={() => setTarget({ kind: "new_run" })}
|
||||||
|
>
|
||||||
|
פתח הרצה חדשה
|
||||||
|
</button>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="כתוב הוראה לסוכנים..."
|
||||||
|
aria-label="הוראה לסוכנים"
|
||||||
|
className="min-h-[60px] resize-none text-sm"
|
||||||
|
dir="rtl"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<span className="text-[11px] text-ink-faint">
|
||||||
|
ההודעה תנותב דרך סוכן ה-CEO · היעד ניתן לשינוי למעלה
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="ms-auto"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!body.trim() || closedTarget || sendComment.isPending}
|
||||||
|
>
|
||||||
|
{sendComment.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 me-1" />
|
||||||
|
)}
|
||||||
|
שלח
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function AgentActivityFeed({
|
export function AgentActivityFeed({
|
||||||
@@ -624,9 +1230,6 @@ export function AgentActivityFeed({
|
|||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
}) {
|
}) {
|
||||||
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
||||||
const sendComment = useSendComment(caseNumber);
|
|
||||||
const [body, setBody] = useState("");
|
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
||||||
// and a fresh Map each render would defeat the child cards' memoization).
|
// and a fresh Map each render would defeat the child cards' memoization).
|
||||||
@@ -636,27 +1239,6 @@ export function AgentActivityFeed({
|
|||||||
return m;
|
return m;
|
||||||
}, [data?.issues]);
|
}, [data?.issues]);
|
||||||
|
|
||||||
// Auto-scroll on new comments or interactions
|
|
||||||
const commentCount = data?.comments?.length ?? 0;
|
|
||||||
const interactionCount = data?.interactions?.length ?? 0;
|
|
||||||
useEffect(() => {
|
|
||||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}, [commentCount, interactionCount]);
|
|
||||||
|
|
||||||
const handleSend = () => {
|
|
||||||
if (!body.trim()) return;
|
|
||||||
sendComment.mutate(
|
|
||||||
{ body: body.trim() },
|
|
||||||
{
|
|
||||||
onSuccess: (res) => {
|
|
||||||
setBody("");
|
|
||||||
toast.success(`נשלח ל-${res.issue_identifier}`);
|
|
||||||
},
|
|
||||||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Empty / loading states ──
|
// ── Empty / loading states ──
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -689,24 +1271,46 @@ export function AgentActivityFeed({
|
|||||||
|
|
||||||
const comments = data.comments ?? [];
|
const comments = data.comments ?? [];
|
||||||
const interactions = data.interactions ?? [];
|
const interactions = data.interactions ?? [];
|
||||||
|
// Pending interactions are surfaced at the top (awaiting-you, mockup 18i) and
|
||||||
|
// NOT repeated in the timeline below.
|
||||||
|
const pending = interactions.filter((i) => i.status === "pending");
|
||||||
|
|
||||||
// Unified, time-sorted feed: comments + interactions interleaved.
|
// Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
|
||||||
type FeedItem =
|
// resolved interactions; pending ones are surfaced above and not repeated.
|
||||||
| { kind: "comment"; at: number; comment: PaperclipComment }
|
const itemsByIssue = new Map<string, FeedItem[]>();
|
||||||
| { kind: "interaction"; at: number; interaction: Interaction };
|
const pushItem = (issueId: string, item: FeedItem) => {
|
||||||
|
const arr = itemsByIssue.get(issueId);
|
||||||
const feed: FeedItem[] = [
|
if (arr) arr.push(item);
|
||||||
...comments.map<FeedItem>((c) => ({
|
else itemsByIssue.set(issueId, [item]);
|
||||||
|
};
|
||||||
|
for (const c of comments) {
|
||||||
|
pushItem(c.issue_id, {
|
||||||
kind: "comment",
|
kind: "comment",
|
||||||
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
||||||
comment: c,
|
comment: c,
|
||||||
})),
|
});
|
||||||
...interactions.map<FeedItem>((i) => ({
|
}
|
||||||
|
for (const i of interactions) {
|
||||||
|
if (i.status === "pending") continue;
|
||||||
|
pushItem(i.issue_id, {
|
||||||
kind: "interaction",
|
kind: "interaction",
|
||||||
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
||||||
interaction: i,
|
interaction: i,
|
||||||
})),
|
});
|
||||||
].sort((a, b) => a.at - b.at);
|
}
|
||||||
|
for (const arr of itemsByIssue.values()) arr.sort((a, b) => a.at - b.at);
|
||||||
|
|
||||||
|
// Order groups by most-recent activity so live work floats to the top.
|
||||||
|
const issueGroups = data.issues
|
||||||
|
.map((iss) => {
|
||||||
|
const items = itemsByIssue.get(iss.id) ?? [];
|
||||||
|
const maxAt = items.reduce((m, it) => Math.max(m, it.at), 0);
|
||||||
|
return { iss, items, maxAt };
|
||||||
|
})
|
||||||
|
.filter((g) => g.items.length > 0)
|
||||||
|
.sort((a, b) => b.maxAt - a.maxAt);
|
||||||
|
|
||||||
|
const totalFeedItems = issueGroups.reduce((n, g) => n + g.items.length, 0);
|
||||||
|
|
||||||
// An issue is "active" if it's not done/cancelled. When everything is closed
|
// An issue is "active" if it's not done/cancelled. When everything is closed
|
||||||
// we should NOT show the "agents are working, waiting for report" spinner.
|
// we should NOT show the "agents are working, waiting for report" spinner.
|
||||||
@@ -715,23 +1319,51 @@ export function AgentActivityFeed({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full gap-3">
|
||||||
{/* Issue summary bar */}
|
{/* directive box — moved to the TOP, above the activity (most available,
|
||||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
|
no scrolling). Carries the target selector. */}
|
||||||
{data.issues.map((iss) => (
|
<Composer caseNumber={caseNumber} issues={data.issues} />
|
||||||
<Badge
|
|
||||||
key={iss.id}
|
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||||
variant={iss.status === "done" ? "secondary" : "default"}
|
<AgentRoster agents={data.agents ?? []} />
|
||||||
className="text-[11px] font-mono"
|
|
||||||
>
|
{/* pending interactions surfaced to the top — each an accordion, all
|
||||||
{iss.identifier} — {issueStatusLabel(iss.status)}
|
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
|
||||||
</Badge>
|
{pending.length > 0 && (
|
||||||
))}
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
ממתין לתשובתך
|
||||||
|
<span className="text-[0.7rem] font-semibold bg-amber-100 rounded-full px-2 py-0.5">
|
||||||
|
{pending.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{pending.map((i) => (
|
||||||
|
<InteractionCard
|
||||||
|
key={`pending-${i.id}`}
|
||||||
|
interaction={i}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
issueMap={issueMap}
|
||||||
|
defaultOpen={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timeline header */}
|
||||||
|
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule">
|
||||||
|
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||||
|
<span className="text-[0.68rem] font-semibold text-gold-deep bg-gold/10 rounded-full px-2 py-0.5">
|
||||||
|
החדש למעלה
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.72rem] text-ink-muted ms-auto">
|
||||||
|
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments + interactions stream */}
|
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||||
{feed.length === 0 ? (
|
{totalFeedItems === 0 ? (
|
||||||
hasActiveIssue ? (
|
hasActiveIssue ? (
|
||||||
<div className="text-center py-8 text-ink-faint text-sm">
|
<div className="text-center py-8 text-ink-faint text-sm">
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
@@ -744,59 +1376,17 @@ export function AgentActivityFeed({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
feed.map((item) =>
|
issueGroups.map(({ iss, items }) => (
|
||||||
item.kind === "comment" ? (
|
<IssueGroup
|
||||||
<CommentCard
|
key={iss.id}
|
||||||
key={`c-${item.comment.id}`}
|
issue={iss}
|
||||||
comment={item.comment}
|
items={items}
|
||||||
issueMap={issueMap}
|
caseNumber={caseNumber}
|
||||||
/>
|
issueMap={issueMap}
|
||||||
) : (
|
defaultOpen={false}
|
||||||
<InteractionCard
|
/>
|
||||||
key={`i-${item.interaction.id}`}
|
))
|
||||||
interaction={item.interaction}
|
|
||||||
caseNumber={caseNumber}
|
|
||||||
issueMap={issueMap}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
<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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,17 +10,14 @@ import {
|
|||||||
APPEAL_SUBTYPE_LABELS,
|
APPEAL_SUBTYPE_LABELS,
|
||||||
isBlamSubtype,
|
isBlamSubtype,
|
||||||
} from "@/lib/practice-area";
|
} from "@/lib/practice-area";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
import type { CaseDetail } from "@/lib/api/cases";
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
function partiesLine(data?: CaseDetail): string | null {
|
|
||||||
const appellant = data?.appellants?.filter(Boolean) ?? [];
|
|
||||||
const respondent = data?.respondents?.filter(Boolean) ?? [];
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (appellant.length) parts.push(`עוררת: ${appellant.join(", ")}`);
|
|
||||||
if (respondent.length) parts.push(`משיבה: ${respondent.join(", ")}`);
|
|
||||||
return parts.length ? parts.join(" · ") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Case header — parchment band (IA-redesign mockup 17): full-bleed band with
|
* Case header — parchment band (IA-redesign mockup 17): full-bleed band with
|
||||||
* the case title + status/type chips inline, a parties line, the case actions
|
* the case title + status/type chips inline, a parties line, the case actions
|
||||||
@@ -38,7 +35,9 @@ export function CaseHeader({
|
|||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
tabs?: ReactNode;
|
tabs?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const parties = partiesLine(data);
|
const appellants = data?.appellants?.filter(Boolean) ?? [];
|
||||||
|
const respondents = data?.respondents?.filter(Boolean) ?? [];
|
||||||
|
const hasParties = appellants.length > 0 || respondents.length > 0;
|
||||||
const isBlam =
|
const isBlam =
|
||||||
data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype);
|
data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype);
|
||||||
|
|
||||||
@@ -61,6 +60,12 @@ export function CaseHeader({
|
|||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
|
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
|
{/* case subject on the same line, smaller font (chair request) */}
|
||||||
|
{data?.title && (
|
||||||
|
<span className="text-navy/75 text-lg font-semibold leading-snug">
|
||||||
|
{data.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{data?.status && <StatusBadge status={data.status} />}
|
{data?.status && <StatusBadge status={data.status} />}
|
||||||
{data?.archived_at && (
|
{data?.archived_at && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -92,27 +97,44 @@ export function CaseHeader({
|
|||||||
)}
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* case title / subject under the heading */}
|
{/* case actions — parties now live in a popover (chair request) so
|
||||||
{data?.title && (
|
they don't push the tab content down the page. */}
|
||||||
<p className="text-navy/90 text-base font-semibold mt-2 max-w-3xl leading-snug">
|
|
||||||
{data.title}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{/* parties line (mockup .parties) — clamped to 2 lines so a long list
|
|
||||||
of appellants/respondents doesn't grow the band; full text on hover. */}
|
|
||||||
{parties ? (
|
|
||||||
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl line-clamp-2" title={parties}>
|
|
||||||
{parties}
|
|
||||||
</p>
|
|
||||||
) : data?.subject ? (
|
|
||||||
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl leading-relaxed line-clamp-2"
|
|
||||||
title={data.subject}>
|
|
||||||
{data.subject}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* case actions — kept verbatim, moved into the band */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap mt-3">
|
<div className="flex items-center gap-2 flex-wrap mt-3">
|
||||||
|
{hasParties && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-rule bg-surface px-3 py-1.5 text-[0.82rem] font-semibold text-ink-soft hover:bg-parchment transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
צדדים
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-72 p-3 space-y-2.5 text-sm">
|
||||||
|
{appellants.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] font-bold text-gold-deep uppercase tracking-wider mb-1">
|
||||||
|
עוררים
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5 text-ink-soft">
|
||||||
|
{appellants.map((a, i) => <li key={`a${i}`}>{a}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{respondents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] font-bold text-gold-deep uppercase tracking-wider mb-1">
|
||||||
|
משיבים
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5 text-ink-soft">
|
||||||
|
{respondents.map((r, i) => <li key={`r${i}`}>{r}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
{data?.case_number && (
|
{data?.case_number && (
|
||||||
<CaseArchiveAction
|
<CaseArchiveAction
|
||||||
caseNumber={data.case_number}
|
caseNumber={data.case_number}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
APPRAISER_SIDE_LABELS,
|
APPRAISER_SIDE_LABELS,
|
||||||
APPRAISER_SIDE_OPTIONS,
|
APPRAISER_SIDE_OPTIONS,
|
||||||
DOC_TYPE_OPTIONS,
|
DOC_TYPE_OPTIONS,
|
||||||
|
PROTOCOL_SCOPE_OPTIONS,
|
||||||
appraiserSideLabel,
|
appraiserSideLabel,
|
||||||
doctypeLabel,
|
doctypeLabel,
|
||||||
doctypeTone,
|
doctypeTone,
|
||||||
@@ -29,14 +31,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
useExtractAppraiserFacts,
|
useExtractAppraiserFacts,
|
||||||
usePatchDocument,
|
usePatchDocument,
|
||||||
|
type DocumentPatch,
|
||||||
type ExtractAppraiserFactsResponse,
|
type ExtractAppraiserFactsResponse,
|
||||||
} from "@/lib/api/documents";
|
} from "@/lib/api/documents";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
||||||
* Popover with two Selects:
|
* Popover with:
|
||||||
* 1. doc_type (always shown)
|
* 1. doc_type (always shown)
|
||||||
* 2. appraiser_side (only when doc_type === "appraisal")
|
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||||
|
* 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית
|
||||||
|
* 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4
|
||||||
*
|
*
|
||||||
* After a successful save we swap the Popover body to a confirmation view
|
* After a successful save we swap the Popover body to a confirmation view
|
||||||
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
|
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
|
||||||
@@ -49,15 +54,25 @@ export function DocumentTypeEditor({
|
|||||||
docId,
|
docId,
|
||||||
docType,
|
docType,
|
||||||
appraiserSide,
|
appraiserSide,
|
||||||
|
isPostHearing,
|
||||||
|
protocolScope,
|
||||||
}: {
|
}: {
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
docType: string;
|
docType: string;
|
||||||
appraiserSide?: string;
|
appraiserSide?: string;
|
||||||
|
isPostHearing?: boolean;
|
||||||
|
protocolScope?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// Effective stored scope: absent === "appeal" (ועדת הערר) by convention.
|
||||||
|
const storedScope = protocolScope === "lower" ? "lower" : "appeal";
|
||||||
|
const storedPost = isPostHearing === true;
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [draftType, setDraftType] = useState<string>(docType || "");
|
const [draftType, setDraftType] = useState<string>(docType || "");
|
||||||
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
||||||
|
const [draftScope, setDraftScope] = useState<string>(storedScope);
|
||||||
|
const [draftPost, setDraftPost] = useState<boolean>(storedPost);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [extractResult, setExtractResult] =
|
const [extractResult, setExtractResult] =
|
||||||
useState<ExtractAppraiserFactsResponse | null>(null);
|
useState<ExtractAppraiserFactsResponse | null>(null);
|
||||||
@@ -67,6 +82,8 @@ export function DocumentTypeEditor({
|
|||||||
function reset() {
|
function reset() {
|
||||||
setDraftType(docType || "");
|
setDraftType(docType || "");
|
||||||
setDraftSide(appraiserSide || "");
|
setDraftSide(appraiserSide || "");
|
||||||
|
setDraftScope(storedScope);
|
||||||
|
setDraftPost(storedPost);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
setExtractResult(null);
|
setExtractResult(null);
|
||||||
patch.reset();
|
patch.reset();
|
||||||
@@ -74,14 +91,17 @@ export function DocumentTypeEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAppraisal = draftType === "appraisal";
|
const isAppraisal = draftType === "appraisal";
|
||||||
|
const isProtocol = draftType === "protocol";
|
||||||
const sideMissing = isAppraisal && !draftSide;
|
const sideMissing = isAppraisal && !draftSide;
|
||||||
const dirty =
|
const dirty =
|
||||||
draftType !== docType ||
|
draftType !== docType ||
|
||||||
(isAppraisal && draftSide !== (appraiserSide || ""));
|
(isAppraisal && draftSide !== (appraiserSide || "")) ||
|
||||||
|
(isProtocol && draftScope !== storedScope) ||
|
||||||
|
draftPost !== storedPost;
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (sideMissing || !dirty) return;
|
if (sideMissing || !dirty) return;
|
||||||
const body: { doc_type?: string; appraiser_side?: string } = {};
|
const body: DocumentPatch = {};
|
||||||
if (draftType !== docType) body.doc_type = draftType;
|
if (draftType !== docType) body.doc_type = draftType;
|
||||||
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
||||||
body.appraiser_side = draftSide;
|
body.appraiser_side = draftSide;
|
||||||
@@ -90,6 +110,16 @@ export function DocumentTypeEditor({
|
|||||||
// clear it so it doesn't dangle confusingly in metadata.
|
// clear it so it doesn't dangle confusingly in metadata.
|
||||||
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
||||||
|
|
||||||
|
// protocol_scope: store only the non-default "lower"; anything else clears
|
||||||
|
// back to the implicit ועדת-הערר default. Also clear when leaving protocol.
|
||||||
|
if (isProtocol && draftScope !== storedScope) {
|
||||||
|
body.protocol_scope = draftScope === "lower" ? "lower" : "";
|
||||||
|
} else if (!isProtocol && protocolScope) {
|
||||||
|
body.protocol_scope = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftPost !== storedPost) body.is_post_hearing = draftPost;
|
||||||
|
|
||||||
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
||||||
// rejection from this async click handler would otherwise leak.
|
// rejection from this async click handler would otherwise leak.
|
||||||
try {
|
try {
|
||||||
@@ -190,6 +220,46 @@ export function DocumentTypeEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isProtocol && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-ink-muted">פרוטוקול של</label>
|
||||||
|
<Select value={draftScope} onValueChange={setDraftScope} dir="rtl">
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROTOCOL_SCOPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||||
|
ועדת הערר → קבוצת הפרוטוקול · מקומית/מחוזית → מסמכים נלווים
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-3 rounded-md border border-rule bg-parchment/40 px-3 py-2.5">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<label
|
||||||
|
htmlFor={`post-${docId}`}
|
||||||
|
className="text-xs font-medium text-ink block"
|
||||||
|
>
|
||||||
|
התקבל אחרי הדיון
|
||||||
|
</label>
|
||||||
|
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||||
|
מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`post-${docId}`}
|
||||||
|
checked={draftPost}
|
||||||
|
onCheckedChange={setDraftPost}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{patch.isError && (
|
{patch.isError && (
|
||||||
<p className="text-[0.7rem] text-danger">
|
<p className="text-[0.7rem] text-danger">
|
||||||
שמירה נכשלה. נסה שוב.
|
שמירה נכשלה. נסה שוב.
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import { casesKeys } from "@/lib/api/cases";
|
|||||||
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
||||||
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
||||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||||
|
import {
|
||||||
|
documentGroup,
|
||||||
|
DOC_GROUP_ORDER,
|
||||||
|
DOC_GROUP_LABELS,
|
||||||
|
DOC_GROUP_HINTS,
|
||||||
|
type DocGroup,
|
||||||
|
} from "@/lib/doc-types";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||||
@@ -224,6 +231,13 @@ function DocumentRow({
|
|||||||
const canPreview =
|
const canPreview =
|
||||||
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
||||||
|
|
||||||
|
const meta = doc.metadata as
|
||||||
|
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
|
||||||
|
| undefined;
|
||||||
|
const isPostHearing = meta?.is_post_hearing === true;
|
||||||
|
const protocolScope = meta?.protocol_scope;
|
||||||
|
const isProtocol = doc.doc_type === "protocol";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
||||||
@@ -246,15 +260,24 @@ function DocumentRow({
|
|||||||
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{isProtocol && (
|
||||||
|
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
|
||||||
|
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPostHearing && (
|
||||||
|
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
|
||||||
|
לאחר הדיון
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{doc.doc_type && (
|
{doc.doc_type && (
|
||||||
<DocumentTypeEditor
|
<DocumentTypeEditor
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
docId={doc.id}
|
docId={doc.id}
|
||||||
docType={doc.doc_type}
|
docType={doc.doc_type}
|
||||||
appraiserSide={
|
appraiserSide={meta?.appraiser_side}
|
||||||
(doc.metadata as { appraiser_side?: string } | undefined)
|
isPostHearing={isPostHearing}
|
||||||
?.appraiser_side
|
protocolScope={protocolScope}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -306,6 +329,30 @@ function DocumentsShell({ count, children }: { count: number; children: ReactNod
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Group section header ──────────────────────────────────────────
|
||||||
|
* The list is split into four convenience buckets (see documentGroup).
|
||||||
|
* The number is the fixed 1-4 position in DOC_GROUP_ORDER so a group keeps
|
||||||
|
* its identity even when earlier groups are empty and hidden. */
|
||||||
|
function GroupHeader({ group, count }: { group: DocGroup; count: number }) {
|
||||||
|
const index = DOC_GROUP_ORDER.indexOf(group) + 1;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 px-2 -mx-2 py-2 border-b border-rule-soft bg-parchment/50">
|
||||||
|
<span className="w-[19px] h-[19px] shrink-0 rounded-full bg-navy text-cream text-[0.66rem] font-bold flex items-center justify-center tabular-nums">
|
||||||
|
{index}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.8rem] font-bold text-navy shrink-0">
|
||||||
|
{DOC_GROUP_LABELS[group]}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[0.62rem] font-bold text-gold-deep bg-gold-wash border border-rule rounded-full px-1.5 tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.66rem] text-ink-muted font-medium truncate">
|
||||||
|
{DOC_GROUP_HINTS[group]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DocumentsPanel({
|
export function DocumentsPanel({
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
@@ -329,6 +376,16 @@ export function DocumentsPanel({
|
|||||||
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
|
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bucket into the four overview groups. Iterating `sorted` preserves the
|
||||||
|
// within-group status ordering. Grouping is display-only — see documentGroup.
|
||||||
|
const grouped = new Map<DocGroup, CaseDocument[]>();
|
||||||
|
for (const doc of sorted) {
|
||||||
|
const g = documentGroup(doc.doc_type, doc.metadata);
|
||||||
|
const bucket = grouped.get(g);
|
||||||
|
if (bucket) bucket.push(doc);
|
||||||
|
else grouped.set(g, [doc]);
|
||||||
|
}
|
||||||
|
|
||||||
const done = docs.filter(
|
const done = docs.filter(
|
||||||
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
|
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
|
||||||
).length;
|
).length;
|
||||||
@@ -378,12 +435,21 @@ export function DocumentsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden" dir="rtl">
|
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden space-y-2" dir="rtl">
|
||||||
<ul className="divide-y divide-rule" dir="rtl">
|
{DOC_GROUP_ORDER.map((group) => {
|
||||||
{sorted.map((doc) => (
|
const groupDocs = grouped.get(group);
|
||||||
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
if (!groupDocs || groupDocs.length === 0) return null;
|
||||||
))}
|
return (
|
||||||
</ul>
|
<section key={group}>
|
||||||
|
<GroupHeader group={group} count={groupDocs.length} />
|
||||||
|
<ul className="divide-y divide-rule" dir="rtl">
|
||||||
|
{groupDocs.map((doc) => (
|
||||||
|
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocumentsShell>
|
</DocumentsShell>
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ import {
|
|||||||
useUploadFinalDecision,
|
useUploadFinalDecision,
|
||||||
useRunFinalLearning,
|
useRunFinalLearning,
|
||||||
useRunFinalHalacha,
|
useRunFinalHalacha,
|
||||||
|
type ExportFile,
|
||||||
} from "@/lib/api/exports";
|
} from "@/lib/api/exports";
|
||||||
|
import {
|
||||||
|
usePartyClaimsSummary,
|
||||||
|
useGeneratePartyClaimsSummary,
|
||||||
|
useGenerateInterimDraft,
|
||||||
|
useGenerateStatus,
|
||||||
|
} from "@/lib/api/generate";
|
||||||
|
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
|
||||||
import {
|
import {
|
||||||
useCaseFeedback,
|
useCaseFeedback,
|
||||||
useCreateFeedback,
|
useCreateFeedback,
|
||||||
@@ -55,6 +63,10 @@ import {
|
|||||||
Stamp,
|
Stamp,
|
||||||
Link2,
|
Link2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Zap,
|
||||||
|
ClipboardList,
|
||||||
|
Files,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* Statuses at which a draft is considered ready */
|
/* Statuses at which a draft is considered ready */
|
||||||
@@ -76,6 +88,31 @@ function formatDate(epoch: number): string {
|
|||||||
return formatDateTime(epoch * 1000);
|
return formatDateTime(epoch * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the newest export file whose name starts with `prefix`, preferring the
|
||||||
|
* highest v-number and falling back to the latest created_at. Used by the
|
||||||
|
* "הפקת מסמכים" card to surface the most recent טיוטה-טענות_הצדדים_* file (#214).
|
||||||
|
*/
|
||||||
|
function pickLatestVersioned(
|
||||||
|
files: ExportFile[] | undefined,
|
||||||
|
prefix: string,
|
||||||
|
): (ExportFile & { version: number | null }) | null {
|
||||||
|
const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix));
|
||||||
|
if (!matches.length) return null;
|
||||||
|
const withVer = matches.map((f) => {
|
||||||
|
// Version is the trailing integer, after either "-v" (final) or "_"
|
||||||
|
// (interim: טיוטה-טענות_הצדדים_{N}). Anchor to the end so the "_" inside
|
||||||
|
// the interim prefix isn't mistaken for the version separator.
|
||||||
|
const m = f.filename.match(/[_v](\d+)(?:\.\w+)?$/);
|
||||||
|
return { ...f, version: m ? parseInt(m[1], 10) : null };
|
||||||
|
});
|
||||||
|
withVer.sort((a, b) => {
|
||||||
|
if (a.version != null && b.version != null) return b.version - a.version;
|
||||||
|
return b.created_at - a.created_at;
|
||||||
|
});
|
||||||
|
return withVer[0];
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main component ─────────────────────────────────── */
|
/* ── Main component ─────────────────────────────────── */
|
||||||
|
|
||||||
export function DraftsPanel({
|
export function DraftsPanel({
|
||||||
@@ -97,6 +134,13 @@ export function DraftsPanel({
|
|||||||
const uploadFinal = useUploadFinalDecision(caseNumber);
|
const uploadFinal = useUploadFinalDecision(caseNumber);
|
||||||
const runLearning = useRunFinalLearning(caseNumber);
|
const runLearning = useRunFinalLearning(caseNumber);
|
||||||
const runHalacha = useRunFinalHalacha(caseNumber);
|
const runHalacha = useRunFinalHalacha(caseNumber);
|
||||||
|
// ── Document generation (#214) — the "הפקת מסמכים" card ──
|
||||||
|
const { data: summary } = usePartyClaimsSummary(caseNumber);
|
||||||
|
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
|
||||||
|
const genInterim = useGenerateInterimDraft(caseNumber);
|
||||||
|
// Server-derived background-run status (#227 ג) — survives navigation/reload.
|
||||||
|
const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary");
|
||||||
|
const interimStatus = useGenerateStatus(caseNumber, "interim_draft");
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -129,6 +173,38 @@ export function DraftsPanel({
|
|||||||
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
|
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── "הפקת מסמכים" derived state (#214) ──
|
||||||
|
// Latest interim ("party-claims") partial draft in the exports table — the poll
|
||||||
|
// target for button 2. The CEO writes it as טיוטה-טענות_הצדדים_{N}.docx.
|
||||||
|
const latestInterim = pickLatestVersioned(exports, "טיוטה-טענות_הצדדים_");
|
||||||
|
const summaryReady = Boolean(summary?.markdown);
|
||||||
|
|
||||||
|
function handleGenerateSummary() {
|
||||||
|
genSummary.mutate(undefined, {
|
||||||
|
onSuccess: (d) => {
|
||||||
|
if (d.status === "ok") {
|
||||||
|
toast.success("הופעלה הפקת סיכום-המנהלים — רצה ברקע (אופוס)");
|
||||||
|
} else {
|
||||||
|
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה בהפעלת הפקת סיכום-המנהלים"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGenerateInterim() {
|
||||||
|
genInterim.mutate(undefined, {
|
||||||
|
onSuccess: (d) => {
|
||||||
|
if (d.status === "ok") {
|
||||||
|
toast.success("הופעלה הפקת טיוטת טענות-הצדדים — רצה ברקע (אופוס)");
|
||||||
|
} else {
|
||||||
|
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה בהפעלת הפקת הטיוטה החלקית"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleUpload(file: File) {
|
function handleUpload(file: File) {
|
||||||
uploadDraft.mutate(file, {
|
uploadDraft.mutate(file, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -233,34 +309,194 @@ export function DraftsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
// flex + per-card `order` so the visual order matches mockup 26:
|
||||||
{/* ── Banner ── */}
|
// generate-card → banner → active-draft → drafts → final+learning → feedback.
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */}
|
||||||
|
<section className="order-0 rounded-lg border border-rule bg-surface overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft bg-parchment">
|
||||||
|
<Zap className="w-[18px] h-[18px] text-gold-deep shrink-0" />
|
||||||
|
<h3 className="text-navy text-base font-semibold">הפקת מסמכים</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-xs text-ink-muted leading-relaxed mb-1">
|
||||||
|
הפקה דטרמיניסטית בלחיצה — בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת,
|
||||||
|
והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 1 — executive summary of party claims */}
|
||||||
|
<div className="flex items-start gap-3 py-3 border-t border-rule-soft first-of-type:border-t-0">
|
||||||
|
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
|
||||||
|
<ClipboardList className="w-[18px] h-[18px]" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-navy">הפק סיכום מנהלים</div>
|
||||||
|
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
|
||||||
|
תמצית מזוקקת של טענות הצדדים לקראת הדיון — מסמך נפרד מהטיוטה.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 mt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateSummary}
|
||||||
|
disabled={genSummary.isPending}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{genSummary.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||||
|
מייצר…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"הפק"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<GenerateStatusChip
|
||||||
|
status={summaryStatus.data}
|
||||||
|
onRetry={handleGenerateSummary}
|
||||||
|
retrying={genSummary.isPending}
|
||||||
|
/>
|
||||||
|
{summaryReady && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-gold-deep"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
`/api/cases/${caseNumber}/research/party-claims-summary/download`,
|
||||||
|
"_blank",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 me-1" />
|
||||||
|
פתח
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-gold-deep"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
`/api/cases/${caseNumber}/research/party-claims-summary/export-docx`,
|
||||||
|
"_blank",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 me-1" />
|
||||||
|
DOCX
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2 — partial draft (parties' claims, blocks ה–ט) */}
|
||||||
|
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
|
||||||
|
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
|
||||||
|
<FileText className="w-[18px] h-[18px]" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-navy">
|
||||||
|
הפק טיוטה של טענות הצדדים{" "}
|
||||||
|
<span className="font-medium text-ink-muted text-xs">
|
||||||
|
(טיוטה חלקית)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
|
||||||
|
בלוקים ה–ט (רקע, טענות, הליכים, תכניות) — עד הדיון, בלי דיון
|
||||||
|
והכרעה. נשמרת בטבלת-הטיוטות.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 mt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateInterim}
|
||||||
|
disabled={genInterim.isPending}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{genInterim.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||||
|
מייצר…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"הפק"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<GenerateStatusChip
|
||||||
|
status={interimStatus.data}
|
||||||
|
onRetry={handleGenerateInterim}
|
||||||
|
retrying={genInterim.isPending}
|
||||||
|
/>
|
||||||
|
{latestInterim && (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted">
|
||||||
|
{latestInterim.version
|
||||||
|
? `v${latestInterim.version} · `
|
||||||
|
: ""}
|
||||||
|
{formatDate(latestInterim.created_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */}
|
||||||
|
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
|
||||||
|
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
|
||||||
|
<Files className="w-[18px] h-[18px]" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-navy">
|
||||||
|
הפק טיוטת החלטה מלאה
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
|
||||||
|
ההחלטה המלאה — כל 12 הבלוקים, כולל דיון והכרעה. נקבעת
|
||||||
|
כמקור-האמת ונשמרת בטבלת-הטיוטות.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 mt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exportDocx.isPending || !isDraftReady}
|
||||||
|
title={!isDraftReady ? "זמין כשיש בלוקים כתובים" : undefined}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{exportDocx.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||||
|
מייצר…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileOutput className="w-3.5 h-3.5 me-1.5" />
|
||||||
|
הפק
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{!isDraftReady && (
|
||||||
|
<span className="rounded bg-warn-bg px-2 py-0.5 text-[0.65rem] text-warn">
|
||||||
|
זמין כשיש בלוקים כתובים
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */}
|
||||||
{isDraftReady && (
|
{isDraftReady && (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
<div className="order-1 flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
||||||
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
|
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
|
||||||
<span className="text-sm font-medium text-gold-deep">
|
<span className="text-sm font-medium text-gold-deep">
|
||||||
{draftLabel}
|
{draftLabel}
|
||||||
</span>
|
</span>
|
||||||
<div className="me-auto" />
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={exportDocx.isPending}
|
|
||||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
|
||||||
>
|
|
||||||
{exportDocx.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
|
||||||
) : (
|
|
||||||
<FileOutput className="w-4 h-4 me-1.5" />
|
|
||||||
)}
|
|
||||||
הפק DOCX
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
|
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
|
||||||
{activeDraft?.filename && (
|
{activeDraft?.filename && (
|
||||||
<div className="flex items-center gap-2 text-xs text-ink-muted">
|
<div className="order-2 flex items-center gap-2 text-xs text-ink-muted">
|
||||||
<span>מקור האמת:</span>
|
<span>מקור האמת:</span>
|
||||||
<Badge variant="outline" className="bg-surface">
|
<Badge variant="outline" className="bg-surface">
|
||||||
{activeDraft.filename}
|
{activeDraft.filename}
|
||||||
@@ -269,7 +505,7 @@ export function DraftsPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
|
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
|
||||||
<section className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
|
<section className="order-4 rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
|
||||||
<input
|
<input
|
||||||
ref={finalFileRef}
|
ref={finalFileRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -347,13 +583,13 @@ export function DraftsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasFinal && <LearningStatusBadges caseNumber={caseNumber} />}
|
{hasFinal && <LearningStatusBadges caseNumber={caseNumber} />}
|
||||||
|
{/* precedents cited inside the signed decision — sub-section of the
|
||||||
|
final card (mockup 18h), collapsible inside CitationsSection itself */}
|
||||||
|
<CitationsSection caseNumber={caseNumber} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Precedents cited inside the signed decision ── */}
|
{/* ── Card: draft files (mockup 18h) ── */}
|
||||||
<CitationsSection caseNumber={caseNumber} />
|
<section className="order-3 rounded-lg border border-rule bg-surface p-4">
|
||||||
|
|
||||||
{/* ── Exports list ── */}
|
|
||||||
<section>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-navy text-base">קבצי טיוטה</h3>
|
<h3 className="text-navy text-base">קבצי טיוטה</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -396,9 +632,9 @@ export function DraftsPanel({
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
|
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
|
||||||
<th className="text-start px-4 py-2 font-medium">File</th>
|
<th className="text-start px-4 py-2 font-medium">קובץ</th>
|
||||||
<th className="text-start px-4 py-2 font-medium">Size</th>
|
<th className="text-start px-4 py-2 font-medium">גודל</th>
|
||||||
<th className="text-start px-4 py-2 font-medium">Date</th>
|
<th className="text-start px-4 py-2 font-medium">תאריך</th>
|
||||||
<th className="px-4 py-2" />
|
<th className="px-4 py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -521,8 +757,8 @@ export function DraftsPanel({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Chair feedback ── */}
|
{/* ── Card: chair feedback (mockup 18h) ── */}
|
||||||
<section>
|
<section className="order-5 rounded-lg border border-rule bg-surface p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-navy text-base">
|
<h3 className="text-navy text-base">
|
||||||
הערות יו״ר
|
הערות יו״ר
|
||||||
|
|||||||
127
web-ui/src/components/cases/generate-status-chip.tsx
Normal file
127
web-ui/src/components/cases/generate-status-chip.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CheckCircle2, Clock, Loader2, RotateCw, XCircle } from "lucide-react";
|
||||||
|
import type { GenerateStatus } from "@/lib/api/generate";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background-run status chip for the "הפקת מסמכים" generation actions (#227 ג).
|
||||||
|
*
|
||||||
|
* The chip is driven entirely by the server-derived {@link GenerateStatus} — it
|
||||||
|
* holds no run state of its own, so it renders correctly after the chair
|
||||||
|
* navigates away and back. The running timer is anchored to the server
|
||||||
|
* `started_at`, not a click-time counter, so the elapsed value is right on
|
||||||
|
* return too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function useElapsedSeconds(startedAt: string | null, active: boolean): number | null {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [active]);
|
||||||
|
if (!startedAt) return null;
|
||||||
|
const start = Date.parse(startedAt);
|
||||||
|
if (Number.isNaN(start)) return null;
|
||||||
|
return Math.max(0, Math.floor((now - start) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (m >= 60) {
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return `${h}:${String(m % 60).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeTime(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const t = Date.parse(iso);
|
||||||
|
if (Number.isNaN(t)) return "";
|
||||||
|
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000));
|
||||||
|
if (sec < 60) return "הרגע";
|
||||||
|
const min = Math.floor(sec / 60);
|
||||||
|
if (min < 60) return `לפני ${min} דק׳`;
|
||||||
|
const hr = Math.floor(min / 60);
|
||||||
|
if (hr < 24) return `לפני ${hr} שע׳`;
|
||||||
|
return `לפני ${Math.floor(hr / 24)} ימים`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIP_BASE =
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold whitespace-nowrap";
|
||||||
|
|
||||||
|
export function GenerateStatusChip({
|
||||||
|
status,
|
||||||
|
onRetry,
|
||||||
|
retrying,
|
||||||
|
}: {
|
||||||
|
status: GenerateStatus | undefined;
|
||||||
|
onRetry?: () => void;
|
||||||
|
retrying?: boolean;
|
||||||
|
}) {
|
||||||
|
const state = status?.state ?? "idle";
|
||||||
|
const elapsed = useElapsedSeconds(status?.started_at ?? null, state === "running");
|
||||||
|
|
||||||
|
if (!status || state === "idle") return null;
|
||||||
|
|
||||||
|
if (state === "queued") {
|
||||||
|
return (
|
||||||
|
<span className={`${CHIP_BASE} bg-rule-soft text-ink-soft border-rule`}>
|
||||||
|
<Clock className="size-3.5" aria-hidden />
|
||||||
|
בתור…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "running") {
|
||||||
|
return (
|
||||||
|
<span className={`${CHIP_BASE} bg-info-bg text-info border-info/35`}>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" aria-hidden />
|
||||||
|
רץ ברקע
|
||||||
|
{elapsed !== null && (
|
||||||
|
<span className="tabular-nums">· {fmtDuration(elapsed)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "done") {
|
||||||
|
const rel = relativeTime(status.finished_at);
|
||||||
|
return (
|
||||||
|
<span className={`${CHIP_BASE} bg-success-bg text-success border-success/35`}>
|
||||||
|
<CheckCircle2 className="size-3.5" aria-hidden />
|
||||||
|
הושלם{rel && ` · ${rel}`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// failed
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className={`${CHIP_BASE} bg-danger-bg text-danger border-danger/35`}>
|
||||||
|
<XCircle className="size-3.5" aria-hidden />
|
||||||
|
{status.detail || "נכשל"}
|
||||||
|
</span>
|
||||||
|
{onRetry && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-gold-deep"
|
||||||
|
disabled={retrying}
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
{retrying ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin me-1" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="size-3.5 me-1" />
|
||||||
|
)}
|
||||||
|
נסה שוב
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
360
web-ui/src/components/cases/hearing-changes-panel.tsx
Normal file
360
web-ui/src/components/cases/hearing-changes-panel.tsx
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
CalendarDays,
|
||||||
|
HelpCircle,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
RotateCw,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
CHANGE_LABELS_HE,
|
||||||
|
PROTOCOL_PARTY_LABELS_HE,
|
||||||
|
PROTOCOL_PARTY_ORDER,
|
||||||
|
useAnalyzeProtocol,
|
||||||
|
useProtocolAnalysis,
|
||||||
|
type ProtocolAnalysisRow,
|
||||||
|
type ProtocolChangeType,
|
||||||
|
type ProtocolPartyRole,
|
||||||
|
} from "@/lib/api/protocol-analysis";
|
||||||
|
|
||||||
|
const PARTY_MARK_TONE: Record<ProtocolPartyRole, string> = {
|
||||||
|
appellant: "bg-info",
|
||||||
|
respondent: "bg-gold-deep",
|
||||||
|
committee: "bg-success",
|
||||||
|
permit_applicant: "bg-warn",
|
||||||
|
unknown: "bg-ink-muted",
|
||||||
|
"": "bg-ink-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANGE_BADGE_TONE: Record<ProtocolChangeType, string> = {
|
||||||
|
strengthened: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
|
newly_raised: "bg-emerald-50 text-emerald-900 border-emerald-200",
|
||||||
|
dropped: "bg-rule-soft text-ink-soft border-rule",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANGE_ACCENT: Record<ProtocolChangeType, string> = {
|
||||||
|
strengthened: "border-s-gold",
|
||||||
|
newly_raised: "border-s-emerald-500",
|
||||||
|
dropped: "border-s-rule",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChangeIcon({ type }: { type: ProtocolChangeType }) {
|
||||||
|
if (type === "strengthened") return <ArrowUp className="size-3.5" aria-hidden />;
|
||||||
|
if (type === "newly_raised") return <Plus className="size-3.5" aria-hidden />;
|
||||||
|
return <ArrowDown className="size-3.5" aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterKey = "all" | ProtocolChangeType;
|
||||||
|
|
||||||
|
function AttendeeColumn({
|
||||||
|
label,
|
||||||
|
dotTone,
|
||||||
|
names,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
dotTone: string;
|
||||||
|
names: string[];
|
||||||
|
}) {
|
||||||
|
if (!names.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="min-w-[210px] flex-1">
|
||||||
|
<div className="text-gold-deep mb-1.5 flex items-center gap-1.5 text-[0.7rem] font-bold">
|
||||||
|
<span className={`size-1.5 flex-none rounded-full ${dotTone}`} aria-hidden />
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="text-ink-soft text-xs leading-relaxed">
|
||||||
|
{names.join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevelopmentCard({ row }: { row: ProtocolAnalysisRow }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-surface border-rule overflow-hidden rounded-lg border border-s-4 shadow-sm ${CHANGE_ACCENT[row.change_type]}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-2.5 px-4 py-3.5">
|
||||||
|
<div className="flex flex-wrap items-start gap-2.5">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${CHANGE_BADGE_TONE[row.change_type]} flex-none gap-1 text-[0.72rem] font-bold`}
|
||||||
|
>
|
||||||
|
<ChangeIcon type={row.change_type} />
|
||||||
|
{CHANGE_LABELS_HE[row.change_type]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-navy flex-1 text-sm font-bold leading-snug">
|
||||||
|
{row.argument_title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{row.summary && (
|
||||||
|
<p className="text-ink-soft text-[0.82rem] leading-relaxed">
|
||||||
|
{row.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.sharpened_question && (
|
||||||
|
<div className="bg-parchment border-rule-soft rounded-md border px-3 py-2">
|
||||||
|
<div className="text-gold-deep mb-1 flex items-center gap-1.5 text-[0.66rem] font-bold">
|
||||||
|
<HelpCircle className="size-3" aria-hidden />
|
||||||
|
השאלה שהתחדדה
|
||||||
|
</div>
|
||||||
|
<div className="text-ink text-xs leading-relaxed">
|
||||||
|
{row.sharpened_question}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.evidence_quote && (
|
||||||
|
<blockquote className="border-rule text-ink-muted border-s-[3px] px-3 text-xs italic leading-relaxed">
|
||||||
|
“{row.evidence_quote}”
|
||||||
|
<span className="text-ink-muted mt-1 block text-[0.68rem] not-italic font-semibold">
|
||||||
|
— מתוך פרוטוקול הדיון
|
||||||
|
</span>
|
||||||
|
</blockquote>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||||
|
{row.change_type === "newly_raised" ? (
|
||||||
|
<span className="text-emerald-800 inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
|
||||||
|
<Sparkles className="size-3.5" aria-hidden />
|
||||||
|
אין מקבילה בכתב — עלה לראשונה בדיון
|
||||||
|
</span>
|
||||||
|
) : row.argument_id ? (
|
||||||
|
<span className="text-ink-muted inline-flex items-center gap-1.5 text-[0.72rem] font-semibold">
|
||||||
|
<Link2 className="size-3.5" aria-hidden />
|
||||||
|
מבוסס על טיעון כתוב קיים
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type HearingChangesPanelProps = {
|
||||||
|
caseNumber: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HearingChangesPanel({ caseNumber }: HearingChangesPanelProps) {
|
||||||
|
const { data, isPending, isError, error } = useProtocolAnalysis(caseNumber);
|
||||||
|
const analyze = useAnalyzeProtocol(caseNumber);
|
||||||
|
const [filter, setFilter] = useState<FilterKey>("all");
|
||||||
|
|
||||||
|
const handleAnalyze = () => {
|
||||||
|
analyze.mutate(undefined, {
|
||||||
|
onSuccess: (res) => {
|
||||||
|
if (res.status === "queued") {
|
||||||
|
toast.success("נשלח למנתח המשפטי — הניתוח ירוץ ברקע; רענן בעוד כמה דקות.");
|
||||||
|
} else {
|
||||||
|
toast.warning(
|
||||||
|
`לא ניתן להריץ אוטומטית (${res.reason}). ניתן להריץ ידנית מ-Claude Code.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(`שגיאה: ${(e as Error).message}`),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = data?.header;
|
||||||
|
const counts = data?.by_change ?? {};
|
||||||
|
const strengthened = counts.strengthened ?? 0;
|
||||||
|
const newlyRaised = counts.newly_raised ?? 0;
|
||||||
|
const dropped = counts.dropped ?? 0;
|
||||||
|
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return PROTOCOL_PARTY_ORDER.map((role) => {
|
||||||
|
const rows = (data.by_party[role] ?? []).filter(
|
||||||
|
(r) => filter === "all" || r.change_type === filter,
|
||||||
|
);
|
||||||
|
return { role, rows };
|
||||||
|
}).filter((s) => s.rows.length > 0);
|
||||||
|
}, [data, filter]);
|
||||||
|
|
||||||
|
const runButton = (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={analyze.isPending}
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
>
|
||||||
|
{analyze.isPending ? (
|
||||||
|
<Loader2 className="me-1.5 size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCw className="me-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
{data?.total ? "נתח מחדש" : "נתח את פרוטוקול הדיון"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isPending ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<p className="text-danger text-sm">
|
||||||
|
שגיאה בטעינת ניתוח-הפרוטוקול: {(error as Error).message}
|
||||||
|
</p>
|
||||||
|
) : !data?.total ? (
|
||||||
|
<div className="bg-surface border-rule rounded-lg border border-dashed px-6 py-8 text-center">
|
||||||
|
<h3 className="text-navy text-sm font-bold">טרם נותח פרוטוקול</h3>
|
||||||
|
<p className="text-ink-muted mx-auto mt-1.5 max-w-md text-xs leading-relaxed">
|
||||||
|
כשיש בתיק פרוטוקול של ועדת-הערר וטיעונים מאוגדים, הניתוח מזהה מה
|
||||||
|
התחזק ומה נטען לראשונה בדיון. ההרצה נשלחת למנתח המשפטי ורצה ברקע.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-center">{runButton}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end">{runButton}</div>
|
||||||
|
{/* hearing header strip */}
|
||||||
|
{header && (
|
||||||
|
<div className="bg-parchment border-rule rounded-lg border px-5 py-4 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="bg-gold-wash border-rule flex size-9 flex-none items-center justify-center rounded-lg border">
|
||||||
|
<CalendarDays className="text-gold-deep size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-navy text-[0.95rem] font-bold">
|
||||||
|
{header.protocol_title || "פרוטוקול דיון"}
|
||||||
|
</div>
|
||||||
|
<div className="text-ink-muted mt-0.5 text-xs">
|
||||||
|
{header.hearing_date
|
||||||
|
? `תאריך דיון: ${header.hearing_date}`
|
||||||
|
: "תאריך דיון לא זוהה"}
|
||||||
|
{header.panel_members.length > 0 &&
|
||||||
|
` · מותב: ${header.panel_members.join(", ")}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-info-bg text-info border-info/30 flex-none rounded-full text-[0.72rem] font-semibold"
|
||||||
|
>
|
||||||
|
פרוטוקול ועדת-הערר
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{(header.appellants_present.length > 0 ||
|
||||||
|
header.respondents_present.length > 0) && (
|
||||||
|
<div className="border-rule-soft mt-3 flex flex-wrap gap-6 border-t pt-3">
|
||||||
|
<AttendeeColumn
|
||||||
|
label="נכחו מטעם העוררים"
|
||||||
|
dotTone="bg-info"
|
||||||
|
names={header.appellants_present}
|
||||||
|
/>
|
||||||
|
<AttendeeColumn
|
||||||
|
label="נכחו מטעם המשיבים"
|
||||||
|
dotTone="bg-gold-deep"
|
||||||
|
names={header.respondents_present}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* filter pills */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<FilterPill
|
||||||
|
active={filter === "all"}
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
label="הכל"
|
||||||
|
count={data.total}
|
||||||
|
/>
|
||||||
|
{strengthened > 0 && (
|
||||||
|
<FilterPill
|
||||||
|
active={filter === "strengthened"}
|
||||||
|
onClick={() => setFilter("strengthened")}
|
||||||
|
label="התחזקו"
|
||||||
|
count={strengthened}
|
||||||
|
icon={<ArrowUp className="size-3.5" aria-hidden />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{newlyRaised > 0 && (
|
||||||
|
<FilterPill
|
||||||
|
active={filter === "newly_raised"}
|
||||||
|
onClick={() => setFilter("newly_raised")}
|
||||||
|
label="נטענו לראשונה"
|
||||||
|
count={newlyRaised}
|
||||||
|
icon={<Plus className="size-3.5" aria-hidden />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dropped > 0 && (
|
||||||
|
<FilterPill
|
||||||
|
active={filter === "dropped"}
|
||||||
|
onClick={() => setFilter("dropped")}
|
||||||
|
label="נזנחו"
|
||||||
|
count={dropped}
|
||||||
|
icon={<ArrowDown className="size-3.5" aria-hidden />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* per-party sections */}
|
||||||
|
{sections.map(({ role, rows }) => (
|
||||||
|
<div key={role || "unassigned"} className="space-y-2.5">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className={`h-5 w-1.5 flex-none rounded ${PARTY_MARK_TONE[role]}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="text-navy text-[0.95rem] font-bold">
|
||||||
|
{PROTOCOL_PARTY_LABELS_HE[role]}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted text-xs">{rows.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<DevelopmentCard key={r.id} row={r} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterPill({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-navy border-navy text-white"
|
||||||
|
: "bg-surface border-rule text-ink-soft hover:bg-parchment"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
<span className="font-bold">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,11 +76,50 @@ type PartySectionProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inner body of a single party — the priority-grouping + per-argument inner
|
* Inner body of a single party. For a multi-litigant side (respondent /
|
||||||
* accordion. The party header (name / sub / count) now lives on the enclosing
|
* permit_applicant) whose arguments carry distinct ``party_name`` brief labels,
|
||||||
* party-accordion trigger (mockup 25b), so this renders the groups only.
|
* we sub-group by brief first — so opposing joint briefs (e.g. "משיבות 2-3" vs
|
||||||
|
* "משיבים 4-6") read as separate positions rather than one merged list (#224).
|
||||||
|
* Single-voice sides (one empty brief) render the priority groups directly.
|
||||||
*/
|
*/
|
||||||
function PartySection({ args }: PartySectionProps) {
|
function PartySection({ args }: PartySectionProps) {
|
||||||
|
const briefs = useMemo(() => {
|
||||||
|
const byBrief = new Map<string, LegalArgument[]>();
|
||||||
|
for (const a of args) {
|
||||||
|
const key = a.party_name?.trim() ?? "";
|
||||||
|
const list = byBrief.get(key);
|
||||||
|
if (list) list.push(a);
|
||||||
|
else byBrief.set(key, [a]);
|
||||||
|
}
|
||||||
|
return [...byBrief.entries()];
|
||||||
|
}, [args]);
|
||||||
|
|
||||||
|
// No real per-brief split (single, unnamed group) → render groups directly.
|
||||||
|
if (briefs.length <= 1) {
|
||||||
|
return <PriorityGroups args={briefs[0]?.[1] ?? args} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{briefs.map(([brief, briefArgs]) => (
|
||||||
|
<div key={brief || "—"} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 border-s-2 border-gold-deep ps-2">
|
||||||
|
<span className="text-navy text-sm font-semibold">
|
||||||
|
{brief || "ללא שיוך לכתב"}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{briefArgs.length} טיעונים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<PriorityGroups args={briefArgs} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority-grouped argument accordion for one coherent set of arguments. */
|
||||||
|
function PriorityGroups({ args }: PartySectionProps) {
|
||||||
const grouped = useMemo(() => groupByPriority(args), [args]);
|
const grouped = useMemo(() => groupByPriority(args), [args]);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import { SubsectionCard } from "@/components/compose/subsection-card";
|
|||||||
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
import { useCase } from "@/lib/api/cases";
|
import { useCase } from "@/lib/api/cases";
|
||||||
import {
|
|
||||||
useCaseLearningStatus,
|
|
||||||
type CaseLearningStatus,
|
|
||||||
} from "@/lib/api/learning";
|
|
||||||
import { useResearchAnalysis } from "@/lib/api/research";
|
import { useResearchAnalysis } from "@/lib/api/research";
|
||||||
import { useCasePrecedents } from "@/lib/api/precedents";
|
import { useCasePrecedents } from "@/lib/api/precedents";
|
||||||
|
|
||||||
@@ -24,45 +20,6 @@ import { useCasePrecedents } from "@/lib/api/precedents";
|
|||||||
* verification moved to their own top-level tabs; /compose was deleted.
|
* verification moved to their own top-level tabs; /compose was deleted.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Staged-pipeline indicator text — derived from the live learning-status,
|
|
||||||
// same source as the drafts-panel LearningStatusBadges. ──────────────────────
|
|
||||||
function voiceLearningText(s?: CaseLearningStatus): string {
|
|
||||||
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
|
|
||||||
const v = s.voice_learning;
|
|
||||||
if (v.outcome === "succeeded") {
|
|
||||||
const bits = [`${v.lessons_count} לקחים הופקו`];
|
|
||||||
if (v.lessons_proposed > 0) bits.push(`${v.lessons_proposed} הוצעו לאישור`);
|
|
||||||
return `✓ הושלם · ${bits.join(" · ")}`;
|
|
||||||
}
|
|
||||||
if (v.outcome === "failed") return v.error ? `✗ נכשל — ${v.error}` : "✗ נכשל";
|
|
||||||
return "ממתין להרצה";
|
|
||||||
}
|
|
||||||
|
|
||||||
function halachaExtractionText(s?: CaseLearningStatus): string {
|
|
||||||
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
|
|
||||||
const h = s.halacha_extraction;
|
|
||||||
if (!h.enrolled_in_corpus)
|
|
||||||
return h.not_enrolled_reason ?? "לא נכנס לקורפוס-הפסיקה";
|
|
||||||
switch (h.status) {
|
|
||||||
case "completed":
|
|
||||||
return `✓ הושלם · חולצו ${h.halachot_count} · ${h.approved} אושרו · ${h.rejected} נדחו`;
|
|
||||||
case "processing":
|
|
||||||
return "רץ עכשיו…";
|
|
||||||
case "pending":
|
|
||||||
case "busy":
|
|
||||||
return "בתור";
|
|
||||||
case "partial":
|
|
||||||
return `חלקי · חולצו ${h.halachot_count}`;
|
|
||||||
case "failed":
|
|
||||||
case "extraction_failed":
|
|
||||||
return "✗ נכשל";
|
|
||||||
case "no_chunks":
|
|
||||||
return "אין טקסט לחילוץ";
|
|
||||||
default:
|
|
||||||
return "ממתין להרצה";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||||
if (!content?.trim()) return null;
|
if (!content?.trim()) return null;
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +32,9 @@ function ProseSection({ title, content }: { title: string; content?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── "השלמה והעברה" rail card — DOCX export, analysis upload/download (real) ──
|
// ── "ייצוא ועדכון הניתוח" rail card — round-trips the analysis-and-research.md
|
||||||
|
// research analysis: export DOCX, upload an updated version, download the raw MD.
|
||||||
|
// (Post-final learning status lives on the drafts tab, not here — #226.) ──────
|
||||||
function FinishRail({
|
function FinishRail({
|
||||||
caseNumber,
|
caseNumber,
|
||||||
hasAnalysis,
|
hasAnalysis,
|
||||||
@@ -88,7 +47,6 @@ function FinishRail({
|
|||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
const learning = useCaseLearningStatus(caseNumber);
|
|
||||||
|
|
||||||
async function handleUpload(file: File) {
|
async function handleUpload(file: File) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -119,9 +77,12 @@ function FinishRail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm h-full">
|
||||||
<CardContent className="px-4 py-4">
|
<CardContent className="px-4 py-4">
|
||||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-3">השלמה והעברה</h3>
|
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">ייצוא ועדכון הניתוח</h3>
|
||||||
|
<p className="text-[0.72rem] text-ink-muted mb-3">
|
||||||
|
פעולות על קובץ ניתוח-המחקר (analysis-and-research.md).
|
||||||
|
</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
@@ -176,16 +137,6 @@ function FinishRail({
|
|||||||
{uploadMsg.text}
|
{uploadMsg.text}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* stage indicators — informational pointers, not actions */}
|
|
||||||
<div className="mt-3 space-y-0">
|
|
||||||
<div className="text-[0.78rem] text-ink-muted pt-2 border-t border-rule-soft">
|
|
||||||
<b className="text-navy">הרץ למידת-קול</b> — {voiceLearningText(learning.data)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[0.78rem] text-ink-muted pt-2 mt-2 border-t border-rule-soft">
|
|
||||||
<b className="text-navy">הרץ אימות-הלכות</b> — {halachaExtractionText(learning.data)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -214,10 +165,6 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-ink-muted text-[0.78rem]">
|
|
||||||
סוגיות-המחלוקת ועמדות הצדדים. עורך עמדת-היו״ר נשמר אוטומטית ומזין את בלוק י׳ (דיון והכרעה).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{analysis.isPending ? (
|
{analysis.isPending ? (
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5 space-y-3">
|
<CardContent className="px-6 py-5 space-y-3">
|
||||||
@@ -322,9 +269,10 @@ export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* case-level פסיקה מצורפת + finish/export gates (INV-IA3: not removed) */}
|
{/* case-level פסיקה מצורפת + analysis export/update (INV-IA3: not removed).
|
||||||
<div className="grid gap-4 lg:grid-cols-2 items-start pt-2">
|
items-stretch keeps both cards the same height (#226). */}
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<div className="grid gap-4 lg:grid-cols-2 items-stretch pt-2">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm h-full">
|
||||||
<CardContent className="px-5 py-4">
|
<CardContent className="px-5 py-4">
|
||||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
|
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
|
||||||
<p className="text-[0.72rem] text-ink-muted mb-3">
|
<p className="text-[0.72rem] text-ink-muted mb-3">
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
|
|||||||
new: FilePlus2,
|
new: FilePlus2,
|
||||||
processing: Loader2,
|
processing: Loader2,
|
||||||
documents_ready: FileCheck,
|
documents_ready: FileCheck,
|
||||||
|
analyst_verified: SearchCheck,
|
||||||
|
research_complete: Compass,
|
||||||
outcome_set: Target,
|
outcome_set: Target,
|
||||||
direction_approved: Compass,
|
direction_approved: Compass,
|
||||||
qa_review: SearchCheck,
|
qa_review: SearchCheck,
|
||||||
@@ -36,6 +38,8 @@ const STATUS_TONE: Record<CaseStatus, string> = {
|
|||||||
new: "bg-rule-soft text-ink-muted border-rule",
|
new: "bg-rule-soft text-ink-muted border-rule",
|
||||||
processing: "bg-info-bg text-info border-info/30",
|
processing: "bg-info-bg text-info border-info/30",
|
||||||
documents_ready: "bg-info-bg text-info border-info/40",
|
documents_ready: "bg-info-bg text-info border-info/40",
|
||||||
|
analyst_verified: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
|
research_complete: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
|
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
|
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
|
||||||
qa_review: "bg-warn-bg text-warn border-warn/40",
|
qa_review: "bg-warn-bg text-warn border-warn/40",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type PaperclipIssue = {
|
|||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
|
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
|
||||||
|
parent_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaperclipComment = {
|
export type PaperclipComment = {
|
||||||
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
|
|||||||
export function useSendComment(caseNumber: string | undefined) {
|
export function useSendComment(caseNumber: string | undefined) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
|
||||||
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
apiRequest<{
|
||||||
|
comment_id: string | null;
|
||||||
|
issue_id: string;
|
||||||
|
issue_identifier: string;
|
||||||
|
issue_status?: string;
|
||||||
|
new_run?: boolean;
|
||||||
|
}>(
|
||||||
`/api/cases/${caseNumber}/agents/comment`,
|
`/api/cases/${caseNumber}/agents/comment`,
|
||||||
{ method: "POST", body: vars },
|
{ method: "POST", body: vars },
|
||||||
),
|
),
|
||||||
@@ -180,6 +188,45 @@ export function useSubmitInteraction(caseNumber: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dismiss a pending interaction WITHOUT waking the issue assignee — used for
|
||||||
|
* stale/duplicate questions. Backend marks it `cancelled` directly (the
|
||||||
|
* `wake_assignee` continuation policy means a normal resolve would wake the
|
||||||
|
* agent; dismiss must not). */
|
||||||
|
export function useDismissInteraction(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { issue_id: string; interaction_id: string }) =>
|
||||||
|
apiRequest<{ ok: boolean; cancelled: boolean }>(
|
||||||
|
`/api/cases/${caseNumber}/agents/interaction-dismiss`,
|
||||||
|
{ method: "POST", body: vars },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manually close a board issue to done/cancelled — the chair tidying the agents
|
||||||
|
* board (e.g. superseded runs left in in_review). Backend does a loop-safe
|
||||||
|
* direct close and issues no wakeup; only the two terminal statuses are allowed. */
|
||||||
|
export function useSetIssueStatus(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { issue_id: string; status: "done" | "cancelled" }) =>
|
||||||
|
apiRequest<{ ok: boolean; id: string; identifier: string; status: string }>(
|
||||||
|
`/api/cases/${caseNumber}/agents/issue-status`,
|
||||||
|
{ method: "POST", body: vars },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentResetResult = {
|
export type AgentResetResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
reassigned_issues: { id: string; identifier: string }[];
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Single source of truth for the case-status lifecycle (UI-B1 / G2).
|
* Frontend mirror of the canonical case-status lifecycle.
|
||||||
*
|
*
|
||||||
* The 17-status manual menu was trimmed to the **10 core statuses** that the
|
* The ONE source of truth is the backend status model
|
||||||
* pipeline actually sets or that gate real logic. Decorative mid-stage markers
|
* (`mcp-server/src/legal_mcp/case_status_model.py`), exposed at
|
||||||
* (uploading, analyst_verified, research_complete, brainstorming,
|
* `GET /api/status-model`; this file mirrors it (order / labels / phases) so the
|
||||||
* analysis_enriched, ready_for_writing, drafting) plus the legacy/dead
|
* UI keeps compile-time `CaseStatus` typing. Keep the two in sync — the backend
|
||||||
* `in_progress` and `qa_failed` were removed — no pipeline code ever set them.
|
* registry is authoritative (it also declares per-status `on_enter` actions).
|
||||||
*
|
*
|
||||||
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards,
|
* The analyst/research intermediate states (`analyst_verified`,
|
||||||
* compose chip) imports the list / labels / phases from here instead of
|
* `research_complete`) are first-class canonical statuses — the agents set them,
|
||||||
* re-declaring its own array. Keep this file in sync with the backend
|
* so the chip, stepper and manual changer all agree instead of the status
|
||||||
* STATUS_ORDER in `mcp-server/src/legal_mcp/tools/cases.py`.
|
* falling between the canonical set and a legacy bucket.
|
||||||
|
*
|
||||||
|
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards)
|
||||||
|
* imports the list / labels / phases from here instead of re-declaring its own.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Ordered lifecycle — also the order shown in the manual status dropdown. */
|
/** Ordered lifecycle — also the order shown in the manual status dropdown. */
|
||||||
@@ -18,6 +21,8 @@ export const CASE_STATUSES = [
|
|||||||
"new",
|
"new",
|
||||||
"processing",
|
"processing",
|
||||||
"documents_ready",
|
"documents_ready",
|
||||||
|
"analyst_verified",
|
||||||
|
"research_complete",
|
||||||
"outcome_set",
|
"outcome_set",
|
||||||
"direction_approved",
|
"direction_approved",
|
||||||
"qa_review",
|
"qa_review",
|
||||||
@@ -35,22 +40,18 @@ export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done";
|
|||||||
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
|
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
|
||||||
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
|
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
|
||||||
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] },
|
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] },
|
||||||
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["outcome_set", "direction_approved"] },
|
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["analyst_verified", "research_complete", "outcome_set", "direction_approved"] },
|
||||||
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
|
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
|
||||||
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy/intermediate statuses that the trimmed PHASES list dropped but the
|
* Truly-legacy statuses that are NOT in the canonical set but might still arrive
|
||||||
* Paperclip agents still set (e.g. the analyst writes `analyst_verified`, the
|
* from an old client / un-migrated row — mapped to a phase for display only, so
|
||||||
* researcher writes `research_complete`). Without this map a case parked on one
|
* the stepper never goes blank. (The analyst/research intermediates are NO
|
||||||
* of them renders with NO active phase — the pipeline stepper goes blank and the
|
* longer here — they are canonical statuses in CASE_STATUSES above.)
|
||||||
* chair can't see where the case stands. Map each to the phase it belongs to for
|
|
||||||
* display purposes only (these are NOT selectable statuses).
|
|
||||||
*/
|
*/
|
||||||
const LEGACY_STATUS_PHASE: Record<string, PhaseKey> = {
|
const LEGACY_STATUS_PHASE: Record<string, PhaseKey> = {
|
||||||
analyst_verified: "thinking",
|
|
||||||
research_complete: "thinking",
|
|
||||||
analysis_enriched: "thinking",
|
analysis_enriched: "thinking",
|
||||||
ready_for_writing: "writing",
|
ready_for_writing: "writing",
|
||||||
drafting: "writing",
|
drafting: "writing",
|
||||||
@@ -80,6 +81,8 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
|
|||||||
new: "חדש",
|
new: "חדש",
|
||||||
processing: "בעיבוד",
|
processing: "בעיבוד",
|
||||||
documents_ready: "מסמכים מוכנים",
|
documents_ready: "מסמכים מוכנים",
|
||||||
|
analyst_verified: "ניתוח אומת",
|
||||||
|
research_complete: "מחקר הושלם",
|
||||||
outcome_set: "תוצאה נקבעה",
|
outcome_set: "תוצאה נקבעה",
|
||||||
direction_approved: "כיוון אושר",
|
direction_approved: "כיוון אושר",
|
||||||
qa_review: "בדיקת איכות",
|
qa_review: "בדיקת איכות",
|
||||||
@@ -97,8 +100,6 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
|
|||||||
const LEGACY_STATUS_LABELS: Record<string, string> = {
|
const LEGACY_STATUS_LABELS: Record<string, string> = {
|
||||||
in_progress: "בעבודה",
|
in_progress: "בעבודה",
|
||||||
uploading: "מעלה",
|
uploading: "מעלה",
|
||||||
analyst_verified: "ניתוח אומת",
|
|
||||||
research_complete: "מחקר הושלם",
|
|
||||||
brainstorming: "סיעור מוחות",
|
brainstorming: "סיעור מוחות",
|
||||||
analysis_enriched: "ניתוח הועמק",
|
analysis_enriched: "ניתוח הועמק",
|
||||||
ready_for_writing: "מוכן לכתיבה",
|
ready_for_writing: "מוכן לכתיבה",
|
||||||
@@ -124,6 +125,8 @@ export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
|
|||||||
new: "התיק נוצר וממתין להעלאת מסמכים",
|
new: "התיק נוצר וממתין להעלאת מסמכים",
|
||||||
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
||||||
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
||||||
|
analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה",
|
||||||
|
research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)",
|
||||||
outcome_set: "נקבעה תוצאה צפויה לערר",
|
outcome_set: "נקבעה תוצאה צפויה לערר",
|
||||||
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
|
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
|
||||||
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export function useUploadDocument(caseNumber: string) {
|
|||||||
export type DocumentPatch = {
|
export type DocumentPatch = {
|
||||||
doc_type?: string;
|
doc_type?: string;
|
||||||
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
|
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
|
||||||
|
is_post_hearing?: boolean; // true = received after the hearing (→ group 4); false clears
|
||||||
|
protocol_scope?: string; // "" clears; "appeal" | "lower" (protocol docs only)
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PatchDocumentResponse = {
|
export type PatchDocumentResponse = {
|
||||||
|
|||||||
148
web-ui/src/lib/api/generate.ts
Normal file
148
web-ui/src/lib/api/generate.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Document-generation domain hooks (TaskMaster #214).
|
||||||
|
*
|
||||||
|
* The "הפקת מסמכים" card in the case "טיוטות והערות" tab fires two deterministic,
|
||||||
|
* host-side generation actions through the CEO (structured-action wakeup):
|
||||||
|
* • party_claims_summary → an executive summary of the parties' claims
|
||||||
|
* • interim_draft → a partial draft (blocks ה–ט, up to the discussion)
|
||||||
|
*
|
||||||
|
* Generation runs on the host (claude_session → claude -p), NOT in the container,
|
||||||
|
* so the trigger endpoints return 202-accepted and the UI POLLS for completion:
|
||||||
|
* • summary → GET /api/cases/{n}/research/party-claims-summary (200 = ready)
|
||||||
|
* • interim draft → the exports list grows a "טיוטה-טענות_הצדדים_{N}.docx" file
|
||||||
|
* (polled via useExports — see exports.ts).
|
||||||
|
*
|
||||||
|
* The full-decision export ("הפק טיוטת החלטה מלאה") is NOT here — it reuses the
|
||||||
|
* existing in-container useExportDocx (exports.ts), no LLM, no wakeup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { ApiError, apiRequest } from "./client";
|
||||||
|
|
||||||
|
/** Shape returned by the structured-action wakeup endpoints (#214). */
|
||||||
|
export type GenerateTriggerResult = {
|
||||||
|
status: "ok" | "skipped" | "error";
|
||||||
|
action?: string;
|
||||||
|
issue_id?: string;
|
||||||
|
ceo_id?: string;
|
||||||
|
reason?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The two host-side background generation actions (poll target of the status chip). */
|
||||||
|
export type GenerateAction = "party_claims_summary" | "interim_draft";
|
||||||
|
|
||||||
|
export const generateKeys = {
|
||||||
|
all: ["generate"] as const,
|
||||||
|
partyClaimsSummary: (caseNumber: string) =>
|
||||||
|
[...generateKeys.all, "party-claims-summary", caseNumber] as const,
|
||||||
|
status: (caseNumber: string, action: GenerateAction) =>
|
||||||
|
[...generateKeys.all, "status", action, caseNumber] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-derived background-run status (#227 ג). Reconstructed on the backend
|
||||||
|
* from the CEO child issue + its heartbeat_run + the output artifact — so it is
|
||||||
|
* correct after navigating away and back; it never depends on the browser having
|
||||||
|
* stayed on the page. `started_at`/`finished_at` are server ISO-8601 UTC stamps,
|
||||||
|
* so the client renders a correct elapsed time on return.
|
||||||
|
*/
|
||||||
|
export type GenerateState = "idle" | "queued" | "running" | "done" | "failed";
|
||||||
|
|
||||||
|
export type GenerateStatus = {
|
||||||
|
case_number: string;
|
||||||
|
action: GenerateAction;
|
||||||
|
state: GenerateState;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
output_ready: boolean;
|
||||||
|
detail: string;
|
||||||
|
issue_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGenerateStatus(
|
||||||
|
caseNumber: string | undefined,
|
||||||
|
action: GenerateAction,
|
||||||
|
) {
|
||||||
|
return useQuery<GenerateStatus>({
|
||||||
|
queryKey: generateKeys.status(caseNumber ?? "", action),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<GenerateStatus>(
|
||||||
|
`/api/cases/${caseNumber}/generate/${action}/status`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
// Poll only while a run is in flight; a terminal state stops the interval.
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const s = query.state.data?.state;
|
||||||
|
return s === "running" || s === "queued" ? 5_000 : false;
|
||||||
|
},
|
||||||
|
staleTime: 2_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Read: party-claims executive summary (poll target for button 1) ──
|
||||||
|
404 (not generated yet) is a normal "not ready" state, not an error — we
|
||||||
|
coerce it to null so the panel renders the empty/idle state cleanly. */
|
||||||
|
export type PartyClaimsSummary = { markdown: string } | null;
|
||||||
|
|
||||||
|
export function usePartyClaimsSummary(caseNumber: string | undefined) {
|
||||||
|
return useQuery<PartyClaimsSummary>({
|
||||||
|
queryKey: generateKeys.partyClaimsSummary(caseNumber ?? ""),
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
try {
|
||||||
|
return await apiRequest<{ markdown: string }>(
|
||||||
|
`/api/cases/${caseNumber}/research/party-claims-summary`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 404) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 5_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Trigger: party-claims executive summary (button 1) ── */
|
||||||
|
export function useGeneratePartyClaimsSummary(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiRequest<GenerateTriggerResult>(
|
||||||
|
`/api/cases/${caseNumber}/generate/party-claims-summary`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Start polling fresh — the file will appear after the host run completes,
|
||||||
|
// and the status chip should pick up the new queued/running run.
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: generateKeys.partyClaimsSummary(caseNumber),
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: generateKeys.status(caseNumber, "party_claims_summary"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
|
||||||
|
export function useGenerateInterimDraft(caseNumber: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiRequest<GenerateTriggerResult>(
|
||||||
|
`/api/cases/${caseNumber}/generate/interim-draft`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
// The result lands in the exports list (טיוטה-טענות_הצדדים_*.docx, polled by
|
||||||
|
// useExports); re-arm the status chip so it shows the new run immediately.
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: generateKeys.status(caseNumber, "interim_draft"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ export type LegalArgument = {
|
|||||||
id: string;
|
id: string;
|
||||||
case_id: string;
|
case_id: string;
|
||||||
party: LegalArgumentParty;
|
party: LegalArgumentParty;
|
||||||
|
/**
|
||||||
|
* The specific pleading this argument belongs to, for sides that may comprise
|
||||||
|
* several litigants with opposing positions (respondent / permit_applicant
|
||||||
|
* briefs). Empty for single-voice sides (appellant / committee). (#224)
|
||||||
|
*/
|
||||||
|
party_name?: string;
|
||||||
argument_index: number;
|
argument_index: number;
|
||||||
argument_title: string;
|
argument_title: string;
|
||||||
argument_body: string;
|
argument_body: string;
|
||||||
|
|||||||
@@ -190,6 +190,61 @@ export function useAgentRuns() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||||
|
export type AgentHealthState = "zombie" | "stalled" | "working" | "idle";
|
||||||
|
|
||||||
|
export type AgentHealthItem = {
|
||||||
|
issue_id: string;
|
||||||
|
identifier: string;
|
||||||
|
status: string;
|
||||||
|
agent_id: string;
|
||||||
|
agent_name: string;
|
||||||
|
health: AgentHealthState;
|
||||||
|
wakeups: number;
|
||||||
|
recovery_wakeups: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentHealthResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
items: AgentHealthItem[]; // worst-first (zombie → stalled → working → idle)
|
||||||
|
counts: Record<AgentHealthState, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-issue agent health — surfaces recovery-loop zombies automatically. */
|
||||||
|
export function useAgentHealth() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["operations", "agents", "health"],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<AgentHealthResponse>("/api/operations/agents/health", { signal }),
|
||||||
|
refetchInterval: 5000, // live alongside the running-agents view
|
||||||
|
staleTime: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EscalationItem = {
|
||||||
|
issue_id: string;
|
||||||
|
identifier: string;
|
||||||
|
severity: "critical" | "high" | "medium";
|
||||||
|
reason: string;
|
||||||
|
created_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecentEscalationsResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
items: EscalationItem[]; // newest-first
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Recent chair escalations (watchdog + manual) — the handled-loop history. */
|
||||||
|
export function useRecentEscalations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["operations", "agents", "escalations"],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<RecentEscalationsResponse>("/api/operations/agents/escalations", { signal }),
|
||||||
|
refetchInterval: 15000, // escalations change slowly (watchdog runs every 15m)
|
||||||
|
staleTime: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Full output log of one run — fetched on demand (drawer open). */
|
/** Full output log of one run — fetched on demand (drawer open). */
|
||||||
export function useRunLog(runId: string | null) {
|
export function useRunLog(runId: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
134
web-ui/src/lib/api/protocol-analysis.ts
Normal file
134
web-ui/src/lib/api/protocol-analysis.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Protocol comparative analysis — the "מה קרה בדיון" panel (#226).
|
||||||
|
*
|
||||||
|
* `analyze_protocol` compares the ערר hearing protocol against the written
|
||||||
|
* pleadings and records, per argument, whether it was strengthened, newly
|
||||||
|
* raised, or dropped at the hearing — plus the sharpened legal question and a
|
||||||
|
* verbatim evidence quote. This module exposes the read + trigger endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
import type { LegalArgumentParty } from "./legal-arguments";
|
||||||
|
|
||||||
|
export type ProtocolChangeType = "strengthened" | "newly_raised" | "dropped";
|
||||||
|
|
||||||
|
/** Empty string = an unassigned party_role on a verdict row. */
|
||||||
|
export type ProtocolPartyRole = LegalArgumentParty | "";
|
||||||
|
|
||||||
|
export type ProtocolAnalysisRow = {
|
||||||
|
id: string;
|
||||||
|
case_id: string;
|
||||||
|
document_id: string;
|
||||||
|
party_role: ProtocolPartyRole;
|
||||||
|
change_type: ProtocolChangeType;
|
||||||
|
/** The pleaded argument this verdict matched (strengthened/dropped); null for newly_raised. */
|
||||||
|
argument_id: string | null;
|
||||||
|
argument_title: string;
|
||||||
|
summary: string;
|
||||||
|
sharpened_question: string;
|
||||||
|
evidence_quote: string;
|
||||||
|
page_number: number | null;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtocolHeader = {
|
||||||
|
hearing_date: string | null;
|
||||||
|
protocol_title: string;
|
||||||
|
protocol_document_id: string;
|
||||||
|
panel_members: string[];
|
||||||
|
appellants_present: string[];
|
||||||
|
respondents_present: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProtocolAnalysisResponse = {
|
||||||
|
case_number: string;
|
||||||
|
total: number;
|
||||||
|
header: ProtocolHeader;
|
||||||
|
by_change: Partial<Record<ProtocolChangeType, number>>;
|
||||||
|
by_party: Partial<Record<ProtocolPartyRole, ProtocolAnalysisRow[]>>;
|
||||||
|
analysis: ProtocolAnalysisRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const protocolAnalysisKeys = {
|
||||||
|
all: ["protocol-analysis"] as const,
|
||||||
|
byCase: (caseNumber: string) =>
|
||||||
|
[...protocolAnalysisKeys.all, caseNumber] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useProtocolAnalysis(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: protocolAnalysisKeys.byCase(caseNumber ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<ProtocolAnalysisResponse>(
|
||||||
|
`/api/cases/${caseNumber}/protocol-analysis`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: Boolean(caseNumber),
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The analysis runs on the legal-analyst agent (host-side, where the `claude`
|
||||||
|
* CLI lives) — NOT inline in the FastAPI container. The endpoint delegates via
|
||||||
|
* a Paperclip wakeup (`queued`), or reports `skipped` when no analyst route is
|
||||||
|
* available (the chair can then run the MCP tool manually).
|
||||||
|
*/
|
||||||
|
export type AnalyzeProtocolResult =
|
||||||
|
| {
|
||||||
|
status: "queued";
|
||||||
|
sub_issue_id: string;
|
||||||
|
analyst_id: string;
|
||||||
|
main_issue_id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "skipped";
|
||||||
|
reason: "no_api_key" | "no_analyst" | "no_issue" | string;
|
||||||
|
company_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAnalyzeProtocol(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (documentId?: string) =>
|
||||||
|
apiRequest<AnalyzeProtocolResult>(
|
||||||
|
`/api/cases/${caseNumber}/analyze-protocol${
|
||||||
|
documentId ? `?document_id=${encodeURIComponent(documentId)}` : ""
|
||||||
|
}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: protocolAnalysisKeys.byCase(caseNumber),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHANGE_LABELS_HE: Record<ProtocolChangeType, string> = {
|
||||||
|
strengthened: "התחזק בדיון",
|
||||||
|
newly_raised: "נטען לראשונה",
|
||||||
|
dropped: "נזנח בדיון",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROTOCOL_PARTY_LABELS_HE: Record<ProtocolPartyRole, string> = {
|
||||||
|
appellant: "עוררים",
|
||||||
|
respondent: "משיבים",
|
||||||
|
committee: "ועדה מקומית",
|
||||||
|
permit_applicant: "מבקשי היתר",
|
||||||
|
unknown: "צד לא מזוהה",
|
||||||
|
"": "צד לא מסווג",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Display order for the per-party sections. */
|
||||||
|
export const PROTOCOL_PARTY_ORDER: ProtocolPartyRole[] = [
|
||||||
|
"appellant",
|
||||||
|
"committee",
|
||||||
|
"respondent",
|
||||||
|
"permit_applicant",
|
||||||
|
"unknown",
|
||||||
|
"",
|
||||||
|
];
|
||||||
@@ -692,6 +692,28 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/status-model": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Status Model
|
||||||
|
* @description Canonical case-status model — ordered statuses (key/label/description/
|
||||||
|
* phase/selectable/terminal/on_enter) + the 5 phases. Single source of truth
|
||||||
|
* (legal_mcp/case_status_model.py); the frontend case-status.ts mirrors it.
|
||||||
|
*/
|
||||||
|
get: operations["api_status_model_api_status_model_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/cases": {
|
"/api/cases": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1353,6 +1375,50 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/cases/{case_number}/generate/party-claims-summary": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Generate Party Claims Summary
|
||||||
|
* @description Fire-and-accept: wake the CEO to generate the party-claims executive summary
|
||||||
|
* (שלב H2 → summarize_party_claims). Runs host-side; poll
|
||||||
|
* GET /api/cases/{n}/research/party-claims-summary for completion.
|
||||||
|
*/
|
||||||
|
post: operations["api_generate_party_claims_summary_api_cases__case_number__generate_party_claims_summary_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/cases/{case_number}/generate/interim-draft": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Generate Interim Draft
|
||||||
|
* @description Fire-and-accept: wake the CEO to generate the partial "party-claims" draft
|
||||||
|
* (שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
|
||||||
|
* exports list for the new טיוטת-ביניים-{case}-vN.docx.
|
||||||
|
*/
|
||||||
|
post: operations["api_generate_interim_draft_api_cases__case_number__generate_interim_draft_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/cases/{case_number}/research/party-claims-summary/download": {
|
"/api/cases/{case_number}/research/party-claims-summary/download": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2061,6 +2127,30 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/cases/{case_number}/agents/interaction-dismiss": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Dismiss Interaction
|
||||||
|
* @description Dismiss a pending interaction WITHOUT waking the issue assignee.
|
||||||
|
*
|
||||||
|
* For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
|
||||||
|
* this does not resolve via Paperclip's wake_assignee path — it cancels the row
|
||||||
|
* so the chair can clear noise without triggering an agent run.
|
||||||
|
*/
|
||||||
|
post: operations["api_dismiss_interaction_api_cases__case_number__agents_interaction_dismiss_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/cases/{case_number}/agents/reset": {
|
"/api/cases/{case_number}/agents/reset": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4587,13 +4677,19 @@ export interface components {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* DocumentPatchRequest
|
* DocumentPatchRequest
|
||||||
* @description Patch payload for a single document. Both fields are optional.
|
* @description Patch payload for a single document. All fields are optional; only the
|
||||||
|
* ones present are applied. The metadata.* flags are display/processing hints
|
||||||
|
* that never change the stored doc_type.
|
||||||
*/
|
*/
|
||||||
DocumentPatchRequest: {
|
DocumentPatchRequest: {
|
||||||
/** Doc Type */
|
/** Doc Type */
|
||||||
doc_type?: string | null;
|
doc_type?: string | null;
|
||||||
/** Appraiser Side */
|
/** Appraiser Side */
|
||||||
appraiser_side?: string | null;
|
appraiser_side?: string | null;
|
||||||
|
/** Is Post Hearing */
|
||||||
|
is_post_hearing?: boolean | null;
|
||||||
|
/** Protocol Scope */
|
||||||
|
protocol_scope?: string | null;
|
||||||
};
|
};
|
||||||
/** EquivalentLinkRequest */
|
/** EquivalentLinkRequest */
|
||||||
EquivalentLinkRequest: {
|
EquivalentLinkRequest: {
|
||||||
@@ -4758,6 +4854,13 @@ export interface components {
|
|||||||
/** Canonical Statement */
|
/** Canonical Statement */
|
||||||
canonical_statement?: string | null;
|
canonical_statement?: string | null;
|
||||||
};
|
};
|
||||||
|
/** InteractionDismissRequest */
|
||||||
|
InteractionDismissRequest: {
|
||||||
|
/** Issue Id */
|
||||||
|
issue_id: string;
|
||||||
|
/** Interaction Id */
|
||||||
|
interaction_id: string;
|
||||||
|
};
|
||||||
/** InteractionResponseRequest */
|
/** InteractionResponseRequest */
|
||||||
InteractionResponseRequest: {
|
InteractionResponseRequest: {
|
||||||
/** Issue Id */
|
/** Issue Id */
|
||||||
@@ -6147,6 +6250,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_status_model_api_status_model_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_cases_api_cases_get: {
|
list_cases_api_cases_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -7151,6 +7274,68 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_generate_party_claims_summary_api_cases__case_number__generate_party_claims_summary_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_number: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_generate_interim_draft_api_cases__case_number__generate_interim_draft_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_number: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_party_claims_summary_download_api_cases__case_number__research_party_claims_summary_download_get: {
|
api_party_claims_summary_download_api_cases__case_number__research_party_claims_summary_download_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8224,6 +8409,41 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_dismiss_interaction_api_cases__case_number__agents_interaction_dismiss_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_number: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InteractionDismissRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_reset_case_agents_api_cases__case_number__agents_reset_post: {
|
api_reset_case_agents_api_cases__case_number__agents_reset_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -64,6 +64,79 @@ export function doctypeTone(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Overview groups (display-only convenience buckets) ─────────────
|
||||||
|
//
|
||||||
|
// The case-overview document list is split into four sections purely for
|
||||||
|
// the chair's convenience — grouping NEVER changes a document's stored
|
||||||
|
// doc_type. Classification is derived on the client from doc_type plus two
|
||||||
|
// metadata flags (is_post_hearing, protocol_scope).
|
||||||
|
|
||||||
|
export type DocGroup = "primary" | "ancillary" | "hearing_protocol" | "post_hearing";
|
||||||
|
|
||||||
|
export const DOC_GROUP_LABELS: Record<DocGroup, string> = {
|
||||||
|
primary: "מסמכים עיקריים",
|
||||||
|
ancillary: "מסמכים נלווים",
|
||||||
|
hearing_protocol: "פרוטוקול דיון ועדת הערר",
|
||||||
|
post_hearing: "מסמכים לאחר הדיון",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOC_GROUP_HINTS: Record<DocGroup, string> = {
|
||||||
|
primary: "כתבי ערר, תשובות ושומות שהוגשו לפני הדיון",
|
||||||
|
ancillary: "פרוטוקולי/החלטות ועדה מקומית-מחוזית, תכניות, וכל השאר",
|
||||||
|
hearing_protocol: "הדיון בפני הוועדה",
|
||||||
|
post_hearing: "השלמות ותגובות שהתקבלו לאחר הדיון",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fixed render order of the groups in the overview panel. */
|
||||||
|
export const DOC_GROUP_ORDER: DocGroup[] = [
|
||||||
|
"primary",
|
||||||
|
"ancillary",
|
||||||
|
"hearing_protocol",
|
||||||
|
"post_hearing",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** doc_types that count as "primary" pre-hearing filings (group 1). */
|
||||||
|
const PRIMARY_DOC_TYPES = new Set<DocType>(["appeal", "response", "appraisal"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a document into one of the four overview groups.
|
||||||
|
*
|
||||||
|
* Priority order matters:
|
||||||
|
* 1. is_post_hearing wins over everything — a post-hearing כתב תשובה
|
||||||
|
* belongs in group 4, not group 1.
|
||||||
|
* 2. A protocol goes to group 3 (ועדת הערר) by default, or group 2 when
|
||||||
|
* explicitly tagged as a lower-committee protocol (protocol_scope==="lower").
|
||||||
|
* 3. appeal/response/appraisal → group 1; everything else → group 2.
|
||||||
|
*/
|
||||||
|
export function documentGroup(
|
||||||
|
docType: string,
|
||||||
|
metadata?: Record<string, unknown> | null,
|
||||||
|
): DocGroup {
|
||||||
|
if (metadata?.is_post_hearing === true) return "post_hearing";
|
||||||
|
if (docType === "protocol") {
|
||||||
|
return metadata?.protocol_scope === "lower" ? "ancillary" : "hearing_protocol";
|
||||||
|
}
|
||||||
|
if (PRIMARY_DOC_TYPES.has(docType as DocType)) return "primary";
|
||||||
|
return "ancillary";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Protocol scope (only relevant when doc_type === "protocol") ────
|
||||||
|
// Distinguishes the appeal-committee hearing protocol (group 3) from a
|
||||||
|
// lower (local/district) committee protocol (group 2). Stored in
|
||||||
|
// documents.metadata.protocol_scope; absent === "appeal" (the default).
|
||||||
|
|
||||||
|
export type ProtocolScope = "appeal" | "lower";
|
||||||
|
|
||||||
|
export const PROTOCOL_SCOPE_LABELS: Record<ProtocolScope, string> = {
|
||||||
|
appeal: "ועדת הערר",
|
||||||
|
lower: "ועדה מקומית/מחוזית",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROTOCOL_SCOPE_OPTIONS: { value: ProtocolScope; label: string }[] = [
|
||||||
|
{ value: "appeal", label: PROTOCOL_SCOPE_LABELS.appeal },
|
||||||
|
{ value: "lower", label: PROTOCOL_SCOPE_LABELS.lower },
|
||||||
|
];
|
||||||
|
|
||||||
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
|
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
|
||||||
|
|
||||||
export type AppraiserSide = "committee" | "appellant" | "deciding";
|
export type AppraiserSide = "committee" | "appellant" | "deciding";
|
||||||
|
|||||||
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,37 +33,118 @@ from web.paperclip_api import (
|
|||||||
pc_request,
|
pc_request,
|
||||||
require_paperclip_db_url,
|
require_paperclip_db_url,
|
||||||
)
|
)
|
||||||
|
from web.agent_telemetry import instrument
|
||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
COMPANIES as PAPERCLIP_COMPANIES,
|
COMPANIES as PAPERCLIP_COMPANIES,
|
||||||
accept_interaction as pc_accept_interaction,
|
accept_interaction as pc_accept_interaction,
|
||||||
archive_project as pc_archive_project,
|
archive_project as pc_archive_project,
|
||||||
cancel_run as pc_cancel_run,
|
|
||||||
create_project as pc_create_project,
|
create_project as pc_create_project,
|
||||||
create_workflow_issue as pc_create_workflow_issue,
|
create_workflow_issue as pc_create_workflow_issue,
|
||||||
|
get_agent_health as pc_get_agent_health,
|
||||||
|
get_recent_escalations as pc_get_recent_escalations,
|
||||||
|
get_predecessor_context as pc_get_predecessor_context,
|
||||||
|
get_predecessor_for_case as pc_get_predecessor_for_case,
|
||||||
get_agents_for_case as pc_get_agents_for_case,
|
get_agents_for_case as pc_get_agents_for_case,
|
||||||
get_agents_for_company as pc_get_agents,
|
get_agents_for_company as pc_get_agents,
|
||||||
get_case_issues as pc_get_case_issues,
|
get_case_issues as pc_get_case_issues,
|
||||||
|
pick_default_comment_target as pc_pick_default_comment_target,
|
||||||
get_issue_comments as pc_get_issue_comments,
|
get_issue_comments as pc_get_issue_comments,
|
||||||
get_issue_interactions as pc_get_issue_interactions,
|
get_issue_interactions as pc_get_issue_interactions,
|
||||||
get_project_url,
|
get_project_url,
|
||||||
get_run_events as pc_get_run_events,
|
get_run_events as pc_get_run_events,
|
||||||
get_run_log as pc_get_run_log,
|
get_run_log as pc_get_run_log,
|
||||||
list_live_runs as pc_list_live_runs,
|
list_live_runs as pc_list_live_runs,
|
||||||
post_comment as pc_post_comment,
|
|
||||||
reject_interaction as pc_reject_interaction,
|
reject_interaction as pc_reject_interaction,
|
||||||
reset_agent_session as pc_reset_agent_session,
|
|
||||||
reset_case_agents as pc_reset_case_agents,
|
|
||||||
respond_to_interaction as pc_respond_to_interaction,
|
respond_to_interaction as pc_respond_to_interaction,
|
||||||
restore_project as pc_restore_project,
|
restore_project as pc_restore_project,
|
||||||
update_project_name as pc_update_project_name,
|
update_project_name as pc_update_project_name,
|
||||||
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
get_generation_run_status as pc_get_generation_run_status,
|
||||||
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
|
# ── raw imports: wrapped with telemetry below (state-affecting ops, #219) ──
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
cancel_interaction as _cancel_interaction,
|
||||||
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
cancel_run as _cancel_run,
|
||||||
wake_curator_for_final as pc_wake_curator_for_final,
|
escalate_issue as _escalate_issue,
|
||||||
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
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) ───────────
|
# ── domain-named lifecycle aliases (preferred for new call sites) ───────────
|
||||||
archive_case_project = pc_archive_project
|
archive_case_project = pc_archive_project
|
||||||
restore_case_project = pc_restore_project
|
restore_case_project = pc_restore_project
|
||||||
@@ -93,23 +174,37 @@ __all__ = [
|
|||||||
# issues / workflow
|
# issues / workflow
|
||||||
"pc_create_workflow_issue",
|
"pc_create_workflow_issue",
|
||||||
"pc_get_case_issues",
|
"pc_get_case_issues",
|
||||||
|
"pc_pick_default_comment_target",
|
||||||
# agents / wakeups
|
# agents / wakeups
|
||||||
"pc_get_agents_for_case",
|
"pc_get_agents_for_case",
|
||||||
"pc_get_agents",
|
"pc_get_agents",
|
||||||
"pc_wake_ceo",
|
"pc_wake_ceo",
|
||||||
|
"pc_wake_ceo_for_action",
|
||||||
|
"pc_open_ceo_run",
|
||||||
"pc_wake_ceo_for_feedback_fold",
|
"pc_wake_ceo_for_feedback_fold",
|
||||||
"pc_wake_curator_for_final",
|
"pc_wake_curator_for_final",
|
||||||
"pc_wake_for_precedent_extraction",
|
"pc_wake_for_precedent_extraction",
|
||||||
"pc_wake_analyst_for_appraiser_facts",
|
"pc_wake_analyst_for_appraiser_facts",
|
||||||
"pc_wake_analyst_for_argument_aggregation",
|
"pc_wake_analyst_for_argument_aggregation",
|
||||||
|
"pc_wake_analyst_for_protocol_analysis",
|
||||||
|
"pc_get_generation_run_status",
|
||||||
# comments / interactions
|
# comments / interactions
|
||||||
"pc_post_comment",
|
"pc_post_comment",
|
||||||
|
"pc_mark_comment_routed",
|
||||||
"pc_get_issue_comments",
|
"pc_get_issue_comments",
|
||||||
"pc_get_issue_interactions",
|
"pc_get_issue_interactions",
|
||||||
"pc_accept_interaction",
|
"pc_accept_interaction",
|
||||||
"pc_reject_interaction",
|
"pc_reject_interaction",
|
||||||
"pc_respond_to_interaction",
|
"pc_respond_to_interaction",
|
||||||
|
"pc_cancel_interaction",
|
||||||
|
"pc_reap_stale_interactions",
|
||||||
|
"pc_escalate_issue",
|
||||||
|
"pc_set_issue_status",
|
||||||
# agent-run observability + control (live view + smart management)
|
# agent-run observability + control (live view + smart management)
|
||||||
|
"pc_get_agent_health",
|
||||||
|
"pc_get_recent_escalations",
|
||||||
|
"pc_get_predecessor_context",
|
||||||
|
"pc_get_predecessor_for_case",
|
||||||
"pc_list_live_runs",
|
"pc_list_live_runs",
|
||||||
"pc_get_run_log",
|
"pc_get_run_log",
|
||||||
"pc_get_run_events",
|
"pc_get_run_events",
|
||||||
|
|||||||
157
web/agent_telemetry.py
Normal file
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
|
||||||
689
web/app.py
689
web/app.py
@@ -57,28 +57,42 @@ from web.agent_platform_port import (
|
|||||||
get_project_url,
|
get_project_url,
|
||||||
pc_accept_interaction,
|
pc_accept_interaction,
|
||||||
pc_archive_project,
|
pc_archive_project,
|
||||||
|
pc_cancel_interaction,
|
||||||
pc_cancel_run,
|
pc_cancel_run,
|
||||||
pc_create_project,
|
pc_create_project,
|
||||||
pc_create_workflow_issue,
|
pc_create_workflow_issue,
|
||||||
|
pc_escalate_issue,
|
||||||
|
pc_get_agent_health,
|
||||||
|
pc_get_recent_escalations,
|
||||||
|
pc_get_predecessor_context,
|
||||||
|
pc_get_predecessor_for_case,
|
||||||
pc_get_agents,
|
pc_get_agents,
|
||||||
pc_get_agents_for_case,
|
pc_get_agents_for_case,
|
||||||
pc_get_case_issues,
|
pc_get_case_issues,
|
||||||
|
pc_pick_default_comment_target,
|
||||||
pc_get_issue_comments,
|
pc_get_issue_comments,
|
||||||
pc_get_issue_interactions,
|
pc_get_issue_interactions,
|
||||||
pc_get_run_events,
|
pc_get_run_events,
|
||||||
pc_get_run_log,
|
pc_get_run_log,
|
||||||
pc_list_live_runs,
|
pc_list_live_runs,
|
||||||
pc_post_comment,
|
pc_post_comment,
|
||||||
|
pc_reap_stale_interactions,
|
||||||
pc_reject_interaction,
|
pc_reject_interaction,
|
||||||
pc_request,
|
pc_request,
|
||||||
pc_reset_agent_session,
|
pc_reset_agent_session,
|
||||||
pc_reset_case_agents,
|
pc_reset_case_agents,
|
||||||
pc_respond_to_interaction,
|
pc_respond_to_interaction,
|
||||||
|
pc_set_issue_status,
|
||||||
pc_restore_project,
|
pc_restore_project,
|
||||||
pc_wake_analyst_for_appraiser_facts,
|
pc_wake_analyst_for_appraiser_facts,
|
||||||
pc_wake_analyst_for_argument_aggregation,
|
pc_wake_analyst_for_argument_aggregation,
|
||||||
|
pc_wake_analyst_for_protocol_analysis,
|
||||||
|
pc_get_generation_run_status,
|
||||||
rename_case_project,
|
rename_case_project,
|
||||||
pc_wake_ceo,
|
pc_wake_ceo,
|
||||||
|
pc_wake_ceo_for_action,
|
||||||
|
pc_open_ceo_run,
|
||||||
|
pc_mark_comment_routed,
|
||||||
pc_wake_ceo_for_feedback_fold,
|
pc_wake_ceo_for_feedback_fold,
|
||||||
pc_wake_curator_for_final,
|
pc_wake_curator_for_final,
|
||||||
pc_wake_for_precedent_extraction,
|
pc_wake_for_precedent_extraction,
|
||||||
@@ -100,19 +114,86 @@ PROGRESS_TTL_SECONDS = 300
|
|||||||
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
|
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _interaction_reaper_loop():
|
||||||
|
"""Safety-net reaper for stranded pending interactions (TaskMaster #215).
|
||||||
|
|
||||||
|
Every 15 min, cancel pending interactions on closed (done/cancelled) issues
|
||||||
|
so the chair's "awaiting you" count never accumulates dead questions.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(900)
|
||||||
|
await pc_reap_stale_interactions()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.warning("interaction reaper sweep failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Watchdog kill-switch — set AGENT_ZOMBIE_AUTO_ESCALATE=0 to observe health
|
||||||
|
# without acting (escalation stays available manually via the endpoint).
|
||||||
|
_ZOMBIE_AUTO_ESCALATE = os.environ.get("AGENT_ZOMBIE_AUTO_ESCALATE", "1") != "0"
|
||||||
|
|
||||||
|
|
||||||
|
async def _zombie_escalation_loop():
|
||||||
|
"""Watchdog: auto-escalate persistent recovery-loop zombies to the chair (#218+#222).
|
||||||
|
|
||||||
|
Every 15 min, derive per-issue health (#222) and hand any persistent zombie
|
||||||
|
(a recovery loop, ``>=`` ``ZOMBIE_ESCALATE_MIN_RECOVERY`` recovery wakeups)
|
||||||
|
to the human via the loop-safe escalate primitive (#218) — closing the loop
|
||||||
|
that today burns budget until a human notices. Server-side on purpose: an
|
||||||
|
agent stuck in a recovery loop cannot reliably self-escalate. Idempotent —
|
||||||
|
an escalated issue is agent-unowned next sweep, so it is not re-escalated.
|
||||||
|
"""
|
||||||
|
from web.agent_health import zombie_escalation
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(900)
|
||||||
|
if not _ZOMBIE_AUTO_ESCALATE:
|
||||||
|
continue
|
||||||
|
health = await pc_get_agent_health()
|
||||||
|
for item in health.get("items", []):
|
||||||
|
decision = zombie_escalation(item)
|
||||||
|
if decision is None:
|
||||||
|
continue
|
||||||
|
severity, reason = decision
|
||||||
|
result = await pc_escalate_issue(item["issue_id"], severity, reason)
|
||||||
|
if result.get("ok"):
|
||||||
|
logger.warning(
|
||||||
|
"watchdog auto-escalated %s (%s, %d recovery wakeups) to chair [%s]",
|
||||||
|
item.get("identifier"), item.get("agent_name"),
|
||||||
|
item.get("recovery_wakeups", 0), severity,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"watchdog escalation failed for %s: %s",
|
||||||
|
item.get("identifier"), result.get("error"),
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.warning("zombie escalation sweep failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
await db.init_schema()
|
await db.init_schema()
|
||||||
sync_task = asyncio.create_task(git_sync.sweep_loop())
|
sync_task = asyncio.create_task(git_sync.sweep_loop())
|
||||||
|
reaper_task = asyncio.create_task(_interaction_reaper_loop())
|
||||||
|
watchdog_task = asyncio.create_task(_zombie_escalation_loop())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
sync_task.cancel()
|
sync_task.cancel()
|
||||||
try:
|
reaper_task.cancel()
|
||||||
await sync_task
|
watchdog_task.cancel()
|
||||||
except asyncio.CancelledError:
|
for _t in (sync_task, reaper_task, watchdog_task):
|
||||||
pass
|
try:
|
||||||
|
await _t
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
await db.close_pool()
|
await db.close_pool()
|
||||||
await _progress.close()
|
await _progress.close()
|
||||||
|
|
||||||
@@ -1828,6 +1909,16 @@ async def health():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/status-model")
|
||||||
|
async def api_status_model():
|
||||||
|
"""Canonical case-status model — ordered statuses (key/label/description/
|
||||||
|
phase/selectable/terminal/on_enter) + the 5 phases. Single source of truth
|
||||||
|
(legal_mcp/case_status_model.py); the frontend case-status.ts mirrors it."""
|
||||||
|
from legal_mcp.case_status_model import to_dict
|
||||||
|
|
||||||
|
return to_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases")
|
@app.get("/api/cases")
|
||||||
async def list_cases(
|
async def list_cases(
|
||||||
detail: bool = False,
|
detail: bool = False,
|
||||||
@@ -2627,6 +2718,97 @@ async def api_get_legal_arguments(case_number: str, party: str = ""):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/protocol-analysis")
|
||||||
|
async def api_get_protocol_analysis(case_number: str):
|
||||||
|
"""Hearing-protocol comparative analysis for the "מה קרה בדיון" panel (#226).
|
||||||
|
|
||||||
|
Read-only surface over the case-knowledge produced by ``analyze_protocol``:
|
||||||
|
the hearing header (date + who appeared, from the canonical case columns)
|
||||||
|
plus the per-argument verdicts (strengthened / newly_raised / dropped)
|
||||||
|
grouped by party. Container-safe — pure DB reads, no LLM. Returns an empty
|
||||||
|
analysis (not an error) when the analysis has not been run yet.
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
rows = await db.list_protocol_analysis(case_id)
|
||||||
|
|
||||||
|
# Protocol title comes from the analysed document (all rows share one
|
||||||
|
# document_id per idempotent replace). Resolve it via the case's documents.
|
||||||
|
protocol_title = ""
|
||||||
|
protocol_document_id = ""
|
||||||
|
if rows:
|
||||||
|
protocol_document_id = rows[0].get("document_id") or ""
|
||||||
|
docs = await db.list_documents(case_id)
|
||||||
|
match = next(
|
||||||
|
(d for d in docs if str(d.get("id")) == protocol_document_id), None,
|
||||||
|
)
|
||||||
|
if match:
|
||||||
|
protocol_title = match.get("title") or ""
|
||||||
|
|
||||||
|
attendees = case.get("hearing_attendees") or {}
|
||||||
|
hearing_date = case.get("hearing_date")
|
||||||
|
header = {
|
||||||
|
"hearing_date": hearing_date.isoformat() if hearing_date else None,
|
||||||
|
"protocol_title": protocol_title,
|
||||||
|
"protocol_document_id": protocol_document_id,
|
||||||
|
"panel_members": attendees.get("panel_members") or [],
|
||||||
|
"appellants_present": attendees.get("appellants_present") or [],
|
||||||
|
"respondents_present": attendees.get("respondents_present") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Group verdicts by party for the panel's per-side sections, in display order.
|
||||||
|
by_party: dict[str, list[dict]] = {}
|
||||||
|
by_change: dict[str, int] = {}
|
||||||
|
for r in rows:
|
||||||
|
by_party.setdefault(r.get("party_role") or "", []).append(r)
|
||||||
|
ct = r.get("change_type", "")
|
||||||
|
by_change[ct] = by_change.get(ct, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"case_number": case_number,
|
||||||
|
"total": len(rows),
|
||||||
|
"header": header,
|
||||||
|
"by_change": by_change,
|
||||||
|
"by_party": by_party,
|
||||||
|
"analysis": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/analyze-protocol")
|
||||||
|
async def api_analyze_protocol(case_number: str, document_id: str = ""):
|
||||||
|
"""Queue hearing-protocol analysis by waking the legal-analyst agent (#226).
|
||||||
|
|
||||||
|
Same delegation rationale as ``aggregate-arguments``: ``analyze_protocol``
|
||||||
|
calls the local ``claude`` CLI, absent in this container, so we route to the
|
||||||
|
company's analyst rather than running a doomed in-container BackgroundTask.
|
||||||
|
|
||||||
|
Response: {"status": "queued", ...} or {"status": "skipped", "reason": ...}.
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
|
prefix = case_number[:1]
|
||||||
|
company_id = (
|
||||||
|
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||||
|
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await pc_wake_analyst_for_protocol_analysis(
|
||||||
|
case_number, company_id=company_id, document_id=document_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("analyst wakeup failed for protocol analysis %s", case_number)
|
||||||
|
raise HTTPException(500, f"לא ניתן לשלוח לאנליטיקאי: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/direction")
|
@app.post("/api/cases/{case_number}/direction")
|
||||||
async def api_set_direction(case_number: str, req: DirectionRequest):
|
async def api_set_direction(case_number: str, req: DirectionRequest):
|
||||||
"""Save the approved direction document for the discussion block."""
|
"""Save the approved direction document for the discussion block."""
|
||||||
@@ -2889,42 +3071,82 @@ async def api_learn(case_number: str):
|
|||||||
# ── Local files API — research, drafts, proofread ──
|
# ── Local files API — research, drafts, proofread ──
|
||||||
|
|
||||||
|
|
||||||
|
# Directories under a case that are infrastructure/cache, not chair-facing
|
||||||
|
# content. Hidden dirs (``.git``, ``.claude`` …) are skipped separately.
|
||||||
|
_CASE_FILE_EXCLUDE_DIRS = {"thumbnails"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_content_dir(p: Path) -> bool:
|
||||||
|
return p.is_dir() and not p.name.startswith(".") and p.name not in _CASE_FILE_EXCLUDE_DIRS
|
||||||
|
|
||||||
|
|
||||||
|
def _case_file_folders(case_dir: Path) -> list[tuple[str, Path]]:
|
||||||
|
"""Enumerate every content subdirectory under a case dir as (key, path).
|
||||||
|
|
||||||
|
Covers *all* subfolders (not just research) — recursing one level into
|
||||||
|
``documents/`` — while skipping hidden (``.git``/``.claude``…) and cache
|
||||||
|
dirs. The ``key`` is the case-relative path with ``/`` encoded as ``__`` so
|
||||||
|
it survives a single URL path segment; ``_resolve_case_file`` re-validates it
|
||||||
|
against this same enumeration, so the key set is the serve allowlist (no path
|
||||||
|
traversal).
|
||||||
|
"""
|
||||||
|
folders: list[tuple[str, Path]] = []
|
||||||
|
if not case_dir.exists():
|
||||||
|
return folders
|
||||||
|
for child in sorted(case_dir.iterdir()):
|
||||||
|
if not _is_content_dir(child):
|
||||||
|
continue
|
||||||
|
if child.name == "documents":
|
||||||
|
folders.extend(
|
||||||
|
(f"documents__{sub.name}", sub)
|
||||||
|
for sub in sorted(child.iterdir())
|
||||||
|
if _is_content_dir(sub)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
folders.append((child.name, child))
|
||||||
|
return folders
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_case_file(case_dir: Path, folder_key: str, filename: str) -> Path | None:
|
||||||
|
"""Safely resolve (folder_key, filename) to a file inside the case dir.
|
||||||
|
|
||||||
|
Returns None unless ``folder_key`` is one of the enumerated content folders
|
||||||
|
AND ``filename`` resolves to a regular file directly inside it (no traversal).
|
||||||
|
"""
|
||||||
|
base = dict(_case_file_folders(case_dir)).get(folder_key)
|
||||||
|
if base is None:
|
||||||
|
return None
|
||||||
|
target = (base / filename).resolve()
|
||||||
|
if target.parent != base.resolve() or not target.is_file():
|
||||||
|
return None
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases/{case_number}/local-files")
|
@app.get("/api/cases/{case_number}/local-files")
|
||||||
async def api_local_files(case_number: str):
|
async def api_local_files(case_number: str):
|
||||||
"""List local files from case subdirectories (research, drafts, proofread)."""
|
"""List local files across ALL content subfolders under a case (not just
|
||||||
|
research) — so the chair can reach any agent-produced file (drafts, OCR text,
|
||||||
|
originals…) from the UI instead of only over SSH. Empty folders are returned
|
||||||
|
too (with an empty ``files`` list) so the UI can show them as such."""
|
||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
result = {}
|
folders = []
|
||||||
for folder in ("research", "proofread"):
|
for key, path in _case_file_folders(case_dir):
|
||||||
folder_path = case_dir / "documents" / folder
|
|
||||||
if folder_path.exists():
|
|
||||||
files = []
|
|
||||||
for f in sorted(folder_path.iterdir()):
|
|
||||||
if f.is_file() and not f.name.startswith("."):
|
|
||||||
stat = f.stat()
|
|
||||||
files.append({
|
|
||||||
"filename": f.name,
|
|
||||||
"size": stat.st_size,
|
|
||||||
"modified_at": stat.st_mtime,
|
|
||||||
"folder": folder,
|
|
||||||
})
|
|
||||||
if files:
|
|
||||||
result[folder] = files
|
|
||||||
# Drafts are at case level, not under documents
|
|
||||||
drafts_path = case_dir / "drafts"
|
|
||||||
if drafts_path.exists():
|
|
||||||
files = []
|
files = []
|
||||||
for f in sorted(drafts_path.iterdir()):
|
for f in sorted(path.iterdir()):
|
||||||
if f.is_file() and not f.name.startswith("."):
|
if f.is_file() and not f.name.startswith("."):
|
||||||
stat = f.stat()
|
stat = f.stat()
|
||||||
files.append({
|
files.append({
|
||||||
"filename": f.name,
|
"filename": f.name,
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified_at": stat.st_mtime,
|
"modified_at": stat.st_mtime,
|
||||||
"folder": "drafts",
|
|
||||||
})
|
})
|
||||||
if files:
|
folders.append({
|
||||||
result["drafts"] = files
|
"key": key,
|
||||||
return result
|
"name": path.name,
|
||||||
|
"path": str(path.relative_to(case_dir)),
|
||||||
|
"files": files,
|
||||||
|
})
|
||||||
|
return {"folders": folders}
|
||||||
|
|
||||||
|
|
||||||
async def serve_blob(
|
async def serve_blob(
|
||||||
@@ -2994,17 +3216,32 @@ async def _seal_blob_file(dest: Path, *, bucket=storage.Bucket.DOCUMENTS) -> Non
|
|||||||
|
|
||||||
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
@app.get("/api/cases/{case_number}/local-files/{folder}/{filename}")
|
||||||
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
async def api_read_local_file(case_number: str, folder: str, filename: str):
|
||||||
"""Read contents of a local case file."""
|
"""Serve a single local case file from any content subfolder.
|
||||||
if folder not in ("research", "proofread", "drafts"):
|
|
||||||
raise HTTPException(400, "Invalid folder")
|
``folder`` is a key from ``/local-files`` (``documents__research``, ``drafts``…);
|
||||||
|
``_resolve_case_file`` validates it against the folder allowlist and blocks
|
||||||
|
path traversal before the bytes are served.
|
||||||
|
"""
|
||||||
case_dir = config.find_case_dir(case_number)
|
case_dir = config.find_case_dir(case_number)
|
||||||
if folder == "drafts":
|
path = _resolve_case_file(case_dir, folder, filename)
|
||||||
path = case_dir / "drafts" / filename
|
if path is None:
|
||||||
else:
|
|
||||||
path = case_dir / "documents" / folder / filename
|
|
||||||
if not path.exists() or not path.is_file():
|
|
||||||
raise HTTPException(404, "קובץ לא נמצא")
|
raise HTTPException(404, "קובץ לא נמצא")
|
||||||
return await serve_blob(path, media_type="text/plain; charset=utf-8", filename=filename)
|
media_type = _local_file_media_type(filename)
|
||||||
|
return await serve_blob(path, media_type=media_type, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
def _local_file_media_type(filename: str) -> str:
|
||||||
|
"""Best-effort content type for a served case file (text renders inline,
|
||||||
|
binaries download)."""
|
||||||
|
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||||
|
return {
|
||||||
|
"md": "text/markdown; charset=utf-8",
|
||||||
|
"txt": "text/plain; charset=utf-8",
|
||||||
|
"json": "application/json; charset=utf-8",
|
||||||
|
"pdf": "application/pdf",
|
||||||
|
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"doc": "application/msword",
|
||||||
|
}.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
# ── Research analysis (analysis-and-research.md) — parse + edit ────
|
||||||
@@ -3080,6 +3317,140 @@ async def api_party_claims_summary(case_number: str):
|
|||||||
return {"markdown": path.read_text(encoding="utf-8")}
|
return {"markdown": path.read_text(encoding="utf-8")}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Deterministic document generation triggers (TaskMaster #214) ─────
|
||||||
|
# The "הפקת מסמכים" buttons in the case "טיוטות והערות" tab. Generation for the
|
||||||
|
# executive summary + interim draft runs HOST-SIDE (claude_session → claude -p,
|
||||||
|
# not in the container), so these endpoints fire a *structured-action* CEO wakeup
|
||||||
|
# (the CEO reads payload.action and routes to שלב H2 / שלב H — legal-ceo.md).
|
||||||
|
# Wakeup goes through the Paperclip API helper (pc_wake_ceo_for_action → the
|
||||||
|
# platform Port → POST /api/agents/{id}/wakeup), NEVER a direct DB insert.
|
||||||
|
# Polling for completion uses the existing read endpoints (research/party-claims-
|
||||||
|
# summary for the summary; the exports list for the טיוטה-טענות_הצדדים_*.docx file).
|
||||||
|
|
||||||
|
|
||||||
|
async def _wake_ceo_action(case_number: str, action: str) -> dict:
|
||||||
|
"""Resolve the case's company + fire a structured-action CEO wakeup (#214)."""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
prefix = case_number[:1]
|
||||||
|
company_id = (
|
||||||
|
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||||
|
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await pc_wake_ceo_for_action(case_number, action, company_id=company_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CEO action %s wakeup failed for %s: %s", action, case_number, e)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/generate/party-claims-summary", status_code=202)
|
||||||
|
async def api_generate_party_claims_summary(case_number: str):
|
||||||
|
"""Fire-and-accept: wake the CEO to generate the party-claims executive summary
|
||||||
|
(שלב H2 → summarize_party_claims). Runs host-side; poll
|
||||||
|
GET /api/cases/{n}/research/party-claims-summary for completion."""
|
||||||
|
return await _wake_ceo_action(case_number, "party_claims_summary")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/generate/interim-draft", status_code=202)
|
||||||
|
async def api_generate_interim_draft(case_number: str):
|
||||||
|
"""Fire-and-accept: wake the CEO to generate the partial "party-claims" draft
|
||||||
|
(שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
|
||||||
|
exports list for the new טיוטה-טענות_הצדדים_{N}.docx."""
|
||||||
|
return await _wake_ceo_action(case_number, "interim_draft")
|
||||||
|
|
||||||
|
|
||||||
|
_GENERATE_ACTIONS = {"party_claims_summary", "interim_draft"}
|
||||||
|
# Run statuses (Paperclip heartbeat_runs.status) grouped for state mapping.
|
||||||
|
_RUN_ACTIVE = {"running", "in_progress", "queued", "pending", "scheduled", "created"}
|
||||||
|
_RUN_FAILED = {"cancelled", "failed", "error", "timed_out", "lost"}
|
||||||
|
|
||||||
|
|
||||||
|
def _generation_output_ready(case_number: str, action: str) -> bool:
|
||||||
|
"""Whether the generation artifact already exists on disk (host-side)."""
|
||||||
|
if action == "party_claims_summary":
|
||||||
|
from legal_mcp.services import party_claims_summary as pcs
|
||||||
|
return pcs.summary_file_path(case_number).exists()
|
||||||
|
# interim_draft → a "טיוטה-טענות_הצדדים_*.docx" file in the case exports dir.
|
||||||
|
export_dir = config.find_case_dir(case_number) / "exports"
|
||||||
|
if not export_dir.exists():
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
f.is_file() and f.suffix.lower() == ".docx" and f.name.startswith("טיוטה-טענות_הצדדים_")
|
||||||
|
for f in export_dir.iterdir()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/generate/{action}/status")
|
||||||
|
async def api_generate_status(case_number: str, action: str):
|
||||||
|
"""Server-derived status of a "הפקת מסמכים" background generation (#227 ג).
|
||||||
|
|
||||||
|
Reconstructs {idle|queued|running|done|failed} from the CEO child issue + its
|
||||||
|
latest heartbeat_run (via the platform port) plus whether the output artifact
|
||||||
|
exists — so the UI indicator is correct after navigating away and back; it
|
||||||
|
never depends on the browser staying on the page. ``started_at``/``finished_at``
|
||||||
|
are server timestamps (ISO-8601 UTC) so the client renders a correct elapsed
|
||||||
|
time on return.
|
||||||
|
"""
|
||||||
|
if action not in _GENERATE_ACTIONS:
|
||||||
|
raise HTTPException(400, f"action לא תקין: {action}")
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
|
||||||
|
output_ready = _generation_output_ready(case_number, action)
|
||||||
|
try:
|
||||||
|
run = await pc_get_generation_run_status(case_number, action)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("generation status lookup failed for %s/%s: %s", case_number, action, e)
|
||||||
|
run = {"issue_id": None}
|
||||||
|
|
||||||
|
run_status = (run.get("run_status") or "").lower()
|
||||||
|
started_at = run.get("started_at")
|
||||||
|
active = run_status in _RUN_ACTIVE or (
|
||||||
|
run.get("issue_status") == "in_progress"
|
||||||
|
and run_status not in _RUN_FAILED
|
||||||
|
and run_status not in {"completed", "succeeded", "done"}
|
||||||
|
and not run.get("finished_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Precedence: an in-flight run wins; then a produced artifact; then a failed
|
||||||
|
# run; else idle. (A produced file reads as "done" even if a later retry
|
||||||
|
# failed — the artifact exists.)
|
||||||
|
if active:
|
||||||
|
state = "running" if started_at else "queued"
|
||||||
|
elif output_ready:
|
||||||
|
state = "done"
|
||||||
|
elif run_status in _RUN_FAILED:
|
||||||
|
state = "failed"
|
||||||
|
elif run_status in {"completed", "succeeded", "done"}:
|
||||||
|
# Ran to completion but produced no artifact → treat as a failure so the
|
||||||
|
# chair isn't left thinking it worked.
|
||||||
|
state = "failed"
|
||||||
|
else:
|
||||||
|
state = "idle"
|
||||||
|
|
||||||
|
detail = ""
|
||||||
|
if state == "failed":
|
||||||
|
if run.get("error_code") == "issue_assignee_changed":
|
||||||
|
detail = "הריצה בוטלה (הוקצתה מחדש)"
|
||||||
|
else:
|
||||||
|
detail = run.get("error") or "הריצה נכשלה"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"case_number": case_number,
|
||||||
|
"action": action,
|
||||||
|
"state": state,
|
||||||
|
"started_at": started_at,
|
||||||
|
"finished_at": run.get("finished_at"),
|
||||||
|
"output_ready": output_ready,
|
||||||
|
"detail": detail,
|
||||||
|
"issue_id": run.get("issue_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
|
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
|
||||||
async def api_party_claims_summary_download(case_number: str):
|
async def api_party_claims_summary_download(case_number: str):
|
||||||
"""Download the raw party-claims-summary.md file."""
|
"""Download the raw party-claims-summary.md file."""
|
||||||
@@ -4291,14 +4662,39 @@ async def api_case_agents(case_number: str):
|
|||||||
class AgentCommentRequest(BaseModel):
|
class AgentCommentRequest(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
issue_id: str | None = None
|
issue_id: str | None = None
|
||||||
|
# "פתח הרצה חדשה" — skip the comment entirely and only open a CEO run. Kept for
|
||||||
|
# the agents-tab target selector; the default path below now opens a run too, so
|
||||||
|
# this only differs in *not* recording the instruction on the parent thread.
|
||||||
|
new_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/agents/comment")
|
@app.post("/api/cases/{case_number}/agents/comment")
|
||||||
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||||
"""Post a comment on a Paperclip issue linked to a case.
|
"""Post a chair instruction to a Paperclip issue linked to a case.
|
||||||
|
|
||||||
If issue_id is omitted, the most recent non-done issue is used.
|
Routing (agents-tab target selector):
|
||||||
|
- ``new_run=True`` → open a fresh CEO-owned run (child issue assigned to the
|
||||||
|
CEO + wakeup), sidestepping the ``issue_assignee_changed`` cancellation on
|
||||||
|
a human-owned parent. Overrides ``issue_id``.
|
||||||
|
- ``issue_id`` given → post to that issue (explicit chair choice).
|
||||||
|
- neither → prefer the live CEO main issue, never a closed one
|
||||||
|
(``pick_default_comment_target``).
|
||||||
"""
|
"""
|
||||||
|
if req.new_run:
|
||||||
|
result = await pc_open_ceo_run(case_number, req.body)
|
||||||
|
if result.get("status") != "ok":
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
f"פתיחת הרצה חדשה נכשלה: {result.get('reason', 'unknown')}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"comment_id": None,
|
||||||
|
"issue_id": result["issue_id"],
|
||||||
|
"issue_identifier": result.get("identifier", ""),
|
||||||
|
"issue_status": "in_progress",
|
||||||
|
"new_run": True,
|
||||||
|
}
|
||||||
|
|
||||||
issues = await pc_get_case_issues(case_number)
|
issues = await pc_get_case_issues(case_number)
|
||||||
if not issues:
|
if not issues:
|
||||||
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
||||||
@@ -4308,14 +4704,35 @@ async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
|||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
else:
|
else:
|
||||||
# Pick the most recent non-done issue, or the last one
|
target = pc_pick_default_comment_target(issues)
|
||||||
active = [i for i in issues if i["status"] != "done"]
|
|
||||||
target = active[-1] if active else issues[-1]
|
|
||||||
|
|
||||||
result = await pc_post_comment(target["id"], target["company_id"], req.body)
|
result = await pc_post_comment(target["id"], target["company_id"], req.body)
|
||||||
|
|
||||||
# Find the identifier for the response
|
# The comment alone reaches nobody: a case issue awaiting the chair is
|
||||||
|
# `in_review` and owned by a human, so Paperclip cancels any wakeup on it
|
||||||
|
# (`issue_assignee_changed` → "wake the new owner" → the owner is a person).
|
||||||
|
# The plugin sweep that was meant to catch this delivers the instruction as
|
||||||
|
# `payload.prompt`, which the runner drops. So the comment is recorded for the
|
||||||
|
# thread, and the instruction is carried by a CEO-owned child run — the one
|
||||||
|
# path verified to reach the agent (TaskMaster #228; see paperclip-quirks.md).
|
||||||
|
run = await pc_open_ceo_run(case_number, req.body)
|
||||||
|
if run.get("status") != "ok":
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
f"ההערה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא יטפל בה: "
|
||||||
|
f"{run.get('reason', 'unknown')}",
|
||||||
|
)
|
||||||
|
# Claim the comment so the sweep does not re-route it: the CEO answers on the
|
||||||
|
# child issue, leaving the parent with no agent reply, which the sweep reads
|
||||||
|
# as "still pending" forever.
|
||||||
|
await pc_mark_comment_routed(target["id"], result.get("comment_id", ""))
|
||||||
|
|
||||||
|
# Echo the resolved target so the UI can show where it landed and flag it
|
||||||
|
# when the chair explicitly targeted a closed issue (whose wakeup is skipped).
|
||||||
result["issue_identifier"] = target.get("identifier", "")
|
result["issue_identifier"] = target.get("identifier", "")
|
||||||
|
result["issue_status"] = target.get("status", "")
|
||||||
|
result["run_issue_id"] = run["issue_id"]
|
||||||
|
result["run_issue_identifier"] = run.get("identifier", "")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -4332,8 +4749,10 @@ async def api_post_interaction_response(
|
|||||||
):
|
):
|
||||||
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
"""Submit a user's answer to a Paperclip issue-thread interaction.
|
||||||
|
|
||||||
Routes to /respond | /accept | /reject based on `action`. Paperclip
|
Routes to /respond | /accept | /reject based on `action`, then opens a CEO run
|
||||||
auto-wakes the issue assignee after a successful submission.
|
carrying the answer. Paperclip's own `wake_assignee` cannot deliver it: the
|
||||||
|
issue is owned by the chair while it waits for her, so the "assignee" it wakes
|
||||||
|
is a person and no agent ever runs (TaskMaster #228).
|
||||||
"""
|
"""
|
||||||
issues = await pc_get_case_issues(case_number)
|
issues = await pc_get_case_issues(case_number)
|
||||||
if not any(i["id"] == req.issue_id for i in issues):
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
@@ -4345,7 +4764,7 @@ async def api_post_interaction_response(
|
|||||||
"reject": pc_reject_interaction,
|
"reject": pc_reject_interaction,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
return await handlers[req.action](
|
result = await handlers[req.action](
|
||||||
req.issue_id, req.interaction_id, req.payload,
|
req.issue_id, req.interaction_id, req.payload,
|
||||||
)
|
)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
@@ -4354,6 +4773,47 @@ async def api_post_interaction_response(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||||
|
|
||||||
|
answer = json.dumps(req.payload, ensure_ascii=False)
|
||||||
|
run = await pc_open_ceo_run(
|
||||||
|
case_number,
|
||||||
|
f'תשובת היו"ר לשאלה שהוצגה לה ({req.action}):\n\n{answer}\n\n'
|
||||||
|
f"קרא את ה-interaction המקורי על ה-issue והמשך משם.",
|
||||||
|
)
|
||||||
|
if run.get("status") != "ok":
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
f"התשובה נשמרה אך פתיחת ההרצה נכשלה — הסוכן לא ימשיך: "
|
||||||
|
f"{run.get('reason', 'unknown')}",
|
||||||
|
)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
result["run_issue_id"] = run["issue_id"]
|
||||||
|
result["run_issue_identifier"] = run.get("identifier", "")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionDismissRequest(BaseModel):
|
||||||
|
issue_id: str
|
||||||
|
interaction_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/agents/interaction-dismiss")
|
||||||
|
async def api_dismiss_interaction(
|
||||||
|
case_number: str, req: InteractionDismissRequest,
|
||||||
|
):
|
||||||
|
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
|
||||||
|
|
||||||
|
For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
|
||||||
|
this does not resolve via Paperclip's wake_assignee path — it cancels the row
|
||||||
|
so the chair can clear noise without triggering an agent run.
|
||||||
|
"""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
|
result = await pc_cancel_interaction(req.issue_id, req.interaction_id)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(404, result.get("error", "ביטול הבקשה נכשל"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/agents/reset")
|
@app.post("/api/cases/{case_number}/agents/reset")
|
||||||
async def api_reset_case_agents(case_number: str):
|
async def api_reset_case_agents(case_number: str):
|
||||||
@@ -4368,6 +4828,53 @@ async def api_reset_case_agents(case_number: str):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class EscalateRequest(BaseModel):
|
||||||
|
issue_id: str
|
||||||
|
severity: Literal["critical", "high", "medium"]
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/agents/escalate")
|
||||||
|
async def api_escalate_issue(case_number: str, req: EscalateRequest):
|
||||||
|
"""Escalate a single stuck issue to the chair (#218).
|
||||||
|
|
||||||
|
The loop-safe alternative to leaving an issue agent-owned+blocked: hands the
|
||||||
|
issue to the human in one atomic transition (in_review + assignee_user_id)
|
||||||
|
and records a severity note, without re-waking any agent. Emits an
|
||||||
|
``agent.escalated`` telemetry event via the Port.
|
||||||
|
"""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
|
result = await pc_escalate_issue(req.issue_id, req.severity, req.reason)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(400, result.get("error", "ההסלמה נכשלה"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
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 ────────────────────────────
|
# ── Settings: MCP Server Configuration ────────────────────────────
|
||||||
#
|
#
|
||||||
# Source of truth for legal-ai env vars is Coolify (see memory:
|
# Source of truth for legal-ai env vars is Coolify (see memory:
|
||||||
@@ -5621,13 +6128,21 @@ async def api_reprocess_document(case_number: str, doc_id: str):
|
|||||||
|
|
||||||
|
|
||||||
_ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
|
_ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
|
||||||
|
# metadata.protocol_scope — only meaningful when doc_type == "protocol".
|
||||||
|
# "appeal" = ועדת הערר (default when absent), "lower" = ועדה מקומית/מחוזית.
|
||||||
|
# Used by the overview panel to sort a protocol into group 3 vs group 2.
|
||||||
|
_ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||||
|
|
||||||
|
|
||||||
class DocumentPatchRequest(BaseModel):
|
class DocumentPatchRequest(BaseModel):
|
||||||
"""Patch payload for a single document. Both fields are optional."""
|
"""Patch payload for a single document. All fields are optional; only the
|
||||||
|
ones present are applied. The metadata.* flags are display/processing hints
|
||||||
|
that never change the stored doc_type."""
|
||||||
|
|
||||||
doc_type: str | None = None
|
doc_type: str | None = None
|
||||||
appraiser_side: str | None = None # committee | appellant | deciding | "" to clear
|
appraiser_side: str | None = None # committee | appellant | deciding | "" to clear
|
||||||
|
is_post_hearing: bool | None = None # material submitted after the hearing (→ group 4, block-chet)
|
||||||
|
protocol_scope: str | None = None # appeal | lower | "" to clear (protocol docs only)
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/cases/{case_number}/documents/{doc_id}")
|
@app.patch("/api/cases/{case_number}/documents/{doc_id}")
|
||||||
@@ -5652,6 +6167,13 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
|
|||||||
|
|
||||||
updates: dict = {}
|
updates: dict = {}
|
||||||
|
|
||||||
|
# Load metadata once — appraiser_side, is_post_hearing and protocol_scope
|
||||||
|
# all live in the same JSONB blob, so a single patch may touch several.
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
metadata_dirty = False
|
||||||
|
|
||||||
if req.doc_type is not None:
|
if req.doc_type is not None:
|
||||||
if req.doc_type not in DOC_TYPE_NAMES:
|
if req.doc_type not in DOC_TYPE_NAMES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -5668,13 +6190,34 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
|
|||||||
f"appraiser_side לא תקין: {req.appraiser_side}. ערכים מותרים: "
|
f"appraiser_side לא תקין: {req.appraiser_side}. ערכים מותרים: "
|
||||||
f"{', '.join(sorted(_ALLOWED_APPRAISER_SIDES))}",
|
f"{', '.join(sorted(_ALLOWED_APPRAISER_SIDES))}",
|
||||||
)
|
)
|
||||||
metadata = doc.get("metadata") or {}
|
|
||||||
if isinstance(metadata, str):
|
|
||||||
metadata = json.loads(metadata)
|
|
||||||
if req.appraiser_side:
|
if req.appraiser_side:
|
||||||
metadata["appraiser_side"] = req.appraiser_side
|
metadata["appraiser_side"] = req.appraiser_side
|
||||||
else:
|
else:
|
||||||
metadata.pop("appraiser_side", None)
|
metadata.pop("appraiser_side", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if req.is_post_hearing is not None:
|
||||||
|
# Store True; drop the key entirely when False so absence == "before".
|
||||||
|
if req.is_post_hearing:
|
||||||
|
metadata["is_post_hearing"] = True
|
||||||
|
else:
|
||||||
|
metadata.pop("is_post_hearing", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if req.protocol_scope is not None:
|
||||||
|
if req.protocol_scope and req.protocol_scope not in _ALLOWED_PROTOCOL_SCOPES:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"protocol_scope לא תקין: {req.protocol_scope}. ערכים מותרים: "
|
||||||
|
f"{', '.join(sorted(_ALLOWED_PROTOCOL_SCOPES))}",
|
||||||
|
)
|
||||||
|
if req.protocol_scope:
|
||||||
|
metadata["protocol_scope"] = req.protocol_scope
|
||||||
|
else:
|
||||||
|
metadata.pop("protocol_scope", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if metadata_dirty:
|
||||||
updates["metadata"] = metadata
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
@@ -7187,6 +7730,50 @@ async def operations_agents():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/operations/agents/health")
|
||||||
|
async def operations_agent_health():
|
||||||
|
"""Per-issue agent health taxonomy (#222): zombie / stalled / working / idle.
|
||||||
|
|
||||||
|
Read-only. Surfaces the stranded-child / recovery-loop cases (``zombie``)
|
||||||
|
automatically — the ``gt feed --problems`` idea grounded in our failure
|
||||||
|
modes — so they no longer need hand-querying the Paperclip DB. Consumed by
|
||||||
|
the /operations dashboard (UI is design-gated, follow-up).
|
||||||
|
"""
|
||||||
|
return await pc_get_agent_health()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/operations/agents/escalations")
|
||||||
|
async def operations_agent_escalations(limit: int = 10, hours: int = 24):
|
||||||
|
"""Recent chair escalations — watchdog + manual (#218/#222).
|
||||||
|
|
||||||
|
Read-only history of what was handed to the chair, for the ops health panel.
|
||||||
|
"""
|
||||||
|
return await pc_get_recent_escalations(limit, hours)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/operations/issues/{issue_id}/predecessor")
|
||||||
|
async def operations_issue_predecessor(issue_id: str, limit: int = 3):
|
||||||
|
"""Recent finished runs on an issue for session continuation (#220, "seance").
|
||||||
|
|
||||||
|
Read-only. Lets a fresh heartbeat read what its predecessors on the same
|
||||||
|
issue concluded (the run ``summary`` each leaves) instead of rediscovering
|
||||||
|
context from scratch. Agent-facing wiring (MCP tool + HEARTBEAT) is a
|
||||||
|
follow-up; this is the backend it will call.
|
||||||
|
"""
|
||||||
|
return await pc_get_predecessor_context(issue_id, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/operations/cases/{case_number}/predecessor")
|
||||||
|
async def operations_case_predecessor(case_number: str, limit: int = 5):
|
||||||
|
"""Recent finished runs across a case's issues (#220, agent-facing "seance").
|
||||||
|
|
||||||
|
Case-scoped sibling of the by-issue endpoint — the plugin tool
|
||||||
|
``legal_predecessor_context`` calls this so a resuming agent reads what prior
|
||||||
|
sessions on the case concluded (agents key on case_number, not issue UUID).
|
||||||
|
"""
|
||||||
|
return await pc_get_predecessor_for_case(case_number, limit)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/operations/agents/runs/{run_id}/log")
|
@app.get("/api/operations/agents/runs/{run_id}/log")
|
||||||
async def operations_agent_run_log(run_id: str):
|
async def operations_agent_run_log(run_id: str):
|
||||||
"""Full output log (NDJSON stream) of one heartbeat run."""
|
"""Full output log (NDJSON stream) of one heartbeat run."""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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