Compare commits
113 Commits
4feac756ba
...
worktree-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 253f8ff97b | |||
| 044aecb034 | |||
| 1c7464c2ce | |||
| 8bcf05df26 | |||
| cb4aa703ec | |||
| 4d227a4999 | |||
| f58ddba93e | |||
| 29970ad8b2 | |||
| 85af98cded | |||
| 107be235f5 | |||
| 9e7d98a47a | |||
| 0a5be942ba | |||
| 67d5835eb8 | |||
| 1d8c4e29c8 | |||
| 943696efbd | |||
| 7d1fc49e35 | |||
| ad04291d39 | |||
| 8d0f8ad17b | |||
| cf7c918bed | |||
| a9db657541 | |||
| c7941a63ec | |||
| dede33cdff | |||
| e203474548 | |||
| 00d362ec5d | |||
| bfc352c7b5 | |||
| 653b951d30 | |||
| b3b7c48b8f | |||
| 0966a49afb | |||
| 5dbe970dbc | |||
| c4f3048248 | |||
| f15cc896d6 | |||
| 6ad0a387fe | |||
| 0fa2281ada | |||
| 9f1ad67536 | |||
| 1da1585ca8 | |||
| 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 | |||
| 39aff8fd45 | |||
| 72f9bbb77c | |||
| b17a2e4997 | |||
| 4d7e623607 | |||
| 7fd94cdd30 | |||
| 858e05fb8b | |||
| ff07e0bc98 | |||
| 3f253269f6 | |||
| 38d73c4f89 | |||
| 88b7dd1004 | |||
| b79c7bf161 | |||
| 7c48c8753e | |||
| 5987d50ebf | |||
| 9836957848 | |||
| 850ca25239 | |||
| 88ca96bf19 | |||
| b2d673fdb0 | |||
| 9402c7302f | |||
| d3d99180ec | |||
| 67c2c43777 | |||
| c71bbc0df2 | |||
| a8dd8ee152 | |||
| 73d2f15915 | |||
| 70a06c3745 | |||
| c6d20654a9 | |||
| c84417369d | |||
| d9783b1c14 | |||
| c68f3e004e | |||
| f1e9103723 | |||
| 6f60dc7a14 | |||
| 9ef0547997 |
@@ -86,6 +86,8 @@
|
||||
|
||||
**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.
|
||||
|
||||
```bash
|
||||
@@ -99,6 +101,8 @@ ATTACHMENTS=$(echo "$CONTEXT" | jq '.attachments')
|
||||
```
|
||||
**אסור** לפענח טקסט מ-comment חופשי כשיש APPROVAL_ID — זה הקלט הסטרוקטורלי.
|
||||
|
||||
**1.5ד. המשך-עבודה על תיק — קרא predecessor תחילה (seance, #220).** אם אתה מתעורר-מחדש על תיק שכבר עבדת עליו (continuation — issue פתוח עם היסטוריית-ריצות), **לפני** שתגלה-מחדש את ההקשר מאפס, קרא את הכלי `legal_predecessor_context(case_number=<מספר-התיק>)`. הוא מחזיר את מסקנות הריצות-הקודמות על התיק (ה-`summary` שכל heartbeat השאיר, newest-first) — כך תמשיך מנקודת-העצירה במקום לחזור על עבודה שכבר בוצעה (מונע את ה-*blind heartbeat*). אם אין ריצות-קודמות עם מסקנות — המשך רגיל.
|
||||
|
||||
---
|
||||
|
||||
## §2. קבצים מצורפים — דרך `heartbeat-context`, **לא psql**
|
||||
@@ -220,17 +224,24 @@ 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*
|
||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
||||
→ drafted → qa_passed / qa_failed → exported
|
||||
קליטה : new · processing
|
||||
הכנה : documents_ready
|
||||
ניתוח וכיוון : analyst_verified · research_complete · outcome_set · direction_approved
|
||||
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
|
||||
כתיבת טיוטה : qa_review · drafted
|
||||
סגירה : exported · reviewed · final
|
||||
```
|
||||
|
||||
`research_complete` — **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
|
||||
`analyst_verified` ו-`research_complete` הם **סטטוסים קנוניים מהמעלה הראשונה** (לא legacy) — המנתח/חוקר מציבים אותם, ומקומם בשלב "ניתוח וכיוון". `research_complete` מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח; ה-CEO מטפל בו כמו `analyst_verified`.
|
||||
|
||||
> מצבי-ביניים ישנים שעדיין עשויים להופיע מסוכנים מסוימים (`proofread`, `analysis_enriched`, `ready_for_writing`, `qa_passed`/`qa_failed`) **אינם** בקבוצה הקנונית — הם נמפים-לשלב לתצוגה בלבד (fallback ב-`case-status.ts`) ואינם ניתנים-לבחירה ידנית. אם נדרש לקבע אחד מהם — להוסיף ל-`case_status_model.py` (המקור-היחיד).
|
||||
|
||||
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
name: legal-analyst-gemini-critique
|
||||
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
|
||||
role: adversarial second-opinion / devil's advocate על תוצר ה-Case Analyst (Opus)
|
||||
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר
|
||||
trigger: שער-קבע (standing gate) — מופעל אוטומטית ע"י ה-CEO אחרי שלב הניתוח, לפני הכותב (ולא on-demand בלבד)
|
||||
mode: read-only · output = מזכר-לידים לא-סמכותי ליו"ר (נעצר לאישור-יו"ר; לעולם לא מוזן לכותב אוטומטית)
|
||||
-->
|
||||
|
||||
## מי אתה
|
||||
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
|
||||
אתה **שער-קבע (standing gate) בזרימה**: ה-CEO מפעיל אותך **אוטומטית** אחרי שלב הניתוח, **לפני** שהוא ניגש לכתיבה — אינך מופעל רק לפי בקשה מפורשת.
|
||||
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
|
||||
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
|
||||
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
||||
שמוגש ליו"ר/CEO **כקלט לבדיקת-יו"ר לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
||||
|
||||
> **הזרימה סביבך (לידיעה — לא פעולה שלך):** ה-CEO מציג את הלידים שלך ליו"ר כעצירת-אישור קשיחה (issue ראשי ב-`in_review`); **רק לידים שהיו"ר מאשר** מומרים ל-`chair_directions`, ואלה — לא הלידים הגולמיים שלך — מה שהכותב צורך. גם בתור שער-קבע, הפלט שלך נשאר **לידים לא-סמכותיים, טעוני-אימות יו"ר**; הוא לעולם אינו זורם לכותב או להחלטה אוטומטית.
|
||||
|
||||
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
|
||||
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%–33%**
|
||||
|
||||
@@ -234,7 +234,7 @@ search_precedent_library(
|
||||
|
||||
### 5ד. תיעוד מחייב — סעיף "שאילתות לקורפוסים" ב-`analysis-and-research.md`
|
||||
|
||||
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא:
|
||||
ב-artifact הסופי, חובה להופיע סעיף חדש בשם **"7א. שאילתות לקורפוסים — log מלא"**, עם הפורמט הבא. (זהו סעיף 7א שמופיע גם בתבנית-הפלט המלאה שבהמשך — **אותו סעיף אחד**, לא תבנית נפרדת.)
|
||||
|
||||
```markdown
|
||||
## 7א. שאילתות לקורפוסים — log מלא
|
||||
@@ -323,7 +323,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
|
||||
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג. **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
wakeup ל-CEO עם `payload.issueId=$PAPERCLIP_TASK_ID` ו-`reason="מנתח משפטי סיים $PAPERCLIP_TASK_ID בסטטוס done/blocked"` — הפרוטוקול המלא (CEO לפי חברה, אזהרות) במקור היחיד [HEARTBEAT.md](HEARTBEAT.md) §4ג.
|
||||
**⚠️ `$PAPERCLIP_TASK_ID` — זה UUID, לא CMP-XX.** מוגדר אוטומטית ע"י Paperclip; ב-double-quotes bash מרחיב לערך האמיתי. שגיאת `invalid input syntax for type uuid` = שלחת CMP-XX במקום UUID.
|
||||
|
||||
## מבנה הפלט המלא — analysis-and-research.md
|
||||
|
||||
@@ -47,6 +47,7 @@ tools:
|
||||
- mcp__legal-ai__plan_list
|
||||
- mcp__legal-ai__write_interim_draft
|
||||
- mcp__legal-ai__export_interim_draft
|
||||
- mcp__legal-ai__summarize_party_claims
|
||||
---
|
||||
|
||||
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
||||
@@ -145,17 +146,17 @@ internal_decision_upload(
|
||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||
| מנהל ידע (Hermes) | CMP: 60dce831-5c5b-4bae-bda9-5282d506f0dc · CMPA: d6f7c55d-570a-46b8-8d72-1286d07da0d8 | סקירת החלטות סופיות, הצעות לעדכון style guide / lessons. **לא קורא ישירות מ-CEO** — מופעל אוטומטית מ-`web/app.py:api_mark_final` כשדפנה לוחצת "סמן כסופי" ב-UI. |
|
||||
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **on-demand בלבד — אינו חלק מהפייפליין.** ראה למטה. |
|
||||
| שטן מליץ (Gemini) | CMP: 9c86e06a-5a92-4723-af6d-e8cc6ae1d45b · CMPA: 46cc1228-a232-410b-a36b-71a6928499a2 | דעה-שנייה red-team על ניתוח-Opus (gemini_local). **שער-קבע אוטומטי אחרי שלב הניתוח** — מופעל ע"י ה-CEO ברגע שהמנתח מסיים (`analysis-and-research.md`), **לפני** יצירת issue לכותב. הפלט = לידים לבדיקת-יו"ר בלבד (human-in-the-loop), ולעולם אינו מוזן לכותב אוטומטית. ראה למטה. |
|
||||
|
||||
### שטן מליץ (Gemini) — דעה-שנייה on-demand בלבד ⚠️
|
||||
### שטן מליץ (Gemini) — שער-קבע אוטומטי אחרי הניתוח, עם עצירת-אישור ליו"ר ⚠️
|
||||
|
||||
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. **אינו נמצא בזרימת analyst→writer→qa.**
|
||||
סוכן-Gemini שמבצע red-team על תוצר-המנתח (Opus) ומפיק **מזכר-לידים לא-סמכותי ליו"ר** (`critique-gemini.md`), read-only. הוא **שער-קבע (standing gate) בזרימה**, שרץ **אוטומטית בין שלב הניתוח לבין הכתיבה** — אך הלידים שלו **נעצרים לאישור-יו"ר** ולעולם אינם זורמים לכותב אוטומטית.
|
||||
|
||||
**מתי להפעיל:** **רק כשחיים/דפנה מבקשים מפורשות** "תן שטן-מליץ / דעה-שנייה על תיק X". אל תפעיל אותו אוטומטית, אל תכלול אותו בתזמור רגיל, ואל תציע אותו מיוזמתך.
|
||||
**מתי מופעל אוטומטית:** ברגע שהמנתח מסיים את הניתוח (`analysis-and-research.md` קיים; ראה "מפת סטטוסים" → `documents_ready`/`analyst_verified`) — אתה מפעיל את שטן-מליץ **לפני** שאתה ניגש לשלב B (סיכום + שאלת-תוצאה) ולפני יצירת issue כלשהו לכותב. אינך מחכה לבקשה מפורשת. (חיים עדיין יכול לבקש הרצה נוספת on-demand — אותו מנגנון בדיוק.)
|
||||
|
||||
**כשמבקשים — איך:** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) ו-wakeup רגיל עם `payload.issueId`.
|
||||
**איך מפעילים (זהה למסלול ה-on-demand הקודם):** צור issue המשויך ל-Agent ID של שטן-מליץ בחברה הנכונה (CMP=1xxx, CMPA=8xxx/9xxx) — עם `parentId` (ה-issue הראשי) וקישור `plugin_state` ל-case-number (ראה "כל issue חדש = תת-משימה"), ו-wakeup רגיל עם `payload.issueId`. ראה "שלב A2: שער שטן-מליץ" ו"שלב A3: עצירת-אישור הלידים" למטה לזרימה המדויקת.
|
||||
|
||||
**הגבול הקריטי:** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן. אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך להנחיה.
|
||||
**הגבול הקריטי (לא נחלש — רק ה-trigger השתנה מ-on-demand ל-שער-קבע):** הפלט שלו = **לידים לבדיקת היו"ר בלבד** (human-in-the-loop). **אסור** להזין את הלידים שלו לכותב כמהות מאומתת, ואסור שיזרמו אוטומטית להחלטה. ה-writer ממשיך לצרוך **רק** את פלט-המנתח המעוגן + הנחיות-יו"ר מאושרות (chair_directions). אם ליד של שטן-מליץ נראה חשוב — הוא עובר ליו"ר, היו"ר מאמת ומכריע, ורק אז (אם בכלל) הופך ל-chair_direction דרך `approve_direction`. **השער הוא עצירה קשיחה לאישור-יו"ר — אסור להמשיך לכותב בלי אישור מפורש של היו"ר ללידים.** (מקיים G10 — שער אנושי; INV-AH/INV-LRN5 — לידים לא-סמכותיים, לא מוזנים אוטומטית.)
|
||||
|
||||
## כלל: כל issue חדש = תת-משימה
|
||||
|
||||
@@ -225,6 +226,7 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
||||
### שלב 0: בדוק למה התעוררת
|
||||
|
||||
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
||||
- **פעולות סטרוקטורליות (כפתורי-UI, בלי פענוח-טקסט):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` → נתב דטרמיניסטית לפי הערך: `action == "interim_draft"` → **שלב H** (טיוטת ביניים); `action == "party_claims_summary"` → **שלב H2** (סיכום מנהלים). אלו side-quests — אל תסרוק תיקים אחרים, טפל רק בתיק שב-payload (`case_number`/`issueId`). ה-`issueId` שב-payload הוא **issue-ילד ייעודי שמשויך אליך** (נוצר ע"י המערכת כדי שהריצה לא תבוטל כשה-issue הראשי ממתין-ליו"ר; #227) — הרץ את הפעולה, פרסם comment עם התוצאה, ו**סגור את ה-issue-ילד הזה כ-done** בסיום. אל תיגע ב-issue הראשי של התיק.
|
||||
- אם ה-reason מכיל `user_commented` **ו**-`$PAPERCLIP_WAKE_PAYLOAD_JSON` כולל `issueId` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"** עם ה-issue הזה. אל תסרוק תיקים אחרים. **טפל רק בתגובה.**
|
||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||
@@ -358,6 +360,57 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
|
||||
**עיקרון מנחה:** עדיף לעכב את התהליך מאשר לייצר החלטה על בסיס חלקי או פגום.
|
||||
|
||||
> **⛔ סדר-זרימה מחייב אחרי שלב A — שער שטן-מליץ קודם לכותב.** ברגע ששלב A עבר (`analysis-and-research.md` מלא ותקין), **אסור** לקפוץ ישירות לשלב B/הכותב. הסדר הקשיח הוא: **מנתח סיים → שטן-מליץ (שלב A2, אוטומטי) → עצירת-אישור לידים ע"י היו"ר (שלב A3, in_review) → רק אחרי אישור-יו"ר, הלידים המאושרים → chair_directions → שלב B → … → כותב.** שטן-מליץ הוא שער-קבע, לא on-demand. ראה גם הערת "מפת סטטוסים" ל-`documents_ready`/`analyst_verified`.
|
||||
|
||||
### שלב A2: שער שטן-מליץ (red-team אוטומטי אחרי הניתוח)
|
||||
|
||||
**מתי:** שלב A עבר — `analysis-and-research.md` קיים ותקין, ועוד **לא** הופעל שטן-מליץ לתיק הזה (אין `critique-gemini.md`, ואין issue שטן-מליץ פתוח/סגור לתיק). זה השער הראשון אחרי הניתוח, **לפני** שלב B.
|
||||
|
||||
1. **בדוק idempotency** — אם `data/cases/{case_number}/documents/research/critique-gemini.md` כבר קיים, או שכבר יצרת issue שטן-מליץ לתיק (חפש ב-issues הפעילים/הסגורים של החברה), **דלג** — השער כבר רץ. אם ה-critique קיים אבל עדיין לא הצגת אותו ליו"ר → לך ישר לשלב A3.
|
||||
2. **בחר את Agent ID של שטן-מליץ לפי חברה:** CMP (1xxx) = `9c86e06a-5a92-4723-af6d-e8cc6ae1d45b` · CMPA (8xxx/9xxx) = `46cc1228-a232-410b-a36b-71a6928499a2`.
|
||||
3. **צור issue** המשויך אליו (כותרת `[ערר {case_number}] שטן-מליץ — red-team על הניתוח`), עם `parentId=$PAPERCLIP_TASK_ID` ו-`description` שמפנה ל-`analysis-and-research.md` ומבקש להפיק `critique-gemini.md`. **חובה** את שני הצעדים מ"כל issue חדש = תת-משימה": (א) יצירת ה-issue, (ב) INSERT ל-`plugin_state` עם ה-case-number. ה-assignment מפעיל wakeup אוטומטי; אם נדרש wakeup ידני — `payload.issueId` של ה-issue החדש.
|
||||
4. **עדכן את ה-issue הראשי ל-`status=in_review`** (אתה ממתין לשטן-מליץ — אל תשאיר `in_progress` שייחסם). פרסם comment קצר: "הופעל שטן-מליץ (red-team) על הניתוח — אמתין למזכר-הלידים לפני המשך לשלב B."
|
||||
5. **אל תמשיך לשלב B כעת.** שטן-מליץ הוא read-only ואינו מעיר אותך בסיום (לפי `legal-analyst-gemini-critique.md` — הוא רק סוגר את ה-issue שלו). תתעורר עליו דרך heartbeat רגיל / סריקת-הערות; כשתראה ש-`critique-gemini.md` קיים → המשך לשלב A3.
|
||||
|
||||
**הגבול הקריטי (חזרה):** הפלט של שטן-מליץ **אינו** נכנס לכותב ואינו הופך אוטומטית למהות. הוא קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5).
|
||||
|
||||
### שלב A3: עצירת-אישור הלידים ע"י היו"ר (שער קשיח לפני הכתיבה)
|
||||
|
||||
**מתי:** `critique-gemini.md` קיים (שטן-מליץ סיים), ועדיין לא הצגת את הלידים ליו"ר / לא קיבלת הכרעה.
|
||||
|
||||
**זו עצירה מחייבת — אסור להמשיך לכותב בלי אישור-יו"ר ללידים.**
|
||||
|
||||
1. **קרא** את `data/cases/{case_number}/documents/research/critique-gemini.md` במלואו.
|
||||
2. **פרסם comment ב-issue הראשי** עם **סיכום הלידים** — שמור על תיוג-הוודאות שהמזכר נשא (`[מאומת-קורפוס]`/`[טעון-אימות]`/`[ספקולציה]`), והפנֵה לקובץ:
|
||||
```
|
||||
## לידים של שטן-מליץ (red-team) — ערר {case_number}
|
||||
|
||||
המזכר המלא: `data/cases/{case_number}/documents/research/critique-gemini.md`
|
||||
⚠️ לידים לא-סמכותיים, טעוני-אימות יו"ר — לא הוזנו לכותב ולא להחלטה.
|
||||
|
||||
1. [תג-ודאות] {ליד מקוצר}
|
||||
2. [תג-ודאות] {ליד מקוצר}
|
||||
...
|
||||
|
||||
אנא הכרע לכל ליד: אשר / דחה / הערה. רק לידים שתאשר יהפכו ל-chair_direction.
|
||||
```
|
||||
3. **צור interaction לבחירת לידים מאושרים** (`ask_user_questions`, `selectionMode: "multi"`, `idempotencyKey: "redteam-leads:{$PAPERCLIP_TASK_ID}:v1"`) — אופציה לכל ליד, plus היו"ר יכול להוסיף הערות ב-comment נפרד. (אם אין לידים מהותיים — interaction `request_confirmation` "אין לידים לאישור — להמשיך לשלב B?".)
|
||||
4. **עדכן את ה-issue הראשי ל-`status=in_review`** ושלח מייל:
|
||||
```bash
|
||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
"נדרשת תשובתך — לידים של שטן-מליץ לתיק {case_number}" \
|
||||
"שטן-מליץ הפיק מזכר-לידים על הניתוח. אנא בדוק ואשר/דחה לכל ליד לפני שנמשיך לכתיבה. קישור ל-issue."
|
||||
```
|
||||
5. **המתן.** אל תיגש לשלב B ואל תיצור issue לכותב — השער פתוח עד שהיו"ר מכריע.
|
||||
|
||||
**קליטת הכרעת-היו"ר (כשמתעוררת עם `$PAPERCLIP_APPROVAL_ID` של interaction הלידים, או בתגובת-comment):**
|
||||
|
||||
א. החזר את ה-issue הראשי ל-`status=in_progress`.
|
||||
ב. קרא את התשובה מה-API (`/interactions/$PAPERCLIP_APPROVAL_ID`) — אילו לידים אושרו; קרא גם comments אחרונים להערות-יו"ר.
|
||||
ג. **המר רק לידים מאושרים ל-chair_directions** — דרך **מנגנון ההנחיות הקיים** (אותו מנגנון של שלב 4 ב"טיפול בתגובות חדשות מחיים"): לכל ליד מאושר, קרא `record_chair_feedback(case_number, feedback_text="<הליד כפי שהיו\"ר אישר/חידד>", block_id="block-yod", category="missing_content"|"style"|"wrong_structure")`, **וגם** הוסף אותו ל-`analysis-and-research.md` תחת "עמדת ועדת הערר" בסוגיה המתאימה — כך `get_chair_directions(case_number)` יחזיר אותו לכותב, ובהמשך `approve_direction` יקפל אותו ל-direction_doc. **לידים שנדחו — מושלכים, לא נרשמים.**
|
||||
ד. פרסם comment: "הכרעת-יו"ר נקלטה. {N} לידים אושרו והומרו ל-chair_directions; {M} נדחו ונמחקו. ממשיך לשלב B." סגור את issue שטן-מליץ אם עוד פתוח.
|
||||
ה. **רק עכשיו** עבור לשלב B — עם הניתוח המעוגן + ההנחיות המאושרות בלבד. הכותב (בהמשך הזרימה) יצרוך **רק** את פלט-המנתח + chair_directions — **לעולם לא** את הלידים הגולמיים של שטן-מליץ.
|
||||
|
||||
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
||||
|
||||
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
||||
@@ -633,7 +686,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
|
||||
### שלב 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 + ייצוא סופי).
|
||||
|
||||
@@ -664,13 +719,13 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
```
|
||||
mcp__legal-ai__export_interim_draft(case_number="...")
|
||||
```
|
||||
מייצר `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`, מעדכן `active_draft_path`.
|
||||
מייצר `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`, מעדכן `active_draft_path`.
|
||||
|
||||
5. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||
```
|
||||
## טיוטת ביניים מוכנה — ערר {case_number}
|
||||
|
||||
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטת-ביניים-v{N}.docx`
|
||||
📄 **קובץ:** `data/cases/{case_number}/exports/טיוטה-טענות_הצדדים_{N}.docx`
|
||||
|
||||
### מה כלול
|
||||
| בלוק | כותרת | מילים |
|
||||
@@ -697,6 +752,49 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
- אל תפעיל את שלב 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`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
||||
@@ -706,9 +804,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
||||
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → שלב B (סיכום + שאלת תוצאה לחיים). **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך ל-§B כרגיל. |
|
||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
|
||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||
@@ -752,7 +850,8 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
||||
...
|
||||
|
||||
### 4. chair directions
|
||||
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions")
|
||||
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions").
|
||||
- **עטוף את ההעתק המילולי בתגית `<chair_directions>…</chair_directions>`** — כך הכותב מבחין בין הוראות-היו"ר המילוליות לבין הערות ה-CEO, ואינו דורס אותן. בתוך התגית: טקסט מילולי בלבד, בלי פרפרזה.
|
||||
|
||||
### 5. הנחיות סגנון
|
||||
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
|
||||
|
||||
@@ -96,7 +96,7 @@ tools:
|
||||
"id": "r1",
|
||||
"type": "insert_after", // או insert_before, replace, delete
|
||||
"anchor_bookmark": "block-yod", // מ-list_bookmarks
|
||||
"content": "וכך נפסק בעניין פלוני. בבג\"ץ 1234/21 קבע השופט...",
|
||||
"content": "<תוכן ההוספה — טקסט עברי נקי, בלי ציטוט מומצא>",
|
||||
"style": "body", // או heading, quote
|
||||
"reason": "הוספת פסק הלכה שחסר לפי בקשת יו\"ר"
|
||||
}
|
||||
@@ -128,7 +128,7 @@ tools:
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מייצא טיוטה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
|
||||
@@ -96,5 +96,4 @@ tools:
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל / markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**⚠️ אסור לקבע UUID של CEO** — UUID שונה לכל חברה. תמיד דרך `$PAPERCLIP_COMPANY_ID`. wakeup לחברה אחרת נדחה: `Agent key cannot access another company`.
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked` (`blocked` אם markers `[?]` רבים), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="מגיה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
@@ -56,6 +56,8 @@ tools:
|
||||
|
||||
## 7 בדיקות
|
||||
|
||||
> **סף-הקבלה המשותף — `docs/qa-acceptance-rubric.md`:** 7 הבדיקות למטה הן האכיפה המפורטת שלו (מקור-אמת אחד; הכותב מכוון לאותו rubric). בדיווח — מפה כל `fail` לחוסם/אזהרה שם.
|
||||
|
||||
### 1. שלמות מבנית (structural_integrity)
|
||||
- כל בלוקי חובה קיימים (ה עד יא)
|
||||
- מספור רציף ללא קפיצות
|
||||
@@ -179,10 +181,11 @@ tools:
|
||||
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
||||
|
||||
```
|
||||
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
|
||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
||||
→ drafted (אתה כאן!) → qa_passed / qa_failed → exported
|
||||
new → processing → documents_ready → analyst_verified → research_complete
|
||||
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
|
||||
→ exported → reviewed → final
|
||||
```
|
||||
(מקור-אמת יחיד: `case_status_model.py` / `GET /api/status-model`. `analyst_verified`+`research_complete` קנוניים, שלב "ניתוח וכיוון". מצבי-ביניים ישנים כמו `proofread`/`analysis_enriched`/`qa_passed` נמפים-לשלב לתצוגה בלבד.)
|
||||
|
||||
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
||||
|
||||
@@ -231,14 +234,24 @@ new → proofread → documents_ready → analyst_verified → research_complete
|
||||
6. האם יש אנלוגיה לתקדים ללא הסבר מדיניות (למה הדמיון רלוונטי)?
|
||||
|
||||
### שלב 3: דיווח — חובה!
|
||||
פרסם comment ב-Paperclip עם:
|
||||
- תוצאת כל בדיקה (pass/fail)
|
||||
- רשימת שגיאות מפורטת (אם יש)
|
||||
- האם מותר לייצא (כל הקריטיים pass?)
|
||||
- עדכן סטטוס ל-qa_review (אם נכשל) או drafted (אם עבר)
|
||||
פרסם comment ב-Paperclip **במבנה קבוע** (markdown — נקרא ע"י היו"ר וע"י ה-CEO), כדי שהפסיקה תהיה עקבית וניתנת-לפרסינג. מלא שורה לכל אחת מ-7 הבדיקות:
|
||||
|
||||
```markdown
|
||||
## דוח בדיקת איכות — ערר {case_number}
|
||||
|
||||
| בדיקה | תוצאה | חומרה | ממצא (אם fail) |
|
||||
|-------|:-----:|:-----:|-----------------|
|
||||
| {שם הבדיקה} | pass / fail | critical / major / minor | {פירוט קצר} |
|
||||
| ... שורה לכל 7 הבדיקות ... | | | |
|
||||
|
||||
**מותר לייצא:** כן / לא ← "לא" אם ולו בדיקה **critical** אחת ב-fail
|
||||
**סטטוס חדש:** drafted (עבר) / qa_review (נכשל)
|
||||
```
|
||||
|
||||
- עדכן סטטוס בהתאם: `drafted` (אם עבר) או `qa_review` (אם נכשל).
|
||||
|
||||
### סגור את ה-issue של עצמך + העֵר CEO — חובה!
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="בודק איכות סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
@@ -410,7 +410,7 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="חוקר תקדימים סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
## כללים
|
||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||
|
||||
@@ -53,6 +53,8 @@ tools:
|
||||
|
||||
## לפני שאתה מתחיל — קרא!
|
||||
|
||||
> **המטרה שלך — `docs/qa-acceptance-rubric.md`:** זהו סף-הקבלה שה-QA יאכוף. כתוב כדי לעבור אותו מהפעם הראשונה, ולפני מסירה ל-CEO עבור על החוסמים ואשר שכל אחד מתקיים.
|
||||
|
||||
### חובה לפני כל כתיבה — נקודת ההתחלה:
|
||||
0. **עץ ההחלטה: `docs/daphna-decision-tree.md`** — **כלי הפעולה היומיומי**. מאחד את כל המסמכים לתהליך אנליטי קצר: מהי הראיה הניצחת? איזה ארכיטקטורה? איזה מוד פתיחה? איזה אורך? **תמיד להתחיל כאן** — המסמך מצביע איזה מסמך אחר לקרוא לפי השאלה.
|
||||
|
||||
@@ -122,9 +124,11 @@ tools:
|
||||
4. החזר את הטקסט ל-CEO (בקומנט או כ-return value) — **לא** שומר ב-DB
|
||||
5. CEO יקרא ל-`revise_draft` עם הטקסט שלך
|
||||
|
||||
**דוגמה לפלט מצופה:**
|
||||
**דוגמה לפלט מצופה** (עטופה בתגית כדי שלא תתבלבל עם הוראה):
|
||||
|
||||
> בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
|
||||
<example_output>
|
||||
בבג"ץ 1234/21 [פלוני נ' הוועדה המחוזית] קבע בית המשפט העליון כי הוועדה המקומית מחויבת לשקול שיקולי Y גם בהיעדר התנגדות מפורשת. הלכה זו חלה ישירות על ענייננו: הוועדה המקומית לא בחנה את Y, ודי בכך כדי להחזיר את הדיון לוועדה.
|
||||
</example_output>
|
||||
|
||||
---
|
||||
|
||||
@@ -218,18 +222,13 @@ case_update(case_number, status="drafted")
|
||||
|
||||
בלי סגירת-issue, Paperclip מזהה "in_progress בלי execution חיה" ומפעיל auto-retry בלולאה (נצפה ב-CMPA-17, 30/04/26 — 4 איטרציות מיותרות עד הריגה ידנית).
|
||||
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO לפי חברה).** בקצרה: PATCH סטטוס `done` (הצלחה) או `blocked` (כשל/פלט-חסר), ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`. **אסור** `done` עם פלט חסר; **אסור** `INSERT INTO agent_wakeup_requests` ישיר; **אסור** לקבע UUID של CEO (נגזר מ-`$PAPERCLIP_COMPANY_ID`).
|
||||
**הפרוטוקול המלא — מקור יחיד: [HEARTBEAT.md](HEARTBEAT.md) §4ב (סטטוס) + §4ג (wake CEO).** בקצרה: PATCH סטטוס `done`/`blocked`, ואז wakeup ל-CEO עם `payload.issueId` ו-`reason="כותב החלטה סיים [issue-id] בסטטוס [done/blocked]"`.
|
||||
|
||||
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
||||
|
||||
## בלוק י — דיון (הבלוק החשוב ביותר)
|
||||
|
||||
**קריאת חובה לפני כתיבה (5 מסמכים)**:
|
||||
1. `docs/daphna-voice-fingerprint.md` — קבועים, פעלי "אנחנו", אנטי-דפוסים
|
||||
2. `docs/daphna-precedent-network.md` — לכל סוגיה משפטית, איזה תקדם
|
||||
3. `docs/daphna-architecture-by-outcome.md` — מבנה לפי תוצאה + עץ החלטה
|
||||
4. `docs/daphna-acceptance-architecture.md` — **חובה אם תוצאה צפויה: קבלה (לא חלקית).** חמש תבניות מובחנות
|
||||
5. `docs/voice-1130-25.md` — דוגמה עמוקה
|
||||
> קרא קודם את **"חובה לפני בלוק י (חמישיית הקול)"** שלמעלה — חמשת מסמכי-הקול.
|
||||
|
||||
**עץ החלטה לבחירת ארכיטקטורה**:
|
||||
1. מה התוצאה?
|
||||
@@ -373,6 +372,8 @@ case_update(case_number, status="drafted")
|
||||
|
||||
### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions`
|
||||
|
||||
> **מקור העמדות:** בדרך-כלל מ-`get_chair_directions` (שלב 1ג). אם ה-CEO **העתיק** עמדות-יו"ר לתוך ה-issue שלך, הן מגיעות עטופות ב-`<chair_directions>…</chair_directions>` — טקסט מילולי זה **מחייב אותך בדיוק כמו** `chair_ruling` מהכלי: אל תדרוס, אל תפרפרז, נסח בעדו.
|
||||
|
||||
עבור **כל טענת סף** ו**כל סוגיה** ב-direction_doc שבנית בשלב 1ג:
|
||||
|
||||
1. **פתח את הדיון במסקנה של דפנה** — למשל "**טענת הסף הראשונה נדחית**"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,6 +19,9 @@ kiryat-yearim/
|
||||
continuation-prompt.md
|
||||
node_modules/
|
||||
data/eval/eval-report-*
|
||||
# effort-calibration run outputs (#208 / #212) — generated, not source
|
||||
data/eval/effort-calibration-*
|
||||
data/eval/block-yod-calibration-*
|
||||
# revert snapshot for migrate_agent_adapter.py (runtime state)
|
||||
data/adapter-migration-state.json
|
||||
# frontmatter-stripped instruction copies for content_arg adapters (generated)
|
||||
|
||||
@@ -275,3 +275,40 @@ WARN [plugin] issue.comment.created event missing issueId in payload, skipping
|
||||
- **תוקן בצד שלנו** (PR #2, נפרס דרך `npm run build` + `pm2 restart paperclip` — הפלאגין נטען מ-`/home/chaim/plugin-legal-ai` לפי `package_path`).
|
||||
- **לקח כללי**: ל-events של הפלאגין — מזהה-הישות-הראשית הוא תמיד `event.entityId`; אל תניח ששדות נמצאים ב-`payload` בלי לאמת מול ה-`.d.ts` של ה-SDK או מול לוג חי.
|
||||
- TaskMaster: `legal-ai` #149.
|
||||
|
||||
---
|
||||
|
||||
## 7. `agents.invoke({prompt})` — הפרומפט נבלע; רק `payload.issueId` נמסר
|
||||
|
||||
**התסמין (מנקודת-מבט היו"ר):** כותבים הוראה לסוכן — והוא "מתעלם". ההרצה נראית תקינה
|
||||
לחלוטין: `status=succeeded`, `exit_code=0`, בלי שגיאה, ואפילו עולה כסף. הסוכן מדווח
|
||||
"כל התורים ריקים — אין עבודה".
|
||||
|
||||
**הסיבה:** `ctx.agents.invoke(agentId, companyId, {prompt, reason})` הוא ה-API **המתועד**
|
||||
של ה-Plugin SDK ("*Invoke an agent with a prompt payload*"), ו-`prompt` הוא שדה חובה.
|
||||
Paperclip מקבל אותו, שומר אותו ב-`agent_wakeup_requests.payload.prompt` — **וה-runner קורא
|
||||
רק `payload.issueId`**. הפרומפט לא מגיע למודל. ה-`reason` כן שורד (ל-`context_snapshot.wakeReason`),
|
||||
ולכן זה נראה כאילו משהו נמסר.
|
||||
|
||||
מדידה ב-DB (2026-07-16): **18 מתוך 18** ה-wakeups שאי-פעם נשאו `payload.prompt` לא הגיעו
|
||||
לאף הרצה — 0% מסירה. מול 107,172 wakeups עם `issueId` שעובדים. אימות ישיר: בלוג-ההרצה
|
||||
שנבלעה אין ולו אזכור אחד של מספר-התיק, והמודל פותח ב-*"This heartbeat has no scoped wake payload"*.
|
||||
|
||||
**מה עושים במקום:** נושאים את ההוראה על **issue**, לא ב-payload — issue-ילד שכבר משויך
|
||||
ל-CEO, עם ההוראה ב-`description`, ואז wakeup עם `payload.issueId`. זה בדיוק
|
||||
`open_ceo_run` ב-[`web/paperclip_client.py`](../web/paperclip_client.py), והמסלול היחיד
|
||||
שאומת כמגיע לסוכן.
|
||||
|
||||
**מלכודות שנלוות לזה — אל תיפול בהן שוב:**
|
||||
- **`issues.create` של ה-SDK עם `assigneeAgentId` *לא* מעיר את הסוכן.** ה-REST המקביל
|
||||
(`POST /api/issues/:id/children`) *כן* — הוא פולט wakeup `issue_assigned`. אל תכליל
|
||||
מהתנהגות REST על ה-SDK; זה נבדק ונמצא שונה.
|
||||
- **`ctx.issues.requestWakeup` (ה-primitive הנכון ב-SDK) נכשל מתוך scheduled job** עם
|
||||
`missing, expired, or unknown invocation scope` — למרות ש-capability `issues.wakeup`
|
||||
מוצהר ומותקן. אותה שגיאה מפילה גם `companies.list` / `issues.list` / `issues.listComments`
|
||||
בתוך ה-sweep, ולכן **רשת-הביטחון `route-pending-comments` אינה אמינה**. לא נחקר לעומק —
|
||||
לכן ניתוב-ההערות הועבר ל-backend של legal-ai (REST), שאינו חשוף לבעיה.
|
||||
|
||||
**סטטוס:** נעקף — `POST /api/cases/{case}/agents/comment` ו-`/agents/interaction-response`
|
||||
פותחים הרצת-CEO ישירות (REST) ומסמנים את ההערה כמנותבת, כדי שה-sweep השבור לא יירה
|
||||
הרצת-סרק. TaskMaster `legal-ai` #228.
|
||||
|
||||
57
docs/qa-acceptance-rubric.md
Normal file
57
docs/qa-acceptance-rubric.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Rubric קבלה של החלטה — מקור-אמת אחד
|
||||
|
||||
מסמך זה הוא **הגדרת ה"מוכן"** של טיוטת החלטה: סף-הקבלה שהכותב **מכוון אליו מראש**
|
||||
וה-QA **אוכף** כשער. שני הסוכנים מפנים לכאן — אין לשכפל את הקריטריונים בקבצי-הסוכן.
|
||||
|
||||
**זהו סף-הקבלה, לא הוראת-הכתיבה.** ה"איך" המלא חי במסמכי-הקול המופנים בכל שורה
|
||||
(`daphna-voice-fingerprint.md`, `daphna-block-zayin-claims.md`,
|
||||
`daphna-architecture-by-outcome.md`, `daphna-acceptance-architecture.md`,
|
||||
`decision-methodology.md`, `anti-hallucination-gate.md`). כאן — רק המבחן.
|
||||
|
||||
> **לכותב:** לפני מסירה ל-CEO, עבור על החוסמים למטה ואשר שכל אחד מתקיים. טיוטה
|
||||
> שלא עוברת חוסם אחד — תוחזר ב-`needs_revision`. כתוב כדי לעבור מהפעם הראשונה.
|
||||
>
|
||||
> **ל-QA:** אלה הקריטריונים ש-7 הבדיקות שלך אוכפות. `fail` על חוסם = `qa_review`;
|
||||
> `fail` על אזהרה = דיווח לחיים, לא חוסם.
|
||||
|
||||
---
|
||||
|
||||
## חוסמי ייצוא (critical — כל אחד מכשיל את הטיוטה)
|
||||
|
||||
1. **שלמות מבנית** — בלוקים ה–יא קיימים; מספור רציף ללא קפיצות/כפילות; "להלן" בשימוש ראשון.
|
||||
2. **רקע ניטרלי (בלוק ו)** — עובדות בלבד; **אפס** ציטוטים מצדדים; **אפס** מילות-שיפוט
|
||||
("חריג", "בעייתי", "מגוחך", "פגום", "שערורייתי").
|
||||
3. **כיסוי טענות** — כל טענה מהותית מבלוק ז קיבלה מענה בבלוק י (ישיר / קיבוץ / ציון שנבחנה).
|
||||
טענות `[skip]` לא נספרות; `[bundle]` נבדקות כקבוצה. טענה מהותית לא-מסומנת שלא נענתה = כשל.
|
||||
4. **מתודולוגיה** — לכל סוגיה מבנה סילוגיסטי (כלל+עובדות+מסקנה); ממצא עובדתי מופרד ממסקנה
|
||||
משפטית; טענת הצד המפסיד הוצגה בחוזקתה (Steel-Man); איזון מפורש כשנדרש; בלי "נוסחאות ריקות";
|
||||
ציטוטים בסנדוויץ' (הקדמה→ציטוט→ניתוח).
|
||||
5. **קול דפנה** —
|
||||
- בלוק ז: כותרת **"תמצית טענות הצדדים"**; כותרת-משנה לכל צד; בלי רשימה ממוספרת בפסקה;
|
||||
בלי מילות-הערכה; בלי גילוי-מסקנה; קול פעיל ("העורר טוען").
|
||||
- בלוק י: כותרת **"דיון והכרעה"**; קול "אנחנו" תפקידי ("מצאנו", לא "הוועדה מוצאת");
|
||||
דפוס **"אכן... אולם"** לטענות שנדחות; בלי מספור-פסקאות סדרתי; ציטוטי פסיקה במלואם (4–15 שורות);
|
||||
"למעלה מן הצורך" לטיעונים מרכזיים; בלי תוצאת הכל-או-לא-כלום כשיש טענות מהותיות משני הצדדים.
|
||||
- 8xxx עם הכרעה שמאית → ציטוט **בר"מ 3644/13** קיים.
|
||||
6. **עיגון-מקור (INV-AH)** — כל אזכור פסיקה/חוק/הלכה/מספר מעוגן-מקור עם ציטוט; ציטוט חיצוני
|
||||
תואם להלכה מאושרת בקורפוס (`search_precedent_library`). אזכור לא-מעוגן = הסר או הורד ל-`[ספקולציה]`.
|
||||
7. **צירוף פסיקה ל-DB** — כל ציטוט בבלוק י (חיצוני/internal) רשום ב-`case_precedents` דרך
|
||||
`precedent_attach`. ציטוט שקיים רק בטקסט ולא ב-DB = חוסם (ה-exporter ו-Hermes קוראים מה-DB).
|
||||
8. **log שאילתות-קורפוס** — `analysis-and-research.md` §7א ו-`precedent-research.md` §ז קיימים,
|
||||
עם מינימום queries לקורפוס הסמכותי (`analyst ≥ טענות-סף + סוגיות`; `researcher ≥ סוגיות`)
|
||||
ותיעוד negative-evidence (גם 0-results).
|
||||
|
||||
## אזהרות (warning — מדווח לחיים, לא חוסם)
|
||||
- **משקלות בטווח:** ו 15–40% · ז 20–40% · י 32–50% · יא 2–9%.
|
||||
- **בלי כפילות:** בלוק י מפנה לבלוקים קודמים ("כאמור"), לא חוזר על עובדות/טענות.
|
||||
- **מראה-מקום מלא בציטוטים:** מספר-תיק מלא + ערכאה + תאריך/`פורסם בנבו` (+ `page_reference` לציטוט ארוך).
|
||||
|
||||
## אם תוצאה = קבלה (מ-`daphna-acceptance-architecture.md`)
|
||||
- התבנית הנבחרת (A/B/C/D/E) מתאימה לסיבת-הקבלה, ופורמט-הסיום תואם לתבנית
|
||||
(A: "מתבטלת" + הודאת-צד-נגדי; B: "תיקבע לדיון"; C: "בכפוף לתיקונים" + הכרה בוועדה;
|
||||
D: "דרישת התשלום בטלה"; E: "השומה תושב לתיקון").
|
||||
|
||||
---
|
||||
|
||||
> **מקור-אמת:** חומרת-הבדיקות המחייבת חיה ב-`.claude/agents/legal-qa.md` ("7 בדיקות" + טבלת-חומרה)
|
||||
> וב-`docs/spec/05-qa-review.md`. Rubric זה הוא התמצית המשותפת — כשמשתנה שער QA, עדכן שם ואת התמצית כאן.
|
||||
@@ -96,10 +96,11 @@ idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
|
||||
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
|
||||
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
|
||||
[02-data-model.md](02-data-model.md)).
|
||||
**הפרה ידועה:** המסלול הפנימי (`internal_decisions.py:208`) מתזמן **רק**
|
||||
`request_halacha_extraction` ואינו קורא ל-`request_metadata_extraction` (בניגוד
|
||||
ל-`precedent_library.py:292-293` שקורא לשניהם) → ערן סופר 8046/24 נקלטה **בלי
|
||||
metadata** (headnote/summary/tags ריקים) → ממצא ל-[audit](../audit-report.md).
|
||||
**הפרה ידועה — ✅ נסגרה (FU-1, GAP-02):** המסלול הפנימי דילג בעבר על חילוץ-המטא-דאטה
|
||||
(`internal_decisions.py:208` תיזמן רק `request_halacha_extraction`) → ערן סופר 8046/24 נקלטה
|
||||
בלי metadata. **נסגר:** שני מסלולי-הפסיקה זורמים כעת דרך `ingest.ingest_document` הקנוני, שמתזמן
|
||||
**את שני התורים יחד** בנקודה אחת (`ingest.py:233-234`) — תיזמון-המטא אינו מותנה במסלול עוד.
|
||||
מאומת ב-`test_unified_ingest.py` (9/9). תיעוד-מקור: [audit](../audit-report.md).
|
||||
|
||||
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
|
||||
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings
|
||||
|
||||
@@ -73,6 +73,9 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
|
||||
### 2ב. חוזה תמציתי לישויות נוספות
|
||||
|
||||
- `documents` → usable: `file_path`+`doc_type`; searchable: `extraction_status=completed` ו-`extracted_text` לא-ריק ו-≥1 `document_chunk` עם embedding.
|
||||
> **עיצוב-מחדש זרימת-העבודה (WS2, #200):** `documents` מקבל `is_primary`/`doc_category` (רשימה
|
||||
> קנונית: ערר/תשובה/תגובה/פרוטוקול/שומה/החלטת-ועדה) + דגל-ניתוח per-מסמך (`claims_extraction_status`),
|
||||
> כדי לחשב "יש מסמך-עיקרי שלא-נכלל-בניתוח". השדות בבעלות #200; ניתוח-החילוץ עצמו ב-[04-analysis-writing](04-analysis-writing.md) §1.
|
||||
- `halachot` → usable: `rule_statement`+`supporting_quote`; **searchable: `review_status ∈ {approved, published}` בלבד** — `pending_review`/`rejected` מוסתרות מ-`search_precedent_library` (שער-הלכה ידני, `db.py:644-660`, [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
|
||||
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
||||
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
||||
@@ -207,18 +210,22 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
|
||||
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
|
||||
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
|
||||
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
|
||||
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי.** העמודה קיימת (`db.py:373`) אך
|
||||
המסלול הפנימי אינו ממלא אותה (כפועל-יוצא מהיעדר חילוץ-מטא-דאטה, [INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
|
||||
**יעד:** searchable מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
||||
- **שני שדות-סטטוס-חילוץ נפרדים, ללא דגל-`searchable` מפורש.** `extraction_status` +
|
||||
`halacha_extraction_status` (`db.py:603-605`) מתארים תהליך, אך אין שדה יחיד שמסמן "עבר
|
||||
חוזה-שלמות → searchable". **יעד:** דגל/view נגזר ש-search מסנן לפיו, מגובה health-check.
|
||||
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי — ✅ נסגר (FU-1, GAP-02).** העמודה
|
||||
קיימת (`db.py:373`), והמסלול הפנימי לא מילא אותה בעבר (היעדר חילוץ-מטא-דאטה). **נסגר:** שני
|
||||
המסלולים זורמים דרך `ingest.ingest_document` הקנוני שמתזמן metadata לשני הסוגים
|
||||
([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)); `searchable`
|
||||
מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
||||
- **דגל-`searchable` מפורש — ✅ קיים.** מעבר ל-`extraction_status` + `halacha_extraction_status`
|
||||
(`db.py:603-605`, תהליך), קיימת כעת עמודת `case_law.searchable` המסמנת "עבר חוזה-שלמות"; שכבת-
|
||||
החיפוש מסננת לפיה (`cl.searchable = true`, `db.py:7517,7519`) ו-`recompute_searchable_flag`
|
||||
מתחזק אותה. אוכף את [INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים) בשכבת-האחזור.
|
||||
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** ה-tsvectors מסונכרנים אוטומטית
|
||||
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
|
||||
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
|
||||
- **`halachot.review_status` כשער-searchable ללא נראות-backlog.** הסינון תקין (`pending_review`
|
||||
מוסתר, `db.py:659`), אך אין נראות כמה ממתינות — תואם את ההפרה הידועה ב-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
||||
(10/19 מאושרות, התגלה במקרה). **יעד:** health-check חושף backlog-הלכות.
|
||||
- **נראות backlog-הלכות — ✅ נסגר (FU-5, GAP-14).** הסינון תקין (`pending_review` מוסתר,
|
||||
`db.py:659`); ההפרה הישנה היתה היעדר-נראות (10/19 מאושרות, התגלה במקרה). **נסגר:** ה-health-
|
||||
check חושף `halacha_backlog` (`web/app.py:2352-2364`, `metrics_service.halacha_backlog`),
|
||||
והכלים `extraction_status` + `halachot_pending` חושפים את עומק-התור — שער-היו"ר ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)) הופך נראה.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -79,11 +79,12 @@ quality / conformance) | סטטוס: verified
|
||||
**אכיפה:** תנאי-`source_kind` בכל ענף-SQL בשכבת-החיפוש; בדיקת-בריאות שמריצה שאילתת-ביקורת
|
||||
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
|
||||
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
||||
**הפרה ידועה:** משימה #56 — `halacha_filters` **אינם** כוללים `cl.source_kind` ב-
|
||||
`search_precedent_library_semantic` (`db.py:3168`, ענף ה-halacha; לעומת `chunk_filters` שכן —
|
||||
`db.py:3169`) **וב**-`search_precedent_library_lexical` (`db.py:3401` מול `db.py:3402`). שני
|
||||
ה-`halacha_sql` עושים `JOIN case_law cl` בלי לסנן `source_kind` (`db.py:3236-3238`, `db.py:3475-3477`)
|
||||
→ הלכות מהקורפוס הפנימי דולפות לתוצאות החיפוש בקורפוס החיצוני ולהפך → ממצא ל-[audit](../audit-report.md).
|
||||
**הפרה ידועה — ✅ נסגרה (FU-4, GAP-10, משימה #56):** בעבר `halacha_filters` לא כללו
|
||||
`cl.source_kind` (בניגוד ל-`chunk_filters` שכן) → הלכות מהקורפוס הפנימי דלפו לחיפוש בקורפוס
|
||||
החיצוני ולהפך. **נסגר:** שני ה-`halacha_filters` (semantic + lexical) מתחילים כעת ב-
|
||||
`cl.source_kind = '{source_kind}'` בדיוק כמו `chunk_filters` (`db.py:7516,7519` ומקבילו ב-
|
||||
`search_precedent_library_lexical`). מאומת ב-`test_precedent_corpus_isolation.py` (semantic+lexical,
|
||||
שני ה-source_kind) → תיעוד-מקור: [audit](../audit-report.md).
|
||||
|
||||
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
|
||||
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
|
||||
@@ -121,10 +122,13 @@ relevance judgments, precision/recall) · RAG evaluation literature (Lewis et al
|
||||
Elastic — *relevance evaluation guidance* | סטטוס: verified
|
||||
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
|
||||
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
|
||||
**הפרה ידועה (GAP):** אין כיום eval harness ולא gold-set — קיים רק `telemetry.log_search_bg`
|
||||
(`search.py:62,118,190,271`; `precedent_library.py:280`) שמתעד שאילתות בפועל, אך **אינו מודד
|
||||
precision/recall מול תיוג** (תצפית, לא הערכה). היעד: harness שמריץ סט קבוע ומחזיר metrics →
|
||||
ממצא ל-[audit](../audit-report.md).
|
||||
**הפרה ידועה — ✅ נסגרה (FU-5, GAP-11):** בעבר היה רק `telemetry.log_search_bg` (תצפית, לא
|
||||
הערכה). **נסגר:** `scripts/eval_retrieval.py` מריץ את **מסלול-האחזור בייצור** (אותן פונקציות-
|
||||
שירות שכלי-ה-MCP קוראים) מול gold-set מתויג (`data/eval/gold-set.jsonl`, נבנה ב-
|
||||
`scripts/eval_gold_bootstrap.py`) ומחזיר precision/recall/MRR/nDCG@{5,10} — סך-הכול, פר-קורפוס
|
||||
ופר-`practice_area` — מול baseline מקובע (`data/eval/baseline.json`). מטריקות-הליבה נבדקות
|
||||
offline ב-`eval_retrieval.py --self-test` ("ALL PASS"). **משמעת-CI:** להריץ לפני/אחרי כל שינוי
|
||||
שכבת-אחזור (משקלי-RRF/`k`/סף-chunk/embedder/rerank) ולהשוות ל-baseline. תיעוד-מקור: [audit](../audit-report.md).
|
||||
|
||||
### INV-RET5: כל span מוחזר עקיב למקורו
|
||||
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
|
||||
@@ -156,16 +160,15 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
||||
|
||||
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
|
||||
|
||||
- **דליפת-הלכות חוצת-קורפוס (משימה #56).** `halacha_filters` נפתחים רק עם `review_status`
|
||||
(`db.py:3168`, `db.py:3401`) ואינם מוסיפים `cl.source_kind`, בעוד `chunk_filters` כן
|
||||
(`db.py:3169`, `db.py:3402`). שני ה-`halacha_sql` עושים `JOIN case_law` בלי סינון
|
||||
(`db.py:3236-3242`, `db.py:3463-3482`). **תסמין:** חיפוש בקורפוס החיצוני
|
||||
(`search_precedent_library`, `source_kind="external_upload"`) יכול להחזיר הלכה שמקורה
|
||||
בהחלטת-ועדה פנימית — ולהפך עבור `search_internal_decisions` (`source_kind="internal_committee"`,
|
||||
`internal_decisions.py:418`). **יעד:** `halacha_filters` יתחילו ב-`cl.source_kind = '{source_kind}'`
|
||||
בדיוק כמו `chunk_filters` ([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
||||
- **אין eval harness — מדידת-איכות לא קיימת.** רק `telemetry.log_search_bg` מתעד שאילתות
|
||||
(`search.py:62,118,190,271`); אין gold-set מתויג ואין precision/recall. **יעד:** harness עומד
|
||||
- **דליפת-הלכות חוצת-קורפוס (משימה #56) — ✅ נסגר (FU-4, GAP-10).** בעבר `halacha_filters` לא
|
||||
כללו `cl.source_kind` (בניגוד ל-`chunk_filters`) → הלכה מהקורפוס הפנימי יכלה לחזור בחיפוש
|
||||
בקורפוס החיצוני, ולהפך. **נסגר:** `halacha_filters` מתחילים כעת ב-`cl.source_kind = '{source_kind}'`
|
||||
+ `cl.searchable = true` בדיוק כמו `chunk_filters` (`db.py:7514-7519`), בשתי הפונקציות
|
||||
(semantic+lexical). מאומת ב-`test_precedent_corpus_isolation.py`
|
||||
([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
||||
- **eval harness — ✅ נסגר (FU-5, GAP-11).** בעבר רק `telemetry.log_search_bg` (תצפית).
|
||||
**נסגר:** `scripts/eval_retrieval.py` (precision/recall/MRR/nDCG מול `data/eval/gold-set.jsonl`
|
||||
+ baseline; `--self-test` עובר offline) — מדידה אמפירית מול תיוג קבוע
|
||||
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
|
||||
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
|
||||
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176` — `logger.warning`, ממשיך). **יעד:** הפרדה
|
||||
|
||||
@@ -47,6 +47,17 @@
|
||||
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
||||
> שלו מקבל מענה בבלוק י.
|
||||
|
||||
> **עיצוב-מחדש זרימת-העבודה (WS2–WS4) — מסלול-החילוץ אינו מתפצל ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)):**
|
||||
> ניתוח-ערר-לבד כבר עובד (`extract_claims` רץ על `appeal` בנפרד). ההרחבות נשענות על אותו צינור-
|
||||
> חילוץ, **לא** על מסלול-ניתוח שני: (א) **ניתוח-מחדש מאחד** (WS2, #200/#201) — מחלץ מ-מסמכים
|
||||
> חדשים/מעודכנים בלבד ומאחד עם הקיים (לא מוחק-ומחשב-מחדש), על-בסיס דגל-הניתוח per-מסמך
|
||||
> ([02-data-model §2ב](02-data-model.md#2ב-חוזה-תמציתי-לישויות-נוספות)); (ב) **סיכום-מנהלים**
|
||||
> (WS3, #202, `summarize_party_claims` — ראה §1.4) — נגזר מ-`claims`/`legal_arguments`, **מסמך-פרוזה נפרד**
|
||||
> מטיוטת-הביניים (G2: תצוגה-נגזרת, לא מקור-אמת שני); (ג) **ניתוח-פרוטוקול** (WS4, #203,
|
||||
> `analyze_protocol` — ראה §1.3) — `protocol` נכנס לחילוץ ההשוואתי (ירד/חוזק/חדש) ומזין ידע-תיק, ונשאר
|
||||
> פוסט-דיון ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד): טענות-פוסט-דיון → בלוק ח, לא ז).
|
||||
> חוזי-הכלים המלאים בבעלות המשימות הנ"ל (§1.3/§1.4); כאן רק עיגון-הספ שהם מקיימים G2.
|
||||
|
||||
### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
|
||||
|
||||
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
|
||||
@@ -74,6 +85,11 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
|
||||
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
||||
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
||||
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
||||
**חלון-הקשר (#216):** הבנייה הסטנדרטית רצה 200K-טוקן (~400K תווים עברית). פרומפט גדול
|
||||
(בעיקר בלוק-י, שנושא את התיק המלא כ-source-context) מוסלם אוטומטית לבניית-ה-1M של *אותו*
|
||||
מודל — `claude-opus-4-8[1m]` (1M-טוקן, מחיר רגיל; `claude -p` חושף אותה ישירות כ-model-id,
|
||||
בלי beta-header). **עידון של אותו pin, לא מסלול-מודל מקביל (G2).** הסף ב-`block_writer`:
|
||||
פרומפט > ~350K תווים → `[1m]`; אחרת הבנייה הסטנדרטית. תקרת-קשיחה: 1.5M תווים.
|
||||
|
||||
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
||||
|
||||
@@ -96,6 +112,12 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
|
||||
(git + S3, באותו מסלול-אחסון של `analysis-and-research.md`); ניתן-לייצוא ל-DOCX בסגנון-תבנית
|
||||
דפנה (`build_party_claims_summary_docx`). מימוש: `tools/drafting.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)
|
||||
|
||||
@@ -117,6 +139,30 @@ reanalyze_all_primary=False)` סוגר את הפער **בלי force-delete גו
|
||||
|
||||
ראה `tools/legal_arguments.py` (`reanalyze_claims`, `_snapshot`, `_impact_diff`).
|
||||
|
||||
### 1.6 שער שטן-מליץ (red-team) — בין הניתוח לכתיבה, תחת אישור-יו"ר
|
||||
|
||||
אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הכותב, ה-CEO מפעיל **אוטומטית**
|
||||
שכבת **דעה-שנייה אדוורסרית** מ-lineage שונה (Gemini, `legal-analyst-gemini-critique`,
|
||||
read-only) — **שער-קבע (standing gate)**, לא on-demand. השכבה תוקפת את ניתוח-Opus ומפיקה
|
||||
`critique-gemini.md` = **מזכר-לידים לא-סמכותי**, מתויג-ודאות (`[מאומת-קורפוס]`/`[טעון-אימות]`/
|
||||
`[ספקולציה]`), כפוף לשער ה-anti-hallucination ([INV-AH](../anti-hallucination-gate.md);
|
||||
כלי-RAG משפטיים הוזים פסיקה 17–33%, Stanford RegLab/Magesh JELS 2025).
|
||||
|
||||
- **human-in-the-loop קשיח ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)):**
|
||||
ה-CEO מציג את הלידים ליו"ר כ**עצירת-אישור** (ה-issue הראשי ל-`in_review` + מייל), ואינו מתקדם
|
||||
לכותב בלי הכרעת-יו"ר מפורשת. **רק לידים שהיו"ר אישר** מומרים ל-`chair_directions` דרך
|
||||
מנגנון-ההנחיות הקיים (`record_chair_feedback` → "עמדת ועדת הערר" ב-`analysis-and-research.md`
|
||||
→ `get_chair_directions` → `approve_direction`); לידים שנדחו נמחקים.
|
||||
- **הכותב צורך מקור-מעוגן בלבד ([INV-WR1–WR5](#4-invariants-של-התחום--תוכן-החלטה-מנומקת) +
|
||||
[INV-LRN5](07-learning.md)):** הקלט לכתיבה הוא **פלט-המנתח המעוגן + ההנחיות-המאושרות** —
|
||||
**לעולם לא** הלידים הגולמיים של שטן-מליץ. השכבה אינה מסלול-נתונים מקביל לכתיבה
|
||||
([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)) ואינה כותבת
|
||||
שום שכבת-קול/ידע (INV-LRN5) — read-only ל-`critique-gemini.md` בלבד.
|
||||
- **מקור-אמת לזרימה:** [X4 INV-AG4](X4-agents.md#inv-ag4-שער-שטן-מליץ--red-team-לידים-לא-סמכותיים-תחת-אישור-יור)
|
||||
+ [legal-ceo.md](../../.claude/agents/legal-ceo.md) "שלב A2"/"שלב A3" +
|
||||
[legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md).
|
||||
(שינוי-מדיניות יו"ר 2026-06-30: from on-demand to standing gate — TaskMaster `legal-ai` #211.)
|
||||
|
||||
---
|
||||
|
||||
## 2. ארכיטקטורת 12 הבלוקים (סיכום)
|
||||
|
||||
@@ -111,10 +111,14 @@ Model / Single Source of Truth) · SSOT (Single Source of Truth principle) | ס
|
||||
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
|
||||
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
|
||||
"סימטריה" (חוקה §6).
|
||||
**הפרה ידועה:** אחרי `revise_draft`/`apply_user_edit`, ה-DOCX המסומן `active_draft_path` הופך
|
||||
ל"מקור-האמת" לעריכות-Track-Changes העוקבות (`db.py:185-188`), ו**בלוקי-ה-DB אינם מתעדכנים
|
||||
חזרה** — הנתון-הנגזר זוחל למקור-אמת בפועל בלי סנכרון לאחור. **יעד:** או re-sync מהבלוקים, או
|
||||
חוזה מפורש ש-`active_draft_path` הוא רק אנקור-revision ולא מקור-תוכן → ראה [§5](#5-current-vs-target).
|
||||
**הפרה ידועה — ✅ מגודרת (FU-7, GAP-17):** אחרי `revise_draft`/`apply_user_edit` ה-DOCX
|
||||
המסומן `active_draft_path` משמש כאנקור לעריכות-Track-Changes העוקבות, ובלוקי-ה-DB אינם
|
||||
מתעדכנים חזרה — סטייה אפשרית בין הבלוקים למסמך-החי. **התיקון שנבחר (חוזה מפורש, לא re-sync):**
|
||||
`active_draft_path` הוא **אנקור-revision בלבד, לא מקור-תוכן מתחרה** — ה-DB נשאר מקור-האמת. סטייה
|
||||
נלכדת בדגל `cases.blocks_stale` (V22, `db.py:1142-1148`): `revise_draft`/`apply_user_edit`
|
||||
מסמנים `mark_blocks_stale(case_id, True)` (`drafting.py:688,789`) ו-`export_docx` מנקה
|
||||
(`mark_blocks_stale(case_id, False)`, `drafting.py:504`) — הדגל הוא ה-drift-detection שחושף מתי
|
||||
ה-DOCX-החי נסחף מהבלוקים. ראה [§5](#5-current-vs-target).
|
||||
|
||||
### INV-EX2: עקיבוּת-מקור נשמרת בהחלטה המיוצאת (→G9)
|
||||
**כלל:** ההחלטה המיוצאת **שומרת על עקיבוּת-מקור** היכן שנדרש — סמכויות-משפטיות מצוטטות
|
||||
@@ -137,29 +141,53 @@ Lewis et al. (2020, NeurIPS — RAG attribution) | סטטוס: verified
|
||||
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
|
||||
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
|
||||
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); **נאכף ברמת-
|
||||
הזרימה/agent בלבד** — הסוכן `legal-exporter` מחויב להריץ `validate_decision` ולבדוק
|
||||
כשלים-קריטיים לפני ייצוא (`.claude/agents/legal-exporter.md:71,149`).
|
||||
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** קורא ל-`validate_decision` בעצמו —
|
||||
הוא ניגש ישירות ל-`docx_exporter.export_decision` בלי לבדוק `export_blocked`. החסימה תלויה
|
||||
במשמעת-הסוכן ואינה hard-block בקוד-הייצוא → ראה [§5](#5-current-vs-target) (תואם
|
||||
[05-qa-review §4](05-qa-review.md#4-current-vs-target--ממצאי-audit)).
|
||||
**אכיפה — ✅ hard-block בקוד (FU-6, GAP-15):** `export_docx` (`drafting.py:462`) **בודק לעצמו**
|
||||
לפני כל ייצוא — `db.qa_run_exists` (אם QA לא רץ כלל → חסום) ו-`db.get_critical_qa_failures`
|
||||
(אם יש כשל-קריטי → חסום) — **לפני** הגישה ל-`docx_exporter.export_decision`. אלו `SELECT` זולים
|
||||
על ה-`qa_results` המאוחסנים (לא הרצת-LLM חוזרת). נוסף על `export_blocked = critical_failures > 0`
|
||||
ברמת-הזרימה ועל משמעת-הסוכן `legal-exporter` (`.claude/agents/legal-exporter.md:71,149`) — כך
|
||||
שאי-אפשר לעקוף את השער אפילו בקריאה ישירה ל-`export_docx`.
|
||||
**הפרה ידועה — ✅ נסגרה (FU-6, GAP-15):** בעבר `export_docx` ניגש ישירות ל-exporter בלי לבדוק
|
||||
`export_blocked` (אכוף-זרימה בלבד, ניתן-לעקיפה). נסגר ע"י ה-hard-block לעיל; מאומת ב-
|
||||
`test_export_qa_gate.py` (חסום ללא-QA · חסום בכשל-קריטי · עובר כשנקי).
|
||||
|
||||
### INV-EX4: מקור-תבנית-יחיד — כל מפיק-מסמך מחיל סגנונות-טמפלט בלבד (→G2)
|
||||
**כלל:** **כל** מפיק של מסמך-Word הקשור-להחלטה — סופי (`export_docx`), טיוטת-ביניים
|
||||
(`export_interim_draft`), סיכום-מנהלים (`party-claims-summary`), ומסמך-הניתוח
|
||||
(`build_analysis_docx`), וכן מסלול-העריכה (`revise_draft`/`apply_user_edit` →
|
||||
`docx_reviser`) — **חייב לטעון את הטמפלט היחיד** `skills/docx/decision_template.docx`
|
||||
ולהחיל עיצוב **אך-ורק דרך שמות-הסגנונות שמוגדרים בו** (`paragraph.style = "…"` /
|
||||
`<w:pStyle w:val="…"/>`). **אסור hand-setting** של משפחת-גופן (`rFonts`), גודל-גופן
|
||||
(`sz`/`szCs`), או שוליים — אלה נגזרים מ-`styles.xml` של הטמפלט (cascade). היחיד שמותר
|
||||
לכפות מפורשות הוא **יישור-הגוף הקנוני `jc=both`** היכן שהוא דורס סגנון שמגיע ממורכז
|
||||
(`_set_paragraph_jc`, `docx_exporter.py:80`) — כי זו ההתנהגות-הקנונית, לא עיצוב-אד-הוק.
|
||||
סמני-כיווניות (`<w:bidi>`, run `<w:rtl/>`) מותרים (כיווניות, לא עיצוב). **טמפלט אחד = מקור-סגנון
|
||||
יחיד; אין שכבת-סגנון מקבילה.**
|
||||
**מקורות:** SSOT (Single Source of Truth) · Martin Fowler — Canonical Data Model · ECMA-376
|
||||
(OOXML) §17.7.2 — הורשת-סגנונות-פסקה (paragraph style inheritance / cascade) | סטטוס: verified
|
||||
**אכיפה:** `docx_exporter` ו-`analysis_docx_exporter` טוענים `TEMPLATE_PATH` ומחילים style-by-name
|
||||
(ראה §2); `docx_reviser` מחיל `pStyle` מן-הטמפלט + `jc=both` (לא פונט/גודל ידני). שומר-CI
|
||||
`test_docx_template_single_source.py` גורף את מודולי-מפיקי-ה-DOCX ונכשל על `rFonts`/`sz`/`szCs`
|
||||
ידני מחוץ לטוען-הטמפלט. אוכף את
|
||||
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) בשכבת-הרינדור.
|
||||
**הפרה ידועה — ✅ נסגרה:** `docx_reviser._build_paragraph` כפה `jc=right` + David/`sz` ידני על כל
|
||||
פסקה שהוכנסה במסלול-העריכה (`revise_draft`/`apply_user_edit`) → פלט מסלול-העריכה איבד את היישור
|
||||
הדו-צדדי של הטמפלט והכניס עיצוב-אד-הוק. נסגר ע"י החלת `pStyle` מן-הטמפלט; מאומת ב-
|
||||
`test_docx_reviser.py` + `test_docx_template_single_source.py`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Current vs Target
|
||||
|
||||
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-EX3 / INV-QA3).** אומת בקוד: `export_docx`
|
||||
(`drafting.py:384-419`) קורא ישירות ל-`docx_exporter.export_decision` (`:403`) ללא קריאה
|
||||
ל-`qa_validator.validate_decision` ובלי בדיקת `export_blocked`. החסימה מתקיימת רק כי הסוכן
|
||||
`legal-exporter` מחויב להריץ QA קודם (`legal-exporter.md:71,149`) — אדם/סוכן שיקרא
|
||||
ל-`export_docx` ישירות **יעקוף** את השער. **יעד:** hard-block בתוך `export_docx` — שליפת
|
||||
`qa_results`/`export_blocked` ודחייה לפני כתיבת ה-DOCX, כך שאי-אפשר לעקוף.
|
||||
- **`active_draft_path` כ-derived-שזוחל-למקור (INV-EX1).** ה-DOCX נגזר מהבלוקים בייצוא הראשון,
|
||||
אך אחרי עריכה (`revise_draft`/`apply_user_edit`) ה-DOCX הופך ל"מקור-האמת" לעריכות הבאות
|
||||
(`db.py:185-188`) בלי לעדכן את `decision_blocks` חזרה — סטייה אפשרית בין הבלוקים למסמך-החי.
|
||||
**יעד:** חוזה מפורש — או re-sync מהבלוקים, או הגדרת `active_draft_path` כאנקור-revision בלבד
|
||||
(לא מקור-תוכן), עם בדיקת-בריאות לגילוי drift בין הבלוקים ל-DOCX הפעיל.
|
||||
- **שער-ייצוא — ✅ hard-block בקוד (INV-EX3 / INV-QA3, FU-6 / GAP-15).** `export_docx`
|
||||
(`drafting.py:462-494`) בודק `db.qa_run_exists` + `db.get_critical_qa_failures` ודוחה לפני
|
||||
הגישה ל-`docx_exporter.export_decision` — לא ניתן לעקוף בקריאה ישירה. מאומת ב-
|
||||
`test_export_qa_gate.py` (3 מקרים: ללא-QA / כשל-קריטי / נקי).
|
||||
- **`active_draft_path` כ-derived (INV-EX1, FU-7 / GAP-17) — ✅ מגודר בחוזה מפורש.** ה-DB נשאר
|
||||
מקור-האמת; `active_draft_path` הוא אנקור-revision בלבד. ה-drift בין הבלוקים ל-DOCX-החי נלכד
|
||||
בדגל `cases.blocks_stale`: נדלק ב-`revise_draft`/`apply_user_edit` (`drafting.py:688,789`),
|
||||
כובה ב-`export_docx` (`drafting.py:504`). **שארית (low-pri):** health-check שמתריע על
|
||||
`blocks_stale=true` עתיק — תיעוד-המשך, לא חוסם.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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-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. הטבלה מתמלאת קדימה מהסופי הבא.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,6 +59,16 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
|
||||
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
||||
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
||||
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
|
||||
- **שטן-מליץ (Gemini red-team) — שער-קבע אחרי הניתוח, לא חלק מ-7-הדומייניים.** סוכן
|
||||
`legal-analyst-gemini-critique` (`gemini_local`, CMP+CMPA) רץ **אחרי** שלב-הניתוח של ה-Case
|
||||
Analyst (Opus) ו**לפני** הכותב, כ**שער-קבע (standing gate)** שה-CEO מפעיל **אוטומטית** — לא
|
||||
on-demand. הוא read-only ומפיק `critique-gemini.md` = **מזכר-לידים לא-סמכותי**. ה-CEO **עוצר
|
||||
את הזרימה לאישור-יו"ר של הלידים** (ה-issue הראשי ל-`in_review`); רק לידים שהיו"ר מאשר מומרים
|
||||
ל-`chair_directions` (דרך מנגנון-ההנחיות הקיים — `record_chair_feedback` → `get_chair_directions`
|
||||
→ `approve_direction`), והכותב צורך **רק** את פלט-המנתח המעוגן + ההנחיות-המאושרות — **לעולם
|
||||
לא** את הלידים הגולמיים. מקיים [INV-AG4](#inv-ag4-שער-שטן-מליץ--red-team-לידים-לא-סמכותיים-תחת-אישור-יור) +
|
||||
[04-analysis-writing §1.6](04-analysis-writing.md). מקור-אמת לזרימה: [legal-ceo.md](../../.claude/agents/legal-ceo.md)
|
||||
"שלב A2"/"שלב A3" + [legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md).
|
||||
|
||||
### 2א. מפת-הרשאות (tool grants) — frontmatter מול הוראות
|
||||
|
||||
@@ -140,6 +150,25 @@ another company`, [X2 §2](X2-multi-company.md)).
|
||||
**אכיפה:** בדיקת-עקביות tools↔instructions (FU-13 ✅ 2026-06-06). אכיפה אוטומטית עתידית — בתת-פרויקט 5 (spec-guardian).
|
||||
**הפרה ידועה:** — (טופל ב-FU-13: legal-analyst קיבל `aggregate_claims_to_arguments`; researcher כבר היה תקין; `extract_references`/`extract_internal_citations` הם מטלת-researcher, לא analyst — ראה §2א).
|
||||
|
||||
### INV-AG4: שער שטן-מליץ — red-team לידים לא-סמכותיים תחת אישור-יו"ר
|
||||
**כלל:** אחרי שלב-הניתוח (`analysis-and-research.md` תקין) וב**לפני** הפעלת הכותב, ה-CEO מפעיל
|
||||
**אוטומטית** את סוכן שטן-מליץ (Gemini red-team, read-only) כ**שער-קבע** — לא on-demand. הפלט
|
||||
(`critique-gemini.md`) הוא **מזכר-לידים לא-סמכותי**; ה-CEO **עוצר את הזרימה לעצירת-אישור קשיחה
|
||||
של היו"ר** (issue ראשי ל-`in_review` + מייל) ואינו מתקדם לכותב בלי הכרעת-יו"ר מפורשת. **רק לידים
|
||||
שהיו"ר אישר** מומרים ל-`chair_directions` דרך מנגנון-ההנחיות הקיים (`record_chair_feedback`
|
||||
→ `get_chair_directions` → `approve_direction`); לידים שנדחו נמחקים. הכותב צורך **אך-ורק** את
|
||||
פלט-המנתח המעוגן + ההנחיות-המאושרות — **לעולם לא** את הלידים הגולמיים. מופע של
|
||||
[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant) (שער אנושי
|
||||
לא-עקיף) ושל [INV-AH](../anti-hallucination-gate.md) / [INV-LRN5](07-learning.md) (לידים
|
||||
לא-סמכותיים, אינם מוזנים אוטומטית לקול/למהות).
|
||||
**מקור-סמכות:** [legal-ceo.md](../../.claude/agents/legal-ceo.md) ("שלב A2"/"שלב A3" + "מפת
|
||||
סטטוסים") + [legal-analyst-gemini-critique.md](../../.claude/agents/legal-analyst-gemini-critique.md)
|
||||
+ [HEARTBEAT.md §7](../../.claude/agents/HEARTBEAT.md). (invariant פרויקטלי-תפעולי — ללא
|
||||
פרוטוקול ≥3-המקורות; משרת את G10 + INV-AH/INV-LRN5.)
|
||||
**אכיפה:** פרוצדורלית (נוהל ה-CEO — "אל תמשיך לכותב בלי `critique-gemini.md` + אישור-יו"ר ללידים")
|
||||
+ עצירת-`in_review` של ה-issue הראשי; אין שער-קוד אוטומטי (כמו יתר ה-INV-AG*).
|
||||
**הפרה ידועה:** — (שינוי-מדיניות יו"ר 2026-06-30: שטן-מליץ עבר מ-on-demand ל-שער-קבע; TaskMaster `legal-ai` #211).
|
||||
|
||||
---
|
||||
|
||||
## 5. חיווט הספ לסוכנים — בוצע (FU-8b)
|
||||
|
||||
@@ -105,13 +105,36 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co
|
||||
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
|
||||
החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130).
|
||||
|
||||
### INV-UI9: זמן-מאוחסן ב-UTC · תצוגה אנושית דטרמיניסטית ב-Asia/Jerusalem
|
||||
**כלל:** **האחסון נשאר UTC** מקצה-לקצה — Postgres `TIMESTAMPTZ`, וה-API מחזיר ISO-UTC; אין נרמול-tz
|
||||
בשכבת-האחסון/ה-API. **שכבת-התצוגה בלבד** ממירה, ובאופן **דטרמיניסטי**: כל חותמת-זמן אנושית מרונדרת
|
||||
ב-`timeZone: "Asia/Jerusalem"` (locale `he-IL`), בלי תלות ב-tz של ה-host/קונטיינר/דפדפן. כיוון
|
||||
שהקונטיינר רץ ב-UTC, פירמוט ad-hoc (`new Date(iso).toLocaleDateString("he-IL")`, `.getHours()`,
|
||||
`toISOString().slice(0,10)`) מציג UTC תחת SSR (ושוב UTC בכל runtime לא-ישראלי) → drift. נעיצת
|
||||
`Asia/Jerusalem` מבטיחה ש-SSR והדפדפן מסכימים. **פורמט-התצוגה (צורת התאריך/שעה) נשמר זהה — רק ה-tz
|
||||
מנורמל.** מופע של [G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)
|
||||
(מפרמט-תאריך **יחיד**, אין פירמוט-זמן מקביל-מפוזר) בשכבת-התצוגה.
|
||||
**מקורות:** ECMAScript Internationalization API — `Intl.DateTimeFormat` `timeZone` option
|
||||
(https://tc39.es/ecma402/#datetimeformat-objects) · MDN — *Intl.DateTimeFormat* (IANA time zones)
|
||||
(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) ·
|
||||
Next.js — *Rendering* (SSR משתמש בשעון-השרת אם לא ננעץ)
|
||||
(https://nextjs.org/docs/app/building-your-application/rendering) | סטטוס: verified
|
||||
**אכיפה:** מפרמט-תאריך יחיד ב-[web-ui/src/lib/format-date.ts](../../web-ui/src/lib/format-date.ts)
|
||||
(`formatDate`/`formatDateShort`/`formatDateLong`/`formatDateTime`/`formatDateTimeFull`/`formatTime`/
|
||||
`formatIsoDate`/`formatRelative` + עוזרי-Israel `getIsraelYear`/`israelDayKey`/`israelMidnightMs`/
|
||||
`israelParts`), כולם נעוצים ל-`Asia/Jerusalem`; כל משטחי-ה-UI מייבאים ממנו ולא מפרמטים tz ad-hoc.
|
||||
פירמוט-**מספרים** (`n.toLocaleString`) מחוץ-לתחום (אינו חותמת-זמן). **חריג מוצהר:** קלט
|
||||
`<input type="datetime-local">` (בקרת-burst ב-`/operations`) הוא ערך הניתן-לכתיבה ע"י המפעיל, לא תצוגת-
|
||||
מאוחסן — נשאר בנייה מקומית. **אודיט-נלווה (לא מיושם):** הגדרת `TZ=Asia/Jerusalem` בקונטיינר בטוחה רק
|
||||
אם אף לוגיקה עסקית אינה תלויה בשעון-ה-process כ-UTC.
|
||||
|
||||
---
|
||||
|
||||
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
|
||||
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
|
||||
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
|
||||
- **helpers משותפים:** פירמוט-תאריך, builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) —
|
||||
משותפים, לא מועתקים.
|
||||
- **helpers משותפים:** פירמוט-תאריך (מפרמט-יחיד נעוץ-Asia/Jerusalem ב-[format-date.ts](../../web-ui/src/lib/format-date.ts),
|
||||
INV-UI9), builder ל-FormData (העלאות), רכיב-שגיאה, query-config (intervals) — משותפים, לא מועתקים.
|
||||
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
|
||||
|
||||
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.
|
||||
|
||||
@@ -232,3 +232,25 @@ FU-7 אחרי FU-1; FU-5 אחרי FU-2; FU-8 אחרי ייצוב-הספ. **(מח
|
||||
אחרי FU-1. FU-11 (מילוי-שדות) עצמאי. FU-13 (סוכנים+skills) אחרי ייצוב-הספ.
|
||||
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
|
||||
priority בפועל — של היו"ר.
|
||||
|
||||
---
|
||||
|
||||
## ✅ ווידוא-סגירה — 7 דליפות עיצוב-מחדש זרימת-העבודה (WS7, #207, 2026-06-30)
|
||||
|
||||
תת-פרויקט עיצוב-מחדש זרימת-העבודה (תוכנית `groovy-doodling-token`, WS7) דרש סגירת **7 דליפות
|
||||
ידועות** תוך-כדי. ביקורת חוצת-קוד (#207) אימתה שכל ה-7 כבר **נסגרו במחזורים קודמים** (בעיקר
|
||||
FU-1/4/5/6/7 + גלי-ה-IA), ושהספ נשא תיאורי-הפרה מיושנים. ה-PR מיישר את הספ למצב-הקוד בפועל
|
||||
ומאמת כל סגירה בבדיקה. **אין כאן GAP/FU חדש — זו ביקורת-יישור, לא פתרון-מחדש (G2).**
|
||||
|
||||
| # | דליפה | invariant | סטטוס | FU | סגירה (קוד) | בדיקה |
|
||||
|---|-------|-----------|-------|-----|-------------|-------|
|
||||
| 1 | הלכות חוצות-קורפוס (#56) | INV-RET1/G5 (GAP-10) | ✅ סגור | FU-4 | `cl.source_kind` ב-`halacha_filters` בשתי הפונקציות (`db.py:7516,7519`) | `test_precedent_corpus_isolation.py` |
|
||||
| 2 | backlog-הלכות בלתי-נראה | INV-QA1/G10 (GAP-14) | ✅ סגור | FU-5 | health `halacha_backlog` (`app.py:2352-2364`) + כלי `extraction_status`/`halachot_pending` | — (health-endpoint) |
|
||||
| 3 | אין eval-harness | INV-RET4/G8 (GAP-11) | ✅ סגור | FU-5 | `scripts/eval_retrieval.py` (P/R/MRR/nDCG מול gold-set+baseline) | `eval_retrieval.py --self-test` ("ALL PASS") |
|
||||
| 4 | שער-ייצוא לא hard-block | INV-EX3/QA3/G10 (GAP-15) | ✅ סגור | FU-6 | `export_docx` בודק `qa_run_exists`+`get_critical_qa_failures` לפני exporter (`drafting.py:462-494`) | `test_export_qa_gate.py` |
|
||||
| 5 | מטא במסלול-פנימי | INV-ING3/G2 (GAP-02) | ✅ סגור | FU-1 | מסלול קנוני `ingest.ingest_document` מתזמן metadata+halacha יחד (`ingest.py:233-234`) | `test_unified_ingest.py` |
|
||||
| 6 | DOCX זוחל למקור-אמת | INV-EX1/G2 (GAP-17) | ✅ מגודר | FU-7 | חוזה: `active_draft_path`=אנקור בלבד; drift בדגל `cases.blocks_stale` (V22) | (חוזה+דגל; שארית health-check low-pri) |
|
||||
| 7 | cache-invalidation ב-UI | INV-UI5/IA (GAP-33) | ✅ סגור | FU-10/IA | `qc.invalidateQueries` על מוטציות (`web-ui/src/lib/api/*.ts`) + גלי-IA #130-132 | (TanStack Query) |
|
||||
|
||||
**שאריות (low-pri, מתועדות לא-חוסמות):** דליפה #6 — health-check שמתריע על `blocks_stale=true`
|
||||
עתיק (הדגל קיים ונאכף; חסר רק התרעת-נראות). אינה חוסמת את WS7; ניתן ל-FU-7-המשך.
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the
|
||||
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the
|
||||
# decorative mid-stage markers that no pipeline code ever set were removed).
|
||||
# Core case lifecycle. The canonical key set, order, labels, phases and (future)
|
||||
# per-status actions all live in ONE place — legal_mcp/case_status_model.py —
|
||||
# which asserts this enum matches it. The frontend mirror is
|
||||
# web-ui/src/lib/api/case-status.ts (generated against GET /api/status-model).
|
||||
# The analyst/research intermediate states are first-class canonical statuses
|
||||
# (the agents set them) — not legacy.
|
||||
class CaseStatus(str, enum.Enum):
|
||||
NEW = "new"
|
||||
PROCESSING = "processing"
|
||||
DOCUMENTS_READY = "documents_ready"
|
||||
ANALYST_VERIFIED = "analyst_verified"
|
||||
RESEARCH_COMPLETE = "research_complete"
|
||||
OUTCOME_SET = "outcome_set"
|
||||
DIRECTION_APPROVED = "direction_approved"
|
||||
QA_REVIEW = "qa_review"
|
||||
|
||||
@@ -759,13 +759,15 @@ async def get_appraiser_facts(case_number: str) -> str:
|
||||
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
||||
|
||||
@mcp.tool()
|
||||
async def analyze_protocol(case_number: str) -> str:
|
||||
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
||||
|
||||
מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' +
|
||||
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent).
|
||||
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
|
||||
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
|
||||
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
|
||||
re-run מחליף (idempotent).
|
||||
"""
|
||||
return await drafting.analyze_protocol(case_number)
|
||||
return await drafting.analyze_protocol(case_number, document_id=document_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Allowed enum values mirror the DB CHECK constraints.
|
||||
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
||||
# Sides that may comprise multiple distinct litigants with potentially opposing
|
||||
# positions — aggregated per source pleading (party_name) rather than collapsed
|
||||
# into one bucket (#224). Appellant/committee speak with a single voice.
|
||||
SPLIT_PARTIES = {"respondent", "permit_applicant"}
|
||||
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
||||
|
||||
# Hebrew labels for the prompt (Claude needs context in the same
|
||||
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
|
||||
"""
|
||||
|
||||
|
||||
def _build_prompt(party: str, propositions: list[dict]) -> str:
|
||||
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
|
||||
"""Compose the per-party aggregation prompt."""
|
||||
n = len(propositions)
|
||||
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
||||
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
|
||||
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
||||
|
||||
party_he = PARTY_LABELS_HE.get(party, party)
|
||||
# For a split side (e.g. a specific respondent brief), name the brief so
|
||||
# Claude aggregates only that litigant's position and does not conflate it
|
||||
# with a co-respondent's separate — possibly opposing — pleading (#224).
|
||||
if party_name:
|
||||
party_he = f"{party_he} — {party_name}"
|
||||
# Strip noise from propositions for the prompt — Claude only needs
|
||||
# the id and the text to do the grouping.
|
||||
compact = [
|
||||
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
||||
|
||||
|
||||
async def _aggregate_party(
|
||||
party: str, propositions: list[dict],
|
||||
party: str, propositions: list[dict], party_name: str = "",
|
||||
) -> list[dict]:
|
||||
"""Ask Claude to group one party's propositions; return normalized rows."""
|
||||
"""Ask Claude to group one party's propositions; return normalized rows.
|
||||
|
||||
``party_name`` names the specific pleading when this is a split side
|
||||
(respondent / permit_applicant brief), so the prompt scopes to that
|
||||
litigant's position only (#224).
|
||||
"""
|
||||
if not propositions:
|
||||
return []
|
||||
prompt = _build_prompt(party, propositions)
|
||||
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||
|
||||
try:
|
||||
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||
@@ -207,7 +221,7 @@ async def aggregate_claims_to_arguments(
|
||||
|
||||
# Pull all claims for this case, grouped by party.
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, party_role, claim_text, claim_index, source_document
|
||||
"""SELECT id, party_role, claim_text, claim_index, source_document, party_name
|
||||
FROM claims
|
||||
WHERE case_id = $1
|
||||
ORDER BY party_role, claim_index""",
|
||||
@@ -221,14 +235,28 @@ async def aggregate_claims_to_arguments(
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
# Group propositions by party.
|
||||
by_party: dict[str, list[dict]] = {}
|
||||
# Group propositions by party — and, for the multi-party sides (respondent /
|
||||
# permit_applicant), further by their source pleading so opposing joint
|
||||
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
|
||||
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
|
||||
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
|
||||
# the large respondent set per-brief also keeps each Claude call small enough
|
||||
# to succeed — a single 100+ proposition call previously returned non-JSON
|
||||
# and silently dropped the whole side.
|
||||
by_group: dict[tuple[str, str], list[dict]] = {}
|
||||
for r in rows:
|
||||
party = r["party_role"]
|
||||
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
||||
if party not in ALLOWED_PARTIES:
|
||||
party = "unknown"
|
||||
by_party.setdefault(party, []).append(dict(r))
|
||||
# Prefer the stored party_name (stamped by the extractor, #224); fall
|
||||
# back to source_document for legacy claims predating the stamping.
|
||||
# Single-voice sides (appellant/committee) stay ''.
|
||||
if party in SPLIT_PARTIES:
|
||||
party_name = (r["party_name"] or "").strip() or (r["source_document"] or "").strip()
|
||||
else:
|
||||
party_name = ""
|
||||
by_group.setdefault((party, party_name), []).append(dict(r))
|
||||
|
||||
# Valid claim_ids for this case == the ids of the claims we just fetched.
|
||||
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
|
||||
@@ -243,9 +271,11 @@ async def aggregate_claims_to_arguments(
|
||||
inserted = 0
|
||||
errors: list[str] = []
|
||||
|
||||
for party, props in by_party.items():
|
||||
for (party, party_name), props in by_group.items():
|
||||
# Display key for the per-side summary: keep opposing briefs distinct.
|
||||
group_key = f"{party}·{party_name}" if party_name else party
|
||||
try:
|
||||
arguments = await _aggregate_party(party, props)
|
||||
arguments = await _aggregate_party(party, props, party_name=party_name)
|
||||
except RuntimeError as e:
|
||||
# Most likely cause: Claude CLI not installed (running from
|
||||
# the container). Don't crash — record the gap and continue.
|
||||
@@ -259,11 +289,11 @@ async def aggregate_claims_to_arguments(
|
||||
),
|
||||
"total": 0,
|
||||
}
|
||||
errors.append(f"{party}: {msg}")
|
||||
errors.append(f"{group_key}: {msg}")
|
||||
continue
|
||||
|
||||
if not arguments:
|
||||
party_counts[party] = 0
|
||||
party_counts[group_key] = 0
|
||||
continue
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
@@ -271,12 +301,13 @@ async def aggregate_claims_to_arguments(
|
||||
for idx, arg in enumerate(arguments):
|
||||
arg_id = await conn.fetchval(
|
||||
"""INSERT INTO legal_arguments
|
||||
(case_id, party, argument_index, argument_title,
|
||||
argument_body, legal_topic, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
(case_id, party, party_name, argument_index,
|
||||
argument_title, argument_body, legal_topic, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id""",
|
||||
case_id,
|
||||
party,
|
||||
party_name,
|
||||
idx + 1,
|
||||
arg["title"],
|
||||
arg["body"],
|
||||
@@ -313,7 +344,7 @@ async def aggregate_claims_to_arguments(
|
||||
cid, arg_id, e,
|
||||
)
|
||||
inserted += 1
|
||||
party_counts[party] = len(arguments)
|
||||
party_counts[group_key] = len(arguments)
|
||||
|
||||
result: dict = {
|
||||
"status": "completed",
|
||||
@@ -338,9 +369,9 @@ async def get_legal_arguments(
|
||||
async with pool.acquire() as conn:
|
||||
if party and party in ALLOWED_PARTIES:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
||||
argument_body, legal_topic, priority, cited_precedents,
|
||||
created_at, updated_at
|
||||
"""SELECT id, case_id, party, party_name, argument_index,
|
||||
argument_title, argument_body, legal_topic, priority,
|
||||
cited_precedents, created_at, updated_at
|
||||
FROM legal_arguments
|
||||
WHERE case_id = $1 AND party = $2
|
||||
ORDER BY priority, argument_index""",
|
||||
@@ -348,12 +379,12 @@ async def get_legal_arguments(
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
||||
argument_body, legal_topic, priority, cited_precedents,
|
||||
created_at, updated_at
|
||||
"""SELECT id, case_id, party, party_name, argument_index,
|
||||
argument_title, argument_body, legal_topic, priority,
|
||||
cited_precedents, created_at, updated_at
|
||||
FROM legal_arguments
|
||||
WHERE case_id = $1
|
||||
ORDER BY party, priority, argument_index""",
|
||||
ORDER BY party, party_name, priority, argument_index""",
|
||||
case_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from legal_mcp.services import db
|
||||
@@ -38,7 +38,7 @@ async def log_action(
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
||||
uuid4(), action, case_id, document_id,
|
||||
json.dumps(details or {}, ensure_ascii=False, default=str),
|
||||
user, datetime.utcnow(),
|
||||
user, datetime.now(timezone.utc),
|
||||
)
|
||||
logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user,
|
||||
json.dumps(details or {}, ensure_ascii=False)[:200])
|
||||
|
||||
@@ -58,6 +58,18 @@ logger = logging.getLogger(__name__)
|
||||
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
|
||||
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
|
||||
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
|
||||
# 1M-context build of the SAME pinned model (#216). The default build runs a
|
||||
# 200K-token context (~400K Hebrew chars at ~2 chars/token); large prompts —
|
||||
# notably block-yod, which carries the full case as source-context — overflow it.
|
||||
# Opus 4.8 offers a 1M-token window at standard pricing; `claude -p` exposes it
|
||||
# via the `[1m]` model id directly (no beta header — verified CLI 2.1.196). We
|
||||
# escalate to it only when the prompt is large, so small blocks stay on the
|
||||
# cheaper/faster 200K build. NOT a parallel model (G2) — same model, wider window.
|
||||
GENERATION_MODEL_1M = "claude-opus-4-8[1m]"
|
||||
# Escalate to the 1M build above this prompt size. Set below the 200K-token wall
|
||||
# (~400K chars) with headroom for output tokens (max_tokens up to 16K) + tokenizer
|
||||
# variance, so we switch before the standard build can overflow.
|
||||
_CTX_1M_THRESHOLD_CHARS = 350_000
|
||||
|
||||
BLOCK_CONFIG = {
|
||||
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
|
||||
@@ -65,7 +77,7 @@ BLOCK_CONFIG = {
|
||||
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
|
||||
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"},
|
||||
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096},
|
||||
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 16384},
|
||||
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "low", "max_tokens": 16384},
|
||||
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192},
|
||||
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||
@@ -180,16 +192,16 @@ BLOCK_PROMPTS = {
|
||||
## כללים קריטיים:
|
||||
- **סנתז טענות דומות** — אל תרשום כל טענה בנפרד. קבץ טענות דומות לנושא אחד. למשל: כל הטענות על הודעות → סעיף אחד, כל הטענות על רכוש משותף → סעיף אחד.
|
||||
- גוף שלישי: "העוררים טוענים כי...", "הוועדה המקומית ציינה כי..."
|
||||
- **מבנה קבוע עם 3 חלקים:**
|
||||
1. "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא
|
||||
2. "עמדת הוועדה המקומית" — 5-8 סעיפים
|
||||
3. "עמדת מבקשי ההיתר" (אם יש) — 5-10 סעיפים
|
||||
- **מבנה: חלק (סעיף עם כותרת-משנה) לכל צד שמופיע בטענות שחולצו למטה** — לפי הכותרות שם (### ...). אל תמציא צדדים שאינם מופיעים ואל תשמיט צד שמופיע.
|
||||
- "טענות העוררים" — 8-12 סעיפים מקובצים לפי נושא.
|
||||
- **צד-משיב/מתנגד: כתוב חלק נפרד לכל כתב-תשובה** שמופיע (למשל "עמדת משיבות 2-3" ו-"עמדת משיבים 4-6" בנפרד) — 5-10 סעיפים לכל אחד. **אל תמזג משיבים שונים לסעיף אחד**, ואם עמדותיהם מנוגדות — שקף זאת.
|
||||
- "עמדת הוועדה המקומית" / "עמדת מבקשי ההיתר" (אם מופיעים) — 5-8 סעיפים.
|
||||
- כותרת: "תמצית טענות הצדדים"
|
||||
- נאמנות למקור — לא להמציא טענות, אבל כן לאחד ולסכם טענות חוזרות
|
||||
- אין ניתוח, אין מסקנות, אין הערכה ("טענה חלשה/חזקה")
|
||||
- רק מכתבי טענות מקוריים (לא השלמות טיעון)
|
||||
- מספור רציף
|
||||
- **יעד אורך: 800-1500 מילים**
|
||||
- **יעד אורך: 800-1500 מילים** (יותר כשיש כמה כתבי-תשובה נפרדים — כל צד מקבל את מלוא ההתייחסות)
|
||||
|
||||
## טענות שחולצו (קבץ טענות דומות לנושאים):
|
||||
{claims_context}
|
||||
@@ -272,6 +284,7 @@ BLOCK_PROMPTS = {
|
||||
- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל"
|
||||
- **מספור רציף** — המשך מספור מהבלוק הקודם
|
||||
- מותרות כותרות-משנה כשיש נושאים נפרדים לחלוטין
|
||||
- **צדדים מרובים** — כשיש כמה משיבים/מתנגדים או כתבי-תשובה נפרדים (ראה הכותרות שתחת "טענות" לעיל, למשל "משיבות 2-3" מול "משיבים 4-6"), התייחס לעמדת כל אחד לגופה ואל תמזג אותם ל"טענות המשיבים" גורפות. אם משיבים שונים נוקטים **עמדות מנוגדות זו לזו** — ציין זאת במפורש והכרע ביניהן בנימוק; אם עמדותיהם משלימות — ניתן לאגד תוך שמירת הייחוס.
|
||||
|
||||
## כיוון מאושר (חובה):
|
||||
{direction_context}
|
||||
@@ -461,17 +474,21 @@ async def write_block(
|
||||
if not dir_doc.get("approved"):
|
||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||
|
||||
# Guard against context overflow before calling claude -p.
|
||||
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
||||
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
||||
# that as a conservative ceiling (well below the token limit).
|
||||
_MAX_PROMPT_CHARS = 400_000
|
||||
# Pick the model build by prompt size (#216). Above the 200K-token wall we
|
||||
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
|
||||
# block-yod legitimately carries the whole case as source-context. The 400K
|
||||
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
|
||||
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
|
||||
|
||||
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
|
||||
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
|
||||
_MAX_PROMPT_CHARS = 1_500_000
|
||||
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||
raise RuntimeError(
|
||||
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
||||
f"(limit {_MAX_PROMPT_CHARS:,}, even on the 1M-context build). "
|
||||
f"source_context: {len(source_context):,} chars. "
|
||||
f"Reduce documents or call extract_appraiser_facts first."
|
||||
f"Reduce source-context (summaries / appraiser_facts / focused RAG)."
|
||||
)
|
||||
|
||||
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
||||
@@ -483,14 +500,14 @@ async def write_block(
|
||||
content = await claude_session.query(
|
||||
prompt,
|
||||
timeout=timeout,
|
||||
model=GENERATION_MODEL,
|
||||
model=gen_model,
|
||||
effort=effort,
|
||||
tools="", # prose gen — no tool_use → no error_max_turns
|
||||
)
|
||||
|
||||
sources = await _collect_block_sources(case_id, block_id)
|
||||
sources["case_law_ids"] = _precedent_case_law_ids
|
||||
result = _build_result(block_id, content, block_cfg)
|
||||
result = _build_result(block_id, content, block_cfg, model_used=gen_model)
|
||||
# Record the EFFECTIVE effort (override wins) so the harness can attribute
|
||||
# the measured distance to the effort that actually produced the text.
|
||||
if result.get("effort") is not None:
|
||||
@@ -499,7 +516,8 @@ async def write_block(
|
||||
return result
|
||||
|
||||
|
||||
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
||||
def _build_result(block_id: str, content: str, block_cfg: dict,
|
||||
model_used: str = GENERATION_MODEL) -> dict:
|
||||
word_count = len(content.split())
|
||||
is_ai = block_cfg["model"] == "ai"
|
||||
return {
|
||||
@@ -509,8 +527,9 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
||||
"content": content,
|
||||
"word_count": word_count,
|
||||
"generation_type": block_cfg["gen_type"],
|
||||
# AI blocks record the pinned model; template blocks record "script".
|
||||
"model_used": GENERATION_MODEL if is_ai else block_cfg["model"],
|
||||
# AI blocks record the model build actually used (200K or [1m], #216);
|
||||
# template blocks record "script".
|
||||
"model_used": model_used if is_ai else block_cfg["model"],
|
||||
# The real generation knob (#204). None for template/script blocks.
|
||||
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
|
||||
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
|
||||
@@ -611,18 +630,49 @@ async def _build_claims_context(case_id: UUID) -> str:
|
||||
# Fallback to all non-block-zayin claims if no source claims exist.
|
||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
||||
|
||||
lines = []
|
||||
current_role = ""
|
||||
# Group by (party_role, brief) so a multi-litigant side (respondent /
|
||||
# permit_applicant) whose claims come from distinct pleadings reads as
|
||||
# SEPARATE positions — e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6" — instead
|
||||
# of one merged "טענות המשיבים" (#224). The brief label is the source
|
||||
# pleading, and which sides split is SPLIT_PARTIES — the SAME rule the
|
||||
# argument aggregator uses, so block ז and the arguments panel agree (G2).
|
||||
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
|
||||
|
||||
role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
|
||||
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
|
||||
claim_num = 0
|
||||
role_order = ["appellant", "committee", "respondent", "permit_applicant"]
|
||||
|
||||
groups: dict[tuple[str, str], list[dict]] = {}
|
||||
for c in source_claims:
|
||||
if c["party_role"] != current_role:
|
||||
current_role = c["party_role"]
|
||||
lines.append(f"\n### {role_heb.get(current_role, current_role)}")
|
||||
claim_num += 1
|
||||
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
||||
lines.append(f"\n**סה\"כ {claim_num} טענות. ענה על כל טענה מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**")
|
||||
role = c.get("party_role", "") or ""
|
||||
# Prefer the stored party_name (stamped by the extractor, #224); fall
|
||||
# back to source_document for legacy claims predating the stamping.
|
||||
if role in SPLIT_PARTIES:
|
||||
brief = (c.get("party_name") or "").strip() or (c.get("source_document") or "").strip()
|
||||
else:
|
||||
brief = ""
|
||||
groups.setdefault((role, brief), []).append(c)
|
||||
|
||||
def _sort_key(k: tuple[str, str]) -> tuple[int, str]:
|
||||
role, brief = k
|
||||
idx = role_order.index(role) if role in role_order else len(role_order)
|
||||
return (idx, brief)
|
||||
|
||||
lines: list[str] = []
|
||||
claim_num = 0
|
||||
for role, brief in sorted(groups.keys(), key=_sort_key):
|
||||
header = role_heb.get(role, role or "טענות נוספות")
|
||||
if brief:
|
||||
header = f"{header} — {brief}"
|
||||
lines.append(f"\n### {header}")
|
||||
for c in groups[(role, brief)]:
|
||||
claim_num += 1
|
||||
lines.append(f"טענה #{claim_num}: {c['claim_text'][:400]}")
|
||||
lines.append(
|
||||
f"\n**סה\"כ {claim_num} טענות. שמור על ההפרדה בין הצדדים לעיל — "
|
||||
f"לכל צד/כתב-תשובה עמדה נפרדת (אל תמזג משיבים שונים). ענה על כל טענה "
|
||||
f"מהותית; טענות [bundle] — אגד; טענות [skip] — ציון קצר בלבד.**"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ rows. Reuses the one corpus search + the one authority query + the one radar (G2
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
@@ -65,25 +66,37 @@ async def build_view(case_number: str) -> dict:
|
||||
except Exception as e: # noqa: BLE001 — radar is best-effort
|
||||
logger.warning("citation_verification radar failed for %s: %s", case_number, e)
|
||||
|
||||
out_args: list[dict] = []
|
||||
n_verified = 0
|
||||
for a in args:
|
||||
aid = str(a["id"])
|
||||
async def _fetch(a: dict) -> tuple[list[dict], dict]:
|
||||
"""Per-argument corpus search + batched authority — run concurrently.
|
||||
|
||||
Each call is one Voyage embed + one vector search (+ one batched authority
|
||||
query); independent across arguments, so they fan out rather than waterfall.
|
||||
"""
|
||||
title = (a.get("argument_title") or "").strip()
|
||||
topic = (a.get("legal_topic") or "").strip()
|
||||
query = f"{ctx} {title}. {topic}".strip()
|
||||
|
||||
hits = []
|
||||
hits: list[dict] = []
|
||||
try:
|
||||
hits = await precedent_library.search_library(
|
||||
query=query, limit=_SUGGEST_PER_ISSUE, include_halachot=True)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
||||
|
||||
# Resolve the authority breakdown for the hit set in one batched query.
|
||||
clids = [UUID(str(h["case_law_id"])) for h in hits
|
||||
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
|
||||
authority = await db.citation_authority(clids) if clids else {}
|
||||
return hits, authority
|
||||
|
||||
# Fan out the expensive per-argument retrieval concurrently — was a sequential
|
||||
# waterfall (N args × Voyage embed + vector search each). gather preserves order.
|
||||
fetched = await asyncio.gather(*(_fetch(a) for a in args)) if args else []
|
||||
|
||||
out_args: list[dict] = []
|
||||
n_verified = 0
|
||||
for a, (hits, authority) in zip(args, fetched):
|
||||
aid = str(a["id"])
|
||||
title = (a.get("argument_title") or "").strip()
|
||||
topic = (a.get("legal_topic") or "").strip()
|
||||
|
||||
seen: set[str] = set()
|
||||
supporting: list[dict] = []
|
||||
|
||||
@@ -368,8 +368,15 @@ async def extract_and_store_claims(
|
||||
|
||||
# Determine claim_type from document type and title
|
||||
claim_type = _infer_claim_type(doc_type, source_name)
|
||||
# Stamp party_name at the source (#224). For a multi-litigant side
|
||||
# (respondent / permit_applicant) the brief label IS the source pleading, so
|
||||
# opposing briefs (משיבות 2-3 vs משיבים 4-6) stay distinct downstream without
|
||||
# the aggregator/block-writer having to re-derive it. SPLIT_PARTIES is the
|
||||
# single rule shared with the aggregator (G2); single-voice sides stay ''.
|
||||
from legal_mcp.services.argument_aggregator import SPLIT_PARTIES
|
||||
for c in claims:
|
||||
c["claim_type"] = claim_type
|
||||
c["party_name"] = source_name if c.get("party_role") in SPLIT_PARTIES else ""
|
||||
|
||||
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
||||
# Mark this document analysed (WS2 / #201). store_claims already replaced
|
||||
|
||||
@@ -146,8 +146,15 @@ async def query(
|
||||
"""
|
||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||
|
||||
if len(full_prompt) > 150_000:
|
||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
||||
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
|
||||
# chars/token). Above it callers should pass the 1M-context model id
|
||||
# (`claude-opus-4-8[1m]`) — block_writer escalates automatically (#216).
|
||||
if len(full_prompt) > 350_000 and "[1m]" not in (model or ""):
|
||||
logger.warning(
|
||||
"Large prompt: %d chars on a non-[1m] model (%s) — may overflow the "
|
||||
"200K-token context. Pass claude-opus-4-8[1m] for the 1M window.",
|
||||
len(full_prompt), model or "CLI-default",
|
||||
)
|
||||
|
||||
cmd = [
|
||||
"claude", "-p",
|
||||
|
||||
@@ -1888,6 +1888,50 @@ CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
|
||||
"""
|
||||
|
||||
|
||||
# V50 (#224): per-respondent-brief separation. The aggregator groups respondent /
|
||||
# permit_applicant claims by their source pleading — each joint response brief
|
||||
# (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") is one coherent litigation
|
||||
# position, so opposing briefs no longer collapse into a single "respondent"
|
||||
# bucket. ``party_name`` carries the brief label; appellant/committee stay ''.
|
||||
SCHEMA_V50_SQL = """
|
||||
ALTER TABLE legal_arguments ADD COLUMN IF NOT EXISTS party_name TEXT NOT NULL DEFAULT '';
|
||||
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name
|
||||
ON legal_arguments(case_id, party, party_name);
|
||||
"""
|
||||
|
||||
|
||||
# V51 (#226): hearing-attendance provenance for the "מה קרה בדיון" panel. The
|
||||
# ערר-hearing protocol names who actually appeared (עוררים/משיבים + their
|
||||
# counsel) and, when stated, the presiding panel. ``_extract_header`` already
|
||||
# extracts this feed but only ``hearing_date`` had a canonical home (on cases);
|
||||
# the attendee lists were returned as provenance and dropped. This column gives
|
||||
# them a home so the panel can display them.
|
||||
#
|
||||
# This is DISTINCT from ``decisions.panel_members`` (G2, not a parallel path):
|
||||
# that column is the authoring tribunal recorded on a specific written-decision
|
||||
# version (the DOCX signature block). ``hearing_attendees`` is a snapshot of who
|
||||
# was present at the *hearing*, extracted from the protocol — hearing-event
|
||||
# provenance, not decision-authorship. Shape:
|
||||
# {"panel_members": [...], "appellants_present": [...], "respondents_present": [...]}
|
||||
SCHEMA_V51_SQL = """
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS hearing_attendees JSONB NOT NULL DEFAULT '{}';
|
||||
"""
|
||||
|
||||
|
||||
# V52 (#224): backfill claims.party_name for existing rows. The extractor now
|
||||
# stamps party_name = source_document for the multi-litigant sides at write time
|
||||
# (claims_extractor), but claims stored before that shipped have party_name=''.
|
||||
# For those, the brief label is the source pleading — same rule the aggregator
|
||||
# uses (respondent / permit_applicant split). Idempotent: only fills empties, so
|
||||
# re-running is a no-op. Single-voice sides (appellant/committee) stay ''.
|
||||
SCHEMA_V52_SQL = """
|
||||
UPDATE claims SET party_name = source_document
|
||||
WHERE party_role IN ('respondent', 'permit_applicant')
|
||||
AND COALESCE(party_name, '') = ''
|
||||
AND COALESCE(source_document, '') <> '';
|
||||
"""
|
||||
|
||||
|
||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||
@@ -1959,6 +2003,9 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(SCHEMA_V47_SQL)
|
||||
await conn.execute(SCHEMA_V48_SQL)
|
||||
await conn.execute(SCHEMA_V49_SQL)
|
||||
await conn.execute(SCHEMA_V50_SQL)
|
||||
await conn.execute(SCHEMA_V51_SQL)
|
||||
await conn.execute(SCHEMA_V52_SQL)
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -2181,7 +2228,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
|
||||
set_clauses = []
|
||||
values = []
|
||||
for i, (key, val) in enumerate(fields.items(), start=2):
|
||||
if key in ("appellants", "respondents", "tags"):
|
||||
if key in ("appellants", "respondents", "tags", "hearing_attendees"):
|
||||
val = json.dumps(val)
|
||||
set_clauses.append(f"{key} = ${i}")
|
||||
values.append(val)
|
||||
@@ -2194,7 +2241,7 @@ async def update_case(case_id: UUID, **fields) -> dict | None:
|
||||
|
||||
def _row_to_case(row: asyncpg.Record) -> dict:
|
||||
d = dict(row)
|
||||
for field in ("appellants", "respondents", "tags"):
|
||||
for field in ("appellants", "respondents", "tags", "hearing_attendees"):
|
||||
if isinstance(d.get(field), str):
|
||||
d[field] = json.loads(d[field])
|
||||
d["id"] = str(d["id"])
|
||||
|
||||
@@ -43,10 +43,16 @@ HEBREW_FONT = "David"
|
||||
|
||||
|
||||
def _mark_run_rtl(run) -> None:
|
||||
"""Force David font on all four slots, then add <w:rtl/>."""
|
||||
"""Force David font on all four slots, then add <w:rtl/>.
|
||||
|
||||
INV-EX4 note: this reinforces the template's OWN font (David, == the
|
||||
template's cs slot) to work around Word's RTL ascii-slot fallback to
|
||||
Times New Roman — it is NOT an ad-hoc font/size override. Marked
|
||||
INV-EX4-ok for the single-source guard for that reason.
|
||||
"""
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
if rPr.find(qn("w:rFonts")) is None:
|
||||
fonts = OxmlElement("w:rFonts")
|
||||
fonts = OxmlElement("w:rFonts") # INV-EX4-ok: reinforces template font David
|
||||
fonts.set(qn("w:ascii"), HEBREW_FONT)
|
||||
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
|
||||
fonts.set(qn("w:cs"), HEBREW_FONT)
|
||||
@@ -393,8 +399,15 @@ _INTERIM_BLOCK_ORDER = [
|
||||
]
|
||||
|
||||
|
||||
def _draft_filename_prefix(mode: str) -> str:
|
||||
return "טיוטת-ביניים" if mode == "interim" else "טיוטה"
|
||||
def _draft_naming(mode: str) -> tuple[str, str]:
|
||||
"""(filename prefix, version separator) per export mode.
|
||||
|
||||
interim → ``טיוטה-טענות_הצדדים_{N}.docx`` (chair-requested naming);
|
||||
final → ``טיוטה-v{N}.docx``.
|
||||
"""
|
||||
if mode == "interim":
|
||||
return "טיוטה-טענות_הצדדים", "_"
|
||||
return "טיוטה", "-v"
|
||||
|
||||
|
||||
async def export_decision(
|
||||
@@ -479,16 +492,19 @@ async def export_decision(
|
||||
if not output_path:
|
||||
export_dir = config.find_case_dir(case["case_number"]) / "exports"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
prefix = _draft_filename_prefix(mode)
|
||||
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
||||
prefix, sep = _draft_naming(mode)
|
||||
existing = sorted(export_dir.glob(f"{prefix}{sep}*.docx"))
|
||||
next_ver = 1
|
||||
for p in existing:
|
||||
try:
|
||||
ver = int(p.stem.split("-v")[1])
|
||||
# Version is the trailing integer after the separator. Using
|
||||
# rsplit keeps this correct even when the prefix itself contains
|
||||
# the separator char (e.g. "טיוטה-טענות_הצדדים" with sep="_").
|
||||
ver = int(p.stem.rsplit(sep, 1)[1])
|
||||
next_ver = max(next_ver, ver + 1)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
output_path = str(export_dir / f"{prefix}-v{next_ver}.docx")
|
||||
output_path = str(export_dir / f"{prefix}{sep}{next_ver}.docx")
|
||||
|
||||
# Persist through the storage layer (INV-STG1). Under the filesystem
|
||||
# backend the bytes land at output_path exactly as before; a caller-
|
||||
|
||||
@@ -199,27 +199,86 @@ def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
|
||||
return None
|
||||
|
||||
|
||||
# ── Template style resolution (INV-EX4) ────────────────────────────
|
||||
# Every inserted paragraph carries a <w:pStyle> pointing at one of the
|
||||
# template's named styles — font (David), size, and indent come from
|
||||
# styles.xml, NOT from hand-set rFonts/sz here. Single template = single
|
||||
# style source (INV-EX4 / G2). docx_exporter.py applies the same styles by
|
||||
# name via python-docx; here (raw lxml) we resolve name → styleId.
|
||||
|
||||
# StyleType → candidate style names in the template (case-insensitive).
|
||||
# Mirrors the names docx_exporter uses (paragraph.style = "Heading 2"/"Normal"/"Quote").
|
||||
_STYLE_NAME_CANDIDATES: dict[str, tuple[str, ...]] = {
|
||||
"body": ("Normal", "Default Paragraph Font", "Body Text"),
|
||||
"bold": ("Normal", "Body Text"),
|
||||
"heading": ("Heading 2", "heading 2"),
|
||||
"quote": ("Quote", "quote", "Intense Quote"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_style_ids(members: dict[str, bytes]) -> dict[str, str]:
|
||||
"""Map each StyleType → the template's w:styleId, read from styles.xml.
|
||||
|
||||
Returns {} when styles.xml is absent — callers then fall back to
|
||||
minimal inline emphasis (so a missing style never crashes rendering).
|
||||
"""
|
||||
raw = members.get("word/styles.xml")
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
styles_tree = etree.fromstring(raw)
|
||||
except etree.XMLSyntaxError: # pragma: no cover - corrupt template
|
||||
return {}
|
||||
# Build a lowercase lookup over both the style's display name and its id.
|
||||
by_key: dict[str, str] = {}
|
||||
for style in styles_tree.iterfind(".//w:style", NSMAP):
|
||||
sid = style.get(_w("styleId"))
|
||||
if not sid:
|
||||
continue
|
||||
by_key.setdefault(sid.lower(), sid)
|
||||
name_el = style.find(_w("name"))
|
||||
if name_el is not None:
|
||||
nm = name_el.get(_w("val"))
|
||||
if nm:
|
||||
by_key.setdefault(nm.lower(), sid)
|
||||
resolved: dict[str, str] = {}
|
||||
for style_type, candidates in _STYLE_NAME_CANDIDATES.items():
|
||||
for cand in candidates:
|
||||
sid = by_key.get(cand.lower())
|
||||
if sid is not None:
|
||||
resolved[style_type] = sid
|
||||
break
|
||||
return resolved
|
||||
|
||||
|
||||
def _emphasis_fallback(style: StyleType, *, has_pstyle: bool) -> tuple[bool, bool]:
|
||||
"""(bold, italic) for a run. When the paragraph carries a resolved
|
||||
template pStyle, emphasis comes from the style (return no inline override),
|
||||
except 'bold' which is inline emphasis with no dedicated style. When no
|
||||
pStyle resolved, fall back to minimal inline emphasis so heading/quote
|
||||
stay visually distinct."""
|
||||
if style == "bold":
|
||||
return True, False
|
||||
if has_pstyle:
|
||||
return False, False
|
||||
return (style == "heading"), (style == "quote")
|
||||
|
||||
|
||||
# ── Paragraph builders ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
||||
font: str = "David", size_half_pt: int | None = None) -> etree._Element:
|
||||
"""Build a w:r (run) element with RTL/David defaults and given text."""
|
||||
def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree._Element:
|
||||
"""Build a w:r (run): RTL marker + optional bold/italic + text.
|
||||
|
||||
Font (David) and size are NOT set here — they derive from the paragraph's
|
||||
template style (INV-EX4). The <w:rtl/> marker is still required so Word
|
||||
picks the style's complex-script (cs=David) slot rather than the ascii
|
||||
(Times New Roman) slot for Hebrew (same reason as _mark_run_rtl in
|
||||
analysis_docx_exporter.py).
|
||||
"""
|
||||
r = etree.Element(_w("r"))
|
||||
rPr = etree.SubElement(r, _w("rPr"))
|
||||
|
||||
rFonts = etree.SubElement(rPr, _w("rFonts"))
|
||||
rFonts.set(_w("ascii"), font)
|
||||
rFonts.set(_w("hAnsi"), font)
|
||||
rFonts.set(_w("cs"), font)
|
||||
rFonts.set(_w("hint"), "cs")
|
||||
|
||||
if size_half_pt is not None:
|
||||
sz = etree.SubElement(rPr, _w("sz"))
|
||||
sz.set(_w("val"), str(size_half_pt))
|
||||
szCs = etree.SubElement(rPr, _w("szCs"))
|
||||
szCs.set(_w("val"), str(size_half_pt))
|
||||
|
||||
if bold:
|
||||
etree.SubElement(rPr, _w("b"))
|
||||
etree.SubElement(rPr, _w("bCs"))
|
||||
@@ -235,28 +294,36 @@ def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
||||
return r
|
||||
|
||||
|
||||
def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
|
||||
"""Build a w:p (paragraph) with RTL + David + given text."""
|
||||
def _build_paragraph(text: str, *, style: StyleType = "body",
|
||||
style_id: str | None = None) -> etree._Element:
|
||||
"""Build a w:p (paragraph) carrying the template's named style.
|
||||
|
||||
The paragraph references `style_id` (resolved from the template's
|
||||
styles.xml) via <w:pStyle> — font/size/indent come from there, not from
|
||||
hand-set rFonts/sz (INV-EX4). The only explicit paragraph-level override
|
||||
is the canonical body alignment jc=both, matching docx_exporter's
|
||||
_set_paragraph_jc (which forces both over style-center headings).
|
||||
"""
|
||||
p = etree.Element(_w("p"))
|
||||
pPr = etree.SubElement(p, _w("pPr"))
|
||||
|
||||
if style_id:
|
||||
pStyle = etree.SubElement(pPr, _w("pStyle"))
|
||||
pStyle.set(_w("val"), style_id)
|
||||
|
||||
bidi = etree.SubElement(pPr, _w("bidi"))
|
||||
bidi.set(_w("val"), "1")
|
||||
|
||||
# Right alignment for body/RTL
|
||||
# Canonical body alignment (two-sided justification) — overrides any
|
||||
# style-center, exactly as docx_exporter._set_paragraph_jc does.
|
||||
jc = etree.SubElement(pPr, _w("jc"))
|
||||
jc.set(_w("val"), "right")
|
||||
jc.set(_w("val"), "both")
|
||||
|
||||
rPr_p = etree.SubElement(pPr, _w("rPr"))
|
||||
etree.SubElement(rPr_p, _w("rtl"))
|
||||
|
||||
bold = style in ("heading", "bold")
|
||||
italic = style == "quote"
|
||||
size = None
|
||||
if style == "heading":
|
||||
size = 28 # 14pt
|
||||
elif style == "quote":
|
||||
size = 22 # 11pt
|
||||
run = _build_run(text, bold=bold, italic=italic, size_half_pt=size)
|
||||
bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
|
||||
run = _build_run(text, bold=bold, italic=italic)
|
||||
p.append(run)
|
||||
return p
|
||||
|
||||
@@ -275,7 +342,7 @@ def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
|
||||
|
||||
def _make_tracked_paragraph_insert(
|
||||
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
|
||||
mark_id: int | None = None,
|
||||
mark_id: int | None = None, style_id: str | None = None,
|
||||
) -> etree._Element:
|
||||
"""Build a whole tracked-inserted paragraph.
|
||||
|
||||
@@ -286,7 +353,7 @@ def _make_tracked_paragraph_insert(
|
||||
"""
|
||||
if mark_id is None:
|
||||
mark_id = ins_id
|
||||
p = _build_paragraph(text, style=style)
|
||||
p = _build_paragraph(text, style=style, style_id=style_id)
|
||||
pPr = p.find(_w("pPr"))
|
||||
assert pPr is not None
|
||||
rPr = pPr.find(_w("rPr"))
|
||||
@@ -339,6 +406,7 @@ def _apply_insert(
|
||||
ins_id: int,
|
||||
author: str,
|
||||
date_iso: str,
|
||||
style_ids: dict[str, str] | None = None,
|
||||
) -> RevisionResult:
|
||||
"""Apply insert_after / insert_before relative to a bookmark."""
|
||||
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
||||
@@ -362,6 +430,7 @@ def _apply_insert(
|
||||
revision.content, style=revision.style,
|
||||
ins_id=ins_id, mark_id=ins_id + 1,
|
||||
author=author, date_iso=date_iso,
|
||||
style_id=(style_ids or {}).get(revision.style),
|
||||
)
|
||||
|
||||
parent = enclosing_p.getparent()
|
||||
@@ -407,6 +476,7 @@ def _apply_replace(
|
||||
del_id: int,
|
||||
author: str,
|
||||
date_iso: str,
|
||||
style_ids: dict[str, str] | None = None,
|
||||
) -> RevisionResult:
|
||||
"""Replace = delete the existing paragraph + insert new one after it."""
|
||||
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
||||
@@ -428,6 +498,7 @@ def _apply_replace(
|
||||
revision.content, style=revision.style,
|
||||
ins_id=ins_id, mark_id=ins_id + 1,
|
||||
author=author, date_iso=date_iso,
|
||||
style_id=(style_ids or {}).get(revision.style),
|
||||
)
|
||||
idx = list(parent).index(enclosing_p)
|
||||
parent.insert(idx + 1, new_p)
|
||||
@@ -473,6 +544,10 @@ def apply_tracked_revisions(
|
||||
members, doc_tree, settings_tree = _load_docx_xml(source_path)
|
||||
_ensure_track_revisions(settings_tree)
|
||||
|
||||
# Resolve template style ids once (INV-EX4) — inserted paragraphs inherit
|
||||
# font/size/indent from these named styles instead of hand-set rFonts/sz.
|
||||
style_ids = _resolve_style_ids(members)
|
||||
|
||||
next_id = _next_revision_id(doc_tree)
|
||||
|
||||
batch = RevisionBatchResult()
|
||||
@@ -480,7 +555,8 @@ def apply_tracked_revisions(
|
||||
try:
|
||||
if rev.type in ("insert_after", "insert_before"):
|
||||
result = _apply_insert(doc_tree, rev, ins_id=next_id,
|
||||
author=author, date_iso=date_iso)
|
||||
author=author, date_iso=date_iso,
|
||||
style_ids=style_ids)
|
||||
# insert consumes 2 IDs: run-wrapper + paragraph-mark
|
||||
next_id += 2
|
||||
elif rev.type == "delete":
|
||||
@@ -490,7 +566,8 @@ def apply_tracked_revisions(
|
||||
elif rev.type == "replace":
|
||||
result = _apply_replace(doc_tree, rev,
|
||||
ins_id=next_id, del_id=next_id + 2,
|
||||
author=author, date_iso=date_iso)
|
||||
author=author, date_iso=date_iso,
|
||||
style_ids=style_ids)
|
||||
# replace consumes 3 IDs: ins-run, ins-mark, del
|
||||
next_id += 3
|
||||
else:
|
||||
|
||||
@@ -120,15 +120,47 @@ HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול די
|
||||
MAX_PROTOCOL_CHARS = 120_000
|
||||
|
||||
|
||||
def _find_protocol(docs: list[dict]) -> dict | None:
|
||||
"""The protocol document for the case, if present (doc_type or title)."""
|
||||
for d in docs:
|
||||
if d.get("doc_type") == "protocol":
|
||||
return d
|
||||
for d in docs:
|
||||
if "פרוטוקול" in (d.get("title") or ""):
|
||||
return d
|
||||
return None
|
||||
def _protocol_scope(doc: dict) -> str:
|
||||
"""Protocol scope from ``metadata.protocol_scope``.
|
||||
|
||||
Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
|
||||
document-type-editor convention where only the non-default ``'lower'``
|
||||
(ועדה מקומית/מחוזית) is persisted.
|
||||
"""
|
||||
meta = doc.get("metadata") or {}
|
||||
return (meta.get("protocol_scope") or "").strip() or "appeal"
|
||||
|
||||
|
||||
def _find_protocol(
|
||||
docs: list[dict], document_id: UUID | None = None,
|
||||
) -> dict | None:
|
||||
"""The ועדת-הערר hearing protocol to compare against the pleadings.
|
||||
|
||||
- ``document_id`` given → return exactly that document (explicit target),
|
||||
so a case with several protocols can be pointed at the right hearing.
|
||||
- Otherwise prefer a protocol scoped to the appeals committee
|
||||
(``scope != 'lower'``). A ``'lower'`` protocol is the local/district
|
||||
committee's proceedings — it feeds background (block ו) only and must NOT
|
||||
drive the hearing-vs-pleadings comparison (#223). If every protocol is
|
||||
lower-scoped there is no ערר-hearing to compare → return None.
|
||||
- Among appeal-scoped protocols the most recent (by ``created_at``) wins —
|
||||
the operative hearing; ties fall back to list order.
|
||||
"""
|
||||
if document_id is not None:
|
||||
return next(
|
||||
(d for d in docs if str(d.get("id")) == str(document_id)), None,
|
||||
)
|
||||
|
||||
protocols = [d for d in docs if d.get("doc_type") == "protocol"]
|
||||
if not protocols:
|
||||
# Legacy fallback: untyped docs whose title says "פרוטוקול".
|
||||
protocols = [d for d in docs if "פרוטוקול" in (d.get("title") or "")]
|
||||
|
||||
appeal_scoped = [d for d in protocols if _protocol_scope(d) != "lower"]
|
||||
if not appeal_scoped:
|
||||
return None
|
||||
appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True)
|
||||
return appeal_scoped[0]
|
||||
|
||||
|
||||
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
||||
@@ -227,21 +259,39 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
|
||||
except ValueError:
|
||||
logger.info("protocol header: unparseable hearing_date %r", hearing_date)
|
||||
|
||||
# Attendees → cases.hearing_attendees (#226). Unlike hearing_date there is no
|
||||
# chair-entry path to protect, and re-running to point at the correct ערר
|
||||
# protocol (#223) must refresh who appeared — so write whenever the operative
|
||||
# protocol yielded any names, but never clobber good data with an empty
|
||||
# extraction (all-empty feed → leave the prior snapshot intact).
|
||||
attendees = {
|
||||
"panel_members": feed.get("panel_members") or [],
|
||||
"appellants_present": feed.get("appellants_present") or [],
|
||||
"respondents_present": feed.get("respondents_present") or [],
|
||||
}
|
||||
if any(attendees.values()):
|
||||
await db.update_case(case_id, hearing_attendees=attendees)
|
||||
applied["hearing_attendees"] = True
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"feed": {
|
||||
"hearing_date": hearing_date,
|
||||
"panel_members": feed.get("panel_members") or [],
|
||||
"appellants_present": feed.get("appellants_present") or [],
|
||||
"respondents_present": feed.get("respondents_present") or [],
|
||||
**attendees,
|
||||
},
|
||||
"applied_to_case": applied,
|
||||
}
|
||||
|
||||
|
||||
async def analyze_protocol(case_id: UUID) -> dict:
|
||||
async def analyze_protocol(
|
||||
case_id: UUID, target_document_id: UUID | None = None,
|
||||
) -> dict:
|
||||
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
|
||||
|
||||
``target_document_id`` pins the analysis to a specific protocol document —
|
||||
required when a case holds several protocols (e.g. a lower-committee annex
|
||||
plus the ועדת-הערר hearing) and the auto-pick would be ambiguous (#223).
|
||||
|
||||
1. Locates the protocol document.
|
||||
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
|
||||
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
|
||||
@@ -252,11 +302,20 @@ async def analyze_protocol(case_id: UUID) -> dict:
|
||||
Returns a serializable summary dict.
|
||||
"""
|
||||
docs = await db.list_documents(case_id)
|
||||
protocol = _find_protocol(docs)
|
||||
protocol = _find_protocol(docs, document_id=target_document_id)
|
||||
if not protocol:
|
||||
if target_document_id is not None:
|
||||
return {
|
||||
"status": "no_protocol",
|
||||
"message": f"מסמך {target_document_id} לא נמצא בתיק.",
|
||||
}
|
||||
return {
|
||||
"status": "no_protocol",
|
||||
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.",
|
||||
"message": (
|
||||
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
|
||||
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
|
||||
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
|
||||
),
|
||||
}
|
||||
|
||||
document_id = UUID(protocol["id"])
|
||||
|
||||
@@ -15,8 +15,14 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Any
|
||||
|
||||
# Display timezone (INV-UI9): timestamps surfaced to humans are rendered in
|
||||
# Israel time deterministically, regardless of the process clock (the container
|
||||
# may run UTC or Asia/Jerusalem). Storage stays UTC; this is display-only.
|
||||
IL_TZ = ZoneInfo("Asia/Jerusalem")
|
||||
|
||||
# Placeholder strings — any of these means "not yet filled"
|
||||
CHAIR_POSITION_PLACEHOLDERS = (
|
||||
"[ימולא ע\"י יו\"ר הוועדה]",
|
||||
@@ -191,7 +197,7 @@ def parse(file_path: Path) -> dict[str, Any]:
|
||||
date_str = date_match.group(1) if date_match else ""
|
||||
|
||||
stat = file_path.stat()
|
||||
mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
mtime_iso = datetime.fromtimestamp(stat.st_mtime, tz=IL_TZ).isoformat()
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"header": {
|
||||
@@ -354,7 +360,7 @@ def update_chair_position(
|
||||
"saved": True,
|
||||
"section_id": section_id,
|
||||
"preview": preview,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"timestamp": datetime.now(IL_TZ).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from uuid import UUID
|
||||
import httpx
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.case_status_model import STATUS_ORDER # status SSoT
|
||||
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||
|
||||
@@ -341,16 +342,8 @@ async def case_update(
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
|
||||
# Ordered core lifecycle — regression protection (forward-only).
|
||||
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and
|
||||
# models.CaseStatus. Trimmed from 17 → 10 (decorative statuses removed).
|
||||
STATUS_ORDER = [
|
||||
"new", "processing", "documents_ready",
|
||||
"outcome_set", "direction_approved",
|
||||
"qa_review", "drafted",
|
||||
"exported", "reviewed", "final",
|
||||
]
|
||||
|
||||
# Ordered core lifecycle (forward-only regression guard). Single source of
|
||||
# truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return err(f"תיק {case_number} לא נמצא.")
|
||||
@@ -363,6 +356,9 @@ async def case_update(
|
||||
# Only update if advancing or status is unknown to the order
|
||||
if new_idx >= cur_idx or new_idx == -1:
|
||||
fields["status"] = status
|
||||
# per-status on_enter hook (case_status_model) — the single place a
|
||||
# status's behaviour is declared. No dispatcher wired yet; when the
|
||||
# first action is added, dispatch BY_KEY[status].on_enter here.
|
||||
if title:
|
||||
fields["title"] = title
|
||||
if subject:
|
||||
|
||||
@@ -433,17 +433,24 @@ ALLOWED_DOC_TYPES = {
|
||||
# Allowed appraiser_side values; '' (empty) clears the tag.
|
||||
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
||||
|
||||
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
|
||||
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
|
||||
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||
|
||||
|
||||
async def document_update(
|
||||
case_number: str,
|
||||
doc_id: str,
|
||||
doc_type: str = "",
|
||||
appraiser_side: str = "",
|
||||
is_post_hearing: bool | None = None,
|
||||
protocol_scope: str = "",
|
||||
) -> str:
|
||||
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
|
||||
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
|
||||
|
||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
|
||||
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
|
||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
|
||||
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
|
||||
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר (לאישור שייכות)
|
||||
@@ -452,6 +459,9 @@ async def document_update(
|
||||
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
||||
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
||||
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
||||
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
|
||||
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
|
||||
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
@@ -477,17 +487,37 @@ async def document_update(
|
||||
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
||||
updates["doc_type"] = doc_type
|
||||
|
||||
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
||||
# "set to empty string", so we use the convention: only update if non-empty.
|
||||
# To clear, the operator must edit metadata directly (rare).
|
||||
# appraiser_side / protocol_scope are optional. The MCP tool can't
|
||||
# distinguish "skip" from "set to empty string", so we use the convention:
|
||||
# only update if non-empty. To clear, edit metadata directly (rare).
|
||||
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
|
||||
metadata = doc.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
metadata_dirty = False
|
||||
|
||||
if appraiser_side:
|
||||
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
||||
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
||||
metadata = doc.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
metadata["appraiser_side"] = appraiser_side
|
||||
metadata_dirty = True
|
||||
|
||||
if is_post_hearing is not None:
|
||||
if is_post_hearing:
|
||||
metadata["is_post_hearing"] = True
|
||||
else:
|
||||
metadata.pop("is_post_hearing", None)
|
||||
metadata_dirty = True
|
||||
|
||||
if protocol_scope:
|
||||
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
|
||||
return err(f"protocol_scope לא תקין: {protocol_scope}",
|
||||
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
|
||||
metadata["protocol_scope"] = protocol_scope
|
||||
metadata_dirty = True
|
||||
|
||||
if metadata_dirty:
|
||||
updates["metadata"] = metadata
|
||||
|
||||
if not updates:
|
||||
|
||||
@@ -580,7 +580,7 @@ async def get_appraiser_facts(case_number: str) -> str:
|
||||
return err(str(e))
|
||||
|
||||
|
||||
async def analyze_protocol(case_number: str) -> str:
|
||||
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
||||
|
||||
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
||||
@@ -588,12 +588,17 @@ async def analyze_protocol(case_number: str) -> str:
|
||||
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
||||
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
||||
|
||||
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
|
||||
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
|
||||
ציין document_id כדי לכוון לדיון המדויק (#223).
|
||||
|
||||
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
||||
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
||||
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
|
||||
"""
|
||||
from legal_mcp.services import protocol_analyzer
|
||||
|
||||
@@ -601,8 +606,16 @@ async def analyze_protocol(case_number: str) -> str:
|
||||
if not case:
|
||||
return err(f"תיק {case_number} לא נמצא.")
|
||||
case_id = UUID(case["id"])
|
||||
target_doc_id: UUID | None = None
|
||||
if document_id.strip():
|
||||
try:
|
||||
target_doc_id = UUID(document_id.strip())
|
||||
except ValueError:
|
||||
return err(f"document_id לא תקין: {document_id}")
|
||||
try:
|
||||
result = await protocol_analyzer.analyze_protocol(case_id)
|
||||
result = await protocol_analyzer.analyze_protocol(
|
||||
case_id, target_document_id=target_doc_id,
|
||||
)
|
||||
await audit.log_action_safe(
|
||||
"analyze_protocol", case_id=case_id,
|
||||
details={"status": result.get("status"), "total": result.get("total", 0)},
|
||||
@@ -703,7 +716,7 @@ async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
||||
async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
||||
"""ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL,
|
||||
bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא
|
||||
דיון/סיכום/חתימות. שם הקובץ: טיוטת-ביניים-v{N}.docx.
|
||||
דיון/סיכום/חתימות. שם הקובץ: טיוטה-טענות_הצדדים_{N}.docx.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
|
||||
@@ -130,6 +130,16 @@ def _find_ins_with_runs(tree: etree._Element) -> etree._Element | None:
|
||||
return None
|
||||
|
||||
|
||||
def _find_enclosing_p(element: etree._Element) -> etree._Element | None:
|
||||
"""Walk up to the enclosing <w:p>."""
|
||||
cur = element
|
||||
while cur is not None:
|
||||
if cur.tag == _w("p"):
|
||||
return cur
|
||||
cur = cur.getparent()
|
||||
return None
|
||||
|
||||
|
||||
def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path) -> None:
|
||||
out = tmp_path / "out.docx"
|
||||
rev = Revision(id="r1", type="insert_after",
|
||||
@@ -147,7 +157,10 @@ def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path)
|
||||
assert date_str.endswith("Z") # ISO 8601 UTC
|
||||
|
||||
|
||||
def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> None:
|
||||
def test_insert_after_uses_rtl_and_template_style(sample_docx: Path, tmp_path: Path) -> None:
|
||||
"""INV-EX4: an inserted paragraph carries the template's named style via
|
||||
<w:pStyle> and an <w:rtl/> run marker — but does NOT hand-set rFonts/sz
|
||||
(font/size come from the style). jc is the canonical body 'both'."""
|
||||
out = tmp_path / "out.docx"
|
||||
rev = Revision(id="r1", type="insert_after",
|
||||
anchor_bookmark="block-alef", content="מוסף")
|
||||
@@ -162,10 +175,22 @@ def test_insert_after_uses_rtl_and_david(sample_docx: Path, tmp_path: Path) -> N
|
||||
assert run is not None
|
||||
rPr = run.find(_w("rPr"))
|
||||
assert rPr is not None
|
||||
# RTL marker preserved (so Word picks the style's cs=David slot)…
|
||||
assert rPr.find(_w("rtl")) is not None
|
||||
rFonts = rPr.find(_w("rFonts"))
|
||||
assert rFonts is not None
|
||||
assert rFonts.get(_w("ascii")) == "David"
|
||||
# …but NO hand-set font/size on the run (INV-EX4).
|
||||
assert rPr.find(_w("rFonts")) is None
|
||||
assert rPr.find(_w("sz")) is None
|
||||
assert rPr.find(_w("szCs")) is None
|
||||
|
||||
# The enclosing paragraph references a template style + canonical jc=both.
|
||||
para = _find_enclosing_p(ins)
|
||||
assert para is not None
|
||||
pPr = para.find(_w("pPr"))
|
||||
assert pPr is not None
|
||||
pStyle = pPr.find(_w("pStyle"))
|
||||
assert pStyle is not None and pStyle.get(_w("val")) # Normal → real styleId
|
||||
jc = pPr.find(_w("jc"))
|
||||
assert jc is not None and jc.get(_w("val")) == "both"
|
||||
|
||||
|
||||
# ── apply_tracked_revisions: insert_before ────────────────────────
|
||||
|
||||
76
mcp-server/tests/test_docx_template_single_source.py
Normal file
76
mcp-server/tests/test_docx_template_single_source.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""שומר INV-EX4 — מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה.
|
||||
|
||||
כל מפיק של מסמך-Word הקשור-להחלטה חייב להחיל עיצוב דרך הסגנונות שמוגדרים
|
||||
בטמפלט היחיד (skills/docx/decision_template.docx), ולא לקבוע פונט/גודל ידנית
|
||||
(``rFonts`` / ``sz`` / ``szCs``) — אלה נגזרים מ-styles.xml של הטמפלט.
|
||||
|
||||
הטסט גורף את מודולי-המפיקים ונכשל אם נבנה אלמנט ``w:rFonts`` / ``w:sz`` /
|
||||
``w:szCs`` בקוד, אלא אם השורה נושאת את הסמן המפורש ``# INV-EX4-ok`` (חריג
|
||||
מתועד — למשל חיזוק גופן-התבנית עצמו David כעקיפת באג-RTL של Word).
|
||||
|
||||
ספ: docs/spec/06-export.md §INV-EX4.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from legal_mcp.services import (
|
||||
analysis_docx_exporter,
|
||||
docx_exporter,
|
||||
docx_reviser,
|
||||
docx_retrofit,
|
||||
)
|
||||
|
||||
# Every module that builds/edits a decision-related Word document.
|
||||
_PRODUCER_MODULES = [
|
||||
docx_exporter,
|
||||
analysis_docx_exporter,
|
||||
docx_reviser,
|
||||
docx_retrofit,
|
||||
]
|
||||
|
||||
# Construction (not lookup) of a hand-set font/size element:
|
||||
# OxmlElement("w:rFonts") (python-docx producers)
|
||||
# etree.SubElement(rPr, _w("sz")) (raw-lxml producer: docx_reviser)
|
||||
_HANDSET_RE = re.compile(
|
||||
r'OxmlElement\(\s*["\']w:(?:rFonts|sz|szCs)["\']'
|
||||
r'|SubElement\([^)]*_w\(\s*["\'](?:rFonts|sz|szCs)["\']'
|
||||
)
|
||||
|
||||
# Producers that build a document FROM SCRATCH must load the single template.
|
||||
# (docx_reviser/docx_retrofit operate on an already-templated existing doc.)
|
||||
_FROM_SCRATCH_MODULES = [docx_exporter, analysis_docx_exporter]
|
||||
|
||||
|
||||
def _module_path(module) -> Path:
|
||||
return Path(module.__file__)
|
||||
|
||||
|
||||
def test_no_handset_font_or_size_in_producers() -> None:
|
||||
"""INV-EX4: no hand-set rFonts/sz/szCs in any producer (only via the
|
||||
template's named styles), unless explicitly marked ``# INV-EX4-ok``."""
|
||||
violations: list[str] = []
|
||||
for module in _PRODUCER_MODULES:
|
||||
path = _module_path(module)
|
||||
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if _HANDSET_RE.search(line) and "INV-EX4-ok" not in line:
|
||||
violations.append(f"{path.name}:{lineno}: {line.strip()}")
|
||||
assert not violations, (
|
||||
"INV-EX4 violation — hand-set font/size outside the template styles "
|
||||
"(use a named template style, or mark with # INV-EX4-ok):\n "
|
||||
+ "\n ".join(violations)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", _FROM_SCRATCH_MODULES)
|
||||
def test_from_scratch_producers_load_the_single_template(module) -> None:
|
||||
"""INV-EX4: from-scratch producers reference the one template file."""
|
||||
source = _module_path(module).read_text(encoding="utf-8")
|
||||
assert "decision_template.docx" in source, (
|
||||
f"{_module_path(module).name} must load the single template "
|
||||
"skills/docx/decision_template.docx (INV-EX4)."
|
||||
)
|
||||
@@ -49,6 +49,65 @@ def test_find_protocol_none():
|
||||
assert pa._find_protocol(docs) is None
|
||||
|
||||
|
||||
def test_find_protocol_skips_lower_scope():
|
||||
# A local/district-committee protocol (scope='lower') must NOT be picked for
|
||||
# the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223).
|
||||
docs = [
|
||||
{"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית",
|
||||
"metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"},
|
||||
{"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26",
|
||||
"metadata": {}, "created_at": "2026-06-23"},
|
||||
]
|
||||
assert pa._find_protocol(docs)["id"] == "appeal"
|
||||
|
||||
|
||||
def test_find_protocol_all_lower_returns_none():
|
||||
# If every protocol is lower-scoped there is no ערר-hearing to compare.
|
||||
docs = [
|
||||
{"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי",
|
||||
"metadata": {"protocol_scope": "lower"}},
|
||||
]
|
||||
assert pa._find_protocol(docs) is None
|
||||
|
||||
|
||||
def test_find_protocol_prefers_most_recent_appeal():
|
||||
docs = [
|
||||
{"id": "old", "doc_type": "protocol", "title": "פרוטוקול א",
|
||||
"metadata": {}, "created_at": "2026-03-01"},
|
||||
{"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב",
|
||||
"metadata": {}, "created_at": "2026-06-23"},
|
||||
]
|
||||
assert pa._find_protocol(docs)["id"] == "new"
|
||||
|
||||
|
||||
def test_find_protocol_explicit_document_id_overrides():
|
||||
# Explicit target wins even over scope/recency heuristics — including the
|
||||
# ability to point at a lower-scoped doc if the caller insists.
|
||||
docs = [
|
||||
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
|
||||
"title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"},
|
||||
{"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol",
|
||||
"title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"},
|
||||
"created_at": "2026-01-01"},
|
||||
]
|
||||
from uuid import UUID
|
||||
picked = pa._find_protocol(
|
||||
docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"),
|
||||
)
|
||||
assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002"
|
||||
|
||||
|
||||
def test_find_protocol_unknown_document_id_returns_none():
|
||||
from uuid import UUID
|
||||
docs = [
|
||||
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
|
||||
"title": "פרוטוקול", "metadata": {}},
|
||||
]
|
||||
assert pa._find_protocol(
|
||||
docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"),
|
||||
) is None
|
||||
|
||||
|
||||
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
|
||||
|
||||
_AID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
@@ -207,8 +207,9 @@ def test_multiple_revisions_all_tracked_independently(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
|
||||
"""Inserted paragraph must have bidi + rtl + David font so it renders
|
||||
correctly in Word alongside the user's content."""
|
||||
"""Inserted paragraph must have bidi + an <w:rtl/> run marker so Hebrew
|
||||
renders via the template's cs=David slot — but font/size come from the
|
||||
template's named paragraph style (INV-EX4), not hand-set rFonts/sz."""
|
||||
user_file = tmp_path / "user.docx"
|
||||
_make_user_edited_docx(user_file)
|
||||
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
||||
@@ -229,9 +230,10 @@ def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
|
||||
if any("עברית RTL" in (t.text or "") for t in text_els):
|
||||
rPr = r.find(_w("rPr"))
|
||||
assert rPr is not None
|
||||
# RTL marker preserved…
|
||||
assert rPr.find(_w("rtl")) is not None
|
||||
rFonts = rPr.find(_w("rFonts"))
|
||||
assert rFonts is not None
|
||||
assert rFonts.get(_w("ascii")) == "David"
|
||||
# …but NO hand-set font/size on the run (INV-EX4).
|
||||
assert rPr.find(_w("rFonts")) is None
|
||||
assert rPr.find(_w("sz")) is None
|
||||
return
|
||||
pytest.fail("tracked insertion with 'עברית RTL' not found")
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
| `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_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}`. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--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, צורך-מכסה) |
|
||||
| `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 של מטא-דאטה |
|
||||
| `backfill_style_exemplars.py` | python | **T1 (style-acquisition)** — מאכלס `style_exemplars` מקורפוס דפנה (`style_corpus` + `internal_committee` chair=דפנה): מפצל לסעיפים (`chunker._split_into_sections`) → פסקאות (25-450 מילים) → embed (Voyage) → שמירה עם `section`/`outcome`/`practice_area`. מאפשר לכותב לאחזר פסקאות-בלוק אמיתיות של דפנה (T2/T3). מקור-סגנון בלבד (INV-LRN5). אידמפוטנטי (מנקה per-decision). `--dry-run` (default) / `--apply`. דורש POSTGRES_URL + Voyage. **רץ מקומית** (venv). | ידני (`python scripts/backfill_style_exemplars.py --apply`) |
|
||||
@@ -174,6 +175,7 @@
|
||||
|
||||
| Script | Original Purpose | Superseded By |
|
||||
|--------|-----------------|---------------|
|
||||
| `exec_summary_1043.py` | חד-פעמי — רינדור DOCX של סיכום-מנהלים לתיק 1043-02-26 דרך עוזרי-התבנית של `analysis_docx_exporter` (פרוטוטיפ). | `analysis_docx_exporter.build_party_claims_summary_docx()` + endpoint `GET /api/cases/{n}/research/party-claims-summary/export-docx` (WS3 #202; תבניתי, INV-EX4) |
|
||||
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
|
||||
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
|
||||
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
|
||||
|
||||
@@ -12,8 +12,6 @@ Mapping (removed → kept):
|
||||
|
||||
uploading → processing
|
||||
in_progress → outcome_set
|
||||
analyst_verified → documents_ready
|
||||
research_complete → documents_ready
|
||||
brainstorming → outcome_set
|
||||
analysis_enriched → direction_approved
|
||||
ready_for_writing → direction_approved
|
||||
@@ -50,12 +48,14 @@ from legal_mcp.services import db # noqa: E402
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
log = logging.getLogger("status-trim")
|
||||
|
||||
# removed status → nearest preceding kept status
|
||||
# removed status → nearest preceding kept status.
|
||||
# NOTE (2026-06-30): analyst_verified + research_complete were RE-CANONICALISED
|
||||
# into the status model (legal_mcp/case_status_model.py, "thinking" phase) — they
|
||||
# are valid statuses again, so they were removed from this map. Do NOT downgrade
|
||||
# them; only genuinely-removed markers remain below.
|
||||
STATUS_MAP = {
|
||||
"uploading": "processing",
|
||||
"in_progress": "outcome_set",
|
||||
"analyst_verified": "documents_ready",
|
||||
"research_complete": "documents_ready",
|
||||
"brainstorming": "outcome_set",
|
||||
"analysis_enriched": "direction_approved",
|
||||
"ready_for_writing": "direction_approved",
|
||||
|
||||
646
scripts/calibrate_block_yod.py
Normal file
646
scripts/calibrate_block_yod.py
Normal file
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""#212 (WS5 / Q1, INV-G8 eval-harness) — DEDICATED effort calibration for בלוק י'
|
||||
(block-yod, the discussion/decision block — דיון והכרעה).
|
||||
|
||||
WHY A SEPARATE HARNESS (the #208 harness could NOT calibrate block-yod):
|
||||
block-yod is the most important block — its effort is currently `xhigh`, set
|
||||
"by feel" (block_writer.py:72), never empirically calibrated. The general #208
|
||||
harness (scripts/calibrate_effort.py) EXCLUDES it from CALIBRATABLE_BLOCKS
|
||||
because block_writer.write_block(case_id, "block-yod", …) raises
|
||||
ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר…")
|
||||
— the discussion block requires an APPROVED DIRECTION (brainstorm →
|
||||
approve_direction → decisions.direction_doc{"approved": True, …}) that the
|
||||
calibration cases (the draft_final_pairs finals) do not have. So block-yod is
|
||||
not calibratable standalone without first SUPPLYING a direction.
|
||||
|
||||
WHAT THIS HARNESS ADDS over #208 — the DIRECTION-SUPPLY layer (read-only):
|
||||
Before regenerating block-yod for a calibration case, it makes a usable
|
||||
`direction_doc` available to write_block via a READ-ONLY DB OVERLAY (it
|
||||
monkeypatches db.get_decision_by_case / db.get_decision FOR THIS PROCESS ONLY
|
||||
to overlay the direction into the returned decision dict). NOTHING is written
|
||||
to the DB — production case state is never mutated (no UPDATE), satisfying the
|
||||
"do not pollute real case state" constraint. The overlay is reverted in a
|
||||
finally block.
|
||||
|
||||
DIRECTION-SUPPLY STRATEGY (per-case, documented in the report):
|
||||
1. STORED (preferred, most faithful): if the case's decision already carries
|
||||
a usable approved direction (direction_doc.approved is truthy and it has a
|
||||
selected_direction, OR the 8126-style schema with direction_id/structure),
|
||||
use it verbatim. This is the production-faithful path.
|
||||
2. RECONSTRUCTED (fallback, directional-only): otherwise synthesize a MINIMAL
|
||||
direction_doc from the case's decision.outcome + the final's OWN discussion
|
||||
section (the chair's signed reasoning is the ground truth for "what
|
||||
direction was taken"). Clearly LABELLED reconstructed in the report → the
|
||||
resulting numbers are directional, not faithful to a real brainstorm.
|
||||
INV-LRN5: the reconstructed direction carries the case's OWN outcome +
|
||||
a neutral pointer to its OWN final — no substance dragged across cases.
|
||||
|
||||
EMPIRICAL NOTE (measured on the current corpus, 2026-06-30): of the 8 finals,
|
||||
only 8174-12-24 has a STORED approved direction — and its final has NO parsable
|
||||
discussion section (0 words), so it is not scorable for block-yod anyway. The 7
|
||||
finals that DO have a scorable discussion section all LACK a usable stored
|
||||
direction. Hence the RECONSTRUCTED fallback is what makes this harness useful
|
||||
today; --dry-run prints the per-case supply mode so the operator sees this.
|
||||
|
||||
WHAT IT MEASURES (identical to #208, REUSED — no parallel metric path, G2):
|
||||
Regenerate block-yod at each effort via block_writer.write_block(effort_override)
|
||||
(the PRODUCTION generation path → claude_session.query → `claude -p`, pinned
|
||||
Opus 4.8, local-only), then score vs the final's DISCUSSION section via
|
||||
services.style_distance.block_distance_to_final (change_percent /
|
||||
anti_pattern_total / golden_ratio_deviation_pp / composite distance). The
|
||||
recommend_effort, aggregate_cell, _score_cell and direction-free scaffolding are
|
||||
imported from calibrate_effort (#208) — this file only supplies the direction
|
||||
and restricts the grid to block-yod.
|
||||
|
||||
GENERATION PATH (do not violate — reference_claude_generation_path /
|
||||
claude_session docstring): write_block → claude_session.query → `claude -p` uses
|
||||
the local claude.ai session. It runs ONLY on the host where the `claude` CLI
|
||||
exists (NOT the legal-ai container, NOT a symlinked worktree without CLI access).
|
||||
Hence the live A/B is HOST-ONLY; --self-test proves the measurement +
|
||||
direction-supply logic offline with zero model calls.
|
||||
|
||||
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded
|
||||
final WITH a parsable discussion section. The report prints n_finals PROMINENTLY
|
||||
and labels the output DIRECTIONAL EVIDENCE, not a regression. With n<3 the
|
||||
recommendation is advisory only; the chair/operator decides whether to adopt.
|
||||
|
||||
Usage (mcp-server venv; live needs POSTGRES + the `claude` CLI on the host):
|
||||
PY=/home/chaim/legal-ai/mcp-server/.venv/bin/python
|
||||
$PY scripts/calibrate_block_yod.py --self-test # offline proof, no DB/CLI
|
||||
$PY scripts/calibrate_block_yod.py --dry-run # plan grid + supply modes (needs DB, no model)
|
||||
POSTGRES_PASSWORD=… POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 \
|
||||
$PY scripts/calibrate_block_yod.py # live A/B over finals (host-only)
|
||||
… --efforts low,medium,high,xhigh # override the effort grid
|
||||
… --case 1130-08-25 # a single case
|
||||
… --repeats 2 # avg N gens/cell (noise)
|
||||
… --allow-reconstructed false # STORED-direction cases only (faithful)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from statistics import mean
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
if "POSTGRES_URL" not in os.environ:
|
||||
os.environ["POSTGRES_URL"] = (
|
||||
f"postgres://{os.environ.get('POSTGRES_USER','legal_ai')}:"
|
||||
f"{os.environ.get('POSTGRES_PASSWORD','')}@"
|
||||
f"{os.environ.get('POSTGRES_HOST','127.0.0.1')}:"
|
||||
f"{os.environ.get('POSTGRES_PORT','5433')}/"
|
||||
f"{os.environ.get('POSTGRES_DB','legal_ai')}"
|
||||
)
|
||||
|
||||
# Reuse the #208 machinery wholesale (G2 — no parallel harness/metric path).
|
||||
# These are pure/offline-safe imports; calibrate_effort does no work at import.
|
||||
from calibrate_effort import ( # noqa: E402
|
||||
IL_TZ,
|
||||
OUT_DIR,
|
||||
VALID_EFFORTS,
|
||||
_current_default,
|
||||
_finals_for_calibration,
|
||||
_score_cell,
|
||||
recommend_effort,
|
||||
)
|
||||
|
||||
BLOCK_ID = "block-yod"
|
||||
SECTION = "discussion" # block-yod's golden-ratio section (style_distance._BLOCK_TO_SECTION)
|
||||
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
|
||||
|
||||
|
||||
# ── direction supply (the block-yod-specific piece) ──────────────────────────
|
||||
def _stored_direction_is_usable(direction_doc: dict | None) -> bool:
|
||||
"""True if the case's stored direction_doc can drive write_block(block-yod).
|
||||
|
||||
write_block gates on `direction_doc.get("approved")` being truthy. Two stored
|
||||
schemas exist in the corpus:
|
||||
• brainstorm schema (build_direction_doc): {"approved": True, "selected_direction": …}
|
||||
• chair-approved schema (8126-style): {"direction_id", "direction_name",
|
||||
"structure", "template", …} — has NO "approved" key, so write_block would
|
||||
reject it as-is. We treat it as usable IFF it carries a concrete direction
|
||||
(direction_id/direction_name), and _normalize_stored_direction() stamps
|
||||
approved=True onto the OVERLAY copy (never the DB).
|
||||
Empty / brainstorm-only ({"brainstorm": …, "approved": False}) → not usable.
|
||||
"""
|
||||
dd = direction_doc or {}
|
||||
if dd.get("approved") and (dd.get("selected_direction") or dd.get("reasoning")):
|
||||
return True
|
||||
if dd.get("direction_id") or dd.get("direction_name"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_stored_direction(direction_doc: dict) -> dict:
|
||||
"""Return an overlay copy of a usable stored direction with approved=True.
|
||||
|
||||
Pure dict transform on a COPY — the real decisions.direction_doc is untouched.
|
||||
The 8126-style schema lacks `approved`; stamping it here lets write_block's gate
|
||||
pass without us mutating the DB.
|
||||
"""
|
||||
dd = dict(direction_doc)
|
||||
dd["approved"] = True
|
||||
return dd
|
||||
|
||||
|
||||
def reconstruct_minimal_direction(outcome: str, final_discussion: str) -> dict:
|
||||
"""Build a MINIMAL, clearly-reconstructed direction_doc (fallback path).
|
||||
|
||||
Faithful enough to make write_block(block-yod) runnable, honest about being a
|
||||
reconstruction: the chair's SIGNED final discussion is the ground truth for
|
||||
"what direction was taken", and the case's OWN outcome fixes the structure.
|
||||
INV-LRN5: case's own outcome + a neutral pointer to its own final only — no
|
||||
cross-case substance. Pure (no DB/LLM) → unit-tested in --self-test.
|
||||
"""
|
||||
from legal_mcp.services.lessons import OUTCOME_LABELS_HE, canonical_outcome
|
||||
|
||||
oc = canonical_outcome(outcome or "rejection")
|
||||
snippet = " ".join((final_discussion or "").split()[:120])
|
||||
return {
|
||||
"approved": True,
|
||||
"reconstructed": True, # marker so the report can label it
|
||||
"outcome": oc,
|
||||
"outcome_hebrew": OUTCOME_LABELS_HE.get(oc, oc),
|
||||
"reasoning": (
|
||||
"כיוון משוחזר לצורך כיול-effort בלבד (לא סיעור-מוחות אמיתי): "
|
||||
"התוצאה והכיוון נגזרים מההחלטה החתומה של היו\"ר בתיק זה. "
|
||||
"תמצית פתיח-הדיון של הסופי: " + snippet
|
||||
),
|
||||
"selected_direction": {
|
||||
"name": f"כיוון משוחזר — {OUTCOME_LABELS_HE.get(oc, oc)}",
|
||||
"reasoning": [
|
||||
"שחזר את שלד-הנימוק מהדיון החתום של היו\"ר בתיק זה.",
|
||||
],
|
||||
"precedents": [],
|
||||
},
|
||||
"additional_notes": (
|
||||
"⚠️ כיוון משוחזר — תוצאת-הכיול היא עדות-כיוון, לא נאמנה למסמך-כיוון אמיתי."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def plan_direction_supply(
|
||||
decision: dict | None,
|
||||
outcome: str,
|
||||
final_discussion: str,
|
||||
allow_reconstructed: bool,
|
||||
) -> tuple[dict | None, str]:
|
||||
"""Decide how to supply block-yod's direction for one case.
|
||||
|
||||
Returns (overlay_direction_doc | None, mode) where mode ∈
|
||||
{"stored", "reconstructed", "skip"}. Pure (no I/O) → unit-tested.
|
||||
"""
|
||||
stored = (decision or {}).get("direction_doc")
|
||||
if _stored_direction_is_usable(stored):
|
||||
return _normalize_stored_direction(stored), "stored"
|
||||
if allow_reconstructed and (final_discussion or "").strip():
|
||||
return reconstruct_minimal_direction(outcome, final_discussion), "reconstructed"
|
||||
return None, "skip"
|
||||
|
||||
|
||||
class _DirectionOverlay:
|
||||
"""Read-only overlay: make db.get_decision_by_case / db.get_decision return the
|
||||
case's decision WITH `direction_doc` replaced by the supplied overlay — for the
|
||||
target case_id only, for the duration of one cell. Reverted in __exit__.
|
||||
|
||||
write_block re-fetches the decision internally (block_writer.py:383), so passing
|
||||
a direction dict in isn't enough — the DB read must yield it. We patch the read,
|
||||
not the write: ZERO DB mutation (no UPDATE ever issued).
|
||||
"""
|
||||
|
||||
def __init__(self, case_id, direction_doc: dict):
|
||||
self._case_id = str(case_id)
|
||||
self._dir = direction_doc
|
||||
self._orig_by_case = None
|
||||
self._orig_by_id = None
|
||||
|
||||
def __enter__(self):
|
||||
from legal_mcp.services import db
|
||||
|
||||
self._orig_by_case = db.get_decision_by_case
|
||||
self._orig_by_id = db.get_decision
|
||||
|
||||
async def patched_by_case(case_id, _orig=self._orig_by_case):
|
||||
dec = await _orig(case_id)
|
||||
if dec and str(case_id) == self._case_id:
|
||||
dec = dict(dec)
|
||||
dec["direction_doc"] = self._dir
|
||||
return dec
|
||||
|
||||
async def patched_by_id(decision_id, _orig=self._orig_by_id):
|
||||
dec = await _orig(decision_id)
|
||||
if dec and dec.get("case_id") == self._case_id:
|
||||
dec = dict(dec)
|
||||
dec["direction_doc"] = self._dir
|
||||
return dec
|
||||
|
||||
db.get_decision_by_case = patched_by_case
|
||||
db.get_decision = patched_by_id
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
from legal_mcp.services import db
|
||||
|
||||
if self._orig_by_case is not None:
|
||||
db.get_decision_by_case = self._orig_by_case
|
||||
if self._orig_by_id is not None:
|
||||
db.get_decision = self._orig_by_id
|
||||
return False
|
||||
|
||||
|
||||
async def _score_block_yod_cell(case_id, effort, final_section, final_total_words,
|
||||
outcome, repeats, direction_doc):
|
||||
"""Score one (case, block-yod, effort) cell with the direction overlaid.
|
||||
|
||||
Wraps the #208 _score_cell (REUSED) inside the read-only direction overlay so
|
||||
write_block's approved-direction gate passes without DB mutation.
|
||||
"""
|
||||
with _DirectionOverlay(case_id, direction_doc):
|
||||
return await _score_cell(
|
||||
case_id, BLOCK_ID, effort, final_section, final_total_words, outcome, repeats,
|
||||
)
|
||||
|
||||
|
||||
# ── self-test (no DB, no model) ──────────────────────────────────────────────
|
||||
def _self_test() -> int:
|
||||
ok = True
|
||||
|
||||
def chk(name, cond):
|
||||
nonlocal ok
|
||||
ok = ok and cond
|
||||
print(f" {name:50} {'ok' if cond else 'FAIL'}")
|
||||
|
||||
# measurement reuse: block_distance_to_final on the discussion section.
|
||||
from legal_mcp.services.style_distance import block_distance_to_final, split_final_by_section
|
||||
|
||||
final_disc = "אנו סבורים כי דין הערר להידחות. כידוע, הלכה פסוקה היא. " * 30
|
||||
d_same = block_distance_to_final(BLOCK_ID, final_disc, final_disc, "rejection",
|
||||
section_target_total_words=len(final_disc.split()))
|
||||
chk("identical regen ⇒ change_percent==0", d_same["change_percent"] == 0.0)
|
||||
chk("identical regen ⇒ section==discussion", d_same["section"] == SECTION)
|
||||
|
||||
clean = "אין בידנו לקבל את הטענה. יחד עם זאת, מצאנו טעם. " * 20
|
||||
dirty = "## כותרת\n- נקודה\n- נקודה\n* עוד\n### תת\n" * 10
|
||||
d_clean = block_distance_to_final(BLOCK_ID, clean, final_disc, "rejection")
|
||||
d_dirty = block_distance_to_final(BLOCK_ID, dirty, final_disc, "rejection")
|
||||
chk("dirty regen ⇒ more anti-patterns", d_dirty["anti_pattern_total"] > d_clean["anti_pattern_total"])
|
||||
chk("dirty regen ⇒ larger distance", d_dirty["distance"] > d_clean["distance"])
|
||||
|
||||
# ── direction-supply logic ──
|
||||
chk("empty direction ⇒ not usable", not _stored_direction_is_usable({}))
|
||||
chk("brainstorm-only ⇒ not usable",
|
||||
not _stored_direction_is_usable({"brainstorm": {"directions": [1]}, "approved": False}))
|
||||
chk("approved+selected ⇒ usable",
|
||||
_stored_direction_is_usable({"approved": True, "selected_direction": {"name": "x"}}))
|
||||
chk("8126-style (direction_id) ⇒ usable",
|
||||
_stored_direction_is_usable({"direction_id": "d1", "structure": "..."}))
|
||||
|
||||
src = {"direction_id": "d1", "structure": "..."}
|
||||
norm = _normalize_stored_direction(src)
|
||||
chk("normalize stamps approved=True", norm["approved"] is True)
|
||||
chk("normalize does not mutate source", "approved" not in src)
|
||||
|
||||
rec = reconstruct_minimal_direction("rejection", final_disc)
|
||||
chk("reconstruct ⇒ approved", rec["approved"] is True)
|
||||
chk("reconstruct ⇒ marked reconstructed", rec["reconstructed"] is True)
|
||||
chk("reconstruct ⇒ canonical outcome", rec["outcome"] == "rejection")
|
||||
chk("reconstruct ⇒ has selected_direction", bool(rec.get("selected_direction")))
|
||||
chk("reconstruct ⇒ no cross-case precedents (INV-LRN5)",
|
||||
rec["selected_direction"]["precedents"] == [])
|
||||
|
||||
# _build_direction_context must accept BOTH supply shapes (write_block uses it).
|
||||
from legal_mcp.services.block_writer import _build_direction_context
|
||||
ctx_recon = _build_direction_context({"direction_doc": rec})
|
||||
chk("recon direction renders (not 'לא אושר')", "כיוון לא אושר" not in ctx_recon)
|
||||
ctx_stored = _build_direction_context({"direction_doc": norm})
|
||||
chk("stored(8126) direction renders", "כיוון לא אושר" not in ctx_stored)
|
||||
|
||||
# plan_direction_supply: stored wins; else reconstructed; else skip.
|
||||
dd_stored = {"direction_doc": {"approved": True, "selected_direction": {"name": "x"}}}
|
||||
_, m1 = plan_direction_supply(dd_stored, "rejection", final_disc, allow_reconstructed=True)
|
||||
chk("plan: stored-direction case ⇒ stored", m1 == "stored")
|
||||
_, m2 = plan_direction_supply({"direction_doc": {}}, "rejection", final_disc, True)
|
||||
chk("plan: no stored + discussion ⇒ reconstructed", m2 == "reconstructed")
|
||||
_, m3 = plan_direction_supply({"direction_doc": {}}, "rejection", final_disc, False)
|
||||
chk("plan: no stored + reconstructed disabled ⇒ skip", m3 == "skip")
|
||||
_, m4 = plan_direction_supply({"direction_doc": {}}, "rejection", "", True)
|
||||
chk("plan: no stored + no discussion ⇒ skip", m4 == "skip")
|
||||
|
||||
# overlay monkeypatch installs + reverts cleanly (no live DB needed).
|
||||
import legal_mcp.services.db as real_db
|
||||
saved_bc, saved_bi = real_db.get_decision_by_case, real_db.get_decision
|
||||
|
||||
async def fake_by_case(cid):
|
||||
return {"id": "d", "case_id": "C1", "direction_doc": {"approved": False}}
|
||||
|
||||
async def fake_by_id(did):
|
||||
return {"id": "d", "case_id": "C1", "direction_doc": {"approved": False}}
|
||||
|
||||
real_db.get_decision_by_case = fake_by_case
|
||||
real_db.get_decision = fake_by_id
|
||||
try:
|
||||
ov_dir = {"approved": True, "reconstructed": True}
|
||||
|
||||
async def _probe():
|
||||
with _DirectionOverlay("C1", ov_dir):
|
||||
d1 = await real_db.get_decision_by_case("C1")
|
||||
d2 = await real_db.get_decision_by_case("OTHER")
|
||||
d3 = await real_db.get_decision("d")
|
||||
return d1, d2, d3
|
||||
|
||||
# --self-test is dispatched at the __main__ entry, OUTSIDE asyncio.run,
|
||||
# so a plain asyncio.run here is safe (no enclosing event loop).
|
||||
d1, d2, d3 = asyncio.run(_probe())
|
||||
chk("overlay: target case gets overlaid direction", d1["direction_doc"]["approved"] is True)
|
||||
chk("overlay: non-target case untouched", d2["direction_doc"]["approved"] is False)
|
||||
chk("overlay: get_decision path overlaid", d3["direction_doc"]["reconstructed"] is True)
|
||||
chk("overlay: reverted after context", real_db.get_decision_by_case is fake_by_case)
|
||||
finally:
|
||||
real_db.get_decision_by_case = saved_bc
|
||||
real_db.get_decision = saved_bi
|
||||
|
||||
# reused #208 primitives still behave.
|
||||
r = recommend_effort([
|
||||
{"effort": "high", "distance": 0.22, "anti_pattern_total": 2, "change_percent": 25, "n": 1},
|
||||
{"effort": "xhigh", "distance": 0.22, "anti_pattern_total": 1, "change_percent": 22, "n": 1},
|
||||
])
|
||||
chk("recommend tie → fewer anti-patterns", r["effort"] == "xhigh")
|
||||
chk("current default for block-yod == xhigh", _current_default(BLOCK_ID) == "xhigh")
|
||||
secs = split_final_by_section(
|
||||
"רקע עובדתי\nהמקרקעין. " * 8 + "\n\nדיון והכרעה\nאנו סבורים. " * 8)
|
||||
chk("split returns golden sections only",
|
||||
set(secs).issubset({"background", "claims", "discussion", "summary"}))
|
||||
|
||||
print("ALL PASS" if ok else "*** FAILURES ***")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
# ── live A/B (host-only — needs DB + `claude` CLI) ───────────────────────────
|
||||
async def _gather_cases(case_filter, allow_reconstructed):
|
||||
"""Build per-case metadata + direction-supply plan over the finals."""
|
||||
from uuid import UUID
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services.lessons import canonical_outcome
|
||||
from legal_mcp.services.style_distance import split_final_by_section
|
||||
|
||||
finals = await _finals_for_calibration(case_filter)
|
||||
cases = []
|
||||
for f in finals:
|
||||
case = await db.get_case_by_number(f["case_number"]) if f.get("case_number") else None
|
||||
if not case:
|
||||
continue
|
||||
decision = await db.get_decision_by_case(UUID(case["id"]))
|
||||
outcome = canonical_outcome((decision or {}).get("outcome", "rejection"))
|
||||
sections = split_final_by_section(f.get("final_text", ""))
|
||||
final_discussion = sections.get(SECTION, "")
|
||||
final_total_words = len((f.get("final_text", "") or "").split())
|
||||
overlay, mode = plan_direction_supply(
|
||||
decision, outcome, final_discussion, allow_reconstructed)
|
||||
cases.append({
|
||||
"case_number": f["case_number"],
|
||||
"case_id": case["id"],
|
||||
"outcome": outcome,
|
||||
"final_discussion": final_discussion,
|
||||
"final_discussion_words": len(final_discussion.split()),
|
||||
"final_total_words": final_total_words,
|
||||
"direction": overlay,
|
||||
"supply_mode": mode,
|
||||
# scorable IFF it has a discussion section AND a supplied direction.
|
||||
"scorable": bool(final_discussion.strip() and overlay is not None),
|
||||
})
|
||||
return cases
|
||||
|
||||
|
||||
async def _run(args, ts):
|
||||
from uuid import UUID
|
||||
|
||||
cases = await _gather_cases(args.case, args.allow_reconstructed)
|
||||
scorable = [c for c in cases if c["scorable"]]
|
||||
|
||||
grid_summary = {
|
||||
"block": BLOCK_ID,
|
||||
"section": SECTION,
|
||||
"n_finals": len(cases),
|
||||
"n_scorable": len(scorable),
|
||||
"current_default": _current_default(BLOCK_ID),
|
||||
"efforts": args.efforts,
|
||||
"repeats": args.repeats,
|
||||
"total_generations": len(scorable) * len(args.efforts) * args.repeats,
|
||||
"allow_reconstructed": args.allow_reconstructed,
|
||||
"cases": [
|
||||
{"case_number": c["case_number"], "supply_mode": c["supply_mode"],
|
||||
"scorable": c["scorable"], "outcome": c["outcome"],
|
||||
"discussion_words": c["final_discussion_words"]}
|
||||
for c in cases
|
||||
],
|
||||
}
|
||||
|
||||
if args.dry_run:
|
||||
return {"dry_run": True, "grid": grid_summary, "by_effort": []}
|
||||
|
||||
per_effort_runs = {e: [] for e in args.efforts}
|
||||
per_case = []
|
||||
for c in scorable:
|
||||
case_cells = []
|
||||
for effort in args.efforts:
|
||||
# Per-cell guard (INV-G8): any single (case, effort) failure — a
|
||||
# transient rate-limit, a too-large prompt, an unexpected raise — is
|
||||
# logged and skipped, never allowed to abort the whole run.
|
||||
try:
|
||||
cell = await _score_block_yod_cell(
|
||||
UUID(c["case_id"]), effort, c["final_discussion"],
|
||||
c["final_total_words"], c["outcome"], args.repeats, c["direction"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
|
||||
logger.warning(
|
||||
"block-yod cell skipped: case=%s effort=%s mode=%s — %s",
|
||||
c["case_number"], effort, c["supply_mode"], exc,
|
||||
)
|
||||
continue
|
||||
per_effort_runs[effort].append(cell)
|
||||
case_cells.append({k: cell[k] for k in
|
||||
("effort", "distance", "anti_pattern_total",
|
||||
"change_percent", "golden_ratio_deviation_pp", "n")})
|
||||
per_case.append({"case_number": c["case_number"],
|
||||
"supply_mode": c["supply_mode"], "cells": case_cells})
|
||||
|
||||
# Incremental persistence (INV-G8): flush after EACH case so a crash later
|
||||
# never loses cases already generated. _write_report tolerates partial rows.
|
||||
try:
|
||||
_write_report({"dry_run": False, "grid": grid_summary,
|
||||
"by_effort": _effort_rows(per_effort_runs, args.efforts),
|
||||
"per_case": per_case,
|
||||
"recommended": (recommend_effort(_effort_rows(per_effort_runs, args.efforts)) or {}).get("effort")},
|
||||
ts)
|
||||
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
|
||||
logger.warning("incremental report write failed after case=%s — %s",
|
||||
c["case_number"], exc)
|
||||
|
||||
effort_rows = _effort_rows(per_effort_runs, args.efforts)
|
||||
rec = recommend_effort(effort_rows)
|
||||
return {"dry_run": False, "grid": grid_summary, "by_effort": effort_rows,
|
||||
"per_case": per_case, "recommended": rec["effort"] if rec else None}
|
||||
|
||||
|
||||
def _effort_rows(per_effort_runs, efforts):
|
||||
"""Mean each metric across cases for each effort → one comparable row/effort."""
|
||||
rows = []
|
||||
for effort in efforts:
|
||||
cells = per_effort_runs[effort]
|
||||
if not cells:
|
||||
continue
|
||||
ratios = [c["golden_ratio_deviation_pp"] for c in cells
|
||||
if c.get("golden_ratio_deviation_pp") is not None]
|
||||
rows.append({
|
||||
"effort": effort,
|
||||
"distance": round(mean(c["distance"] for c in cells), 4),
|
||||
"anti_pattern_total": round(mean(c["anti_pattern_total"] for c in cells), 2),
|
||||
"change_percent": round(mean(c["change_percent"] for c in cells), 2),
|
||||
"golden_ratio_deviation_pp": round(mean(ratios), 2) if ratios else None,
|
||||
"n": len(cells),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# ── report ───────────────────────────────────────────────────────────────────
|
||||
def _ts():
|
||||
return datetime.now(IL_TZ).strftime("%Y%m%dT%H%M%S-IL")
|
||||
|
||||
|
||||
def _write_report(result, ts):
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
jp = OUT_DIR / f"block-yod-calibration-{ts}.json"
|
||||
mp = OUT_DIR / f"block-yod-calibration-{ts}.md"
|
||||
jp.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
g = result["grid"]
|
||||
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
|
||||
n_recon = sum(1 for c in g["cases"] if c["supply_mode"] == "reconstructed")
|
||||
n_stored = sum(1 for c in g["cases"] if c["supply_mode"] == "stored")
|
||||
n_skip = sum(1 for c in g["cases"] if c["supply_mode"] == "skip")
|
||||
lines = [
|
||||
f"# #212 — כיול effort לבלוק י' (דיון והכרעה) מול הסופיים — {ts}\n",
|
||||
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
|
||||
f"> ⚠️ **גודל-מדגם: {g['n_scorable']} תיקים ניתנים-לכיול** "
|
||||
f"(מתוך {g['n_finals']} סופיים). זוהי **עדות-כיוון, לא רגרסיה**. "
|
||||
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
|
||||
f"> אספקת-כיוון: {n_stored} מאוחסן · {n_recon} משוחזר · {n_skip} מדולג. "
|
||||
"כיוון **משוחזר** = נגזר מתוצאת-ההחלטה + פתיח-הדיון של הסופי באותו תיק "
|
||||
"(לא סיעור-מוחות אמיתי) → התוצאה נאמנה-לכיוון פחות מתיק עם כיוון-מאושר שמור.\n",
|
||||
f"- בלוק: {g['block']} (section={g['section']}) · ברירת-מחדל נוכחית: "
|
||||
f"**{g['current_default'] or '—'}** (נקבע 'by feel', block_writer.py:72)",
|
||||
f"- efforts: {', '.join(g['efforts'])} · repeats/cell: {g['repeats']}",
|
||||
f"- סך ייצורי-מודל: {g['total_generations']}",
|
||||
"",
|
||||
"## אספקת-כיוון per-תיק\n",
|
||||
"| case | supply_mode | discussion_words | outcome | scorable |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for c in g["cases"]:
|
||||
lines.append(
|
||||
f"| {c['case_number']} | {c['supply_mode']} | {c['discussion_words']} | "
|
||||
f"{c['outcome']} | {'✓' if c['scorable'] else '—'} |")
|
||||
lines.append("")
|
||||
|
||||
if result.get("dry_run"):
|
||||
lines += ["## DRY-RUN — תכנון הגריד בלבד (ללא ייצור)\n",
|
||||
f"- תיקים ניתנים-לכיול: **{g['n_scorable']}** · "
|
||||
f"ייצורים מתוכננים: **{g['total_generations']}**",
|
||||
""]
|
||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return jp, mp
|
||||
|
||||
rec = result.get("recommended") or "—"
|
||||
mark = "" if rec == g["current_default"] else " ⬅︎ שינוי מומלץ"
|
||||
lines += [
|
||||
f"## המלצה: effort = **{rec}**{mark} (current = {g['current_default'] or '—'})\n",
|
||||
"## טבלת effort (distance נמוך = קרוב יותר לדיון של דפנה)\n",
|
||||
"| effort | distance | anti_total | change% | ratioΔpp | n |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for r in result.get("by_effort", []):
|
||||
star = " ⭐" if r["effort"] == result.get("recommended") else ""
|
||||
ratio = r["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 += ["## פירוט per-תיק\n", "| case | mode | effort | distance | anti | change% |",
|
||||
"|---|---|---|---|---|---|"]
|
||||
for pc in result.get("per_case", []):
|
||||
for cell in pc["cells"]:
|
||||
lines.append(
|
||||
f"| {pc['case_number']} | {pc['supply_mode']} | {cell['effort']} | "
|
||||
f"{cell['distance']:.4f} | {cell['anti_pattern_total']} | {cell['change_percent']} |")
|
||||
lines.append("")
|
||||
lines.append("> change% מערבב סגנון עם שלמות-תוכן (07-learning §0.7); "
|
||||
"anti_total הוא הסיגנל הנקי-יותר לסגנון. תיקים עם supply_mode=reconstructed "
|
||||
"תורמים עדות-כיוון בלבד.\n")
|
||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return jp, mp
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="#212 block-yod (discussion) effort calibration harness")
|
||||
ap.add_argument("--self-test", action="store_true",
|
||||
help="offline measurement + direction-supply proof (no DB/CLI)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="plan the grid + per-case direction-supply mode, no model calls (needs DB)")
|
||||
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
|
||||
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
|
||||
ap.add_argument("--case", default=None, help="restrict to a single case_number")
|
||||
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
|
||||
ap.add_argument("--allow-reconstructed", default="true",
|
||||
help="true|false — allow the reconstructed-direction fallback (default true). "
|
||||
"false = STORED-direction cases only (most faithful, may yield 0 cells).")
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
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]
|
||||
if bad:
|
||||
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
|
||||
return 2
|
||||
args.allow_reconstructed = str(args.allow_reconstructed).strip().lower() in ("1", "true", "yes", "y")
|
||||
|
||||
ts = _ts()
|
||||
result = await _run(args, ts)
|
||||
jp, mp = _write_report(result, ts)
|
||||
|
||||
g = result["grid"]
|
||||
print(f"BLOCK-YOD CALIBRATION: {g['n_scorable']}/{g['n_finals']} scorable finals — "
|
||||
"DIRECTIONAL EVIDENCE, not a regression")
|
||||
n_recon = sum(1 for c in g["cases"] if c["supply_mode"] == "reconstructed")
|
||||
n_stored = sum(1 for c in g["cases"] if c["supply_mode"] == "stored")
|
||||
print(f" direction supply: {n_stored} stored · {n_recon} reconstructed · "
|
||||
f"{sum(1 for c in g['cases'] if c['supply_mode']=='skip')} skipped")
|
||||
if g["n_scorable"] == 0:
|
||||
print(" no scorable finals (need a parsable discussion section + a suppliable direction).")
|
||||
elif result.get("dry_run"):
|
||||
print(f" dry-run: {g['total_generations']} generations planned "
|
||||
f"({len(g['efforts'])} efforts × {g['n_scorable']} cases × {g['repeats']} repeats)")
|
||||
else:
|
||||
print(f" current default={g['current_default']} → recommended={result.get('recommended')}")
|
||||
print(f" report: {mp}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# --self-test is OFFLINE and synchronous (it spins its own short-lived loop for
|
||||
# the overlay probe), so dispatch it BEFORE asyncio.run(main()) — otherwise the
|
||||
# probe's asyncio.run would nest inside main's running loop.
|
||||
if "--self-test" in sys.argv:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
sys.exit(_self_test())
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -19,9 +19,15 @@ WHAT IT MEASURES (per (case, block, effort) cell — the A/B grid):
|
||||
• distance — normalized composite (lower = closer to Dafna)
|
||||
No parallel metric path: it reuses style_distance + learning_loop (G2).
|
||||
|
||||
RECOMMENDATION: for each block, the effort with the lowest MEAN composite distance
|
||||
across cases (ties → fewer anti-patterns → lower change_percent). Reported next to
|
||||
the #204 current default so a regression/improvement is visible.
|
||||
RECOMMENDATION (#213 — STYLE-CLEAN, anti-primary): for each block, the effort is
|
||||
ranked by `anti_pattern_total` FIRST (the clean Dafna-style-rule-violation count),
|
||||
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
|
||||
(draft_final_pairs.final_text non-empty). The report prints n_finals PROMINENTLY and
|
||||
@@ -55,12 +61,16 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
from statistics import mean
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
|
||||
|
||||
@@ -77,28 +87,79 @@ OUT_DIR = REPO_ROOT / "data" / "eval"
|
||||
|
||||
# Only the AI blocks that map to a golden-ratio section can be scored against the
|
||||
# final's matching section (split_final_by_section). Template blocks (א-ד, יב) are
|
||||
# deterministic template-fill — no effort knob. block-yod (discussion) is out of
|
||||
# WS5's interim scope but mappable, so it's included when present.
|
||||
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod", "block-yod-alef"]
|
||||
# deterministic template-fill — no effort knob. block-yod (discussion) is EXCLUDED
|
||||
# from the defaults: it requires an approved direction (brainstorm → approve_direction)
|
||||
# that calibration cases lack, so block_writer.write_block raises and it is NOT
|
||||
# calibratable standalone — and it's out of WS5's interim-draft scope. It stays in
|
||||
# VALID_BLOCKS so a user can still force it (--blocks block-yod) at their own risk;
|
||||
# the per-cell guard in _run() keeps such a failure non-fatal.
|
||||
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod-alef"]
|
||||
VALID_BLOCKS = CALIBRATABLE_BLOCKS + ["block-yod"]
|
||||
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
|
||||
VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
|
||||
|
||||
|
||||
# ── pure helpers (offline-testable) ──────────────────────────────────────────
|
||||
def recommend_effort(cells: list[dict]) -> dict | None:
|
||||
"""Pick the best effort for ONE block from its scored cells.
|
||||
# Style-clean ranking epsilon (#213). Confidence in a pick = how far the best
|
||||
# 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,
|
||||
then lower change_percent. Pure → unit-tested in --self-test.
|
||||
|
||||
def _anti_confidence(rows: list[dict]) -> tuple[str, float, float]:
|
||||
"""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:
|
||||
return None
|
||||
ranked = sorted(
|
||||
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:
|
||||
@@ -124,6 +185,88 @@ def _current_default(block_id: str) -> str | None:
|
||||
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) ──────────────────────────────────────────────
|
||||
def _self_test() -> int:
|
||||
ok = True
|
||||
@@ -171,16 +314,66 @@ def _self_test() -> int:
|
||||
{"background", "claims", "discussion", "summary"}))
|
||||
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([
|
||||
{"effort": "low", "distance": 0.40, "anti_pattern_total": 5, "change_percent": 40, "n": 1},
|
||||
{"effort": "high", "distance": 0.20, "anti_pattern_total": 2, "change_percent": 25, "n": 1},
|
||||
{"effort": "xhigh", "distance": 0.20, "anti_pattern_total": 1, "change_percent": 22, "n": 1},
|
||||
{"effort": "low", "distance": 0.20, "anti_pattern_total": 5, "change_percent": 22,
|
||||
"golden_ratio_deviation_pp": 3.0, "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 tie → fewer anti-patterns", rec["effort"] == "xhigh")
|
||||
chk("recommend: anti-primary (not distance)", 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)
|
||||
|
||||
# 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.
|
||||
agg = aggregate_cell([
|
||||
{"distance": 0.2, "anti_pattern_total": 2, "change_percent": 20, "golden_ratio_deviation_pp": 3.0},
|
||||
@@ -190,6 +383,23 @@ def _self_test() -> int:
|
||||
chk("aggregate n counted", agg["n"] == 2)
|
||||
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 ***")
|
||||
return 0 if ok else 1
|
||||
|
||||
@@ -228,7 +438,7 @@ async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
|
||||
return agg
|
||||
|
||||
|
||||
async def _run(args) -> dict:
|
||||
async def _run(args, ts: str) -> dict:
|
||||
from uuid import UUID
|
||||
from legal_mcp.services import db
|
||||
from legal_mcp.services.lessons import canonical_outcome
|
||||
@@ -279,10 +489,21 @@ async def _run(args) -> dict:
|
||||
final_section = c["sections"][section]
|
||||
case_cells = []
|
||||
for effort in efforts:
|
||||
cell = await _score_cell(
|
||||
UUID(c["case_id"]), block_id, effort, final_section,
|
||||
c["final_total_words"], c["outcome"], args.repeats,
|
||||
)
|
||||
# Per-cell guard: ANY failure of a single (case, block, effort) cell
|
||||
# — e.g. block-yod raising "ללא כיוון מאושר", or a transient rate-limit
|
||||
# mid-run — is logged and skipped, NOT allowed to kill the whole run
|
||||
# (INV-G8 eval-harness robustness). Partial results still persist below.
|
||||
try:
|
||||
cell = await _score_cell(
|
||||
UUID(c["case_id"]), block_id, effort, final_section,
|
||||
c["final_total_words"], c["outcome"], args.repeats,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — harness must survive any cell failure
|
||||
logger.warning(
|
||||
"calibration cell skipped: case=%s block=%s effort=%s — %s",
|
||||
c["case_number"], block_id, effort, exc,
|
||||
)
|
||||
continue
|
||||
per_effort_runs[effort].append(cell)
|
||||
case_cells.append({k: cell[k] for k in
|
||||
("effort", "distance", "anti_pattern_total",
|
||||
@@ -309,15 +530,35 @@ async def _run(args) -> dict:
|
||||
"section": section,
|
||||
"current_default": _current_default(block_id),
|
||||
"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,
|
||||
"per_case": per_case,
|
||||
}
|
||||
|
||||
# Incremental persistence (INV-G8): flush the report after every completed
|
||||
# block so a crash later in the grid never loses blocks already generated.
|
||||
# Blocks not yet done are simply absent from by_block; _write_report tolerates
|
||||
# partial results. main() does the final flush once the loop finishes.
|
||||
try:
|
||||
_write_report({"dry_run": False, "grid": grid_summary, "by_block": by_block}, ts)
|
||||
except Exception as exc: # noqa: BLE001 — a write hiccup must not abort the run
|
||||
logger.warning("incremental report write failed after block=%s — %s", block_id, exc)
|
||||
|
||||
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
|
||||
|
||||
|
||||
IL_TZ = ZoneInfo("Asia/Jerusalem")
|
||||
|
||||
|
||||
def _ts() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
"""Filename-safe report stamp in Israel time (INV-UI9: human-facing display).
|
||||
|
||||
This script runs on the host, which is UTC, so a process-clock stamp would
|
||||
mislead the chair. We pin Asia/Jerusalem and suffix ``IL`` (instead of a bare
|
||||
``Z``) so the displayed/filename time is unambiguously Israel-local.
|
||||
"""
|
||||
return datetime.now(IL_TZ).strftime("%Y%m%dT%H%M%S-IL")
|
||||
|
||||
|
||||
def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
@@ -328,8 +569,10 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
|
||||
g = result["grid"]
|
||||
n = g["n_finals"]
|
||||
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
|
||||
lines = [
|
||||
f"# #208 — כיול model×effort מול הסופיים — {ts}\n",
|
||||
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
|
||||
f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or '—'}). "
|
||||
"זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. "
|
||||
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
|
||||
@@ -346,13 +589,18 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return jp, mp
|
||||
|
||||
lines += ["## המלצה per-בלוק (distance נמוך = קרוב יותר לדפנה)\n",
|
||||
"| block | section | current | **recommended** | n |", "|---|---|---|---|---|"]
|
||||
lines += ["## המלצה per-בלוק (style-clean: anti_pattern_total ראשי — #213)\n",
|
||||
"| block | section | current | **recommended** | confidence | n |",
|
||||
"|---|---|---|---|---|---|"]
|
||||
for b, bd in result["by_block"].items():
|
||||
rec = bd.get("recommended") or "—"
|
||||
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
|
||||
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("")
|
||||
for b, bd in result["by_block"].items():
|
||||
lines += [f"### {b} ({bd.get('section','')})\n",
|
||||
@@ -365,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['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||
lines.append("")
|
||||
lines.append("> change% מערבב סגנון עם שלמות-תוכן (07-learning §0.7); "
|
||||
"anti_total הוא הסיגנל הנקי-יותר לסגנון.\n")
|
||||
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
|
||||
"**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")
|
||||
return jp, mp
|
||||
|
||||
@@ -374,6 +624,8 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
||||
async def main() -> int:
|
||||
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("--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("--efforts", default=",".join(DEFAULT_EFFORTS),
|
||||
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
|
||||
@@ -383,22 +635,27 @@ async def main() -> int:
|
||||
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
if args.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()]
|
||||
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
|
||||
if bad:
|
||||
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
|
||||
return 2
|
||||
args.blocks = [b.strip() for b in args.blocks.split(",") if b.strip()]
|
||||
bad_b = [b for b in args.blocks if b not in CALIBRATABLE_BLOCKS]
|
||||
bad_b = [b for b in args.blocks if b not in VALID_BLOCKS]
|
||||
if bad_b:
|
||||
print(f"non-calibratable block(s): {bad_b}. valid: {CALIBRATABLE_BLOCKS}", file=sys.stderr)
|
||||
print(f"non-calibratable block(s): {bad_b}. valid: {VALID_BLOCKS}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = await _run(args)
|
||||
ts = _ts()
|
||||
result = await _run(args, ts)
|
||||
jp, mp = _write_report(result, ts)
|
||||
|
||||
g = result["grid"]
|
||||
@@ -410,7 +667,8 @@ async def main() -> int:
|
||||
else:
|
||||
for b, bd in result["by_block"].items():
|
||||
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}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ApprovalCategory,
|
||||
type ApprovalSeverity,
|
||||
} from "@/lib/api/chair";
|
||||
import { formatDateLong } from "@/lib/format-date";
|
||||
|
||||
/**
|
||||
* מרכז אישורים — דפנה (INV-G10).
|
||||
@@ -35,18 +36,7 @@ const SEVERITY_RAIL: Record<ApprovalSeverity, string> = {
|
||||
ok: "border-s-success",
|
||||
};
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const formatDate = (iso?: string | null) => formatDateLong(iso);
|
||||
|
||||
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
|
||||
const cleared = cat.count === 0;
|
||||
|
||||
@@ -28,19 +28,7 @@ import {
|
||||
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
|
||||
import { subtypeOf } from "@/components/cases/appeal-type-bars";
|
||||
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
import { formatDate, getIsraelYear } from "@/lib/format-date";
|
||||
|
||||
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
|
||||
const TYPE_CHIP: Record<string, string> = {
|
||||
@@ -177,8 +165,8 @@ export default function ArchivePage() {
|
||||
const set = new Set<string>();
|
||||
for (const c of rows) {
|
||||
if (!c.archived_at) continue;
|
||||
const y = new Date(c.archived_at).getFullYear();
|
||||
if (!Number.isNaN(y)) set.add(String(y));
|
||||
const y = getIsraelYear(c.archived_at);
|
||||
if (y !== null) set.add(String(y));
|
||||
}
|
||||
return [...set].sort((a, b) => Number(b) - Number(a));
|
||||
}, [rows]);
|
||||
@@ -209,7 +197,7 @@ export default function ArchivePage() {
|
||||
if (yearFilter !== "all") {
|
||||
all = all.filter((r) => {
|
||||
const iso = r.original.archived_at;
|
||||
return iso != null && String(new Date(iso).getFullYear()) === yearFilter;
|
||||
return iso != null && String(getIsraelYear(iso)) === yearFilter;
|
||||
});
|
||||
}
|
||||
return all;
|
||||
|
||||
@@ -1,506 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileText } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SubsectionCard } from "@/components/compose/subsection-card";
|
||||
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useCase, type CaseStatus } from "@/lib/api/cases";
|
||||
import {
|
||||
useCaseLearningStatus,
|
||||
type CaseLearningStatus,
|
||||
} from "@/lib/api/learning";
|
||||
import { useResearchAnalysis } from "@/lib/api/research";
|
||||
import { useCasePrecedents } from "@/lib/api/precedents";
|
||||
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
|
||||
import { DOC_TYPE_LABELS, type DocType } from "@/lib/doc-types";
|
||||
|
||||
// ── Case-status → Hebrew label + tone (mockup 03 status chip) ────────────────
|
||||
const STATUS_CHIP: Record<string, { label: string; cls: string }> = {
|
||||
new: { label: "חדש", cls: "bg-rule-soft text-ink-muted border-rule" },
|
||||
processing: { label: "בעיבוד", cls: "bg-info-bg text-info border-info/30" },
|
||||
documents_ready: { label: "מסמכים מוכנים", cls: "bg-info-bg text-info border-info/30" },
|
||||
outcome_set: { label: "תוצאה נקבעה", cls: "bg-info-bg text-info border-info/30" },
|
||||
direction_approved: { label: "כיוון אושר", cls: "bg-info-bg text-info border-info/30" },
|
||||
qa_review: { label: "בדיקת-איכות", cls: "bg-gold-wash text-gold-deep border-gold/40" },
|
||||
drafted: { label: "טיוטה", cls: "bg-gold-wash text-gold-deep border-gold/40" },
|
||||
exported: { label: "יוצא", cls: "bg-success-bg text-success border-success/40" },
|
||||
reviewed: { label: "נסקר", cls: "bg-success-bg text-success border-success/40" },
|
||||
final: { label: "סופי", cls: "bg-success-bg text-success border-success/40" },
|
||||
};
|
||||
|
||||
function StatusChip({ status }: { status?: CaseStatus }) {
|
||||
const c = (status && STATUS_CHIP[status]) || {
|
||||
label: "בעריכה",
|
||||
cls: "bg-info-bg text-info border-info/30",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full text-[0.78rem] font-semibold px-3 py-0.5 border ${c.cls}`}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function subtypeLabel(subtype?: string | null): string | null {
|
||||
if (!subtype) return null;
|
||||
return APPEAL_SUBTYPES.find((s) => s.value === subtype)?.label ?? null;
|
||||
}
|
||||
|
||||
// ── Staged-pipeline indicator text (mockup 03) — derived from the live
|
||||
// learning-status, same source as the drafts-panel LearningStatusBadges. ──────
|
||||
function voiceLearningText(s?: CaseLearningStatus): string {
|
||||
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
|
||||
const v = s.voice_learning;
|
||||
if (v.outcome === "succeeded") {
|
||||
const bits = [`${v.lessons_count} לקחים הופקו`];
|
||||
if (v.lessons_proposed > 0) bits.push(`${v.lessons_proposed} הוצעו לאישור`);
|
||||
return `✓ הושלם · ${bits.join(" · ")}`;
|
||||
}
|
||||
if (v.outcome === "failed") return v.error ? `✗ נכשל — ${v.error}` : "✗ נכשל";
|
||||
return "ממתין להרצה";
|
||||
}
|
||||
|
||||
function halachaExtractionText(s?: CaseLearningStatus): string {
|
||||
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
|
||||
const h = s.halacha_extraction;
|
||||
if (!h.enrolled_in_corpus)
|
||||
return h.not_enrolled_reason ?? "לא נכנס לקורפוס-הפסיקה";
|
||||
switch (h.status) {
|
||||
case "completed":
|
||||
return `✓ הושלם · חולצו ${h.halachot_count} · ${h.approved} אושרו · ${h.rejected} נדחו`;
|
||||
case "processing":
|
||||
return "רץ עכשיו…";
|
||||
case "pending":
|
||||
case "busy":
|
||||
return "בתור";
|
||||
case "partial":
|
||||
return `חלקי · חולצו ${h.halachot_count}`;
|
||||
case "failed":
|
||||
case "extraction_failed":
|
||||
return "✗ נכשל";
|
||||
case "no_chunks":
|
||||
return "אין טקסט לחילוץ";
|
||||
default:
|
||||
return "ממתין להרצה";
|
||||
}
|
||||
}
|
||||
|
||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
if (!content?.trim()) return null;
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
|
||||
{title}
|
||||
</h3>
|
||||
<Markdown content={content.trim()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── "השלמה והעברה" rail card — DOCX export, upload, download (all real) ──────
|
||||
function FinishRail({
|
||||
caseNumber,
|
||||
hasAnalysis,
|
||||
onUploaded,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
hasAnalysis: boolean;
|
||||
onUploaded: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const learning = useCaseLearningStatus(caseNumber);
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploading(true);
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`/api/cases/${caseNumber}/research/analysis/upload`, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setUploadMsg({ ok: false, text: data.detail || "שגיאה בהעלאה" });
|
||||
return;
|
||||
}
|
||||
setUploadMsg({
|
||||
ok: true,
|
||||
text: `הקובץ הועלה — ${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
|
||||
});
|
||||
onUploaded();
|
||||
} catch {
|
||||
setUploadMsg({ ok: false, text: "שגיאת רשת" });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-4 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-3">השלמה והעברה</h3>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUpload(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
ייצוא DOCX
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full justify-center bg-gold text-white hover:bg-gold-deep"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{uploading ? "מעלה…" : "העלאת ניתוח מעודכן"}
|
||||
</Button>
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
|
||||
a.download = `analysis-${caseNumber}.md`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
הורד ניתוח (MD)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadMsg && (
|
||||
<p className={`text-xs mt-2 ${uploadMsg.ok ? "text-success" : "text-danger"}`}>
|
||||
{uploadMsg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* mockup 03: 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>
|
||||
|
||||
<Button asChild variant="ghost" className="w-full justify-center mt-3 text-ink-muted">
|
||||
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComposePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ caseNumber: string }>;
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const caseQuery = useCase(caseNumber);
|
||||
const analysis = useResearchAnalysis(caseNumber);
|
||||
const precedentsQuery = useCasePrecedents(caseNumber);
|
||||
|
||||
/* Partition the flat list into scopes so each child renders its own slice
|
||||
* without re-fetching. Done once at the page level. */
|
||||
const allPrecedents = precedentsQuery.data ?? [];
|
||||
const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null);
|
||||
const precedentsBySection = new Map<string, typeof allPrecedents>();
|
||||
for (const p of allPrecedents) {
|
||||
if (p.section_id) {
|
||||
const existing = precedentsBySection.get(p.section_id) ?? [];
|
||||
existing.push(p);
|
||||
precedentsBySection.set(p.section_id, existing);
|
||||
}
|
||||
}
|
||||
const practiceArea = caseQuery.data?.practice_area ?? null;
|
||||
const subtype = subtypeLabel(caseQuery.data?.appeal_subtype);
|
||||
const parties = (() => {
|
||||
const c = caseQuery.data;
|
||||
if (!c) return null;
|
||||
const app = c.appellants?.length ? c.appellants.join(", ") : null;
|
||||
const resp = c.respondents?.length ? c.respondents.join(", ") : null;
|
||||
const out: string[] = [];
|
||||
if (app) out.push(`עוררים: ${app}`);
|
||||
if (resp) out.push(`משיבה: ${resp}`);
|
||||
return out.length ? out.join(" · ") : c.title || null;
|
||||
})();
|
||||
const documents = caseQuery.data?.documents ?? [];
|
||||
|
||||
const isNotFound =
|
||||
analysis.error instanceof Error &&
|
||||
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
{/* ── Case header band (mockup 03) — parchment strip, full-bleed to the
|
||||
AppShell <main> edges (which pads px-10 py-10) ── */}
|
||||
<div className="-mx-10 -mt-10 mb-6 border-b border-rule bg-parchment px-10 py-5">
|
||||
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-2">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden>·</span>
|
||||
<Link href={`/cases/${caseNumber}`} className="hover:text-gold-deep">
|
||||
ערר {caseNumber}
|
||||
</Link>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="text-navy">עורך החלטה</span>
|
||||
</nav>
|
||||
<Link
|
||||
href={`/cases/${caseNumber}`}
|
||||
className="inline-flex items-center gap-1.5 mb-3 rounded-md border border-rule bg-surface px-3 py-1.5 text-[0.82rem] font-semibold text-gold-deep hover:bg-gold-wash hover:text-navy transition-colors"
|
||||
>
|
||||
<span aria-hidden>→</span> חזרה לדף התיק
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-navy text-2xl font-bold mb-0">ערר {caseNumber}</h1>
|
||||
<StatusChip status={caseQuery.data?.status} />
|
||||
{subtype && (
|
||||
<span className="rounded-full text-[0.78rem] font-semibold px-3 py-0.5 border border-rule bg-gold-wash text-gold-deep">
|
||||
{subtype}
|
||||
</span>
|
||||
)}
|
||||
{/* INV-G10: source-of-truth pill — the blocks are the canonical text */}
|
||||
<span className="ms-auto rounded-lg text-[0.8rem] font-semibold px-3.5 py-1.5 border border-gold bg-gold-wash text-gold-deep">
|
||||
מקור-אמת: בלוקים
|
||||
</span>
|
||||
</div>
|
||||
{parties && <p className="text-ink-soft text-sm mt-2">{parties}</p>}
|
||||
</div>
|
||||
|
||||
{caseQuery.isPending ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* ── Two-column workspace: tabbed main editor + 320px side rail ──── */
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px] items-start">
|
||||
{/* MAIN — block editor (default) + chair positions, as tabs (mockup 03) */}
|
||||
<div className="min-w-0">
|
||||
<Tabs defaultValue="blocks" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="blocks">עורך הבלוקים</TabsTrigger>
|
||||
<TabsTrigger value="positions">עמדות וטענות</TabsTrigger>
|
||||
<TabsTrigger value="verify">אימות פסיקה</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab 1 — the 12-block decision editor (reused DecisionBlocksPanel) */}
|
||||
<TabsContent value="blocks" className="mt-5">
|
||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 3 — citation verification: per-argument support + verify gate (#154) */}
|
||||
<TabsContent value="verify" className="mt-5">
|
||||
<CitationVerificationPanel caseNumber={caseNumber} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab 2 — chair positions on the analyst's threshold-claims + issues */}
|
||||
<TabsContent value="positions" className="mt-5">
|
||||
{analysis.isPending ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isNotFound ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-12 text-center space-y-3">
|
||||
<div className="text-gold text-3xl" aria-hidden>❦</div>
|
||||
<h2 className="text-navy text-lg mb-0">
|
||||
טרם בוצע ניתוח משפטי לתיק זה
|
||||
</h2>
|
||||
<p className="text-ink-muted text-sm max-w-md mx-auto">
|
||||
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
|
||||
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-5 text-center">
|
||||
<p className="text-danger">{analysis.error.message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.data ? (
|
||||
<div className="space-y-6">
|
||||
{/* Threshold claims */}
|
||||
{analysis.data.threshold_claims &&
|
||||
analysis.data.threshold_claims.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">טענות סף</h2>
|
||||
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||
{analysis.data.threshold_claims.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{analysis.data.threshold_claims.map((tc) => (
|
||||
<SubsectionCard
|
||||
key={tc.id}
|
||||
caseNumber={caseNumber}
|
||||
item={tc}
|
||||
precedents={precedentsBySection.get(tc.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{analysis.data.issues && analysis.data.issues.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">סוגיות להכרעה</h2>
|
||||
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||
{analysis.data.issues.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{analysis.data.issues.map((iss) => (
|
||||
<SubsectionCard
|
||||
key={iss.id}
|
||||
caseNumber={caseNumber}
|
||||
item={iss}
|
||||
precedents={precedentsBySection.get(iss.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!analysis.data.threshold_claims?.length &&
|
||||
!analysis.data.issues?.length && (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-6 py-10 text-center text-ink-muted">
|
||||
לא נמצאו טענות סף או סוגיות בניתוח זה.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Background prose — supporting context after the decision points */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-5">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">רקע לניתוח</h2>
|
||||
<ProseSection title="צד מיוצג" content={analysis.data.represented_party} />
|
||||
<ProseSection title="רקע דיוני" content={analysis.data.procedural_background} />
|
||||
<ProseSection title="עובדות מוסכמות" content={analysis.data.agreed_facts} />
|
||||
<ProseSection title="עובדות במחלוקת" content={analysis.data.disputed_facts} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{analysis.data.conclusions?.trim() && (
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<h2 className="text-gold-deep text-lg font-semibold mb-0">מסקנות</h2>
|
||||
<Markdown content={analysis.data.conclusions.trim()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* SIDE RAIL — documents · attached precedents · finish-and-transfer */}
|
||||
<aside className="space-y-4 lg:sticky lg:top-4">
|
||||
{/* מסמכי התיק */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-4 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-2">מסמכי התיק</h3>
|
||||
{documents.length === 0 ? (
|
||||
<p className="text-[0.78rem] text-ink-muted">אין מסמכים מצורפים</p>
|
||||
) : (
|
||||
<ul>
|
||||
{documents.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="flex items-center gap-2 text-[0.82rem] text-ink-soft py-1.5 border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-ink-muted shrink-0" aria-hidden />
|
||||
<span className="truncate flex-1" title={d.title}>
|
||||
{d.title || "מסמך"}
|
||||
</span>
|
||||
<span className="rounded bg-rule-soft text-ink-muted text-[0.68rem] px-1.5 py-0.5 shrink-0 whitespace-nowrap">
|
||||
{DOC_TYPE_LABELS[d.doc_type as DocType] ?? d.doc_type}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* פסיקה מצורפת (case-level) */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-4 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
|
||||
<p className="text-[0.72rem] text-ink-muted mb-3">
|
||||
ציטוטים התומכים בעמדה באופן רוחבי — ישולבו בפתיחת בלוק י (דיון).
|
||||
</p>
|
||||
<PrecedentsSection
|
||||
caseNumber={caseNumber}
|
||||
sectionId={null}
|
||||
precedents={caseLevelPrecedents}
|
||||
practiceArea={practiceArea}
|
||||
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* השלמה והעברה */}
|
||||
<FinishRail
|
||||
caseNumber={caseNumber}
|
||||
hasAnalysis={!!analysis.data}
|
||||
onUploaded={() => analysis.refetch()}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { use, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CaseHeader } from "@/components/cases/case-header";
|
||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||
import { StatusGuide } from "@/components/cases/status-guide";
|
||||
import { StatusChanger } from "@/components/cases/status-changer";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
|
||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||
import { HearingChangesPanel } from "@/components/cases/hearing-changes-panel";
|
||||
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||
import { toast } from "sonner";
|
||||
import { Play, Loader2 } from "lucide-react";
|
||||
|
||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||
);
|
||||
import {
|
||||
Play, Loader2, LayoutGrid, Scale, BadgeCheck, FileText, MessageSquare, Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/*
|
||||
* Next 16 breaking change: route params are now a Promise.
|
||||
@@ -39,12 +40,10 @@ export default function CaseDetailPage({
|
||||
params: Promise<{ caseNumber: string }>;
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const [tab, setTab] = useState("overview");
|
||||
const { data, isPending, error, refetch } = useCase(caseNumber);
|
||||
const startWorkflow = useStartWorkflow(caseNumber);
|
||||
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
||||
const expectedOutcomeLabel = data?.expected_outcome
|
||||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||
: null;
|
||||
|
||||
// Only take over the whole page when there is NO data to show. A transient
|
||||
// 5xx on the 5s background refetch must not blow away an already-loaded page.
|
||||
@@ -71,23 +70,32 @@ export default function CaseDetailPage({
|
||||
);
|
||||
}
|
||||
|
||||
// Workflow order (X17): intake → arguments+positions → verify citations →
|
||||
// write decision → drafts/final → agents (monitoring).
|
||||
const tabDefs: [string, string, LucideIcon][] = [
|
||||
["overview", "סקירה", LayoutGrid],
|
||||
["arguments", "טיעונים ועמדות", Scale],
|
||||
["verify", "אימות פסיקה", BadgeCheck],
|
||||
["decision", "ההחלטה", FileText],
|
||||
["drafts", "טיוטות והערות", MessageSquare],
|
||||
["agents", "סוכנים", Users],
|
||||
];
|
||||
|
||||
// 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 = (
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="gap-6 h-auto p-0 rounded-none -mb-px"
|
||||
className="!h-auto w-full gap-2 p-0 justify-between"
|
||||
>
|
||||
{[
|
||||
["overview", "סקירה"],
|
||||
["arguments", "טיעונים"],
|
||||
["decision", "ההחלטה"],
|
||||
["drafts", "טיוטות והערות"],
|
||||
["agents", "סוכנים"],
|
||||
].map(([value, label]) => (
|
||||
{tabDefs.map(([value, label, Icon]) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className="flex-none rounded-none px-0 pb-3.5 pt-0 text-[0.92rem] font-medium text-ink-muted data-active:text-navy data-active:font-semibold data-active:after:bg-gold data-active:after:bottom-0"
|
||||
className="flex-none gap-1.5 rounded-lg border border-transparent px-3.5 py-1.5 text-[0.9rem] font-medium text-ink-soft after:hidden hover:bg-parchment/60 data-active:bg-surface data-active:text-navy data-active:font-semibold data-active:border-rule data-active:shadow-sm [&_svg]:text-ink-light data-active:[&_svg]:text-gold-deep"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
@@ -98,9 +106,6 @@ export default function CaseDetailPage({
|
||||
<>
|
||||
{data && <CaseEditDialog data={data} />}
|
||||
<UploadSheet caseNumber={caseNumber} />
|
||||
<Button asChild className="bg-gold text-white hover:bg-gold-deep border-transparent">
|
||||
<Link href={`/cases/${caseNumber}/compose`}>פתח עורך החלטה</Link>
|
||||
</Button>
|
||||
{canStartWorkflow && (
|
||||
<Button
|
||||
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
||||
@@ -126,7 +131,7 @@ export default function CaseDetailPage({
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<Tabs defaultValue="overview" dir="rtl">
|
||||
<Tabs value={tab} onValueChange={setTab} dir="rtl">
|
||||
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
|
||||
{isPending ? (
|
||||
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
|
||||
@@ -135,76 +140,130 @@ export default function CaseDetailPage({
|
||||
<Skeleton className="h-6 w-96" />
|
||||
</div>
|
||||
) : (
|
||||
<CaseHeader data={data} actions={bandActions} tabs={tabsList} />
|
||||
<CaseHeader caseNumber={caseNumber} data={data} actions={bandActions} tabs={tabsList} />
|
||||
)}
|
||||
|
||||
{/* two-column wrap — main tab content (1fr) + rail (340px) */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start mt-6">
|
||||
<div className="min-w-0">
|
||||
<TabsContent value="overview" className="mt-0 space-y-5">
|
||||
{/* full-width tab content — status now lives in the always-visible band
|
||||
StatusRail (banner A, X17), so every tab keeps the status context. */}
|
||||
<div className="min-w-0 mt-6">
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
{/* documents (wider) + agent-activity preview (mockup 18j) */}
|
||||
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr] items-start">
|
||||
<DocumentsPanel data={data} />
|
||||
|
||||
<AgentActivityPreview caseNumber={caseNumber} />
|
||||
{/* decision-editor CTA moved to the band actions (visible on all tabs) */}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="arguments" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
{/* טיעונים ועמדות — chair positions (editing) over the analyst's
|
||||
issues, with the aggregated by-party arguments collapsible below
|
||||
(merged from the deleted /compose editor, mockup 18f). */}
|
||||
<TabsContent value="arguments" className="mt-0 space-y-4">
|
||||
{/* עמדות וטענות — collapsed by default (chair preference #226),
|
||||
mirroring the two accordions below. */}
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem
|
||||
value="positions"
|
||||
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<span className="flex flex-1 flex-col items-start">
|
||||
<span className="text-navy text-[0.95rem] font-bold">
|
||||
עמדות וטענות
|
||||
</span>
|
||||
<span className="text-ink-muted text-[0.78rem]">
|
||||
סוגיות-המחלוקת ועמדות הצדדים · עורך עמדת-היו״ר מזין את בלוק י׳
|
||||
</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-5 pt-0">
|
||||
<PositionsPanel caseNumber={caseNumber} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem
|
||||
value="byparty"
|
||||
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">
|
||||
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
{/* מה קרה בדיון — comparative analysis of the hearing protocol vs.
|
||||
the written pleadings (#226). Sits below the aggregated arguments
|
||||
because it speaks to those same arguments in their oral gloss.
|
||||
Collapsed by default, mirroring the "by-party" accordion above. */}
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem
|
||||
value="hearing"
|
||||
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||||
>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||
<span className="flex flex-1 flex-col items-start">
|
||||
<span className="text-navy text-[0.95rem] font-bold">
|
||||
מה קרה בדיון
|
||||
</span>
|
||||
<span className="text-ink-muted text-[0.78rem]">
|
||||
השוואת הטענות בכתב מול הדיון בעל-פה — מה התחזק ומה נטען לראשונה
|
||||
</span>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-5 pt-0">
|
||||
<HearingChangesPanel caseNumber={caseNumber} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="decision" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<AgentActivityFeed caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
{/* rail — status timeline + status controls (mockup .rail) */}
|
||||
<div className="space-y-5">
|
||||
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0 h-fit">
|
||||
<div className="px-5 py-3.5 border-b border-rule-soft bg-parchment text-[0.92rem] font-semibold text-navy">
|
||||
סטטוס התיק
|
||||
</div>
|
||||
<CardContent className="px-5 py-4 space-y-4">
|
||||
<AgentStatusWidget caseNumber={caseNumber} />
|
||||
<WorkflowTimeline status={data?.status} />
|
||||
{expectedOutcomeLabel && (
|
||||
<div className="border-t border-rule-soft pt-3">
|
||||
<dl className="flex justify-between items-center text-sm">
|
||||
<dt className="text-ink-muted">תוצאה צפויה</dt>
|
||||
<dd className="rounded-full bg-warn-bg text-warn text-[0.75rem] font-semibold px-3 py-0.5">
|
||||
{expectedOutcomeLabel}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
|
||||
<StatusGuide />
|
||||
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),
|
||||
relocated from the deleted /compose editor to a top-level tab. */}
|
||||
<TabsContent value="verify" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<CitationVerificationPanel caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="decision" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<CaseFilesBrowser caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-0">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<AgentActivityFeed caseNumber={caseNumber} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</AppShell>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type ChairFeedback,
|
||||
type FeedbackCategory,
|
||||
} from "@/lib/api/feedback";
|
||||
import { formatDateLong } from "@/lib/format-date";
|
||||
|
||||
/**
|
||||
* מרכז הערות יו"ר — הדף המרכזי לטיפול בכל הערות דפנה שנרשמו על טיוטות
|
||||
@@ -33,18 +34,7 @@ const CAT_CHIP: Record<FeedbackCategory, string> = {
|
||||
other: "bg-rule-soft text-ink-soft",
|
||||
};
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
const formatDate = (iso?: string | null) => formatDateLong(iso);
|
||||
|
||||
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
|
||||
const resolve = useResolveFeedback();
|
||||
|
||||
@@ -29,13 +29,17 @@ import {
|
||||
useRunLog,
|
||||
useCancelRun,
|
||||
useResetAgentSession,
|
||||
useAgentHealth,
|
||||
useRecentEscalations,
|
||||
type OpsService,
|
||||
type OperationsSnapshot,
|
||||
type PipelineStats,
|
||||
type AgentRun,
|
||||
type SubscriptionUsage,
|
||||
type UsageWindow,
|
||||
type AgentHealthState,
|
||||
} from "@/lib/api/operations";
|
||||
import { formatTime, israelParts } from "@/lib/format-date";
|
||||
|
||||
function mb(bytes: number): string {
|
||||
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
||||
@@ -46,8 +50,7 @@ function mb(bytes: number): string {
|
||||
// and resumes on its own when a window resets.
|
||||
function usageResetLabel(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`;
|
||||
return `איפוס ${formatTime(iso)}`;
|
||||
}
|
||||
|
||||
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
|
||||
@@ -195,8 +198,9 @@ function nextSaturday18(): Date {
|
||||
}
|
||||
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
|
||||
function fmtDeadline(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return `${HE_DAYS[d.getDay()]} ${pad2(d.getDate())}/${pad2(d.getMonth() + 1)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
const p = israelParts(iso);
|
||||
if (!p) return "—";
|
||||
return `${HE_DAYS[p.weekday]} ${pad2(p.day)}/${pad2(p.month)} ${pad2(p.hour)}:${pad2(p.minute)}`;
|
||||
}
|
||||
|
||||
function BurstControl({ s }: { s: OpsService }) {
|
||||
@@ -801,6 +805,154 @@ function LiveAgentsPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||
const HEALTH_LABEL: Record<AgentHealthState, string> = {
|
||||
zombie: "זומבי",
|
||||
stalled: "תקוע",
|
||||
working: "עובד",
|
||||
idle: "רגוע",
|
||||
};
|
||||
|
||||
function AgentHealthPanel() {
|
||||
const { data, isLoading } = useAgentHealth();
|
||||
const { data: esc } = useRecentEscalations();
|
||||
|
||||
const counts = data?.counts;
|
||||
// Only zombies/stalled are "problems" — the backend already sorts worst-first.
|
||||
// An escalated issue is chair-owned, so it leaves this list; the escalations
|
||||
// card below is the handled-loop history.
|
||||
const problems = (data?.items ?? []).filter(
|
||||
(i) => i.health === "zombie" || i.health === "stalled",
|
||||
);
|
||||
const healthyWorking = counts?.working ?? 0;
|
||||
const healthyIdle = counts?.idle ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<StatTile
|
||||
label="זומבי"
|
||||
value={counts?.zombie ?? 0}
|
||||
tone="red"
|
||||
title="לולאת-recovery — משויך+פעיל-כביכול אך אין ריצה חיה. מוסלם אוטומטית."
|
||||
/>
|
||||
<StatTile
|
||||
label="תקוע"
|
||||
value={counts?.stalled ?? 0}
|
||||
tone="amber"
|
||||
title="ננער חוזר בלי התקדמות"
|
||||
/>
|
||||
<StatTile label="עובד" value={counts?.working ?? 0} tone="green" />
|
||||
<StatTile label="רגוע" value={counts?.idle ?? 0} tone="muted" />
|
||||
</div>
|
||||
|
||||
{problems.length === 0 ? (
|
||||
<p className="text-sm text-emerald-600">
|
||||
אין סוכן תקוע כרגע — כל הסוכנים תקינים.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{problems.map((p) => (
|
||||
<div
|
||||
key={p.issue_id}
|
||||
className={
|
||||
p.health === "zombie"
|
||||
? "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2"
|
||||
: "flex items-center justify-between gap-3 rounded-md border border-gold/40 bg-gold-wash px-3 py-2"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<Badge
|
||||
variant={p.health === "zombie" ? "destructive" : "outline"}
|
||||
className={
|
||||
p.health === "stalled"
|
||||
? "border-warn/50 text-warn font-normal"
|
||||
: "font-normal"
|
||||
}
|
||||
>
|
||||
{HEALTH_LABEL[p.health]}
|
||||
</Badge>
|
||||
<span dir="ltr" className="font-mono text-[0.82rem] text-navy font-semibold">
|
||||
{p.identifier}
|
||||
</span>
|
||||
<span className="text-[0.82rem] text-navy font-semibold">
|
||||
{p.agent_name}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-ink-muted">
|
||||
{p.recovery_wakeups > 0
|
||||
? `${p.recovery_wakeups} יקיצות-שחזור`
|
||||
: `${p.wakeups} יקיצות`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[0.7rem] text-ink-muted shrink-0">
|
||||
{p.health === "zombie" ? "יוסלם אוטומטית" : "מנוטר"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-[0.78rem] text-ink-muted">
|
||||
<b className="text-navy">{healthyWorking + healthyIdle} סוכנים תקינים</b> —{" "}
|
||||
{healthyWorking} עובדים, {healthyIdle} רגועים. מוצגות רק בעיות.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* handled-loop history — the watchdog's escalations, gold-wash like a gate */}
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-sm font-semibold mb-0.5">הסלמות אוטומטיות אחרונות</h3>
|
||||
<p className="text-ink-muted text-[0.72rem] mb-3">
|
||||
כל זומבי מתמשך (לולאת-recovery ≥2 יקיצות-שחזור) משויך אליך אוטומטית עם הערת-מערכת
|
||||
— בלי להעיר סוכן מחדש.
|
||||
</p>
|
||||
{!esc?.items?.length ? (
|
||||
<p className="text-sm text-ink-muted">אין הסלמות אחרונות.</p>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{esc.items.map((e) => (
|
||||
<div
|
||||
key={`${e.issue_id}-${e.created_at ?? ""}`}
|
||||
className="flex items-start gap-2.5 border-t border-gold/15 first:border-0 pt-2 first:pt-0"
|
||||
>
|
||||
<Badge
|
||||
variant={e.severity === "medium" ? "outline" : "destructive"}
|
||||
className={
|
||||
e.severity === "medium"
|
||||
? "border-gold/50 text-gold-deep font-normal shrink-0"
|
||||
: "font-normal shrink-0"
|
||||
}
|
||||
>
|
||||
{e.severity}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span dir="ltr" className="font-mono text-[0.8rem] text-navy font-semibold">
|
||||
{e.identifier}
|
||||
</span>
|
||||
<div className="text-[0.78rem] text-ink-soft">{e.reason}</div>
|
||||
</div>
|
||||
<span className="text-[0.68rem] text-ink-muted shrink-0">
|
||||
{e.created_at ? ago(Date.parse(e.created_at)) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { data, isLoading, error } = useOperations();
|
||||
|
||||
@@ -839,6 +991,9 @@ export default function OperationsPage() {
|
||||
<SectionHeader>סוכנים פעילים</SectionHeader>
|
||||
<LiveAgentsPanel />
|
||||
|
||||
<SectionHeader>בריאות-הסוכנים</SectionHeader>
|
||||
<AgentHealthPanel />
|
||||
|
||||
<SectionHeader>מתאמי-סוכנים</SectionHeader>
|
||||
<AgentAdaptersPanel />
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useRunScript,
|
||||
type ScriptRunResult,
|
||||
} from "@/lib/api/scripts";
|
||||
import { formatDateLong } from "@/lib/format-date";
|
||||
|
||||
/*
|
||||
* /scripts — catalog of everything under scripts/, rendered as the
|
||||
@@ -354,11 +355,7 @@ export default function ScriptsPage() {
|
||||
|
||||
const lastModified =
|
||||
data?.last_modified != null
|
||||
? new Date(data.last_modified * 1000).toLocaleDateString("he-IL", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
? formatDateLong(data.last_modified * 1000)
|
||||
: null;
|
||||
|
||||
const giteaBase = data?.gitea_url ?? null;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type DriftEntry,
|
||||
type PaperclipAgent,
|
||||
} from "@/lib/api/paperclip-agents";
|
||||
import { formatDateShort } from "@/lib/format-date";
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
ceo: "CEO",
|
||||
@@ -211,7 +212,7 @@ function PairCard({ pair }: { pair: AgentPair }) {
|
||||
</Button>
|
||||
{pair.master?.updated_at && (
|
||||
<span className="text-[0.7rem] text-ink-light">
|
||||
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
|
||||
עודכן: {formatDateShort(pair.master.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useSkills, type Skill } from "@/lib/api/skills";
|
||||
import { formatIsoDate } from "@/lib/format-date";
|
||||
|
||||
function formatChars(skill: Skill): string {
|
||||
// Mockup column = "גודל (תווים)" — the DB markdown char count, grouped.
|
||||
@@ -21,13 +22,8 @@ function formatChars(skill: Skill): string {
|
||||
}
|
||||
|
||||
function formatUpdated(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
// ISO-style date to match the mockup (2026-06-09), tabular.
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return "—";
|
||||
}
|
||||
// ISO-style date to match the mockup (2026-06-09), tabular — Israel-time day.
|
||||
return formatIsoDate(iso);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
useAgentActivity,
|
||||
useSendComment,
|
||||
useSubmitInteraction,
|
||||
useDismissInteraction,
|
||||
useSetIssueStatus,
|
||||
} from "@/lib/api/agents";
|
||||
import type {
|
||||
Interaction,
|
||||
@@ -16,7 +18,10 @@ import type {
|
||||
InteractionQuestion,
|
||||
InteractionTask,
|
||||
PaperclipComment,
|
||||
PaperclipAgentStatus,
|
||||
PaperclipIssue,
|
||||
} from "@/lib/api/agents";
|
||||
import { formatRelative } from "@/lib/format-date";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Bot,
|
||||
@@ -28,6 +33,11 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
Ban,
|
||||
Target,
|
||||
Plus,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
/* ── Role → color mapping ────────────────────────────────────── */
|
||||
@@ -80,18 +90,83 @@ function issueStatusLabel(status: string) {
|
||||
return ISSUE_STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
/* ── Agent roster (mockup 18i) — who's doing what right now ────── */
|
||||
|
||||
const AGENT_STATUS_LABEL: Record<string, string> = {
|
||||
active: "פעיל",
|
||||
running: "פעיל",
|
||||
idle: "ממתין",
|
||||
error: "שגיאה",
|
||||
};
|
||||
|
||||
function agentIsActive(s: string) {
|
||||
return s === "active" || s === "running";
|
||||
}
|
||||
|
||||
function agentStatusDot(s: string) {
|
||||
return agentIsActive(s)
|
||||
? "bg-success shadow-[0_0_6px_rgba(74,124,89,0.6)]"
|
||||
: s === "error"
|
||||
? "bg-danger"
|
||||
: "bg-rule";
|
||||
}
|
||||
|
||||
function agentStatusTone(s: string) {
|
||||
return agentIsActive(s)
|
||||
? "bg-success-bg text-success"
|
||||
: s === "error"
|
||||
? "bg-danger-bg text-danger"
|
||||
: "bg-rule-soft text-ink-muted";
|
||||
}
|
||||
|
||||
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
|
||||
if (!agents.length) return null;
|
||||
|
||||
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 p-3">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Bot className="w-4 h-4 text-gold-deep shrink-0" />
|
||||
<h3 className="text-[0.82rem] font-bold text-navy">סוכני התיק</h3>
|
||||
<span className="text-[0.72rem] text-ink-muted">
|
||||
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Compact roster — all agents on one row (mockup 18i v2): status · name ·
|
||||
role only. The per-agent "current activity" line was removed: it
|
||||
matched comments by role-fallback, so agents sharing a role showed the
|
||||
same text. */}
|
||||
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
|
||||
{agents.map((a) => {
|
||||
const active = agentIsActive(a.status);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
||||
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
|
||||
</div>
|
||||
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
|
||||
<span
|
||||
className={`inline-block mt-1 text-[0.55rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
|
||||
>
|
||||
{AGENT_STATUS_LABEL[a.status] ?? a.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Time formatting ─────────────────────────────────────────── */
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return "עכשיו";
|
||||
if (mins < 60) return `לפני ${mins} דק׳`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `לפני ${hours} שע׳`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `לפני ${days} ימים`;
|
||||
return formatRelative(iso, { units: "short", fallback: "" });
|
||||
}
|
||||
|
||||
/* ── Issue identifier → find matching identifier ─────────────── */
|
||||
@@ -513,15 +588,21 @@ function InteractionCard({
|
||||
interaction,
|
||||
caseNumber,
|
||||
issueMap,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
interaction: Interaction;
|
||||
caseNumber: string;
|
||||
issueMap: Map<string, string>;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const submit = useSubmitInteraction(caseNumber);
|
||||
const dismiss = useDismissInteraction(caseNumber);
|
||||
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
||||
const isPending = interaction.status === "pending";
|
||||
const summary = summaryAnswer(interaction);
|
||||
// Pending interactions collapse into an accordion (mockup 18i v2); resolved
|
||||
// ones always render their one-line summary.
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
|
||||
submit.mutate(
|
||||
@@ -538,6 +619,16 @@ function InteractionCard({
|
||||
);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
dismiss.mutate(
|
||||
{ issue_id: interaction.issue_id, interaction_id: interaction.id },
|
||||
{
|
||||
onSuccess: () => toast.success("הבקשה בוטלה"),
|
||||
onError: () => toast.error("שגיאה בביטול הבקשה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
||||
@@ -560,15 +651,30 @@ function InteractionCard({
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-navy">
|
||||
{interaction.title || "שאלה לסוכן"}
|
||||
</span>
|
||||
{isPending ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
||||
ממתין לתשובה
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 flex-wrap text-start min-w-0"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-navy">
|
||||
{interaction.title || "שאלה לסוכן"}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
||||
ממתין לתשובה
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<ResolvedBadge status={interaction.status} />
|
||||
<>
|
||||
<span className="text-sm font-semibold text-navy">
|
||||
{interaction.title || "שאלה לסוכן"}
|
||||
</span>
|
||||
<ResolvedBadge status={interaction.status} />
|
||||
</>
|
||||
)}
|
||||
{identifier && (
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
@@ -579,39 +685,58 @@ function InteractionCard({
|
||||
<Clock className="w-3 h-3" />
|
||||
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||
</span>
|
||||
{isPending && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDismiss}
|
||||
disabled={dismiss.isPending}
|
||||
className="h-7 px-2 text-ink-muted hover:text-danger"
|
||||
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
|
||||
>
|
||||
{dismiss.isPending ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Ban className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
התעלם
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{interaction.summary && (
|
||||
{interaction.summary && (!isPending || open) && (
|
||||
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||||
)}
|
||||
|
||||
{isPending ? (
|
||||
interaction.kind === "ask_user_questions" ? (
|
||||
<AskUserQuestionsForm
|
||||
interaction={interaction}
|
||||
onSubmit={(answers) => send("respond", { answers })}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : interaction.kind === "request_confirmation" ? (
|
||||
<RequestConfirmationForm
|
||||
interaction={interaction}
|
||||
onAccept={() => send("accept", {})}
|
||||
onReject={(reason) =>
|
||||
send("reject", reason ? { reason } : {})
|
||||
}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : interaction.kind === "suggest_tasks" ? (
|
||||
<SuggestTasksForm
|
||||
interaction={interaction}
|
||||
onAccept={(keys) =>
|
||||
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
||||
}
|
||||
onReject={(reason) =>
|
||||
send("reject", reason ? { reason } : {})
|
||||
}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
open ? (
|
||||
interaction.kind === "ask_user_questions" ? (
|
||||
<AskUserQuestionsForm
|
||||
interaction={interaction}
|
||||
onSubmit={(answers) => send("respond", { answers })}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : interaction.kind === "request_confirmation" ? (
|
||||
<RequestConfirmationForm
|
||||
interaction={interaction}
|
||||
onAccept={() => send("accept", {})}
|
||||
onReject={(reason) =>
|
||||
send("reject", reason ? { reason } : {})
|
||||
}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : interaction.kind === "suggest_tasks" ? (
|
||||
<SuggestTasksForm
|
||||
interaction={interaction}
|
||||
onAccept={(keys) =>
|
||||
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
||||
}
|
||||
onReject={(reason) =>
|
||||
send("reject", reason ? { reason } : {})
|
||||
}
|
||||
pending={submit.isPending}
|
||||
/>
|
||||
) : null
|
||||
) : null
|
||||
) : summary ? (
|
||||
<div className="text-sm">
|
||||
@@ -623,6 +748,480 @@ function InteractionCard({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Issue group (timeline grouped by issue, accordion) ──────── */
|
||||
|
||||
type FeedItem =
|
||||
| { kind: "comment"; at: number; comment: PaperclipComment }
|
||||
| { kind: "interaction"; at: number; interaction: Interaction };
|
||||
|
||||
/** Drop the "[ערר 1043-02-26] " prefix Paperclip puts on every issue title. */
|
||||
function shortIssueTitle(title: string): string {
|
||||
return title.replace(/^\s*\[[^\]]*\]\s*/, "").trim() || title;
|
||||
}
|
||||
|
||||
function IssueGroup({
|
||||
issue,
|
||||
items,
|
||||
caseNumber,
|
||||
issueMap,
|
||||
defaultOpen,
|
||||
}: {
|
||||
issue: PaperclipIssue;
|
||||
items: FeedItem[];
|
||||
caseNumber: string;
|
||||
issueMap: Map<string, string>;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const setStatus = useSetIssueStatus(caseNumber);
|
||||
const closed = issue.status === "done" || issue.status === "cancelled";
|
||||
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
|
||||
// the live CEO root) so the hierarchy is visible, not flattened.
|
||||
const parentIdentifier = issue.parent_id
|
||||
? issueMap.get(issue.parent_id)
|
||||
: undefined;
|
||||
|
||||
const close = (status: "done" | "cancelled") =>
|
||||
setStatus.mutate(
|
||||
{ issue_id: issue.id, status },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toast.success(
|
||||
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
|
||||
),
|
||||
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-b-2 border-rule last:border-b-0">
|
||||
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex-1 flex items-center gap-2 text-start min-w-0"
|
||||
>
|
||||
<Badge
|
||||
variant={closed ? "secondary" : "default"}
|
||||
className="text-[10px] font-mono shrink-0"
|
||||
>
|
||||
{issue.identifier}
|
||||
</Badge>
|
||||
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
||||
{shortIssueTitle(issue.title)}
|
||||
</span>
|
||||
{parentIdentifier && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parentIdentifier}`}
|
||||
>
|
||||
↳ {parentIdentifier}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||||
{issueStatusLabel(issue.status)} · {items.length}
|
||||
</span>
|
||||
</button>
|
||||
{!closed && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => close("done")}
|
||||
disabled={setStatus.isPending}
|
||||
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
|
||||
title="סמן את המשימה כהושלמה"
|
||||
>
|
||||
{setStatus.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
|
||||
)}
|
||||
הושלם
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => close("cancelled")}
|
||||
disabled={setStatus.isPending}
|
||||
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
|
||||
title="בטל את המשימה"
|
||||
>
|
||||
בטל
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={open ? "כווץ" : "הרחב"}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="pb-1">
|
||||
{items.map((item) =>
|
||||
item.kind === "comment" ? (
|
||||
<CommentCard
|
||||
key={`c-${item.comment.id}`}
|
||||
comment={item.comment}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
) : (
|
||||
<InteractionCard
|
||||
key={`i-${item.interaction.id}`}
|
||||
interaction={item.interaction}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Compose: directive box + target selector (top of tab) ────── */
|
||||
|
||||
const isOpenStatus = (s: string) => s !== "done" && s !== "cancelled";
|
||||
|
||||
/** Client mirror of the server's pick_default_comment_target
|
||||
* (web/paperclip_client.py): prefer the newest OPEN top-level issue (the live
|
||||
* CEO main issue), then any open issue, then any top-level, else newest.
|
||||
* `issues` arrives oldest→newest, so the last match is the newest. */
|
||||
function pickDefaultTarget(issues: PaperclipIssue[]): PaperclipIssue | null {
|
||||
if (!issues.length) return null;
|
||||
const isTop = (i: PaperclipIssue) => i.parent_id == null;
|
||||
const preds: ((i: PaperclipIssue) => boolean)[] = [
|
||||
(i) => isOpenStatus(i.status) && isTop(i),
|
||||
(i) => isOpenStatus(i.status),
|
||||
isTop,
|
||||
];
|
||||
for (const p of preds) {
|
||||
const m = issues.filter(p);
|
||||
if (m.length) return m[m.length - 1];
|
||||
}
|
||||
return issues[issues.length - 1];
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<string, string> = {
|
||||
in_progress: "bg-emerald-100 text-emerald-700",
|
||||
in_review: "bg-amber-100 text-amber-700",
|
||||
todo: "bg-emerald-100 text-emerald-700",
|
||||
backlog: "bg-emerald-100 text-emerald-700",
|
||||
blocked: "bg-red-100 text-red-700",
|
||||
done: "bg-gray-100 text-gray-500",
|
||||
cancelled: "bg-red-50 text-red-600",
|
||||
};
|
||||
function statusTone(s: string) {
|
||||
return STATUS_TONE[s] ?? "bg-gray-100 text-gray-600";
|
||||
}
|
||||
|
||||
/** Where a chair instruction is routed. `auto` lets the server pick the live
|
||||
* CEO main issue; `issue` targets an explicit one; `new_run` opens a fresh
|
||||
* CEO-owned run (bypasses the human-owned-issue cancellation). */
|
||||
type Target =
|
||||
| { kind: "auto" }
|
||||
| { kind: "issue"; id: string }
|
||||
| { kind: "new_run" };
|
||||
|
||||
function TargetSelector({
|
||||
issues,
|
||||
target,
|
||||
onChange,
|
||||
}: {
|
||||
issues: PaperclipIssue[];
|
||||
target: Target;
|
||||
onChange: (t: Target) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
|
||||
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
|
||||
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
|
||||
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
|
||||
const idToIdentifier = useMemo(
|
||||
() => new Map(issues.map((i) => [i.id, i.identifier])),
|
||||
[issues],
|
||||
);
|
||||
const parentTag = (i: PaperclipIssue) =>
|
||||
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
|
||||
|
||||
// The issue the pill currently represents (explicit pick, or the auto default).
|
||||
const effectiveIssue =
|
||||
target.kind === "issue"
|
||||
? issues.find((i) => i.id === target.id) ?? null
|
||||
: target.kind === "auto"
|
||||
? defaultIssue
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 bg-sand-soft/60 border border-rule rounded-lg px-2.5 py-1.5 hover:border-gold/60 min-w-0 max-w-full"
|
||||
>
|
||||
<Target className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||
{target.kind === "new_run" ? (
|
||||
<span className="text-xs font-semibold text-navy">פתח הרצה חדשה</span>
|
||||
) : effectiveIssue ? (
|
||||
<>
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{effectiveIssue.identifier}
|
||||
</Badge>
|
||||
<span className="text-xs font-medium text-navy truncate">
|
||||
{shortIssueTitle(effectiveIssue.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(effectiveIssue.status)}`}
|
||||
>
|
||||
{issueStatusLabel(effectiveIssue.status)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-ink-muted">בחר יעד</span>
|
||||
)}
|
||||
<ChevronDown className="w-3.5 h-3.5 text-ink-faint shrink-0 ms-1" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
|
||||
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
|
||||
visible without scrolling to the bottom of a long task list. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ kind: "new_run" });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
|
||||
>
|
||||
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
|
||||
<Plus className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-semibold text-navy">
|
||||
פתח הרצה חדשה
|
||||
</span>
|
||||
<span className="block text-[10px] text-ink-muted">
|
||||
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||
|
||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||
משימות פעילות
|
||||
</div>
|
||||
{activeIssues.map((i) => {
|
||||
const selected =
|
||||
(target.kind === "issue" && target.id === i.id) ||
|
||||
(target.kind === "auto" && defaultIssue?.id === i.id);
|
||||
const parent = parentTag(i);
|
||||
return (
|
||||
<button
|
||||
key={i.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ kind: "issue", id: i.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{i.identifier}
|
||||
</Badge>
|
||||
{parent && (
|
||||
<span
|
||||
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parent}`}
|
||||
>
|
||||
↳ {parent}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ink-soft truncate flex-1">
|
||||
{shortIssueTitle(i.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||
>
|
||||
{issueStatusLabel(i.status)}
|
||||
</span>
|
||||
{selected && (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-gold-deep shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{closedIssues.length > 0 && (
|
||||
<>
|
||||
<div className="h-px bg-rule-soft my-1.5 mx-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
|
||||
משימות סגורות · שליחה אליהן לא תעיר סוכן
|
||||
</div>
|
||||
{closedIssues.map((i) => {
|
||||
const parent = parentTag(i);
|
||||
return (
|
||||
<div
|
||||
key={i.id}
|
||||
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
|
||||
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
|
||||
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
|
||||
{i.identifier}
|
||||
</Badge>
|
||||
{parent && (
|
||||
<span
|
||||
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
|
||||
title={`תת-משימה של ${parent}`}
|
||||
>
|
||||
↳ {parent}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ink-faint truncate flex-1">
|
||||
{shortIssueTitle(i.title)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
|
||||
>
|
||||
{issueStatusLabel(i.status)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({
|
||||
caseNumber,
|
||||
issues,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
issues: PaperclipIssue[];
|
||||
}) {
|
||||
const sendComment = useSendComment(caseNumber);
|
||||
const [body, setBody] = useState("");
|
||||
const [target, setTarget] = useState<Target>({ kind: "auto" });
|
||||
|
||||
const selectedIssue =
|
||||
target.kind === "issue" ? issues.find((i) => i.id === target.id) ?? null : null;
|
||||
const closedTarget = !!selectedIssue && !isOpenStatus(selectedIssue.status);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!body.trim() || closedTarget) return;
|
||||
const vars =
|
||||
target.kind === "new_run"
|
||||
? { body: body.trim(), new_run: true }
|
||||
: target.kind === "issue"
|
||||
? { body: body.trim(), issue_id: target.id }
|
||||
: { body: body.trim() };
|
||||
sendComment.mutate(vars, {
|
||||
onSuccess: (res) => {
|
||||
setBody("");
|
||||
setTarget({ kind: "auto" });
|
||||
toast.success(
|
||||
res.new_run
|
||||
? `נפתחה הרצה חדשה — ${res.issue_identifier}`
|
||||
: `נשלח ל-${res.issue_identifier}`,
|
||||
);
|
||||
},
|
||||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-rule rounded-xl shadow-sm p-3.5">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<MessageSquare className="w-4 h-4 text-gold-deep" />
|
||||
<h3 className="text-sm font-bold text-navy">הוראה לסוכנים</h3>
|
||||
<span className="text-[11px] text-ink-faint ms-auto">
|
||||
Ctrl+Enter לשליחה
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-[11px] font-semibold text-ink-muted shrink-0">
|
||||
שליחה אל:
|
||||
</span>
|
||||
<TargetSelector issues={issues} target={target} onChange={setTarget} />
|
||||
</div>
|
||||
|
||||
{closedTarget && (
|
||||
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-2 text-[11.5px] text-red-700 leading-snug">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<b className="font-bold">יעד סגור.</b> שליחה למשימה סגורה לא תעיר סוכן —
|
||||
ההוראה לא תטופל. בחר משימה פעילה או{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold underline"
|
||||
onClick={() => setTarget({ kind: "new_run" })}
|
||||
>
|
||||
פתח הרצה חדשה
|
||||
</button>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="כתוב הוראה לסוכנים..."
|
||||
aria-label="הוראה לסוכנים"
|
||||
className="min-h-[60px] resize-none text-sm"
|
||||
dir="rtl"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center mt-2">
|
||||
<span className="text-[11px] text-ink-faint">
|
||||
ההודעה תנותב דרך סוכן ה-CEO · היעד ניתן לשינוי למעלה
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ms-auto"
|
||||
onClick={handleSend}
|
||||
disabled={!body.trim() || closedTarget || sendComment.isPending}
|
||||
>
|
||||
{sendComment.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4 me-1" />
|
||||
)}
|
||||
שלח
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||
|
||||
export function AgentActivityFeed({
|
||||
@@ -631,9 +1230,6 @@ export function AgentActivityFeed({
|
||||
caseNumber: string;
|
||||
}) {
|
||||
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
||||
const sendComment = useSendComment(caseNumber);
|
||||
const [body, setBody] = useState("");
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
||||
// and a fresh Map each render would defeat the child cards' memoization).
|
||||
@@ -643,27 +1239,6 @@ export function AgentActivityFeed({
|
||||
return m;
|
||||
}, [data?.issues]);
|
||||
|
||||
// Auto-scroll on new comments or interactions
|
||||
const commentCount = data?.comments?.length ?? 0;
|
||||
const interactionCount = data?.interactions?.length ?? 0;
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [commentCount, interactionCount]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!body.trim()) return;
|
||||
sendComment.mutate(
|
||||
{ body: body.trim() },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
setBody("");
|
||||
toast.success(`נשלח ל-${res.issue_identifier}`);
|
||||
},
|
||||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// ── Empty / loading states ──
|
||||
|
||||
if (isLoading) {
|
||||
@@ -696,24 +1271,46 @@ export function AgentActivityFeed({
|
||||
|
||||
const comments = data.comments ?? [];
|
||||
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.
|
||||
type FeedItem =
|
||||
| { kind: "comment"; at: number; comment: PaperclipComment }
|
||||
| { kind: "interaction"; at: number; interaction: Interaction };
|
||||
|
||||
const feed: FeedItem[] = [
|
||||
...comments.map<FeedItem>((c) => ({
|
||||
// Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
|
||||
// resolved interactions; pending ones are surfaced above and not repeated.
|
||||
const itemsByIssue = new Map<string, FeedItem[]>();
|
||||
const pushItem = (issueId: string, item: FeedItem) => {
|
||||
const arr = itemsByIssue.get(issueId);
|
||||
if (arr) arr.push(item);
|
||||
else itemsByIssue.set(issueId, [item]);
|
||||
};
|
||||
for (const c of comments) {
|
||||
pushItem(c.issue_id, {
|
||||
kind: "comment",
|
||||
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
||||
comment: c,
|
||||
})),
|
||||
...interactions.map<FeedItem>((i) => ({
|
||||
});
|
||||
}
|
||||
for (const i of interactions) {
|
||||
if (i.status === "pending") continue;
|
||||
pushItem(i.issue_id, {
|
||||
kind: "interaction",
|
||||
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
||||
interaction: i,
|
||||
})),
|
||||
].sort((a, b) => a.at - b.at);
|
||||
});
|
||||
}
|
||||
for (const arr of itemsByIssue.values()) arr.sort((a, b) => a.at - b.at);
|
||||
|
||||
// Order groups by most-recent activity so live work floats to the top.
|
||||
const issueGroups = data.issues
|
||||
.map((iss) => {
|
||||
const items = itemsByIssue.get(iss.id) ?? [];
|
||||
const maxAt = items.reduce((m, it) => Math.max(m, it.at), 0);
|
||||
return { iss, items, maxAt };
|
||||
})
|
||||
.filter((g) => g.items.length > 0)
|
||||
.sort((a, b) => b.maxAt - a.maxAt);
|
||||
|
||||
const totalFeedItems = issueGroups.reduce((n, g) => n + g.items.length, 0);
|
||||
|
||||
// An issue is "active" if it's not done/cancelled. When everything is closed
|
||||
// we should NOT show the "agents are working, waiting for report" spinner.
|
||||
@@ -722,23 +1319,51 @@ export function AgentActivityFeed({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Issue summary bar */}
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
|
||||
{data.issues.map((iss) => (
|
||||
<Badge
|
||||
key={iss.id}
|
||||
variant={iss.status === "done" ? "secondary" : "default"}
|
||||
className="text-[11px] font-mono"
|
||||
>
|
||||
{iss.identifier} — {issueStatusLabel(iss.status)}
|
||||
</Badge>
|
||||
))}
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* directive box — moved to the TOP, above the activity (most available,
|
||||
no scrolling). Carries the target selector. */}
|
||||
<Composer caseNumber={caseNumber} issues={data.issues} />
|
||||
|
||||
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||
<AgentRoster agents={data.agents ?? []} />
|
||||
|
||||
{/* pending interactions surfaced to the top — each an accordion, all
|
||||
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
|
||||
{pending.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
||||
<Clock className="w-4 h-4" />
|
||||
ממתין לתשובתך
|
||||
<span className="text-[0.7rem] font-semibold bg-amber-100 rounded-full px-2 py-0.5">
|
||||
{pending.length}
|
||||
</span>
|
||||
</div>
|
||||
{pending.map((i) => (
|
||||
<InteractionCard
|
||||
key={`pending-${i.id}`}
|
||||
interaction={i}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline header */}
|
||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule">
|
||||
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||
<span className="text-[0.68rem] font-semibold text-gold-deep bg-gold/10 rounded-full px-2 py-0.5">
|
||||
החדש למעלה
|
||||
</span>
|
||||
<span className="text-[0.72rem] text-ink-muted ms-auto">
|
||||
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comments + interactions stream */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
||||
{feed.length === 0 ? (
|
||||
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
|
||||
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||
{totalFeedItems === 0 ? (
|
||||
hasActiveIssue ? (
|
||||
<div className="text-center py-8 text-ink-faint text-sm">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||
@@ -751,59 +1376,17 @@ export function AgentActivityFeed({
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
feed.map((item) =>
|
||||
item.kind === "comment" ? (
|
||||
<CommentCard
|
||||
key={`c-${item.comment.id}`}
|
||||
comment={item.comment}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
) : (
|
||||
<InteractionCard
|
||||
key={`i-${item.interaction.id}`}
|
||||
interaction={item.interaction}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
),
|
||||
)
|
||||
issueGroups.map(({ iss, items }) => (
|
||||
<IssueGroup
|
||||
key={iss.id}
|
||||
issue={iss}
|
||||
items={items}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
{/* Comment input */}
|
||||
<div className="border-t border-rule pt-3 mt-3 space-y-2">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="כתוב הוראה לסוכנים..."
|
||||
aria-label="הוראה לסוכנים"
|
||||
className="min-h-[60px] resize-none text-sm"
|
||||
dir="rtl"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-ink-faint">
|
||||
ההודעה תנותב דרך סוכן ה-CEO · 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>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { Loader2, CheckCircle2, Clock } from "lucide-react";
|
||||
import { useAgentActivity } from "@/lib/api/agents";
|
||||
import type { PaperclipIssue } from "@/lib/api/agents";
|
||||
import { formatRelative } from "@/lib/format-date";
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
in_progress: "bg-gold",
|
||||
@@ -22,13 +23,7 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(diffMs / 60_000);
|
||||
if (m < 60) return `לפני ${m}ד'`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `לפני ${h}ש'`;
|
||||
return `לפני ${Math.floor(h / 24)} ימים`;
|
||||
return formatRelative(iso, { units: "tight", fallback: "—" });
|
||||
}
|
||||
|
||||
function IssueRow({ issue }: { issue: PaperclipIssue }) {
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/cases/status-badge";
|
||||
import { SyncIndicator } from "@/components/cases/sync-indicator";
|
||||
import { StatusRail } from "@/components/cases/status-rail";
|
||||
import { CaseArchiveAction } from "@/components/cases/case-archive-action";
|
||||
import { CreateRepoButton } from "@/components/cases/create-repo-button";
|
||||
import {
|
||||
@@ -10,30 +10,14 @@ import {
|
||||
APPEAL_SUBTYPE_LABELS,
|
||||
isBlamSubtype,
|
||||
} from "@/lib/practice-area";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Users } from "lucide-react";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso ?? "—";
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* the case title + status/type chips inline, a parties line, the case actions
|
||||
@@ -41,15 +25,19 @@ function partiesLine(data?: CaseDetail): string | null {
|
||||
* the tab strip inside the band, anchored to its bottom edge.
|
||||
*/
|
||||
export function CaseHeader({
|
||||
caseNumber,
|
||||
data,
|
||||
actions,
|
||||
tabs,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
data?: CaseDetail;
|
||||
actions?: 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 =
|
||||
data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype);
|
||||
|
||||
@@ -72,6 +60,12 @@ export function CaseHeader({
|
||||
<span className="tabular-nums">
|
||||
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
|
||||
</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?.archived_at && (
|
||||
<Badge
|
||||
@@ -103,27 +97,44 @@ export function CaseHeader({
|
||||
)}
|
||||
</h1>
|
||||
|
||||
{/* case title / subject under the heading */}
|
||||
{data?.title && (
|
||||
<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 */}
|
||||
{/* case actions — parties now live in a popover (chair request) so
|
||||
they don't push the tab content down the page. */}
|
||||
<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 && (
|
||||
<CaseArchiveAction
|
||||
caseNumber={data.case_number}
|
||||
@@ -135,25 +146,13 @@ export function CaseHeader({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* metadata strip — hearing date / updated / sync */}
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm shrink-0">
|
||||
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
תאריך דיון
|
||||
</dt>
|
||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.hearing_date)}</dd>
|
||||
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
עודכן
|
||||
</dt>
|
||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
||||
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
סנכרון
|
||||
</dt>
|
||||
<dd><SyncIndicator caseNumber={data?.case_number} /></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* tab strip anchored to band bottom (mockup .tabs) */}
|
||||
{tabs ? <div className="mt-5">{tabs}</div> : null}
|
||||
{/* ★ integrated status rail — visible on every tab (banner A, X17) */}
|
||||
<StatusRail caseNumber={caseNumber} data={data} />
|
||||
|
||||
{/* tab strip anchored to band bottom */}
|
||||
{tabs ? <div className="mt-4">{tabs}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,32 +24,16 @@ import {
|
||||
import { StatusBadge } from "@/components/cases/status-badge";
|
||||
import { isBlamSubtype } from "@/lib/practice-area";
|
||||
import type { Case } from "@/lib/api/cases";
|
||||
import { formatDate, israelMidnightMs, todayIsraelMidnightMs } from "@/lib/format-date";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Midnight today, in ms — the pivot between an upcoming and a past hearing. */
|
||||
/** Midnight today in Israel time, in ms — pivot between an upcoming and a past hearing. */
|
||||
function startOfToday() {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
return todayIsraelMidnightMs();
|
||||
}
|
||||
|
||||
/** Day-precision ms for a hearing date, or null when absent/unparseable. */
|
||||
/** Day-precision ms (Israel time) for a hearing date, or null when absent/unparseable. */
|
||||
function hearingMs(iso?: string | null): number | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).setHours(0, 0, 0, 0);
|
||||
return Number.isNaN(t) ? null : t;
|
||||
return israelMidnightMs(iso);
|
||||
}
|
||||
|
||||
type HearingClass = "soon" | "upcoming" | "past" | "none";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type BlockStatus,
|
||||
} from "@/lib/api/decision-blocks";
|
||||
import { BLOCK_LABELS } from "@/lib/api/feedback";
|
||||
import { formatTime } from "@/lib/format-date";
|
||||
import { AlertTriangle, Pencil, FileText } from "lucide-react";
|
||||
|
||||
/* ── status badge styling ─────────────────────────────── */
|
||||
@@ -233,10 +234,7 @@ function SaveIndicator({ state }: { state: SaveState }) {
|
||||
return <span className="text-[0.72rem] text-ink-muted">⏳ שומר…</span>;
|
||||
}
|
||||
if (state.kind === "saved") {
|
||||
const time = state.at.toLocaleTimeString("he-IL", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const time = formatTime(state.at);
|
||||
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
||||
}
|
||||
return <span className="text-[0.72rem] text-danger">⚠ {state.message}</span>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
APPRAISER_SIDE_LABELS,
|
||||
APPRAISER_SIDE_OPTIONS,
|
||||
DOC_TYPE_OPTIONS,
|
||||
PROTOCOL_SCOPE_OPTIONS,
|
||||
appraiserSideLabel,
|
||||
doctypeLabel,
|
||||
doctypeTone,
|
||||
@@ -29,14 +31,17 @@ import {
|
||||
import {
|
||||
useExtractAppraiserFacts,
|
||||
usePatchDocument,
|
||||
type DocumentPatch,
|
||||
type ExtractAppraiserFactsResponse,
|
||||
} from "@/lib/api/documents";
|
||||
|
||||
/*
|
||||
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
||||
* Popover with two Selects:
|
||||
* Popover with:
|
||||
* 1. doc_type (always shown)
|
||||
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||
* 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית
|
||||
* 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4
|
||||
*
|
||||
* After a successful save we swap the Popover body to a confirmation view
|
||||
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
|
||||
@@ -49,15 +54,25 @@ export function DocumentTypeEditor({
|
||||
docId,
|
||||
docType,
|
||||
appraiserSide,
|
||||
isPostHearing,
|
||||
protocolScope,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
docId: string;
|
||||
docType: string;
|
||||
appraiserSide?: string;
|
||||
isPostHearing?: boolean;
|
||||
protocolScope?: string;
|
||||
}) {
|
||||
// Effective stored scope: absent === "appeal" (ועדת הערר) by convention.
|
||||
const storedScope = protocolScope === "lower" ? "lower" : "appeal";
|
||||
const storedPost = isPostHearing === true;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftType, setDraftType] = useState<string>(docType || "");
|
||||
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
||||
const [draftScope, setDraftScope] = useState<string>(storedScope);
|
||||
const [draftPost, setDraftPost] = useState<boolean>(storedPost);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [extractResult, setExtractResult] =
|
||||
useState<ExtractAppraiserFactsResponse | null>(null);
|
||||
@@ -67,6 +82,8 @@ export function DocumentTypeEditor({
|
||||
function reset() {
|
||||
setDraftType(docType || "");
|
||||
setDraftSide(appraiserSide || "");
|
||||
setDraftScope(storedScope);
|
||||
setDraftPost(storedPost);
|
||||
setSaved(false);
|
||||
setExtractResult(null);
|
||||
patch.reset();
|
||||
@@ -74,14 +91,17 @@ export function DocumentTypeEditor({
|
||||
}
|
||||
|
||||
const isAppraisal = draftType === "appraisal";
|
||||
const isProtocol = draftType === "protocol";
|
||||
const sideMissing = isAppraisal && !draftSide;
|
||||
const dirty =
|
||||
draftType !== docType ||
|
||||
(isAppraisal && draftSide !== (appraiserSide || ""));
|
||||
(isAppraisal && draftSide !== (appraiserSide || "")) ||
|
||||
(isProtocol && draftScope !== storedScope) ||
|
||||
draftPost !== storedPost;
|
||||
|
||||
async function handleSave() {
|
||||
if (sideMissing || !dirty) return;
|
||||
const body: { doc_type?: string; appraiser_side?: string } = {};
|
||||
const body: DocumentPatch = {};
|
||||
if (draftType !== docType) body.doc_type = draftType;
|
||||
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
||||
body.appraiser_side = draftSide;
|
||||
@@ -90,6 +110,16 @@ export function DocumentTypeEditor({
|
||||
// clear it so it doesn't dangle confusingly in metadata.
|
||||
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
||||
|
||||
// protocol_scope: store only the non-default "lower"; anything else clears
|
||||
// back to the implicit ועדת-הערר default. Also clear when leaving protocol.
|
||||
if (isProtocol && draftScope !== storedScope) {
|
||||
body.protocol_scope = draftScope === "lower" ? "lower" : "";
|
||||
} else if (!isProtocol && protocolScope) {
|
||||
body.protocol_scope = "";
|
||||
}
|
||||
|
||||
if (draftPost !== storedPost) body.is_post_hearing = draftPost;
|
||||
|
||||
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
||||
// rejection from this async click handler would otherwise leak.
|
||||
try {
|
||||
@@ -190,6 +220,46 @@ export function DocumentTypeEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProtocol && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-ink-muted">פרוטוקול של</label>
|
||||
<Select value={draftScope} onValueChange={setDraftScope} dir="rtl">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROTOCOL_SCOPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||
ועדת הערר → קבוצת הפרוטוקול · מקומית/מחוזית → מסמכים נלווים
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-rule bg-parchment/40 px-3 py-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor={`post-${docId}`}
|
||||
className="text-xs font-medium text-ink block"
|
||||
>
|
||||
התקבל אחרי הדיון
|
||||
</label>
|
||||
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||
מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={`post-${docId}`}
|
||||
checked={draftPost}
|
||||
onCheckedChange={setDraftPost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{patch.isError && (
|
||||
<p className="text-[0.7rem] text-danger">
|
||||
שמירה נכשלה. נסה שוב.
|
||||
|
||||
@@ -24,6 +24,14 @@ import { apiRequest } from "@/lib/api/client";
|
||||
import { casesKeys } from "@/lib/api/cases";
|
||||
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
||||
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||
import {
|
||||
documentGroup,
|
||||
DOC_GROUP_ORDER,
|
||||
DOC_GROUP_LABELS,
|
||||
DOC_GROUP_HINTS,
|
||||
type DocGroup,
|
||||
} from "@/lib/doc-types";
|
||||
|
||||
/*
|
||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||
@@ -76,15 +84,6 @@ function StatusIcon({ status }: { status: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function filenameFromPath(path: string): string {
|
||||
const parts = path.split("/");
|
||||
return parts[parts.length - 1] || path;
|
||||
@@ -232,6 +231,13 @@ function DocumentRow({
|
||||
const canPreview =
|
||||
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
||||
|
||||
const meta = doc.metadata as
|
||||
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
|
||||
| undefined;
|
||||
const isPostHearing = meta?.is_post_hearing === true;
|
||||
const protocolScope = meta?.protocol_scope;
|
||||
const isProtocol = doc.doc_type === "protocol";
|
||||
|
||||
return (
|
||||
<>
|
||||
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
||||
@@ -254,15 +260,24 @@ function DocumentRow({
|
||||
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
{isProtocol && (
|
||||
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
|
||||
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
|
||||
</span>
|
||||
)}
|
||||
{isPostHearing && (
|
||||
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
|
||||
לאחר הדיון
|
||||
</span>
|
||||
)}
|
||||
{doc.doc_type && (
|
||||
<DocumentTypeEditor
|
||||
caseNumber={caseNumber}
|
||||
docId={doc.id}
|
||||
docType={doc.doc_type}
|
||||
appraiserSide={
|
||||
(doc.metadata as { appraiser_side?: string } | undefined)
|
||||
?.appraiser_side
|
||||
}
|
||||
appraiserSide={meta?.appraiser_side}
|
||||
isPostHearing={isPostHearing}
|
||||
protocolScope={protocolScope}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
@@ -314,6 +329,30 @@ function DocumentsShell({ count, children }: { count: number; children: ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Group section header ──────────────────────────────────────────
|
||||
* The list is split into four convenience buckets (see documentGroup).
|
||||
* The number is the fixed 1-4 position in DOC_GROUP_ORDER so a group keeps
|
||||
* its identity even when earlier groups are empty and hidden. */
|
||||
function GroupHeader({ group, count }: { group: DocGroup; count: number }) {
|
||||
const index = DOC_GROUP_ORDER.indexOf(group) + 1;
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-2 -mx-2 py-2 border-b border-rule-soft bg-parchment/50">
|
||||
<span className="w-[19px] h-[19px] shrink-0 rounded-full bg-navy text-cream text-[0.66rem] font-bold flex items-center justify-center tabular-nums">
|
||||
{index}
|
||||
</span>
|
||||
<span className="text-[0.8rem] font-bold text-navy shrink-0">
|
||||
{DOC_GROUP_LABELS[group]}
|
||||
</span>
|
||||
<span className="shrink-0 text-[0.62rem] font-bold text-gold-deep bg-gold-wash border border-rule rounded-full px-1.5 tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
<span className="text-[0.66rem] text-ink-muted font-medium truncate">
|
||||
{DOC_GROUP_HINTS[group]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentsPanel({
|
||||
data,
|
||||
}: {
|
||||
@@ -337,6 +376,16 @@ export function DocumentsPanel({
|
||||
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
|
||||
);
|
||||
|
||||
// Bucket into the four overview groups. Iterating `sorted` preserves the
|
||||
// within-group status ordering. Grouping is display-only — see documentGroup.
|
||||
const grouped = new Map<DocGroup, CaseDocument[]>();
|
||||
for (const doc of sorted) {
|
||||
const g = documentGroup(doc.doc_type, doc.metadata);
|
||||
const bucket = grouped.get(g);
|
||||
if (bucket) bucket.push(doc);
|
||||
else grouped.set(g, [doc]);
|
||||
}
|
||||
|
||||
const done = docs.filter(
|
||||
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
|
||||
).length;
|
||||
@@ -386,12 +435,21 @@ export function DocumentsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden" dir="rtl">
|
||||
<ul className="divide-y divide-rule" dir="rtl">
|
||||
{sorted.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden space-y-2" dir="rtl">
|
||||
{DOC_GROUP_ORDER.map((group) => {
|
||||
const groupDocs = grouped.get(group);
|
||||
if (!groupDocs || groupDocs.length === 0) return null;
|
||||
return (
|
||||
<section key={group}>
|
||||
<GroupHeader group={group} count={groupDocs.length} />
|
||||
<ul className="divide-y divide-rule" dir="rtl">
|
||||
{groupDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DocumentsShell>
|
||||
|
||||
@@ -25,7 +25,15 @@ import {
|
||||
useUploadFinalDecision,
|
||||
useRunFinalLearning,
|
||||
useRunFinalHalacha,
|
||||
type ExportFile,
|
||||
} from "@/lib/api/exports";
|
||||
import {
|
||||
usePartyClaimsSummary,
|
||||
useGeneratePartyClaimsSummary,
|
||||
useGenerateInterimDraft,
|
||||
useGenerateStatus,
|
||||
} from "@/lib/api/generate";
|
||||
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
|
||||
import {
|
||||
useCaseFeedback,
|
||||
useCreateFeedback,
|
||||
@@ -38,6 +46,7 @@ import {
|
||||
import { useCaseCitations } from "@/lib/api/citations";
|
||||
import { learningKeys } from "@/lib/api/learning";
|
||||
import { LearningStatusBadges } from "@/components/cases/learning-status-badges";
|
||||
import { formatDateTime, formatDateShort } from "@/lib/format-date";
|
||||
import type { CaseStatus } from "@/lib/api/cases";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -54,6 +63,10 @@ import {
|
||||
Stamp,
|
||||
Link2,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
ClipboardList,
|
||||
Files,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
|
||||
/* Statuses at which a draft is considered ready */
|
||||
@@ -72,13 +85,32 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
function formatDate(epoch: number): string {
|
||||
return new Date(epoch * 1000).toLocaleDateString("he-IL", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
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 ─────────────────────────────────── */
|
||||
@@ -102,6 +134,13 @@ export function DraftsPanel({
|
||||
const uploadFinal = useUploadFinalDecision(caseNumber);
|
||||
const runLearning = useRunFinalLearning(caseNumber);
|
||||
const runHalacha = useRunFinalHalacha(caseNumber);
|
||||
// ── Document generation (#214) — the "הפקת מסמכים" card ──
|
||||
const { data: summary } = usePartyClaimsSummary(caseNumber);
|
||||
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
|
||||
const genInterim = useGenerateInterimDraft(caseNumber);
|
||||
// Server-derived background-run status (#227 ג) — survives navigation/reload.
|
||||
const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary");
|
||||
const interimStatus = useGenerateStatus(caseNumber, "interim_draft");
|
||||
const qc = useQueryClient();
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -134,6 +173,38 @@ export function DraftsPanel({
|
||||
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
|
||||
})();
|
||||
|
||||
// ── "הפקת מסמכים" derived state (#214) ──
|
||||
// Latest interim ("party-claims") partial draft in the exports table — the poll
|
||||
// target for button 2. The CEO writes it as טיוטה-טענות_הצדדים_{N}.docx.
|
||||
const latestInterim = pickLatestVersioned(exports, "טיוטה-טענות_הצדדים_");
|
||||
const summaryReady = Boolean(summary?.markdown);
|
||||
|
||||
function handleGenerateSummary() {
|
||||
genSummary.mutate(undefined, {
|
||||
onSuccess: (d) => {
|
||||
if (d.status === "ok") {
|
||||
toast.success("הופעלה הפקת סיכום-המנהלים — רצה ברקע (אופוס)");
|
||||
} else {
|
||||
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
|
||||
}
|
||||
},
|
||||
onError: () => toast.error("שגיאה בהפעלת הפקת סיכום-המנהלים"),
|
||||
});
|
||||
}
|
||||
|
||||
function handleGenerateInterim() {
|
||||
genInterim.mutate(undefined, {
|
||||
onSuccess: (d) => {
|
||||
if (d.status === "ok") {
|
||||
toast.success("הופעלה הפקת טיוטת טענות-הצדדים — רצה ברקע (אופוס)");
|
||||
} else {
|
||||
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
|
||||
}
|
||||
},
|
||||
onError: () => toast.error("שגיאה בהפעלת הפקת הטיוטה החלקית"),
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpload(file: File) {
|
||||
uploadDraft.mutate(file, {
|
||||
onSuccess: (data) => {
|
||||
@@ -238,34 +309,194 @@ export function DraftsPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Banner ── */}
|
||||
// flex + per-card `order` so the visual order matches mockup 26:
|
||||
// generate-card → banner → active-draft → drafts → final+learning → feedback.
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */}
|
||||
<section className="order-0 rounded-lg border border-rule bg-surface overflow-hidden">
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft bg-parchment">
|
||||
<Zap className="w-[18px] h-[18px] text-gold-deep shrink-0" />
|
||||
<h3 className="text-navy text-base font-semibold">הפקת מסמכים</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-xs text-ink-muted leading-relaxed mb-1">
|
||||
הפקה דטרמיניסטית בלחיצה — בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת,
|
||||
והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית.
|
||||
</p>
|
||||
|
||||
{/* 1 — executive summary of party claims */}
|
||||
<div className="flex items-start gap-3 py-3 border-t border-rule-soft first-of-type:border-t-0">
|
||||
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
|
||||
<ClipboardList className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-navy">הפק סיכום מנהלים</div>
|
||||
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
|
||||
תמצית מזוקקת של טענות הצדדים לקראת הדיון — מסמך נפרד מהטיוטה.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerateSummary}
|
||||
disabled={genSummary.isPending}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
>
|
||||
{genSummary.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||
מייצר…
|
||||
</>
|
||||
) : (
|
||||
"הפק"
|
||||
)}
|
||||
</Button>
|
||||
<GenerateStatusChip
|
||||
status={summaryStatus.data}
|
||||
onRetry={handleGenerateSummary}
|
||||
retrying={genSummary.isPending}
|
||||
/>
|
||||
{summaryReady && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-gold-deep"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/api/cases/${caseNumber}/research/party-claims-summary/download`,
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 me-1" />
|
||||
פתח
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-gold-deep"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/api/cases/${caseNumber}/research/party-claims-summary/export-docx`,
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 me-1" />
|
||||
DOCX
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2 — partial draft (parties' claims, blocks ה–ט) */}
|
||||
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
|
||||
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
|
||||
<FileText className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-navy">
|
||||
הפק טיוטה של טענות הצדדים{" "}
|
||||
<span className="font-medium text-ink-muted text-xs">
|
||||
(טיוטה חלקית)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
|
||||
בלוקים ה–ט (רקע, טענות, הליכים, תכניות) — עד הדיון, בלי דיון
|
||||
והכרעה. נשמרת בטבלת-הטיוטות.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerateInterim}
|
||||
disabled={genInterim.isPending}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
>
|
||||
{genInterim.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||
מייצר…
|
||||
</>
|
||||
) : (
|
||||
"הפק"
|
||||
)}
|
||||
</Button>
|
||||
<GenerateStatusChip
|
||||
status={interimStatus.data}
|
||||
onRetry={handleGenerateInterim}
|
||||
retrying={genInterim.isPending}
|
||||
/>
|
||||
{latestInterim && (
|
||||
<span className="text-[0.7rem] text-ink-muted">
|
||||
{latestInterim.version
|
||||
? `v${latestInterim.version} · `
|
||||
: ""}
|
||||
{formatDate(latestInterim.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */}
|
||||
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
|
||||
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
|
||||
<Files className="w-[18px] h-[18px]" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-navy">
|
||||
הפק טיוטת החלטה מלאה
|
||||
</div>
|
||||
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
|
||||
ההחלטה המלאה — כל 12 הבלוקים, כולל דיון והכרעה. נקבעת
|
||||
כמקור-האמת ונשמרת בטבלת-הטיוטות.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5 mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={exportDocx.isPending || !isDraftReady}
|
||||
title={!isDraftReady ? "זמין כשיש בלוקים כתובים" : undefined}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
>
|
||||
{exportDocx.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||
מייצר…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileOutput className="w-3.5 h-3.5 me-1.5" />
|
||||
הפק
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!isDraftReady && (
|
||||
<span className="rounded bg-warn-bg px-2 py-0.5 text-[0.65rem] text-warn">
|
||||
זמין כשיש בלוקים כתובים
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */}
|
||||
{isDraftReady && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
||||
<div className="order-1 flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
||||
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
|
||||
<span className="text-sm font-medium text-gold-deep">
|
||||
{draftLabel}
|
||||
</span>
|
||||
<div className="me-auto" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={exportDocx.isPending}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
>
|
||||
{exportDocx.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
||||
) : (
|
||||
<FileOutput className="w-4 h-4 me-1.5" />
|
||||
)}
|
||||
הפק DOCX
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
|
||||
{activeDraft?.filename && (
|
||||
<div className="flex items-center gap-2 text-xs text-ink-muted">
|
||||
<div className="order-2 flex items-center gap-2 text-xs text-ink-muted">
|
||||
<span>מקור האמת:</span>
|
||||
<Badge variant="outline" className="bg-surface">
|
||||
{activeDraft.filename}
|
||||
@@ -274,7 +505,7 @@ export function DraftsPanel({
|
||||
)}
|
||||
|
||||
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
|
||||
<section className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
|
||||
<section className="order-4 rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
|
||||
<input
|
||||
ref={finalFileRef}
|
||||
type="file"
|
||||
@@ -352,13 +583,13 @@ export function DraftsPanel({
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* ── Precedents cited inside the signed decision ── */}
|
||||
<CitationsSection caseNumber={caseNumber} />
|
||||
|
||||
{/* ── Exports list ── */}
|
||||
<section>
|
||||
{/* ── Card: draft files (mockup 18h) ── */}
|
||||
<section className="order-3 rounded-lg border border-rule bg-surface p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-navy text-base">קבצי טיוטה</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -401,9 +632,9 @@ export function DraftsPanel({
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<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">Size</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="text-start px-4 py-2 font-medium">גודל</th>
|
||||
<th className="text-start px-4 py-2 font-medium">תאריך</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -526,8 +757,8 @@ export function DraftsPanel({
|
||||
</Dialog>
|
||||
</section>
|
||||
|
||||
{/* ── Chair feedback ── */}
|
||||
<section>
|
||||
{/* ── Card: chair feedback (mockup 18h) ── */}
|
||||
<section className="order-5 rounded-lg border border-rule bg-surface p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-navy text-base">
|
||||
הערות יו״ר
|
||||
@@ -568,9 +799,7 @@ export function DraftsPanel({
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[0.65rem] text-ink-muted me-auto">
|
||||
{fb.created_at
|
||||
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
||||
: ""}
|
||||
{fb.created_at ? formatDateShort(fb.created_at) : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,23 @@ const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
|
||||
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
|
||||
};
|
||||
|
||||
// Per-party colour mark + one-line role description (mockup 25b party trigger).
|
||||
const PARTY_MARK_TONE: Record<LegalArgumentParty, string> = {
|
||||
appellant: "bg-info",
|
||||
respondent: "bg-gold-deep",
|
||||
committee: "bg-success",
|
||||
permit_applicant: "bg-warn",
|
||||
unknown: "bg-ink-muted",
|
||||
};
|
||||
|
||||
const PARTY_SUB_HE: Record<LegalArgumentParty, string> = {
|
||||
appellant: "הצד היוזם של הערר",
|
||||
respondent: "הצד המשיב לערר",
|
||||
committee: "עמדת הגורם המאשר",
|
||||
permit_applicant: "צד שלישי שזכותו עשויה להיפגע",
|
||||
unknown: "צד שלא זוהה אוטומטית",
|
||||
};
|
||||
|
||||
function groupByPriority(
|
||||
args: LegalArgument[],
|
||||
): Record<LegalArgumentPriority, LegalArgument[]> {
|
||||
@@ -55,22 +72,57 @@ function groupByPriority(
|
||||
}
|
||||
|
||||
type PartySectionProps = {
|
||||
party: LegalArgumentParty;
|
||||
args: LegalArgument[];
|
||||
};
|
||||
|
||||
function PartySection({ party, args }: PartySectionProps) {
|
||||
/**
|
||||
* Inner body of a single party. For a multi-litigant side (respondent /
|
||||
* permit_applicant) whose arguments carry distinct ``party_name`` brief labels,
|
||||
* we sub-group by brief first — so opposing joint briefs (e.g. "משיבות 2-3" vs
|
||||
* "משיבים 4-6") read as separate positions rather than one merged list (#224).
|
||||
* Single-voice sides (one empty brief) render the priority groups directly.
|
||||
*/
|
||||
function PartySection({ args }: PartySectionProps) {
|
||||
const briefs = useMemo(() => {
|
||||
const byBrief = new Map<string, LegalArgument[]>();
|
||||
for (const a of args) {
|
||||
const key = a.party_name?.trim() ?? "";
|
||||
const list = byBrief.get(key);
|
||||
if (list) list.push(a);
|
||||
else byBrief.set(key, [a]);
|
||||
}
|
||||
return [...byBrief.entries()];
|
||||
}, [args]);
|
||||
|
||||
// No real per-brief split (single, unnamed group) → render groups directly.
|
||||
if (briefs.length <= 1) {
|
||||
return <PriorityGroups args={briefs[0]?.[1] ?? args} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{briefs.map(([brief, briefArgs]) => (
|
||||
<div key={brief || "—"} className="space-y-2">
|
||||
<div className="flex items-center gap-2 border-s-2 border-gold-deep ps-2">
|
||||
<span className="text-navy text-sm font-semibold">
|
||||
{brief || "ללא שיוך לכתב"}
|
||||
</span>
|
||||
<span className="text-ink-muted text-xs">
|
||||
{briefArgs.length} טיעונים
|
||||
</span>
|
||||
</div>
|
||||
<PriorityGroups args={briefArgs} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Priority-grouped argument accordion for one coherent set of arguments. */
|
||||
function PriorityGroups({ args }: PartySectionProps) {
|
||||
const grouped = useMemo(() => groupByPriority(args), [args]);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between border-b border-rule pb-2">
|
||||
<h3 className="text-navy text-base font-semibold">
|
||||
{PARTY_LABELS_HE[party] ?? party}
|
||||
</h3>
|
||||
<span className="text-ink-muted text-xs">
|
||||
{args.length} טיעונים
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{PRIORITY_ORDER.map((priority) => {
|
||||
const list = grouped[priority];
|
||||
if (!list?.length) return null;
|
||||
@@ -253,15 +305,47 @@ export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
|
||||
החישוב למנתח המשפטי.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{parties.map((party) => (
|
||||
<PartySection
|
||||
key={party}
|
||||
party={party}
|
||||
args={data.by_party[party] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
// Top-level party accordion — each party is its own collapsible section,
|
||||
// closed by default (empty defaultValue). The count badge on every
|
||||
// trigger shows the scope without expanding (mockup 25b).
|
||||
<Accordion type="multiple" className="space-y-3.5">
|
||||
{parties.map((party) => {
|
||||
const args = data.by_party[party] ?? [];
|
||||
return (
|
||||
<AccordionItem
|
||||
key={party}
|
||||
value={party}
|
||||
className="overflow-hidden rounded-lg border border-rule bg-surface shadow-sm"
|
||||
>
|
||||
<AccordionTrigger className="px-5 py-4 hover:no-underline data-[state=open]:border-b data-[state=open]:border-rule-soft data-[state=open]:bg-parchment">
|
||||
<span className="flex flex-1 items-center gap-3.5">
|
||||
<span
|
||||
className={`h-8 w-1.5 flex-none rounded ${PARTY_MARK_TONE[party]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex flex-1 flex-col items-start gap-0.5">
|
||||
<span className="text-navy text-base font-bold leading-tight">
|
||||
{PARTY_LABELS_HE[party] ?? party}
|
||||
</span>
|
||||
<span className="text-ink-muted text-xs">
|
||||
{PARTY_SUB_HE[party]}
|
||||
</span>
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-gold-wash text-gold-deep border-rule rounded-full px-3 py-0.5 text-xs font-bold whitespace-nowrap"
|
||||
>
|
||||
{args.length} טיעונים
|
||||
</Badge>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-5 pb-4 pt-4">
|
||||
<PartySection args={args} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
299
web-ui/src/components/cases/positions-panel.tsx
Normal file
299
web-ui/src/components/cases/positions-panel.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SubsectionCard } from "@/components/compose/subsection-card";
|
||||
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { useCase } from "@/lib/api/cases";
|
||||
import { useResearchAnalysis } from "@/lib/api/research";
|
||||
import { useCasePrecedents } from "@/lib/api/precedents";
|
||||
|
||||
/*
|
||||
* PositionsPanel — the chair's editing surface for the analyst's threshold
|
||||
* claims + issues (chair positions feed block י). Extracted verbatim from the
|
||||
* former /compose "עמדות וטענות" tab when the decision editor was merged into
|
||||
* the main case page (X17 #3). Lives inside the "טיעונים ועמדות" tab above the
|
||||
* collapsible by-party aggregated arguments. The 12-block editor + citation
|
||||
* verification moved to their own top-level tabs; /compose was deleted.
|
||||
*/
|
||||
|
||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
if (!content?.trim()) return null;
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
|
||||
{title}
|
||||
</h3>
|
||||
<Markdown content={content.trim()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── "ייצוא ועדכון הניתוח" rail card — round-trips the analysis-and-research.md
|
||||
// research analysis: export DOCX, upload an updated version, download the raw MD.
|
||||
// (Post-final learning status lives on the drafts tab, not here — #226.) ──────
|
||||
function FinishRail({
|
||||
caseNumber,
|
||||
hasAnalysis,
|
||||
onUploaded,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
hasAnalysis: boolean;
|
||||
onUploaded: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploading(true);
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`/api/cases/${caseNumber}/research/analysis/upload`, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setUploadMsg({ ok: false, text: data.detail || "שגיאה בהעלאה" });
|
||||
return;
|
||||
}
|
||||
setUploadMsg({
|
||||
ok: true,
|
||||
text: `הקובץ הועלה — ${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
|
||||
});
|
||||
onUploaded();
|
||||
} catch {
|
||||
setUploadMsg({ ok: false, text: "שגיאת רשת" });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm h-full">
|
||||
<CardContent className="px-4 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">ייצוא ועדכון הניתוח</h3>
|
||||
<p className="text-[0.72rem] text-ink-muted mb-3">
|
||||
פעולות על קובץ ניתוח-המחקר (analysis-and-research.md).
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUpload(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
ייצוא DOCX
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full justify-center bg-gold text-white hover:bg-gold-deep"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{uploading ? "מעלה…" : "העלאת ניתוח מעודכן"}
|
||||
</Button>
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
|
||||
a.download = `analysis-${caseNumber}.md`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
הורד ניתוח (MD)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadMsg && (
|
||||
<p className={`text-xs mt-2 ${uploadMsg.ok ? "text-success" : "text-danger"}`}>
|
||||
{uploadMsg.text}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
|
||||
const caseQuery = useCase(caseNumber);
|
||||
const analysis = useResearchAnalysis(caseNumber);
|
||||
const precedentsQuery = useCasePrecedents(caseNumber);
|
||||
|
||||
const allPrecedents = precedentsQuery.data ?? [];
|
||||
const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null);
|
||||
const precedentsBySection = new Map<string, typeof allPrecedents>();
|
||||
for (const p of allPrecedents) {
|
||||
if (p.section_id) {
|
||||
const existing = precedentsBySection.get(p.section_id) ?? [];
|
||||
existing.push(p);
|
||||
precedentsBySection.set(p.section_id, existing);
|
||||
}
|
||||
}
|
||||
const practiceArea = caseQuery.data?.practice_area ?? null;
|
||||
|
||||
const isNotFound =
|
||||
analysis.error instanceof Error &&
|
||||
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{analysis.isPending ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isNotFound ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-12 text-center space-y-3">
|
||||
<div className="text-gold text-3xl" aria-hidden>❦</div>
|
||||
<h2 className="text-navy text-lg mb-0">
|
||||
טרם בוצע ניתוח משפטי לתיק זה
|
||||
</h2>
|
||||
<p className="text-ink-muted text-sm max-w-md mx-auto">
|
||||
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
|
||||
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-5 text-center">
|
||||
<p className="text-danger">{analysis.error.message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.data ? (
|
||||
<div className="space-y-6">
|
||||
{analysis.data.threshold_claims &&
|
||||
analysis.data.threshold_claims.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">טענות סף</h2>
|
||||
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||
{analysis.data.threshold_claims.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2 items-start">
|
||||
{analysis.data.threshold_claims.map((tc) => (
|
||||
<SubsectionCard
|
||||
key={tc.id}
|
||||
caseNumber={caseNumber}
|
||||
item={tc}
|
||||
precedents={precedentsBySection.get(tc.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.data.issues && analysis.data.issues.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">סוגיות להכרעה</h2>
|
||||
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||
{analysis.data.issues.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2 items-start">
|
||||
{analysis.data.issues.map((iss) => (
|
||||
<SubsectionCard
|
||||
key={iss.id}
|
||||
caseNumber={caseNumber}
|
||||
item={iss}
|
||||
precedents={precedentsBySection.get(iss.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!analysis.data.threshold_claims?.length &&
|
||||
!analysis.data.issues?.length && (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-6 py-10 text-center text-ink-muted">
|
||||
לא נמצאו טענות סף או סוגיות בניתוח זה.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-5">
|
||||
<h2 className="text-navy text-lg font-semibold mb-0">רקע לניתוח</h2>
|
||||
<ProseSection title="צד מיוצג" content={analysis.data.represented_party} />
|
||||
<ProseSection title="רקע דיוני" content={analysis.data.procedural_background} />
|
||||
<ProseSection title="עובדות מוסכמות" content={analysis.data.agreed_facts} />
|
||||
<ProseSection title="עובדות במחלוקת" content={analysis.data.disputed_facts} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{analysis.data.conclusions?.trim() && (
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<h2 className="text-gold-deep text-lg font-semibold mb-0">מסקנות</h2>
|
||||
<Markdown content={analysis.data.conclusions.trim()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* case-level פסיקה מצורפת + analysis export/update (INV-IA3: not removed).
|
||||
items-stretch keeps both cards the same height (#226). */}
|
||||
<div className="grid gap-4 lg:grid-cols-2 items-stretch pt-2">
|
||||
<Card className="bg-surface border-rule shadow-sm h-full">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
|
||||
<p className="text-[0.72rem] text-ink-muted mb-3">
|
||||
ציטוטים התומכים בעמדה באופן רוחבי — ישולבו בפתיחת בלוק י (דיון).
|
||||
</p>
|
||||
<PrecedentsSection
|
||||
caseNumber={caseNumber}
|
||||
sectionId={null}
|
||||
precedents={caseLevelPrecedents}
|
||||
practiceArea={practiceArea}
|
||||
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FinishRail
|
||||
caseNumber={caseNumber}
|
||||
hasAnalysis={!!analysis.data}
|
||||
onUploaded={() => analysis.refetch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
|
||||
new: FilePlus2,
|
||||
processing: Loader2,
|
||||
documents_ready: FileCheck,
|
||||
analyst_verified: SearchCheck,
|
||||
research_complete: Compass,
|
||||
outcome_set: Target,
|
||||
direction_approved: Compass,
|
||||
qa_review: SearchCheck,
|
||||
@@ -36,6 +38,8 @@ const STATUS_TONE: Record<CaseStatus, string> = {
|
||||
new: "bg-rule-soft text-ink-muted border-rule",
|
||||
processing: "bg-info-bg text-info border-info/30",
|
||||
documents_ready: "bg-info-bg text-info border-info/40",
|
||||
analyst_verified: "bg-gold-wash text-gold-deep border-gold/40",
|
||||
research_complete: "bg-gold-wash text-gold-deep border-gold/40",
|
||||
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
|
||||
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
|
||||
qa_review: "bg-warn-bg text-warn border-warn/40",
|
||||
|
||||
@@ -21,9 +21,13 @@ const ALL_STATUSES: readonly CaseStatus[] = CASE_STATUSES;
|
||||
export function StatusChanger({
|
||||
caseNumber,
|
||||
currentStatus,
|
||||
inline = false,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
currentStatus?: CaseStatus;
|
||||
/** Compact horizontal layout for the status hero (mockup 18b hero-controls);
|
||||
* default is the stacked rail layout. */
|
||||
inline?: boolean;
|
||||
}) {
|
||||
// `null` = untouched → the dropdown tracks the live `currentStatus` (which
|
||||
// arrives async and changes on the 5s poll / external updates). Only an
|
||||
@@ -45,8 +49,10 @@ export function StatusChanger({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-rule pt-3 space-y-2">
|
||||
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
|
||||
<div className={inline ? "space-y-0" : "mt-4 border-t border-rule pt-3 space-y-2"}>
|
||||
{!inline && (
|
||||
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={effective || "__current__"}
|
||||
@@ -77,7 +83,7 @@ export function StatusChanger({
|
||||
disabled={!canSave || mutate.isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{mutate.isPending ? "שומר…" : "עדכן"}
|
||||
{mutate.isPending ? "שומר…" : inline ? "עדכן סטטוס" : "עדכן"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
195
web-ui/src/components/cases/status-rail.tsx
Normal file
195
web-ui/src/components/cases/status-rail.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
PHASES,
|
||||
phaseIndexOf,
|
||||
phaseOf,
|
||||
type CaseStatus,
|
||||
} from "@/lib/api/case-status";
|
||||
import { StatusChanger } from "@/components/cases/status-changer";
|
||||
import { StatusGuide } from "@/components/cases/status-guide";
|
||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||
import { SyncIndicator } from "@/components/cases/sync-indicator";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { Settings2, Users } from "lucide-react";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
|
||||
/*
|
||||
* StatusRail — the integrated case-status strip that lives inside the parchment
|
||||
* band (CaseHeader), so it is visible on EVERY tab (banner "A", X17 case-detail
|
||||
* redesign — supersedes the overview-only StatusHero). One thin row of chips
|
||||
* (current phase · expected outcome · meta · agents popover · manual changer)
|
||||
* over the 5-phase pipeline stepper. Reuses the shared StatusChanger / StatusGuide
|
||||
* / AgentStatusWidget so no parallel status path is created (G2).
|
||||
*/
|
||||
|
||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
/** Semantic colour per expected-outcome value (rejection/partial/full/none). */
|
||||
const OUTCOME_TONE: Record<string, string> = {
|
||||
rejection: "bg-danger-bg text-danger",
|
||||
partial_acceptance: "bg-warn-bg text-warn",
|
||||
full_acceptance: "bg-success-bg text-success",
|
||||
};
|
||||
|
||||
/** Horizontal RTL stepper over the 5 lifecycle phases (mockup A .stepper). */
|
||||
function HorizontalStepper({ status }: { status?: CaseStatus }) {
|
||||
const currentIdx = phaseIndexOf(status);
|
||||
return (
|
||||
<ol className="flex items-start gap-0">
|
||||
{PHASES.map((phase, i) => {
|
||||
const state =
|
||||
currentIdx === -1 ? "pending"
|
||||
: i < currentIdx ? "done"
|
||||
: i === currentIdx ? "now"
|
||||
: "pending";
|
||||
|
||||
const isLast = i === PHASES.length - 1;
|
||||
|
||||
const knobTone =
|
||||
state === "done"
|
||||
? "bg-success [box-shadow:0_0_0_1px_var(--color-success)]"
|
||||
: state === "now"
|
||||
? "bg-gold [box-shadow:0_0_0_2px_var(--color-gold-wash),0_0_0_3px_var(--color-gold)]"
|
||||
: "bg-rule [box-shadow:0_0_0_1px_var(--color-rule)]";
|
||||
|
||||
const labelTone =
|
||||
state === "done"
|
||||
? "text-ink-soft"
|
||||
: state === "now"
|
||||
? "text-navy font-bold"
|
||||
: "text-ink-muted";
|
||||
|
||||
const segTone = state === "done" ? "bg-success" : "bg-rule";
|
||||
|
||||
return (
|
||||
<li key={phase.key} className="relative flex-1 pt-6 text-center">
|
||||
{!isLast && (
|
||||
<span
|
||||
className={`absolute top-[7px] h-0.5 w-full ${segTone}`}
|
||||
style={{ insetInlineStart: "50%" }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`absolute top-[2px] h-3.5 w-3.5 rounded-full border-[2.5px] border-surface ${knobTone}`}
|
||||
style={{ insetInlineStart: "50%", transform: "translateX(50%)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={`block text-[0.74rem] ${labelTone}`}>
|
||||
{phase.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
const PILL =
|
||||
"inline-flex items-center gap-1.5 rounded-full border border-rule bg-surface px-3 py-1 text-[0.72rem] font-semibold text-ink-soft hover:bg-parchment transition-colors";
|
||||
|
||||
export function StatusRail({
|
||||
caseNumber,
|
||||
data,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
data?: CaseDetail;
|
||||
}) {
|
||||
const status = data?.status;
|
||||
const phaseKey = phaseOf(status);
|
||||
const phaseLabel = phaseKey
|
||||
? PHASES.find((p) => p.key === phaseKey)?.label
|
||||
: null;
|
||||
const outcome = data?.expected_outcome ?? "";
|
||||
const outcomeLabel = outcome
|
||||
? EXPECTED_OUTCOME_LABELS[outcome] ?? outcome
|
||||
: null;
|
||||
const outcomeTone = outcome
|
||||
? OUTCOME_TONE[outcome] ?? "bg-warn-bg text-warn"
|
||||
: "bg-rule-soft text-ink-muted";
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-rule-soft pt-3">
|
||||
{/* chip row */}
|
||||
<div className="flex items-center gap-x-3 gap-y-2 flex-wrap mb-3">
|
||||
<span className="text-navy text-[0.8rem] font-bold me-1">סטטוס</span>
|
||||
{phaseLabel && (
|
||||
<span className="rounded-full bg-warn-bg text-warn text-[0.78rem] font-bold px-3 py-1">
|
||||
כעת: {phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-[0.72rem] text-ink-muted">תוצאה צפויה:</span>
|
||||
<span
|
||||
className={`inline-block rounded-full text-[0.74rem] font-semibold px-2.5 py-0.5 ${outcomeTone}`}
|
||||
>
|
||||
{outcomeLabel ?? "טרם נקבעה"}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
<span className="flex items-center gap-3 text-[0.72rem] text-ink-muted">
|
||||
<span>
|
||||
דיון{" "}
|
||||
<b className="text-ink-soft font-semibold tabular-nums">
|
||||
{formatDate(data?.hearing_date)}
|
||||
</b>
|
||||
</span>
|
||||
<span>
|
||||
עודכן{" "}
|
||||
<b className="text-ink-soft font-semibold tabular-nums">
|
||||
{formatDate(data?.updated_at)}
|
||||
</b>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
סנכרון <SyncIndicator caseNumber={data?.case_number} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* agents popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={PILL} title="סוכני התיק">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
סוכנים
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-3">
|
||||
<AgentStatusWidget caseNumber={caseNumber} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* manual status changer + status guide popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" className={PILL} title="שינוי סטטוס ידני">
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
שינוי
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72 p-3 space-y-3">
|
||||
<div className="text-[0.72rem] font-semibold text-ink-muted">
|
||||
שינוי סטטוס ידני
|
||||
</div>
|
||||
<StatusChanger caseNumber={caseNumber} currentStatus={status} inline />
|
||||
<StatusGuide />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* pipeline */}
|
||||
<HorizontalStepper status={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,17 +2,7 @@
|
||||
|
||||
import { useGitStatus } from "@/lib/api/cases";
|
||||
import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react";
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return "עכשיו";
|
||||
if (mins < 60) return `לפני ${mins} דק׳`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `לפני ${hours} שע׳`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `לפני ${days} ימים`;
|
||||
}
|
||||
import { formatRelative } from "@/lib/format-date";
|
||||
|
||||
export function SyncIndicator({ caseNumber }: { caseNumber?: string }) {
|
||||
const { data, isLoading } = useGitStatus(caseNumber);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { PHASES, type CaseStatus, type PhaseKey } from "@/lib/api/case-status";
|
||||
import { PHASES, phaseIndexOf, type CaseStatus, type PhaseKey } from "@/lib/api/case-status";
|
||||
import { STATUS_LABELS, STATUS_ICONS, STATUS_DESCRIPTIONS } from "@/components/cases/status-badge";
|
||||
import {
|
||||
FolderInput, ClipboardList, Brain, PenLine, CheckCircle2,
|
||||
@@ -21,11 +21,6 @@ const PHASE_ICONS: Record<PhaseKey, LucideIcon> = {
|
||||
done: CheckCircle2,
|
||||
};
|
||||
|
||||
function phaseIndexOf(status?: CaseStatus): number {
|
||||
if (!status) return -1;
|
||||
return PHASES.findIndex((p) => p.statuses.includes(status));
|
||||
}
|
||||
|
||||
export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
|
||||
const currentIdx = phaseIndexOf(status);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSaveChairPosition } from "@/lib/api/research";
|
||||
import { formatTime } from "@/lib/format-date";
|
||||
|
||||
/*
|
||||
* Chair-position editor for a single threshold claim or issue.
|
||||
@@ -87,10 +88,7 @@ function SaveIndicator({ state }: { state: SaveState }) {
|
||||
return <span className="text-[0.72rem] text-ink-muted">⏳ שומר…</span>;
|
||||
}
|
||||
if (state.kind === "saved") {
|
||||
const time = state.at.toLocaleTimeString("he-IL", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const time = formatTime(state.at);
|
||||
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
||||
}
|
||||
return <span className="text-[0.72rem] text-danger">⚠ {state.message}</span>;
|
||||
|
||||
@@ -5,12 +5,22 @@
|
||||
* supporting precedents with the cumulative authority signal (cited_by), a verify
|
||||
* gate + chair note, and per-issue radar (unlinked digests). The writer cites only
|
||||
* verified quotes (INV-AH); the digest is never cited (INV-DIG1, radar only).
|
||||
*
|
||||
* Redesign (mockup 18g): summary + verification progress bar, status filters,
|
||||
* collapsible per-argument accordion with a verify-progress chip, and compact
|
||||
* precedent rows (clamped quote, collapsible chair note) — easier to triage.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
useCitationVerification,
|
||||
useVerifyCitation,
|
||||
@@ -33,10 +43,34 @@ const RADAR_LABEL: Record<string, string> = {
|
||||
available_link: "בקורפוס — לקשר",
|
||||
};
|
||||
|
||||
type FilterKey = "all" | "pending" | "verified" | "radar";
|
||||
const FILTERS: { key: FilterKey; label: string }[] = [
|
||||
{ key: "all", label: "הכל" },
|
||||
{ key: "pending", label: "ממתין לאימות" },
|
||||
{ key: "verified", label: "מאומת" },
|
||||
{ key: "radar", label: "עם רדאר" },
|
||||
];
|
||||
|
||||
export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }) {
|
||||
const { data, isPending, isError } = useCitationVerification(caseNumber);
|
||||
const verify = useVerifyCitation(caseNumber);
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
const [openNotes, setOpenNotes] = useState<Set<string>>(new Set());
|
||||
const [expandedQuotes, setExpandedQuotes] = useState<Set<string>>(new Set());
|
||||
const [filter, setFilter] = useState<FilterKey>("all");
|
||||
const [onlyUnverified, setOnlyUnverified] = useState(false);
|
||||
|
||||
function toggleSet(
|
||||
setter: React.Dispatch<React.SetStateAction<Set<string>>>,
|
||||
id: string,
|
||||
) {
|
||||
setter((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -111,137 +145,274 @@ export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }
|
||||
}
|
||||
|
||||
const sum = data.summary;
|
||||
const args = data.arguments;
|
||||
|
||||
// verification progress across all supporting precedents
|
||||
let totalSup = 0;
|
||||
let verifiedSup = 0;
|
||||
for (const a of args) {
|
||||
totalSup += a.supporting.length;
|
||||
verifiedSup += a.supporting.filter((s) => s.verified).length;
|
||||
}
|
||||
const pendingSup = totalSup - verifiedSup;
|
||||
const okPct = totalSup ? Math.round((verifiedSup / totalSup) * 100) : 0;
|
||||
const supPct = totalSup ? Math.round((pendingSup / totalSup) * 100) : 0;
|
||||
const noSupportCount = sum.arguments_total - sum.arguments_with_support;
|
||||
|
||||
const argVerified = (a: ArgumentBlock) =>
|
||||
a.supporting.filter((s) => s.verified).length;
|
||||
const argHasPending = (a: ArgumentBlock) =>
|
||||
a.supporting.some((s) => !s.verified);
|
||||
|
||||
const shownArgs = args.filter((a) => {
|
||||
if (filter === "pending") return argHasPending(a);
|
||||
if (filter === "verified") return argVerified(a) > 0;
|
||||
if (filter === "radar") return a.radar.length > 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
// default-open the actionable arguments (those with unverified support)
|
||||
const defaultOpen = args.filter(argHasPending).map((a) => a.argument_id);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* summary */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-ink-soft bg-parchment border border-rule rounded-lg px-4 py-2.5">
|
||||
<span>סוגיות עם פסיקה: <b className="text-navy tabular-nums">{sum.arguments_with_support}/{sum.arguments_total}</b></span>
|
||||
<span>אומתו: <b className="text-success tabular-nums">{sum.verified}</b></span>
|
||||
<span>לידֵי רדאר: <b className="text-warn tabular-nums">{sum.radar_leads}</b></span>
|
||||
<span className="text-ink-muted text-[0.78rem] ms-auto">ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)</span>
|
||||
{/* summary + progress */}
|
||||
<div className="rounded-xl border border-rule bg-surface shadow-sm px-5 py-4">
|
||||
<div className="flex items-center gap-6 flex-wrap">
|
||||
<Stat k="סוגיות עם פסיקה" v={`${sum.arguments_with_support}/${sum.arguments_total}`} />
|
||||
<Stat k="אומתו" v={String(sum.verified)} tone="text-success" />
|
||||
<Stat k="לידֵי רדאר" v={String(sum.radar_leads)} tone="text-warn" />
|
||||
<span className="ms-auto text-[0.72rem] text-ink-muted max-w-[280px] leading-snug">
|
||||
ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3.5">
|
||||
<div className="h-2 rounded-full bg-rule-soft overflow-hidden flex">
|
||||
<span className="bg-success h-full" style={{ width: `${okPct}%` }} />
|
||||
<span className="bg-gold-soft h-full" style={{ width: `${supPct}%` }} />
|
||||
</div>
|
||||
<div className="flex gap-4 mt-1.5 text-[0.7rem] text-ink-muted flex-wrap">
|
||||
<span><Dot className="bg-success" /><b className="text-ink-soft">{verifiedSup} מאומתים</b></span>
|
||||
<span><Dot className="bg-gold-soft" /><b className="text-ink-soft">{pendingSup} ממתינים לאימות</b></span>
|
||||
<span><Dot className="bg-rule-soft" />{noSupportCount} סוגיות ללא פסיקה תומכת</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.arguments.map((a) => (
|
||||
<Card key={a.argument_id} className="bg-surface border-rule shadow-sm overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{/* issue header */}
|
||||
<div className="flex items-start gap-3 px-5 py-3.5 border-b border-rule-soft bg-parchment/60">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-navy font-bold text-[0.98rem]">{a.title}</div>
|
||||
<div className="text-ink-muted text-xs mt-0.5">
|
||||
סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{a.priority && (
|
||||
<span className="shrink-0 rounded-full bg-gold-wash text-gold-deep text-[0.68rem] font-semibold px-2.5 py-0.5">
|
||||
{PRIORITY_LABEL[a.priority] ?? a.priority}
|
||||
{/* filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`text-[0.78rem] font-semibold rounded-full px-3.5 py-1.5 border transition-colors ${
|
||||
filter === f.key
|
||||
? "bg-navy text-parchment border-transparent"
|
||||
: "bg-surface text-ink-soft border-rule hover:bg-parchment"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
<label className="ms-auto flex items-center gap-2 text-[0.78rem] text-ink-soft font-medium cursor-pointer select-none">
|
||||
רק לא-מאומתים
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onlyUnverified}
|
||||
onChange={(e) => setOnlyUnverified(e.target.checked)}
|
||||
className="accent-gold w-4 h-4"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* arguments */}
|
||||
<Accordion type="multiple" defaultValue={defaultOpen} className="space-y-2.5">
|
||||
{shownArgs.map((a) => {
|
||||
const v = argVerified(a);
|
||||
const t = a.supporting.length;
|
||||
const tone =
|
||||
t === 0 || v === 0
|
||||
? "bg-rule-soft text-ink-muted"
|
||||
: v === t
|
||||
? "bg-success-bg text-success"
|
||||
: "bg-warn-bg text-warn";
|
||||
const supShown = onlyUnverified
|
||||
? a.supporting.filter((s) => !s.verified)
|
||||
: a.supporting;
|
||||
return (
|
||||
<AccordionItem
|
||||
key={a.argument_id}
|
||||
value={a.argument_id}
|
||||
className="border border-rule rounded-xl bg-surface shadow-sm overflow-hidden data-[state=open]:border-gold-soft"
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline gap-3">
|
||||
<span className="flex flex-1 items-center gap-3 min-w-0">
|
||||
<span className="flex flex-col items-start min-w-0 text-start">
|
||||
<span className="text-navy font-bold text-[0.92rem] truncate max-w-full">
|
||||
{a.title}
|
||||
</span>
|
||||
<span className="text-ink-muted text-[0.72rem]">
|
||||
סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
{a.priority && (
|
||||
<span className="shrink-0 rounded-full bg-gold-wash text-gold-deep text-[0.66rem] font-semibold px-2.5 py-0.5">
|
||||
{PRIORITY_LABEL[a.priority] ?? a.priority}
|
||||
</span>
|
||||
)}
|
||||
<span className={`shrink-0 rounded-full text-[0.7rem] font-bold px-2.5 py-0.5 tabular-nums ${tone}`}>
|
||||
{v}/{t} ✓
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* supporting precedents */}
|
||||
<div className="px-5 py-3">
|
||||
<div className="text-[0.72rem] font-bold text-ink-muted mb-1">פסיקה תומכת בקורפוס</div>
|
||||
{a.supporting.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm py-2">לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.</p>
|
||||
) : (
|
||||
<ul className="list-none p-0 m-0 divide-y divide-rule-soft">
|
||||
{a.supporting.map((s) => (
|
||||
<li key={s.case_law_id} className="py-3 first:pt-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/precedents/${s.case_law_id}`}
|
||||
className="font-bold text-gold-deep text-[0.86rem] hover:text-navy"
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-3 pt-0">
|
||||
<div className="text-[0.72rem] font-bold text-ink-muted mb-1">
|
||||
פסיקה תומכת בקורפוס
|
||||
</div>
|
||||
{a.supporting.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm py-2">
|
||||
לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.
|
||||
</p>
|
||||
) : supShown.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm py-2">
|
||||
כל הפסיקה לסוגיה זו אומתה.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="list-none p-0 m-0">
|
||||
{supShown.map((s) => {
|
||||
const noteOpen =
|
||||
openNotes.has(s.case_law_id) || !!s.chair_note;
|
||||
const quoteExpanded = expandedQuotes.has(s.case_law_id);
|
||||
return (
|
||||
<li
|
||||
key={s.case_law_id}
|
||||
className="py-3 border-t border-rule-soft first:border-t-0"
|
||||
>
|
||||
{s.case_number || s.case_name}
|
||||
</a>
|
||||
{s.cited_by.positive > 0 && (
|
||||
<span className="rounded-full bg-success-bg text-success text-[0.68rem] font-semibold px-2 py-0.5">
|
||||
אומץ ×{s.cited_by.positive}
|
||||
</span>
|
||||
)}
|
||||
{s.cited_by.negative > 0 && (
|
||||
<span className="rounded-full bg-danger-bg text-danger text-[0.68rem] font-semibold px-2 py-0.5">
|
||||
אובחן ×{s.cited_by.negative}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{s.quote && (
|
||||
<blockquote className="border-s-[3px] border-gold bg-gold-wash text-ink-soft text-sm leading-7 rounded-e-md px-3.5 py-2 my-2 mx-0">
|
||||
{s.quote}
|
||||
</blockquote>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{s.verified ? (
|
||||
<>
|
||||
<span className="text-success text-xs font-semibold">✓ אומת ע״י היו״ר</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<a
|
||||
href={`/precedents/${s.case_law_id}`}
|
||||
className="font-bold text-gold-deep text-[0.85rem] hover:text-navy"
|
||||
>
|
||||
{s.case_number || s.case_name}
|
||||
</a>
|
||||
{s.cited_by.positive > 0 && (
|
||||
<span className="rounded-full bg-success-bg text-success text-[0.66rem] font-semibold px-2 py-0.5">
|
||||
אומץ ×{s.cited_by.positive}
|
||||
</span>
|
||||
)}
|
||||
{s.cited_by.negative > 0 && (
|
||||
<span className="rounded-full bg-danger-bg text-danger text-[0.66rem] font-semibold px-2 py-0.5">
|
||||
אובחן ×{s.cited_by.negative}
|
||||
</span>
|
||||
)}
|
||||
<span className="ms-auto flex items-center gap-1.5">
|
||||
{s.verified ? (
|
||||
<>
|
||||
<span className="text-success text-[0.72rem] font-semibold">
|
||||
✓ אומת ע״י היו״ר
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, false)}
|
||||
className="text-[0.72rem] text-ink-muted hover:text-danger border border-rule rounded-md px-2.5 py-1 disabled:opacity-40"
|
||||
>
|
||||
בטל אימות
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, true)}
|
||||
className="text-[0.72rem] font-semibold text-success bg-success-bg border border-success/30 rounded-md px-3 py-1 hover:brightness-95 disabled:opacity-40"
|
||||
>
|
||||
✓ מאמת
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, false)}
|
||||
className="text-[0.72rem] font-semibold text-danger border border-rule rounded-md px-3 py-1 hover:bg-danger-bg disabled:opacity-40"
|
||||
>
|
||||
✗ לא רלוונטי
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{s.quote && (
|
||||
<blockquote
|
||||
onClick={() => toggleSet(setExpandedQuotes, s.case_law_id)}
|
||||
title="לחצי להרחבה/כיווץ"
|
||||
className={`mt-2 cursor-pointer border-s-[3px] border-gold bg-gold-wash text-ink-soft text-[0.82rem] leading-6 rounded-e-md px-3 py-1.5 ${quoteExpanded ? "" : "line-clamp-2"}`}
|
||||
>
|
||||
{s.quote}
|
||||
</blockquote>
|
||||
)}
|
||||
{s.cited_by.negative > 0 && !s.verified && (
|
||||
<div className="text-warn text-[0.72rem] mt-1.5">⚠ אובחן — בדוק הקשר</div>
|
||||
)}
|
||||
{noteOpen ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap">
|
||||
הערת יו״ר:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={s.chair_note}
|
||||
placeholder="הוסף הערה לציטוט — מדוע תומך / הסתייגות / איך לשלב…"
|
||||
onChange={(e) =>
|
||||
setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))
|
||||
}
|
||||
onBlur={() => saveNote(a, s)}
|
||||
className="flex-1 text-[0.8rem] text-ink-soft bg-parchment border border-dashed border-rule rounded-md px-2.5 py-1.5 focus:outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, false)}
|
||||
className="text-xs text-ink-muted hover:text-danger border border-rule rounded-md px-2.5 py-1 disabled:opacity-40"
|
||||
onClick={() => toggleSet(setOpenNotes, s.case_law_id)}
|
||||
className="mt-2 text-[0.72rem] text-gold-deep font-semibold hover:underline"
|
||||
>
|
||||
בטל אימות
|
||||
+ הוסף הערת יו״ר
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, true)}
|
||||
className="text-xs font-semibold text-success bg-success-bg border border-success/30 rounded-md px-3 py-1 hover:brightness-95 disabled:opacity-40"
|
||||
>
|
||||
✓ מאמת
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={verify.isPending}
|
||||
onClick={() => runVerify(a, s, false)}
|
||||
className="text-xs font-semibold text-danger border border-rule rounded-md px-3 py-1 hover:bg-danger-bg disabled:opacity-40"
|
||||
>
|
||||
✗ לא רלוונטי
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{s.cited_by.negative > 0 && !s.verified && (
|
||||
<span className="text-warn text-[0.72rem] ms-1">⚠ אובחן — בדוק הקשר</span>
|
||||
)}
|
||||
</div>
|
||||
{/* chair note */}
|
||||
<div className="mt-2 flex items-start gap-2">
|
||||
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap pt-1.5">
|
||||
הערת יו״ר:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={s.chair_note}
|
||||
placeholder="הוסף הערה לציטוט — מדוע תומך / הסתייגות / איך לשלב…"
|
||||
onChange={(e) => setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))}
|
||||
onBlur={() => saveNote(a, s)}
|
||||
className="flex-1 text-[0.8rem] text-ink-soft bg-parchment border border-dashed border-rule rounded-md px-2.5 py-1.5 focus:outline-none focus:border-gold"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* radar — unlinked digests for this issue (INV-DIG1: pointer, not citation) */}
|
||||
<RadarStrip radar={a.radar} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<RadarStrip radar={a.radar} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ k, v, tone }: { k: string; v: string; tone?: string }) {
|
||||
return (
|
||||
<span className="flex flex-col">
|
||||
<span className="text-[0.72rem] text-ink-muted">{k}</span>
|
||||
<span className={`text-lg font-bold tabular-nums mt-0.5 ${tone ?? "text-navy"}`}>{v}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ className }: { className: string }) {
|
||||
return <span className={`inline-block w-2 h-2 rounded-sm me-1 align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
function RadarStrip({ radar }: { radar: RadarLead[] }) {
|
||||
if (!radar.length) return null;
|
||||
return (
|
||||
<div className="mx-5 mb-4 rounded-lg border border-dashed border-gold bg-parchment px-4 py-2.5">
|
||||
<div className="mt-3 rounded-lg border border-dashed border-gold bg-parchment px-4 py-2.5">
|
||||
<div className="text-[0.72rem] font-bold text-gold-deep mb-1">
|
||||
📡 רדאר — פסיקה שאין לנו בקורפוס (מצביע, לא מצוטט)
|
||||
</div>
|
||||
|
||||
@@ -5,15 +5,7 @@ import type { ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { practiceAreaLabel } from "@/components/precedents/practice-area";
|
||||
import type { Digest } from "@/lib/api/digests";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||
|
||||
/**
|
||||
* Presentational card for a single digest ("כל יום" radar entry).
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { GraphFacets } from "@/lib/api/graph";
|
||||
import type { ColorBy, SizeBy } from "@/components/graph/graph-canvas";
|
||||
import { getIsraelYear } from "@/lib/format-date";
|
||||
|
||||
export type GraphControls = {
|
||||
practiceArea: string;
|
||||
@@ -51,7 +52,7 @@ const ALL = "__all__";
|
||||
// Floor covers the oldest dated precedent in the corpus (currently ע"א 725/81 →
|
||||
// 1982); ceiling tracks the current year so the range never ages out.
|
||||
const YEAR_FLOOR = 1980;
|
||||
const CURRENT_YEAR = new Date().getFullYear();
|
||||
const CURRENT_YEAR = getIsraelYear(new Date()) ?? new Date().getFullYear();
|
||||
const YEARS = Array.from(
|
||||
{ length: CURRENT_YEAR - YEAR_FLOOR + 1 },
|
||||
(_, i) => CURRENT_YEAR - i,
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* header. Reflects the current section so the user always sees where they
|
||||
* are without scanning the nav row.
|
||||
*
|
||||
* Special-cases case routes (`/cases/{caseNumber}` and `/compose`) so the
|
||||
* subtitle includes the case number — the most useful piece of context
|
||||
* during decision drafting.
|
||||
* Special-cases case routes (`/cases/{caseNumber}`) so the subtitle includes
|
||||
* the case number — the most useful piece of context during decision drafting.
|
||||
*/
|
||||
export function headerSubtitle(pathname: string): string {
|
||||
if (pathname === "/") return "בית";
|
||||
@@ -13,9 +12,8 @@ export function headerSubtitle(pathname: string): string {
|
||||
if (pathname.startsWith("/cases/")) {
|
||||
const [, , slug] = pathname.split("/");
|
||||
if (!slug || slug === "new") return "תיק חדש";
|
||||
const isCompose = pathname.includes("/compose");
|
||||
const decoded = decodeURIComponent(slug);
|
||||
return isCompose ? `ערר ${decoded} · ניסוח` : `ערר ${decoded}`;
|
||||
return `ערר ${decoded}`;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/archive")) return "ארכיון";
|
||||
|
||||
@@ -20,15 +20,7 @@ import {
|
||||
type MissingPrecedentStatus,
|
||||
} from "@/lib/api/missing-precedents";
|
||||
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||
|
||||
/** Status chip — mockup 09 tones (open=warn, uploaded=info, closed=success,
|
||||
* irrelevant=muted). Pill-shaped, whitespace-nowrap. */
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDiagnostics, type DiagDoc } from "@/lib/api/system";
|
||||
import { formatDateShort, formatRelative } from "@/lib/format-date";
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
cases: "תיקים",
|
||||
@@ -25,16 +26,10 @@ const TABLE_LABELS: Record<string, string> = {
|
||||
|
||||
function formatRelativeTime(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
const then = new Date(iso);
|
||||
const diffMs = Date.now() - then.getTime();
|
||||
const min = Math.floor(diffMs / 60000);
|
||||
if (min < 1) return "עכשיו";
|
||||
if (min < 60) return `לפני ${min} דקות`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `לפני ${hr} שעות`;
|
||||
const days = Math.floor(hr / 24);
|
||||
if (days < 30) return `לפני ${days} ימים`;
|
||||
return then.toLocaleDateString("he-IL");
|
||||
const min = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
|
||||
// Past 30 days, fall back to an absolute Israel-time date.
|
||||
if (min >= 30 * 24 * 60) return formatDateShort(iso);
|
||||
return formatRelative(iso, { units: "long" });
|
||||
}
|
||||
|
||||
function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type Halacha, type CanonicalInstance,
|
||||
} from "@/lib/api/precedent-library";
|
||||
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
|
||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||
|
||||
/** #81 strict-rubric flags — why an item was held back from auto-approval. */
|
||||
const QUALITY_FLAG_LABELS: Record<string, string> = {
|
||||
@@ -28,15 +29,6 @@ const QUALITY_FLAG_LABELS: Record<string, string> = {
|
||||
nevo_preamble_leak: "דליפת רציו נבו",
|
||||
};
|
||||
|
||||
function formatDate(iso: string | null | undefined) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/* Strip Unicode bidi marks that render as zero-width but shift visual position. */
|
||||
function cleanCitation(s: string | null | undefined): string {
|
||||
if (!s) return "—";
|
||||
|
||||
@@ -31,15 +31,7 @@ import {
|
||||
FormattedCitation,
|
||||
CitationCopyButton,
|
||||
} from "./formatted-citation";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||
|
||||
function cleanCitation(s: string | null | undefined): string {
|
||||
if (!s) return "—";
|
||||
|
||||
@@ -14,15 +14,7 @@ import {
|
||||
} from "@/lib/api/precedent-library";
|
||||
import { PRACTICE_AREAS, PRECEDENT_LEVELS } from "./practice-area";
|
||||
import { AuthorityBadge } from "./halacha-meta";
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||
|
||||
/** Score chip — boxed, gold-deep, tabular (mockup 07 `.score`). Sits at the
|
||||
* end of the meta row via `ms-auto`. White fill on gold-wash halacha cards. */
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/lib/api/precedent-library";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { useProgress } from "@/lib/api/documents";
|
||||
import { formatDateShort } from "@/lib/format-date";
|
||||
import {
|
||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
||||
} from "./practice-area";
|
||||
@@ -192,7 +193,7 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
||||
{conflict.citation}
|
||||
{conflict.case_name && ` — ${conflict.case_name}`}
|
||||
{conflict.court && ` · ${conflict.court}`}
|
||||
{conflict.date && ` · ${new Date(conflict.date).toLocaleDateString("he-IL")}`}
|
||||
{conflict.date && ` · ${formatDateShort(conflict.date)}`}
|
||||
</p>
|
||||
{conflict.halacha_extraction_status && (
|
||||
<p className="text-danger/60 text-[0.72rem]">
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
useDeleteChat,
|
||||
type ChatMessage,
|
||||
} from "@/lib/api/training";
|
||||
import { formatDateShort } from "@/lib/format-date";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function ChatPanel() {
|
||||
@@ -200,7 +201,7 @@ function ConversationsSidebar({
|
||||
<span className="tabular-nums">{c.message_count}</span>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span className="grow text-end">
|
||||
{new Date(c.last_message_at).toLocaleDateString("he-IL")}
|
||||
{formatDateShort(c.last_message_at)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(c.id); }}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useCorpus, useDeleteCorpusEntry, type CorpusDecision } from "@/lib/api/training";
|
||||
import { CorpusDetailDrawer } from "./corpus-detail-drawer";
|
||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||
|
||||
/*
|
||||
* Corpus tab: table of all decisions currently in the style corpus.
|
||||
@@ -30,15 +31,6 @@ function formatChars(n: number) {
|
||||
return `${(n / 1000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function Row({
|
||||
item, onOpen,
|
||||
}: { item: CorpusDecision; onOpen: () => void }) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { formatDateShort, formatDateTimeFull } from "@/lib/format-date";
|
||||
import {
|
||||
useCuratorPrompt,
|
||||
useCuratorStats,
|
||||
@@ -256,7 +257,7 @@ function RecentFindings() {
|
||||
)}
|
||||
<span className="grow text-ink-muted text-end">
|
||||
<Clock className="w-3 h-3 inline me-1" />
|
||||
{new Date(f.created_at).toLocaleDateString("he-IL")}
|
||||
{formatDateShort(f.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-ink leading-relaxed">{f.lesson_text}</p>
|
||||
@@ -291,7 +292,7 @@ function CuratorPromptCard() {
|
||||
<h3 className="text-navy font-semibold">{data.filename}</h3>
|
||||
<p className="text-[0.72rem] text-ink-muted">
|
||||
{data.bytes.toLocaleString("he-IL")} בייטים ·
|
||||
עודכן: {new Date(data.last_modified * 1000).toLocaleString("he-IL")}
|
||||
עודכן: {formatDateTimeFull(data.last_modified * 1000)}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning,
|
||||
type DraftFinalPair,
|
||||
} from "@/lib/api/learning";
|
||||
import { formatDateShort as fmtDate } from "@/lib/format-date";
|
||||
|
||||
/**
|
||||
* /training "למידה" tab (T6/T13) — the reconciliation ledger (INV-LRN4): every
|
||||
@@ -28,11 +29,6 @@ const STATUS_CLASS: Record<string, string> = {
|
||||
lessons_folded: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
try { return new Date(iso).toLocaleDateString("he-IL"); } catch { return iso; }
|
||||
}
|
||||
|
||||
function StyleDistanceDetail({ caseNumber }: { caseNumber: string }) {
|
||||
const { data, isPending, error } = useStyleDistance(caseNumber);
|
||||
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
usePatchLesson,
|
||||
type DecisionLesson,
|
||||
} from "@/lib/api/training";
|
||||
import { formatDateShort } from "@/lib/format-date";
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "general", label: "כללי" },
|
||||
@@ -224,7 +225,7 @@ function LessonItem({
|
||||
{reviewBadge.label}
|
||||
</Badge>
|
||||
<span className="grow text-ink-muted tabular-nums">
|
||||
{new Date(lesson.created_at).toLocaleDateString("he-IL")}
|
||||
{formatDateShort(lesson.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export type PaperclipIssue = {
|
||||
completed_at: string | null;
|
||||
created_at: string | null;
|
||||
company_id: string;
|
||||
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
|
||||
parent_id: string | null;
|
||||
};
|
||||
|
||||
export type PaperclipComment = {
|
||||
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
|
||||
export function useSendComment(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
||||
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
||||
mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
|
||||
apiRequest<{
|
||||
comment_id: string | null;
|
||||
issue_id: string;
|
||||
issue_identifier: string;
|
||||
issue_status?: string;
|
||||
new_run?: boolean;
|
||||
}>(
|
||||
`/api/cases/${caseNumber}/agents/comment`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
@@ -180,6 +188,45 @@ export function useSubmitInteraction(caseNumber: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Dismiss a pending interaction WITHOUT waking the issue assignee — used for
|
||||
* stale/duplicate questions. Backend marks it `cancelled` directly (the
|
||||
* `wake_assignee` continuation policy means a normal resolve would wake the
|
||||
* agent; dismiss must not). */
|
||||
export function useDismissInteraction(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { issue_id: string; interaction_id: string }) =>
|
||||
apiRequest<{ ok: boolean; cancelled: boolean }>(
|
||||
`/api/cases/${caseNumber}/agents/interaction-dismiss`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
onSuccess: () => {
|
||||
if (caseNumber) {
|
||||
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Manually close a board issue to done/cancelled — the chair tidying the agents
|
||||
* board (e.g. superseded runs left in in_review). Backend does a loop-safe
|
||||
* direct close and issues no wakeup; only the two terminal statuses are allowed. */
|
||||
export function useSetIssueStatus(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { issue_id: string; status: "done" | "cancelled" }) =>
|
||||
apiRequest<{ ok: boolean; id: string; identifier: string; status: string }>(
|
||||
`/api/cases/${caseNumber}/agents/issue-status`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
onSuccess: () => {
|
||||
if (caseNumber) {
|
||||
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type AgentResetResult = {
|
||||
ok: boolean;
|
||||
reassigned_issues: { id: string; identifier: string }[];
|
||||
|
||||
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
|
||||
* pipeline actually sets or that gate real logic. Decorative mid-stage markers
|
||||
* (uploading, analyst_verified, research_complete, brainstorming,
|
||||
* analysis_enriched, ready_for_writing, drafting) plus the legacy/dead
|
||||
* `in_progress` and `qa_failed` were removed — no pipeline code ever set them.
|
||||
* The ONE source of truth is the backend status model
|
||||
* (`mcp-server/src/legal_mcp/case_status_model.py`), exposed at
|
||||
* `GET /api/status-model`; this file mirrors it (order / labels / phases) so the
|
||||
* UI keeps compile-time `CaseStatus` typing. Keep the two in sync — the backend
|
||||
* registry is authoritative (it also declares per-status `on_enter` actions).
|
||||
*
|
||||
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards,
|
||||
* compose chip) imports the list / labels / phases from here instead of
|
||||
* re-declaring its own array. Keep this file in sync with the backend
|
||||
* STATUS_ORDER in `mcp-server/src/legal_mcp/tools/cases.py`.
|
||||
* The analyst/research intermediate states (`analyst_verified`,
|
||||
* `research_complete`) are first-class canonical statuses — the agents set them,
|
||||
* so the chip, stepper and manual changer all agree instead of the status
|
||||
* falling between the canonical set and a legacy bucket.
|
||||
*
|
||||
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards)
|
||||
* imports the list / labels / phases from here instead of re-declaring its own.
|
||||
*/
|
||||
|
||||
/** Ordered lifecycle — also the order shown in the manual status dropdown. */
|
||||
@@ -18,6 +21,8 @@ export const CASE_STATUSES = [
|
||||
"new",
|
||||
"processing",
|
||||
"documents_ready",
|
||||
"analyst_verified",
|
||||
"research_complete",
|
||||
"outcome_set",
|
||||
"direction_approved",
|
||||
"qa_review",
|
||||
@@ -35,21 +40,49 @@ export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done";
|
||||
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
|
||||
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
|
||||
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] },
|
||||
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["outcome_set", "direction_approved"] },
|
||||
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["analyst_verified", "research_complete", "outcome_set", "direction_approved"] },
|
||||
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
|
||||
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
||||
];
|
||||
|
||||
/** Which phase a status belongs to (undefined for unknown/legacy values). */
|
||||
/**
|
||||
* Truly-legacy statuses that are NOT in the canonical set but might still arrive
|
||||
* from an old client / un-migrated row — mapped to a phase for display only, so
|
||||
* the stepper never goes blank. (The analyst/research intermediates are NO
|
||||
* longer here — they are canonical statuses in CASE_STATUSES above.)
|
||||
*/
|
||||
const LEGACY_STATUS_PHASE: Record<string, PhaseKey> = {
|
||||
analysis_enriched: "thinking",
|
||||
ready_for_writing: "writing",
|
||||
drafting: "writing",
|
||||
in_progress: "thinking",
|
||||
};
|
||||
|
||||
/** Which phase a status belongs to (undefined only for truly unknown values). */
|
||||
export function phaseOf(status?: CaseStatus | string): PhaseKey | undefined {
|
||||
if (!status) return undefined;
|
||||
return PHASES.find((p) => (p.statuses as string[]).includes(status))?.key;
|
||||
return (
|
||||
PHASES.find((p) => (p.statuses as string[]).includes(status))?.key ??
|
||||
LEGACY_STATUS_PHASE[status]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of a status within the 5-phase pipeline (−1 only for unknown values).
|
||||
* Single source of truth for the stepper — handles legacy statuses via phaseOf,
|
||||
* so a case on `analyst_verified` lands on "ניתוח וכיוון" instead of blank.
|
||||
*/
|
||||
export function phaseIndexOf(status?: CaseStatus | string): number {
|
||||
const key = phaseOf(status);
|
||||
return key ? PHASES.findIndex((p) => p.key === key) : -1;
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||
new: "חדש",
|
||||
processing: "בעיבוד",
|
||||
documents_ready: "מסמכים מוכנים",
|
||||
analyst_verified: "ניתוח אומת",
|
||||
research_complete: "מחקר הושלם",
|
||||
outcome_set: "תוצאה נקבעה",
|
||||
direction_approved: "כיוון אושר",
|
||||
qa_review: "בדיקת איכות",
|
||||
@@ -67,8 +100,6 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||
const LEGACY_STATUS_LABELS: Record<string, string> = {
|
||||
in_progress: "בעבודה",
|
||||
uploading: "מעלה",
|
||||
analyst_verified: "ניתוח אומת",
|
||||
research_complete: "מחקר הושלם",
|
||||
brainstorming: "סיעור מוחות",
|
||||
analysis_enriched: "ניתוח הועמק",
|
||||
ready_for_writing: "מוכן לכתיבה",
|
||||
@@ -94,6 +125,8 @@ export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
|
||||
new: "התיק נוצר וממתין להעלאת מסמכים",
|
||||
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
||||
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
||||
analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה",
|
||||
research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)",
|
||||
outcome_set: "נקבעה תוצאה צפויה לערר",
|
||||
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
|
||||
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
||||
|
||||
@@ -84,7 +84,13 @@ export function useCitationVerification(caseNumber: string | undefined) {
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 10_000,
|
||||
// The view is an expensive synthesized build (per-argument Voyage embed +
|
||||
// vector search on the backend). Keep it fresh for 5 min and do NOT refetch
|
||||
// on remount — Radix Tabs unmounts inactive TabsContent, so every tab switch
|
||||
// back used to trigger a full rebuild. Verify mutations invalidate the query,
|
||||
// so edits still pull fresh data.
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@ export function useUploadDocument(caseNumber: string) {
|
||||
export type DocumentPatch = {
|
||||
doc_type?: string;
|
||||
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
|
||||
is_post_hearing?: boolean; // true = received after the hearing (→ group 4); false clears
|
||||
protocol_scope?: string; // "" clears; "appeal" | "lower" (protocol docs only)
|
||||
};
|
||||
|
||||
export type PatchDocumentResponse = {
|
||||
|
||||
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;
|
||||
case_id: string;
|
||||
party: LegalArgumentParty;
|
||||
/**
|
||||
* The specific pleading this argument belongs to, for sides that may comprise
|
||||
* several litigants with opposing positions (respondent / permit_applicant
|
||||
* briefs). Empty for single-voice sides (appellant / committee). (#224)
|
||||
*/
|
||||
party_name?: string;
|
||||
argument_index: number;
|
||||
argument_title: string;
|
||||
argument_body: string;
|
||||
|
||||
@@ -190,6 +190,61 @@ export function useAgentRuns() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent health taxonomy (#222) + recent escalations (#218) ───────────────
|
||||
export type AgentHealthState = "zombie" | "stalled" | "working" | "idle";
|
||||
|
||||
export type AgentHealthItem = {
|
||||
issue_id: string;
|
||||
identifier: string;
|
||||
status: string;
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
health: AgentHealthState;
|
||||
wakeups: number;
|
||||
recovery_wakeups: number;
|
||||
};
|
||||
|
||||
export type AgentHealthResponse = {
|
||||
ok: boolean;
|
||||
items: AgentHealthItem[]; // worst-first (zombie → stalled → working → idle)
|
||||
counts: Record<AgentHealthState, number>;
|
||||
};
|
||||
|
||||
/** Per-issue agent health — surfaces recovery-loop zombies automatically. */
|
||||
export function useAgentHealth() {
|
||||
return useQuery({
|
||||
queryKey: ["operations", "agents", "health"],
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<AgentHealthResponse>("/api/operations/agents/health", { signal }),
|
||||
refetchInterval: 5000, // live alongside the running-agents view
|
||||
staleTime: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
export type EscalationItem = {
|
||||
issue_id: string;
|
||||
identifier: string;
|
||||
severity: "critical" | "high" | "medium";
|
||||
reason: string;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type RecentEscalationsResponse = {
|
||||
ok: boolean;
|
||||
items: EscalationItem[]; // newest-first
|
||||
};
|
||||
|
||||
/** Recent chair escalations (watchdog + manual) — the handled-loop history. */
|
||||
export function useRecentEscalations() {
|
||||
return useQuery({
|
||||
queryKey: ["operations", "agents", "escalations"],
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<RecentEscalationsResponse>("/api/operations/agents/escalations", { signal }),
|
||||
refetchInterval: 15000, // escalations change slowly (watchdog runs every 15m)
|
||||
staleTime: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Full output log of one run — fetched on demand (drawer open). */
|
||||
export function useRunLog(runId: string | null) {
|
||||
return useQuery({
|
||||
|
||||
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",
|
||||
"",
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user