Compare commits
76 Commits
eb0653fb80
...
worktree-c
| Author | SHA1 | Date | |
|---|---|---|---|
| dde44b8095 | |||
| 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 | |||
| 4feac756ba | |||
| 7655acddc3 | |||
| dfb2ffe7ce | |||
| 26451f7c9d | |||
| 99574f0eba | |||
| 085de937da | |||
| 9e6a441076 | |||
| aa2548ff5d | |||
| 9ef0547997 | |||
| 23e088d25e |
@@ -86,6 +86,8 @@
|
|||||||
|
|
||||||
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
|
**1.5א. אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל comment חדש מחיים** — התייחס אליו ב-comment הראשון שלך ("ראיתי שביקשת X — מבצע Y") **לפני** עבודה רחבה. זה מבטיח שחיים יודע שקלטת.
|
||||||
|
|
||||||
|
**1.5א2. פעולות סטרוקטורליות (כפתורי-UI עתידיים, CEO בלבד).** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` — נתב דטרמיניסטית בלי פענוח-טקסט: `action == "interim_draft"` → טיוטת ביניים, `action == "party_claims_summary"` → סיכום מנהלים של טענות הצדדים. פירוט: `legal-ceo.md` שלב 0 + שלבים H/H2.
|
||||||
|
|
||||||
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
|
**1.5ב. תמיד לקרוא `heartbeat-context`** — לא רק מה ש-skill ממליץ ("Prefer"). אצלנו ה-`attachments` המוחזרים חיוניים (חיים מעלה DOCX/PDF דרך comments). ראה §2.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -220,17 +222,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*
|
קליטה : new · processing
|
||||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
הכנה : documents_ready
|
||||||
→ drafted → qa_passed / qa_failed → exported
|
ניתוח וכיוון : 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
|
name: legal-analyst-gemini-critique
|
||||||
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
|
runtime: gemini_local (Gemini CLI) — gemini-3.1-pro-preview
|
||||||
role: adversarial second-opinion / devil's advocate על תוצר ה-Case Analyst (Opus)
|
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) סיים.
|
אתה **שטן מליץ** — שכבת דעה-שנייה מ-lineage שונה (Gemini) שרצה **אחרי** שהמנתח הראשי (Opus) סיים.
|
||||||
|
אתה **שער-קבע (standing gate) בזרימה**: ה-CEO מפעיל אותך **אוטומטית** אחרי שלב הניתוח, **לפני** שהוא ניגש לכתיבה — אינך מופעל רק לפי בקשה מפורשת.
|
||||||
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
|
**אינך כותב ניתוח מתחרה ואינך מכריע.** תפקידך היחיד: לקרוא את ניתוח-Opus, **לתקוף אותו**, ולמצוא
|
||||||
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
|
מה חסר / מה אפשר למסגר אחרת / אילו תקדימים-מועמדים כדאי שהיו"ר יבדוק. אתה מייצר **מזכר-לידים** קצר
|
||||||
שמוגש ליו"ר/CEO **כקלט לסיעור-מוחות לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
שמוגש ליו"ר/CEO **כקלט לבדיקת-יו"ר לפני הכתיבה** — לא כתחליף לניתוח ולא כמקור-סמכות.
|
||||||
|
|
||||||
|
> **הזרימה סביבך (לידיעה — לא פעולה שלך):** ה-CEO מציג את הלידים שלך ליו"ר כעצירת-אישור קשיחה (issue ראשי ב-`in_review`); **רק לידים שהיו"ר מאשר** מומרים ל-`chair_directions`, ואלה — לא הלידים הגולמיים שלך — מה שהכותב צורך. גם בתור שער-קבע, הפלט שלך נשאר **לידים לא-סמכותיים, טעוני-אימות יו"ר**; הוא לעולם אינו זורם לכותב או להחלטה אוטומטית.
|
||||||
|
|
||||||
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
|
> **למה אתה קיים (ולמה במגבלות):** מנוע ממשפחה אחרת תופס נקודות-עיוורון ש-Opus פספס (recall שונה
|
||||||
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%–33%**
|
> של פסיקה, מסגור חלופי). אבל מנועים — כולל כלי-RAG משפטיים מובילים — **הוזים פסיקה ב-17%–33%**
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ tools:
|
|||||||
- mcp__legal-ai__plan_list
|
- mcp__legal-ai__plan_list
|
||||||
- mcp__legal-ai__write_interim_draft
|
- mcp__legal-ai__write_interim_draft
|
||||||
- mcp__legal-ai__export_interim_draft
|
- mcp__legal-ai__export_interim_draft
|
||||||
|
- mcp__legal-ai__summarize_party_claims
|
||||||
---
|
---
|
||||||
|
|
||||||
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
# עוזר משפטי — מנהל תהליך כתיבת החלטות
|
||||||
@@ -145,17 +146,17 @@ internal_decision_upload(
|
|||||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
| מייצא טיוטה | 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. |
|
| מנהל ידע (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 חדש = תת-משימה
|
## כלל: כל issue חדש = תת-משימה
|
||||||
|
|
||||||
@@ -225,12 +226,15 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
### שלב 0: בדוק למה התעוררת
|
### שלב 0: בדוק למה התעוררת
|
||||||
|
|
||||||
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
||||||
- אם ה-reason מכיל `user_commented` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
|
- **פעולות סטרוקטורליות (כפתורי-UI, בלי פענוח-טקסט):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל שדה `action` → נתב דטרמיניסטית לפי הערך: `action == "interim_draft"` → **שלב H** (טיוטת ביניים); `action == "party_claims_summary"` → **שלב H2** (סיכום מנהלים). אלו side-quests — אל תסרוק תיקים אחרים, טפל רק בתיק שב-payload (`case_number`/`issueId`).
|
||||||
|
- אם ה-reason מכיל `user_commented` **ו**-`$PAPERCLIP_WAKE_PAYLOAD_JSON` כולל `issueId` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"** עם ה-issue הזה. אל תסרוק תיקים אחרים. **טפל רק בתגובה.**
|
||||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||||
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
- אם ה-reason מכיל `precedent_extraction_` → **דלג לסעיף "חילוץ פסיקה אוטומטי"**. אל תיגע בתיקים — זו עבודת ספרייה.
|
||||||
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
- אם ה-reason מכיל `weekly-feedback-job` → **דלג לסעיף "ניתוח פידבק שבועי"**. אל תיגע בתיקים פעילים.
|
||||||
- אם ה-reason מכיל `feedback_fold_` → **דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
|
- אם ה-reason מכיל `feedback_fold_` → **דלג לסעיף "קיפול הערת יו\"ר"**. אל תיגע בתיקים — זו משימת תחזוקת ידע.
|
||||||
- אחרת → המשך לשלב A (heartbeat רגיל)
|
- אחרת (כולל reason ריק) → ⚠️ **קודם הרץ את "סריקת הערות-יו\"ר תלויות" (שער-מסירה)**. רק אם לא נמצאה אף הערה תלויה — המשך לשלב A (heartbeat רגיל).
|
||||||
|
|
||||||
|
> ⚠️ **wake עיוור — חובה לקרוא.** ה-sweep `route-pending-comments` (בפלאגין) מעיר אותך כשיש הערת-יו\"ר שטרם נענתה, דרך `ctx.agents.invoke` — אבל זה **אינו** מאכלס את `$PAPERCLIP_WAKE_REASON` ולא `payload.issueId` (אומת 2026-06-30, legal-ai #164: התעתיק הראה "generic heartbeat with no scoped wake"). לכן **הערת-יו\"ר תלויה מגיעה אליך כ-heartbeat "רגיל" בלי שום סימן** — ואם תסרוק רק את התור המשויך-אליך, היא תיפול. **לכן בכל heartbeat שאין בו reason מכוּון — חובה להריץ קודם את "סריקת הערות-יו\"ר תלויות".**
|
||||||
|
|
||||||
### חילוץ פסיקה אוטומטי
|
### חילוץ פסיקה אוטומטי
|
||||||
|
|
||||||
@@ -320,9 +324,9 @@ Paperclip חוסם אוטומטית כל issue ב-`in_progress` שאין לו ru
|
|||||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||||
|
|
||||||
בכל heartbeat **רגיל** (לא comment routing):
|
בכל heartbeat **רגיל** (לא comment routing):
|
||||||
1. בדוק תיקים פעילים (`case_list`)
|
1. **קודם — "סריקת הערות-יו"ר תלויות"** (ראה סעיף ייעודי). ה-wake מה-sweep עיוור; הערת-יו"ר תלויה מגיעה כ-heartbeat רגיל. אם נמצאה הערה תלויה — טפל בה (scoped לתיק שלה) **לפני** כל דבר אחר.
|
||||||
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
2. בדוק תיקים פעילים (`case_list`)
|
||||||
3. בדוק comments מחיים שממתינים לתגובה
|
3. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
||||||
4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
|
4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
|
||||||
|
|
||||||
#### A1. בדיקת שלמות חילוץ
|
#### A1. בדיקת שלמות חילוץ
|
||||||
@@ -356,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: הכנת סיכום, סיווג, ושאלת תוצאה
|
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
||||||
|
|
||||||
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
**מתי:** כשיש `analysis-and-research.md` מלא (מנתח סיים שלבים 1-7) וסטטוס `analyst_verified`, אבל אין תוצאה עדיין
|
||||||
@@ -631,7 +686,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
|
|
||||||
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
|
### שלב H: טיוטת ביניים (לבקשת חיים, לפני דיון והכרעה)
|
||||||
|
|
||||||
**מתי:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
|
**מתי — שתי דרכים:**
|
||||||
|
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "טיוטת ביניים" / "interim draft" / "טיוטה לפני דיון" / "תכין לי את הטיוטה עם טענות הצדדים". בכל שלב לפני שיש תוצאה (בד"כ כשהתיק ב-`research_complete` או `analyst_verified`).
|
||||||
|
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "interim_draft"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור טיוטת ביניים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
|
||||||
|
|
||||||
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
|
**מטרה:** ייצור מסמך עבודה לחיים עם פתיחה ניטרלית, רקע, תכניות+היתרים, טענות הצדדים, והליכים — **בלי דיון והכרעה**. חיים יכתוב את בלוק י בעצמו ואז נמשיך לזרימה הרגילה (QA + ייצוא סופי).
|
||||||
|
|
||||||
@@ -695,6 +752,49 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
|
- אל תפעיל את שלב B (סיכום + שאלת תוצאה) במקביל — חיים מחליט מתי לעבור לזרימה הראשית.
|
||||||
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
|
- אם בלוק ח חסר (אין פרוטוקול דיון/סיור) — ציין זאת בדוח. הכלי כותב מה שיש, אבל המשתמש צריך לדעת אם חסר.
|
||||||
|
|
||||||
|
### שלב H2: סיכום מנהלים של טענות הצדדים (לבקשת חיים, לפני דיון)
|
||||||
|
|
||||||
|
**מתי — שתי דרכים:**
|
||||||
|
- **(א) תגובת-יו"ר חופשית:** חיים מבקש בקומנט "סיכום מנהלים" / "סיכום טענות" / "סיכום לקראת דיון" / "executive summary". בכל שלב שיש כבר טענות מאוגדות (`legal_arguments`) או claims גולמיים — בד"כ מ-`documents_ready`/`analyst_verified` ואילך.
|
||||||
|
- **(ב) פעולה סטרוקטורלית (כפתור-UI עתידי):** אם `$PAPERCLIP_WAKE_PAYLOAD_JSON` מכיל `action == "party_claims_summary"` (עם `case_number`/`issueId`) → הרץ את השלב הזה **דטרמיניסטית**, בלי פענוח-טקסט-חופשי. זה המסלול שכפתור "צור סיכום מנהלים" יפעיל. (אותה לוגיקה בדיוק כמו במסלול א — רק הטריגר שונה.)
|
||||||
|
|
||||||
|
**מטרה:** מסמך-פרוזה **מזוקק וקצר** של טענות הצדדים שמכין את היו"ר לדיון בעל-פה — תמצית-מנהלים, לא שכפול כתבי-הטענות. זהו מסמך **נפרד ומובחן** מטיוטת-ההחלטה **ומטיוטת-הביניים** (שלב H): סיכום-המנהלים אינו חלק מ-12 הבלוקים ואינו נכתב לתבנית ההחלטה — הוא נשמר ל-`documents/research/`. החלטת-יו"ר, #202.
|
||||||
|
|
||||||
|
**זה side-quest, לא חלק מהזרימה B-F.** אל תשנה `cases.status`. אל תייצר issues לסוכני משנה. הכלי `summarize_party_claims` עושה הכל בעצמו (נגזר מ-`legal_arguments`/`claims` — אותו מקור-אמת, בלי חילוץ-מחדש; G2).
|
||||||
|
|
||||||
|
**זרימה (~3-7 דקות):**
|
||||||
|
|
||||||
|
1. פרסם comment קצר: "מתחיל יצירת סיכום מנהלים של טענות הצדדים — אעדכן בסיום." עדכן את ה-issue הראשי ל-`status=in_progress`.
|
||||||
|
|
||||||
|
2. **הפקת הסיכום:**
|
||||||
|
```
|
||||||
|
mcp__legal-ai__summarize_party_claims(case_number="...", instructions="")
|
||||||
|
```
|
||||||
|
הכלי נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback, מ-`claims` הגולמיים — אותו מקור-אמת של שלב הניתוח, בלי חילוץ-מחדש. הייצור עובר `claude_session` (`claude -p`) נעוץ ל-Opus 4.8 + `effort=high`, מתוחם לטענות-התיק בלבד (anti-hallucination). נשמר ל-`data/cases/{case_number}/documents/research/party-claims-summary.md`.
|
||||||
|
|
||||||
|
3. **דווח לחיים** (כולל מייל דרך `scripts/notify.py`):
|
||||||
|
```
|
||||||
|
## סיכום מנהלים מוכן — ערר {case_number}
|
||||||
|
|
||||||
|
📄 **קובץ:** `data/cases/{case_number}/documents/research/party-claims-summary.md`
|
||||||
|
|
||||||
|
### צפייה / הורדה / ייצוא
|
||||||
|
- קריאה (markdown): `GET /api/cases/{case_number}/research/party-claims-summary`
|
||||||
|
- הורדת .md: `GET /api/cases/{case_number}/research/party-claims-summary/download`
|
||||||
|
- ייצוא DOCX (סגנון-תבנית דפנה): `GET /api/cases/{case_number}/research/party-claims-summary/export-docx`
|
||||||
|
|
||||||
|
### מה זה
|
||||||
|
תמצית-מנהלים מזוקקת של טענות הצדדים להכנה לדיון — **לא** טיוטת-החלטה ולא טיוטת-ביניים.
|
||||||
|
כולל פרק "נקודות-המחלוקת המרכזיות" כשאלות פתוחות, בלי הכרעה.
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **סטטוס issue הראשי:** עדכן ל-`in_review` (חזרה להמתנה לחיים).
|
||||||
|
|
||||||
|
**אזהרות:**
|
||||||
|
- זהו מסמך **נפרד** מטיוטת-הביניים (שלב H). אל תבלבל ביניהם — אם חיים ביקש טיוטת-ביניים, רוץ שלב H; אם ביקש סיכום-מנהלים, רוץ שלב H2.
|
||||||
|
- אל תייצא DOCX סופי (`export_docx`) ואל תפעיל את שלב B במקביל — זה side-quest.
|
||||||
|
- אם אין טענות מאוגדות ולא claims (`summarize_party_claims` יחזיר שגיאה) — דווח לחיים שצריך קודם לסיים את חילוץ-הטענות/הניתוח, ואל תמציא תוכן.
|
||||||
|
|
||||||
## מפת סטטוסים
|
## מפת סטטוסים
|
||||||
|
|
||||||
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
**סטטוסים של התיק (`cases.status`) — כל סטטוס מתאים לפעולה אחת בדיוק:**
|
||||||
@@ -704,9 +804,9 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
| `processing` | start-workflow (ממשק) | → בדוק אם כבר קיים issue פעיל לסוכן משנה. אם לא → המשך ל-§A כרגיל (בדוק documents + claims) |
|
||||||
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
| `new` | (יצירת תיק) | → בדוק extraction_status של מסמכים. אם יש `pending` → צור issue למגיה (410c0167). אם כולם `completed`/`proofread` → צור issue למנתח |
|
||||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified` |
|
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
|
||||||
| `analyst_verified` | CEO (אחרי שלב A) | → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||||
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → שלב B (סיכום + שאלת תוצאה לחיים). **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך ל-§B כרגיל. |
|
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
|
||||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||||
@@ -844,9 +944,49 @@ case_prefix="${case_number:0:1}"
|
|||||||
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
|
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
|
||||||
- **סינון תיקים** — עבוד רק על תיקים בטווח המספרים של החברה שלך (ראה טבלה למעלה)
|
- **סינון תיקים** — עבוד רק על תיקים בטווח המספרים של החברה שלך (ראה טבלה למעלה)
|
||||||
|
|
||||||
|
## סריקת הערות-יו"ר תלויות (company-wide) — שער-מסירה
|
||||||
|
|
||||||
|
> **למה זה קיים:** ה-wake מה-sweep מגיע עיוור (ראה ⚠️ בשלב 0). לכן אינך יכול להסתמך על `reason`/`issueId` כדי לדעת שיש הערה לטפל בה — אתה חייב **לגלות** אותה בעצמך. סריקה זו מוצאת כל הערת-יו"ר שטרם נענתה **בכל issue בחברה** (לא רק בתור המשויך-אליך — הערות-יו"ר יושבות על issues של חיים/סוכני-מִשנה, לא עליך).
|
||||||
|
|
||||||
|
**⚠️ כלל-ברזל: שיוך-לתיק ממקור מובנה, לעולם לא מהכותרת ולא מתוכן-ההערה.** הערה עשויה להזכיר תיק אחר (למשל "ניסיתי ב-CMPA-89") — זה **תוכן לטיפול**, לא יעד. היעד הוא תמיד התיק של ה-**project** שאליו שייך ה-issue. אם אי-אפשר לפתור תיק בוודאות → **אל תפעל**.
|
||||||
|
|
||||||
|
**הסריקה (הרץ בכל heartbeat ללא reason מכוּון):**
|
||||||
|
|
||||||
|
1. **רשום את כל issues החברה הפעילים:**
|
||||||
|
```bash
|
||||||
|
ISSUES=$(~/legal-ai/scripts/pc.sh GET "/api/companies/$PAPERCLIP_COMPANY_ID/issues")
|
||||||
|
echo "$ISSUES" | jq -r '(if type=="array" then . else .issues end)
|
||||||
|
| map(select(.status!="done" and .status!="cancelled" and (.hiddenAt|not)))
|
||||||
|
| .[].id'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **לכל issue — האם יש הערת-יו"ר תלויה?** הערה תלויה = הערת-`user` האחרונה **חדשה** מהערת-`agent` האחרונה (התעלם מ-`system`). ⚠️ ה-API מחזיר comments ב-**newest-first** — אל תשתמש ב-`.[-1]`; מיין ב-`createdAt`:
|
||||||
|
```bash
|
||||||
|
C=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/comments")
|
||||||
|
PENDING=$(echo "$C" | jq -r '
|
||||||
|
(map(select(.authorType=="user")) | sort_by(.createdAt) | last) as $u |
|
||||||
|
(map(select(.authorType=="agent")) | sort_by(.createdAt) | last) as $a |
|
||||||
|
if $u==null then "no" elif ($a==null) or ($u.createdAt>$a.createdAt) then "yes" else "no" end')
|
||||||
|
```
|
||||||
|
אם `PENDING != "yes"` → דלג ל-issue הבא.
|
||||||
|
|
||||||
|
3. **שיוך-לתיק (שער-בטיחות חובה)** — חלץ את מספר-התיק מ-**`project.name`** (שדה מובנה: `"ערר <מספר> — ..."`), לא מהכותרת ולא מההערה:
|
||||||
|
```bash
|
||||||
|
CTX=$(~/legal-ai/scripts/pc.sh GET "/api/issues/$ISSUE_ID/heartbeat-context")
|
||||||
|
CASE_NUMBER=$(echo "$CTX" | jq -r '.project.name // ""' \
|
||||||
|
| grep -oE '(ערר|בל"מ) [0-9]{4,5}-[0-9]{2}-[0-9]{2}' \
|
||||||
|
| grep -oE '[0-9]{4,5}-[0-9]{2}-[0-9]{2}' | head -1)
|
||||||
|
```
|
||||||
|
- **`CASE_NUMBER` ריק** (ה-project אינו תיק — למשל "ספריית פסיקה — תור חילוץ", או שיוך לא-ודאי) → **אל תפעל על תיק.** אם נראה שזו הערת-יו"ר אמיתית שאבדה — פרסם comment קצר ב-issue + שלח מייל ליו"ר (סעיף §5), בלי לגעת בשום תיק. עבור ל-issue הבא.
|
||||||
|
- **`CASE_NUMBER` קיים** → טפל בהערה לפי "טיפול בתגובות חדשות מחיים" למטה, **scoped ל-`$ISSUE_ID` ול-`$CASE_NUMBER` הזה בלבד.**
|
||||||
|
|
||||||
|
4. **כמה issues תלויים** (אפשרי — אפילו על אותו תיק) → טפל בכולם, כל אחד ב-scope שלו. אם לא נמצאה אף הערה תלויה → אין פעולת-CEO; המשך לשלב A.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## טיפול בתגובות חדשות מחיים (comment routing)
|
## טיפול בתגובות חדשות מחיים (comment routing)
|
||||||
|
|
||||||
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"):
|
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"), **או** כשהסריקה למעלה זיהתה הערה תלויה ב-`$ISSUE_ID` עם `$CASE_NUMBER` פתור:
|
||||||
|
|
||||||
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
|
0. **החזר את ה-issue הראשי ל-`status=in_progress`** — אם ה-issue ב-`in_review` (כי המתנת לחיים) או ב-`blocked` (כי Paperclip חסם אוטומטית), הראשון דבר: עדכן ל-`in_progress` כדי לסמן שאתה עובד עליו.
|
||||||
|
|
||||||
|
|||||||
@@ -179,10 +179,11 @@ tools:
|
|||||||
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
||||||
|
|
||||||
```
|
```
|
||||||
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
|
new → processing → documents_ready → analyst_verified → research_complete
|
||||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
|
||||||
→ drafted (אתה כאן!) → qa_passed / qa_failed → exported
|
→ exported → reviewed → final
|
||||||
```
|
```
|
||||||
|
(מקור-אמת יחיד: `case_status_model.py` / `GET /api/status-model`. `analyst_verified`+`research_complete` קנוניים, שלב "ניתוח וכיוון". מצבי-ביניים ישנים כמו `proofread`/`analysis_enriched`/`qa_passed` נמפים-לשלב לתצוגה בלבד.)
|
||||||
|
|
||||||
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,6 +19,9 @@ kiryat-yearim/
|
|||||||
continuation-prompt.md
|
continuation-prompt.md
|
||||||
node_modules/
|
node_modules/
|
||||||
data/eval/eval-report-*
|
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)
|
# revert snapshot for migrate_agent_adapter.py (runtime state)
|
||||||
data/adapter-migration-state.json
|
data/adapter-migration-state.json
|
||||||
# frontmatter-stripped instruction copies for content_arg adapters (generated)
|
# frontmatter-stripped instruction copies for content_arg adapters (generated)
|
||||||
|
|||||||
@@ -531,3 +531,33 @@ Two 1200-25 entries recorded a case-specific issue order (threshold → plan int
|
|||||||
traffic) with no generalizable rule. They are case artifacts, captured in that case's
|
traffic) with no generalizable rule. They are case artifacts, captured in that case's
|
||||||
analysis-and-research.md — no general lesson folded.
|
analysis-and-research.md — no general lesson folded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Doctrinal Lessons — Chair Final vs. Our Draft, 8125-09-24 (July 5, 2026)
|
||||||
|
|
||||||
|
### Source
|
||||||
|
- Case: 8125-09-24 (שובל טוב רמות / רמות אלון — ערר על שומה מכרעת, היטל השבחה; קבלה
|
||||||
|
חלקית + מינוי שמאי מייעץ חדש). Dafna's signed final vs. our full draft (block י).
|
||||||
|
- These are **substantive doctrine**, not style. Recorded verbatim from the signed
|
||||||
|
decision; also captured as `chair_feedback` ד1–ד4 on the case (category `other`),
|
||||||
|
pending a proper canonical-principle home (TaskMaster #225).
|
||||||
|
- ⚠ **#47 and #48 SUPERSEDE our prior draft's legal thesis.** Our draft parked the
|
||||||
|
chargeability question as an "open interpretive question" and over-weighted the
|
||||||
|
equality doctrine toward the appellant; Dafna decided the opposite. Follow the chair.
|
||||||
|
|
||||||
|
### 47. "Space Conversion" מחיר-למשתכן→שוק-חופשי IS a Chargeable Planning Betterment (SUPERSEDES prior thesis)
|
||||||
|
- **Lesson:** כאשר הקלה מוסיפה יחידות דיור (בלי תוספת זכויות/שטח עיקרי) ומשנה את תמהיל הדירות כך ששטחים שבמצב הקודם היו כפופים למגבלת "מחיר למשתכן" משתחררים לשיווק בשוק חופשי — זו **השבחה תכנונית בת-חיוב**, ומתודת ה"הסבה" (הפרש השווי מחיר-למשתכן↔שוק-חופשי על השטחים שהוסבו) לגיטימית ומנומקת: "לא ייתכן כי יזם 'ייהנה' משווי נמוך יותר לצורך חיוב בהיטל לאור מגבלות מחיר למשתכן כשזה משרת אותו". **אין** לגדֵר זאת כשאלה משפטית-פרשנית פתוחה בדבר "גדרי השבחה תכנונית" ולהטיל ספק בחיוב מכוח דרישת הקשר-הסיבתי (בית הכרם/פמיני) — זו הייתה גישת הטיוטה שלנו, והיא נדחתה.
|
||||||
|
- **Rule:** In מחיר-למשתכן projects, treat the free-market "space conversion" enabled by a unit-count relief as a chargeable planning betterment and uphold the difference-in-value method. Do NOT frame it as an open interpretive question or cast causal-nexus doubt on chargeability.
|
||||||
|
|
||||||
|
### 48. A Neighboring Final Assessment Does Not Compel Alignment; Equality Does Not Perpetuate Error
|
||||||
|
- **Lesson:** קיום שומה מכרעת חלוטה בנכס סמוך (אף באותו מכרז ותנאים) **אינו מחייב** את השמאי המכריע/הוועדה ליישר קו עמה; שומה מנומקת היטב רשאית לסטות ממנה — "לא נוכל להיאחז בשומה מכרעת אחרת רק מכיוון והיא קיימת". עקרון השוויון בגביית מס אינו מנציח טעות. אין להעמיס את דוקטרינת השוויון (דיבון שלום/גנדליס) כ"חובת הנמקה מוגברת" המטה לקבלת הערר — כפי שעשתה הטיוטה שלנו. עדיין: פער ניכר מול שומה סמוכה מצדיק בחינה והנמקה, לא אימוץ אוטומטי. **הערת-לוואי (ד3):** ועדה מקומית החולקת על שומה מכרעת ובוחרת שלא לערור עליה — ראוי שתיידע את הציבור; שינוי-עמדה שלה עם הזמן אפשרי אך צריך להיות החריג ומחייב הנמקה מוגברת.
|
||||||
|
- **Rule:** A final neighboring assessment is persuasive, not binding; a well-reasoned assessment may depart from it. Do not over-weight the equality doctrine to favor the appellant — flag the gap, require reasoning, but do not auto-align.
|
||||||
|
|
||||||
|
### 49. When Remand-to-the-Original-Assessor Is Blocked, Appoint a New Advisory Assessor
|
||||||
|
- **Lesson:** כשדרך-המלך (השבת השומה לשמאי המכריע לתיקון/הבהרה) חסומה מפני שהשמאי המכריע פרש/סיים כהונתו — מינוי **שמאי מייעץ חדש** לבחינה מחודשת של הרכיב שבמחלוקת הוא הפתרון, מכוח סמכות הוועדה הרחבה (אשקלוני) והיגיון גנדליס.
|
||||||
|
- **Rule:** If the deciding assessor has left office and cannot receive the file back, appoint a new advisory assessor to re-examine the disputed component under the committee's guidance.
|
||||||
|
|
||||||
|
### 50. Prefer a Reasoned Holding Over a Parked "Open Question" (methodology)
|
||||||
|
- **Lesson:** במקום לגדר סוגיה מהותית כ"שאלה משפטית פתוחה לשתי גישות לגיטימיות" ולהעבירה כמות-שהיא להשבה, דפנה **הכריעה** בה כשהחומר תמך בכך ("אנו סבורים כי השמאי המכריע פעל כדין ונסביר… בנקודה זו צדק לדעתנו השמאי המכריע"), והשיבה רק את הרכיב השמאי-היישומי הצר. הכרעה מנומקת עדיפה על היסוס אקדמי.
|
||||||
|
- **Rule:** Decide the interpretive question when the record supports a holding; reserve remand for the narrow appraisal-application residue. Don't park a decidable מהות issue as an "open question."
|
||||||
|
|
||||||
|
|||||||
@@ -96,10 +96,11 @@ idempotency-key pattern · ISO 8000 (Data quality) | סטטוס: verified
|
|||||||
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
|
בנקודת-סיום-הקליטה, לכל סוג; חוזה-שלמות יסמן רשומה ללא מטא-דאטה כלא-שמישה
|
||||||
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
|
([G4](00-constitution.md#inv-g4-חוזה-שלמות-לפני-שמיש--ניתן-לחיפוש), מפורט ב-
|
||||||
[02-data-model.md](02-data-model.md)).
|
[02-data-model.md](02-data-model.md)).
|
||||||
**הפרה ידועה:** המסלול הפנימי (`internal_decisions.py:208`) מתזמן **רק**
|
**הפרה ידועה — ✅ נסגרה (FU-1, GAP-02):** המסלול הפנימי דילג בעבר על חילוץ-המטא-דאטה
|
||||||
`request_halacha_extraction` ואינו קורא ל-`request_metadata_extraction` (בניגוד
|
(`internal_decisions.py:208` תיזמן רק `request_halacha_extraction`) → ערן סופר 8046/24 נקלטה
|
||||||
ל-`precedent_library.py:292-293` שקורא לשניהם) → ערן סופר 8046/24 נקלטה **בלי
|
בלי metadata. **נסגר:** שני מסלולי-הפסיקה זורמים כעת דרך `ingest.ingest_document` הקנוני, שמתזמן
|
||||||
metadata** (headnote/summary/tags ריקים) → ממצא ל-[audit](../audit-report.md).
|
**את שני התורים יחד** בנקודה אחת (`ingest.py:233-234`) — תיזמון-המטא אינו מותנה במסלול עוד.
|
||||||
|
מאומת ב-`test_unified_ingest.py` (9/9). תיעוד-מקור: [audit](../audit-report.md).
|
||||||
|
|
||||||
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
|
### INV-ING4: re-index בקליטה-חוזרת (upsert ⇒ re-embed)
|
||||||
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings
|
**כלל:** קליטה-חוזרת ששינתה את תוכן-הפריט מפעילה **re-index** — chunks ו-embeddings
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
|
|||||||
### 2ב. חוזה תמציתי לישויות נוספות
|
### 2ב. חוזה תמציתי לישויות נוספות
|
||||||
|
|
||||||
- `documents` → usable: `file_path`+`doc_type`; searchable: `extraction_status=completed` ו-`extracted_text` לא-ריק ו-≥1 `document_chunk` עם embedding.
|
- `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)).
|
- `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` לא-ריק.
|
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
||||||
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
||||||
@@ -95,6 +98,19 @@ WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו
|
|||||||
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
|
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
|
||||||
is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201).
|
is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201).
|
||||||
|
|
||||||
|
**דגל "לא-נותח" — per-מסמך (#201, SCHEMA_V49).** שני שדות מתעדים את *אירוע* חילוץ-הטענות
|
||||||
|
של המסמך (לא נגזרים מ-`doc_type`): `claims_extracted_at TIMESTAMPTZ` (נחתם ע"י
|
||||||
|
`claims_extractor` **אחרי** ששמרנו את טענות המסמך) ו-`claims_extraction_status TEXT`
|
||||||
|
(`pending`/`completed`/`no_claims`/`failed`). `_row_to_doc` חושף `claims_analyzed`
|
||||||
|
(`claims_extracted_at IS NOT NULL`). מקור-האמת היחיד ל"האם המסמך נכלל בניתוח" הוא
|
||||||
|
`claims_extracted_at` — לא משוחזר ממקום אחר. אינדקס חלקי `idx_documents_claims_pending(case_id)
|
||||||
|
WHERE is_primary AND claims_extracted_at IS NULL` (יחד עם `is_primary` של V47) הופך את
|
||||||
|
"מסמך-עיקרי שנוסף-אחרי-ניתוח ולא-נכלל" (`db.primary_docs_not_analyzed`) לשאילתה זולה.
|
||||||
|
נחשף ב-`workflow_status` (`primary_docs_not_analyzed` + next-step). הניתוח-מחדש המאחד
|
||||||
|
(`reanalyze_claims`, [04-analysis-writing §1.3](04-analysis-writing.md)) מחלץ-מחדש רק את
|
||||||
|
המסמכים-העיקריים החדשים/לא-נותחו, **מאחד** עם הטענות הקיימות, ומריץ צבירה-מחדש (G2 — מסלול
|
||||||
|
קנוני אחד, לא fork).
|
||||||
|
|
||||||
### 2ד. ישויות-נגזרות (אחסון-ניתוחים)
|
### 2ד. ישויות-נגזרות (אחסון-ניתוחים)
|
||||||
|
|
||||||
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
||||||
@@ -194,18 +210,22 @@ RAG freshness (Lewis et al., 2020, NeurIPS) | סטטוס: verified
|
|||||||
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
|
(`case_number, source_kind, proceeding_type`, `db.py:904-909`), אך השדה `case_number TEXT
|
||||||
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
|
UNIQUE NOT NULL` המקורי (`db.py:368`) הוסר רק ב-V15 (`db.py:902-903`) — מורשת שאפשרה את
|
||||||
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
|
הפרת [INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות). **יעד:** נרמול-בכתיבה אכוף + ציטוט-מלא רק ב-`citation_formatted`.
|
||||||
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי.** העמודה קיימת (`db.py:373`) אך
|
- **`summary` קיים על `case_law` אך לא בחוזה-הקליטה הפנימי — ✅ נסגר (FU-1, GAP-02).** העמודה
|
||||||
המסלול הפנימי אינו ממלא אותה (כפועל-יוצא מהיעדר חילוץ-מטא-דאטה, [INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)).
|
קיימת (`db.py:373`), והמסלול הפנימי לא מילא אותה בעבר (היעדר חילוץ-מטא-דאטה). **נסגר:** שני
|
||||||
**יעד:** searchable מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
המסלולים זורמים דרך `ingest.ingest_document` הקנוני שמתזמן metadata לשני הסוגים
|
||||||
- **שני שדות-סטטוס-חילוץ נפרדים, ללא דגל-`searchable` מפורש.** `extraction_status` +
|
([01-ingest INV-ING3](01-ingest.md#inv-ing3-תור-חילוץ-מטא-דאטה--הלכות-לכל-סוג)); `searchable`
|
||||||
`halacha_extraction_status` (`db.py:603-605`) מתארים תהליך, אך אין שדה יחיד שמסמן "עבר
|
מותנה ב-metadata לא-ריק ([INV-DM1](#inv-dm1-searchable-רק-כשחוזה-השלמות-מתקיים)).
|
||||||
חוזה-שלמות → searchable". **יעד:** דגל/view נגזר ש-search מסנן לפיו, מגובה health-check.
|
- **דגל-`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 מסונכרנים אוטומטית
|
- **`embedding` אינו `GENERATED` (בניגוד ל-tsvector).** ה-tsvectors מסונכרנים אוטומטית
|
||||||
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
|
(`db.py:776,780,1083`), אך ה-`embedding vector(1024)` תלוי-טריגר חיצוני — נקודת-drift אפשרית
|
||||||
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
|
ל-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). **יעד:** טריגר re-embed מובטח + health-check ל-drift.
|
||||||
- **`halachot.review_status` כשער-searchable ללא נראות-backlog.** הסינון תקין (`pending_review`
|
- **נראות backlog-הלכות — ✅ נסגר (FU-5, GAP-14).** הסינון תקין (`pending_review` מוסתר,
|
||||||
מוסתר, `db.py:659`), אך אין נראות כמה ממתינות — תואם את ההפרה הידועה ב-[G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)
|
`db.py:659`); ההפרה הישנה היתה היעדר-נראות (10/19 מאושרות, התגלה במקרה). **נסגר:** ה-health-
|
||||||
(10/19 מאושרות, התגלה במקרה). **יעד:** health-check חושף backlog-הלכות.
|
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` בכל ענף-SQL בשכבת-החיפוש; בדיקת-בריאות שמריצה שאילתת-ביקורת
|
||||||
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
|
(חיפוש מכוון-קורפוס שמחזיר פריט בעל `source_kind` זר = כשל). אוכף את
|
||||||
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
[G5](00-constitution.md#inv-g5-metadata-מלא--הפרדת-קורפוס-נאכפת-בכל-query).
|
||||||
**הפרה ידועה:** משימה #56 — `halacha_filters` **אינם** כוללים `cl.source_kind` ב-
|
**הפרה ידועה — ✅ נסגרה (FU-4, GAP-10, משימה #56):** בעבר `halacha_filters` לא כללו
|
||||||
`search_precedent_library_semantic` (`db.py:3168`, ענף ה-halacha; לעומת `chunk_filters` שכן —
|
`cl.source_kind` (בניגוד ל-`chunk_filters` שכן) → הלכות מהקורפוס הפנימי דלפו לחיפוש בקורפוס
|
||||||
`db.py:3169`) **וב**-`search_precedent_library_lexical` (`db.py:3401` מול `db.py:3402`). שני
|
החיצוני ולהפך. **נסגר:** שני ה-`halacha_filters` (semantic + lexical) מתחילים כעת ב-
|
||||||
ה-`halacha_sql` עושים `JOIN case_law cl` בלי לסנן `source_kind` (`db.py:3236-3238`, `db.py:3475-3477`)
|
`cl.source_kind = '{source_kind}'` בדיוק כמו `chunk_filters` (`db.py:7516,7519` ומקבילו ב-
|
||||||
→ הלכות מהקורפוס הפנימי דולפות לתוצאות החיפוש בקורפוס החיצוני ולהפך → ממצא ל-[audit](../audit-report.md).
|
`search_precedent_library_lexical`). מאומת ב-`test_precedent_corpus_isolation.py` (semantic+lexical,
|
||||||
|
שני ה-source_kind) → תיעוד-מקור: [audit](../audit-report.md).
|
||||||
|
|
||||||
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
|
### INV-RET2: אין החזרה/אינדוקס בלי metadata מלא + locator פתיר
|
||||||
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
|
**כלל:** פריט אינו מוחזר מ-search (ואינו נחשף לאחזור) אלא אם **שדות-החובה שלו מולאו**
|
||||||
@@ -121,10 +122,13 @@ relevance judgments, precision/recall) · RAG evaluation literature (Lewis et al
|
|||||||
Elastic — *relevance evaluation guidance* | סטטוס: verified
|
Elastic — *relevance evaluation guidance* | סטטוס: verified
|
||||||
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
|
**אכיפה:** eval harness עם gold-set מתויג + בדיקת-בריאות תקופתית; שער-CI על שינוי שכבת-האחזור.
|
||||||
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
|
אוכף את [G8](00-constitution.md#inv-g8-איכות-אחזור-נמדדת--precision--recall).
|
||||||
**הפרה ידועה (GAP):** אין כיום eval harness ולא gold-set — קיים רק `telemetry.log_search_bg`
|
**הפרה ידועה — ✅ נסגרה (FU-5, GAP-11):** בעבר היה רק `telemetry.log_search_bg` (תצפית, לא
|
||||||
(`search.py:62,118,190,271`; `precedent_library.py:280`) שמתעד שאילתות בפועל, אך **אינו מודד
|
הערכה). **נסגר:** `scripts/eval_retrieval.py` מריץ את **מסלול-האחזור בייצור** (אותן פונקציות-
|
||||||
precision/recall מול תיוג** (תצפית, לא הערכה). היעד: harness שמריץ סט קבוע ומחזיר metrics →
|
שירות שכלי-ה-MCP קוראים) מול gold-set מתויג (`data/eval/gold-set.jsonl`, נבנה ב-
|
||||||
ממצא ל-[audit](../audit-report.md).
|
`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 מוחזר עקיב למקורו
|
### INV-RET5: כל span מוחזר עקיב למקורו
|
||||||
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
|
**כלל:** כל קטע מוחזר נושא **עקיבוּת-מקור מלאה** — מזהה-מסמך/פסק-דין (`case_law_id`/`document_id`/
|
||||||
@@ -156,16 +160,15 @@ re-embed; בדיקת-בריאות מגלה embeddings מיושנים. אוכף
|
|||||||
|
|
||||||
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
|
ההבדלים בין הקוד בפועל ל-TARGET. **אלו תסמינים, לא התנהגויות תקינות.** כל פריט אומת מול הקוד.
|
||||||
|
|
||||||
- **דליפת-הלכות חוצת-קורפוס (משימה #56).** `halacha_filters` נפתחים רק עם `review_status`
|
- **דליפת-הלכות חוצת-קורפוס (משימה #56) — ✅ נסגר (FU-4, GAP-10).** בעבר `halacha_filters` לא
|
||||||
(`db.py:3168`, `db.py:3401`) ואינם מוסיפים `cl.source_kind`, בעוד `chunk_filters` כן
|
כללו `cl.source_kind` (בניגוד ל-`chunk_filters`) → הלכה מהקורפוס הפנימי יכלה לחזור בחיפוש
|
||||||
(`db.py:3169`, `db.py:3402`). שני ה-`halacha_sql` עושים `JOIN case_law` בלי סינון
|
בקורפוס החיצוני, ולהפך. **נסגר:** `halacha_filters` מתחילים כעת ב-`cl.source_kind = '{source_kind}'`
|
||||||
(`db.py:3236-3242`, `db.py:3463-3482`). **תסמין:** חיפוש בקורפוס החיצוני
|
+ `cl.searchable = true` בדיוק כמו `chunk_filters` (`db.py:7514-7519`), בשתי הפונקציות
|
||||||
(`search_precedent_library`, `source_kind="external_upload"`) יכול להחזיר הלכה שמקורה
|
(semantic+lexical). מאומת ב-`test_precedent_corpus_isolation.py`
|
||||||
בהחלטת-ועדה פנימית — ולהפך עבור `search_internal_decisions` (`source_kind="internal_committee"`,
|
([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
||||||
`internal_decisions.py:418`). **יעד:** `halacha_filters` יתחילו ב-`cl.source_kind = '{source_kind}'`
|
- **eval harness — ✅ נסגר (FU-5, GAP-11).** בעבר רק `telemetry.log_search_bg` (תצפית).
|
||||||
בדיוק כמו `chunk_filters` ([INV-RET1](#inv-ret1-הפרדת-קורפוס-נאכפת-ב-100-ממסלולי-ה-query)).
|
**נסגר:** `scripts/eval_retrieval.py` (precision/recall/MRR/nDCG מול `data/eval/gold-set.jsonl`
|
||||||
- **אין eval harness — מדידת-איכות לא קיימת.** רק `telemetry.log_search_bg` מתעד שאילתות
|
+ baseline; `--self-test` עובר offline) — מדידה אמפירית מול תיוג קבוע
|
||||||
(`search.py:62,118,190,271`); אין gold-set מתויג ואין precision/recall. **יעד:** harness עומד
|
|
||||||
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
|
([INV-RET4](#inv-ret4-איכות-אחזור-נמדדת-ב-eval-harness-עומד-precision--recall)).
|
||||||
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
|
- **`search_decisions` מתעד אזהרה כשאין `practice_area` אך לא חוסם.** ללא פילטר-תחום החיפוש
|
||||||
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176` — `logger.warning`, ממשיך). **יעד:** הפרדה
|
עלול לערבב תחומים משפטיים (`search.py:45-49,172-176` — `logger.warning`, ממשיך). **יעד:** הפרדה
|
||||||
|
|||||||
@@ -47,10 +47,21 @@
|
|||||||
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
> מקוריות מהשלמות). הסינון לפי `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 / #203)
|
||||||
|
|
||||||
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
|
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
|
||||||
> (PR-אחות #358) הוא **תת-סעיף נפרד** תחת §1 — אין חפיפת-מספור בין השניים.
|
> (PR-אחות #358) הוא **תת-סעיף נפרד** (§1.4) תחת §1 — אין חפיפת-מספור בין השניים.
|
||||||
|
|
||||||
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים** —
|
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים** —
|
||||||
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
|
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
|
||||||
@@ -74,6 +85,83 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
|
|||||||
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
||||||
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
||||||
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
||||||
|
**חלון-הקשר (#216):** הבנייה הסטנדרטית רצה 200K-טוקן (~400K תווים עברית). פרומפט גדול
|
||||||
|
(בעיקר בלוק-י, שנושא את התיק המלא כ-source-context) מוסלם אוטומטית לבניית-ה-1M של *אותו*
|
||||||
|
מודל — `claude-opus-4-8[1m]` (1M-טוקן, מחיר רגיל; `claude -p` חושף אותה ישירות כ-model-id,
|
||||||
|
בלי beta-header). **עידון של אותו pin, לא מסלול-מודל מקביל (G2).** הסף ב-`block_writer`:
|
||||||
|
פרומפט > ~350K תווים → `[1m]`; אחרת הבנייה הסטנדרטית. תקרת-קשיחה: 1.5M תווים.
|
||||||
|
|
||||||
|
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
||||||
|
|
||||||
|
`summarize_party_claims(case_number, instructions="")` מפיק **מסמך-פרוזה מזוקק** של
|
||||||
|
טענות הצדדים — תמצית-מנהלים קצרה ומוקפדת שמטרתה **להכין את היו"ר לדיון בעל-פה**. זהו
|
||||||
|
מסמך **נפרד ומובחן מטיוטת-ההחלטה ומטיוטת-הביניים** (החלטת-יו"ר, [תוכנית workflow-redesign](../../.claude/plans/groovy-doodling-token.md)
|
||||||
|
§WS3) — אינו חלק מ-12-הבלוקים ואינו נכתב לתבנית ההחלטה.
|
||||||
|
|
||||||
|
- **מקור-אמת יחיד (G2):** המסמך נגזר מ-`legal_arguments` (טיעונים מאוגדים) או, כ-fallback,
|
||||||
|
מ-`claims` הגולמיים — **אותו מקור** של §§1.1–1.2, **ללא חילוץ-מחדש** ובלי לקרוא לכתבי-הטענות
|
||||||
|
ישירות. אין מסלול-נתונים מקביל.
|
||||||
|
- **זיקוק, לא שכפול:** התמצית מתמצתת כל צד למשפטי-מפתח ומוסיפה פרק "נקודות-המחלוקת המרכזיות"
|
||||||
|
כשאלות פתוחות — לא משכפלת את כתבי-הטענות ולא מכריעה.
|
||||||
|
- **עיגון-מקור (INV-AH):** הפרומפט מתוחם לחלוטין לטענות-התיק שבקלט; אסור להמציא טענה/הלכה/
|
||||||
|
פסק-דין/עובדה שאינם בקלט — טענה לא-ברורה מצוינת במפורש ([anti-hallucination-gate](../anti-hallucination-gate.md)).
|
||||||
|
- **ייצור local-only:** עובר `claude_session` → `claude -p` נעוץ ל-Opus 4.8 + `effort=high`
|
||||||
|
(משימת זיקוק/סינתזה). הקונטיינר חסר ה-CLI — לכן הייצור הוא כלי-MCP מקומי בלבד; endpoints
|
||||||
|
ב-`web/app.py` רק **מגישים/מייצאים** את הקובץ השמור, לא מייצרים.
|
||||||
|
- **שמירה + ייצוא:** נשמר ל-`data/cases/{n}/documents/research/party-claims-summary.md`
|
||||||
|
(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)
|
||||||
|
|
||||||
|
תיק מנותח לרוב מכתב-הערר לבד, ומסמך-עיקרי (תשובה/התנגדות/פרוטוקול…) מגיע מאוחר יותר.
|
||||||
|
כל מסמך נושא **דגל "לא-נותח"** (`claims_extracted_at`, [02-data-model §2ג](02-data-model.md)),
|
||||||
|
ו-`workflow_status` מסמן "מסמך-עיקרי שטרם-נכלל בניתוח". `reanalyze_claims(case_number,
|
||||||
|
reanalyze_all_primary=False)` סוגר את הפער **בלי force-delete גורף**:
|
||||||
|
|
||||||
|
1. **snapshot לפני** — הטיעונים המאוגדים הנוכחיים (בסיס בדיקת-ההשפעה).
|
||||||
|
2. **חילוץ-מאחד** — מחלץ רק את המסמכים-העיקריים החדשים/לא-נותחו (`db.primary_docs_not_analyzed`)
|
||||||
|
דרך **אותו** `claims_extractor.extract_and_store_claims`; `store_claims` מחליף רק את טענות
|
||||||
|
*אותו* מסמך (לפי `source_document`), כך שטענות ממסמכים שכבר-נותחו **נשמרות** (האיחוד).
|
||||||
|
3. **צבירה-מחדש** — `aggregate_claims_to_arguments(force=True)` מחשב את הטיעונים מחדש מתוך
|
||||||
|
מערך-הטענות המאוחד השלם. זהו **מסלול-החישוב הקנוני** ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)),
|
||||||
|
לא מסלול-עיבוד מקביל; הוא מוחק רק `legal_arguments` (נגזר), לעולם לא `claims`.
|
||||||
|
4. **בדיקת-השפעה ליו"ר** — snapshot אחרי + `_impact_diff` מחזיר per-צד אילו טיעונים
|
||||||
|
נוספו/הוסרו ואיך השתנה תמהיל-העדיפויות ("מאזן-ההמלצה"), עם דגל `changed`. הפלט **מוצג**
|
||||||
|
ליו"ר ולא מוחל אוטומטית ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)).
|
||||||
|
|
||||||
|
ראה `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.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -111,10 +111,14 @@ Model / Single Source of Truth) · SSOT (Single Source of Truth principle) | ס
|
|||||||
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
|
פלט מגורסן + idempotent מבחינת-תוכן; אוכף את
|
||||||
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
|
[INV-G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים) וכלל-ההנדסה
|
||||||
"סימטריה" (חוקה §6).
|
"סימטריה" (חוקה §6).
|
||||||
**הפרה ידועה:** אחרי `revise_draft`/`apply_user_edit`, ה-DOCX המסומן `active_draft_path` הופך
|
**הפרה ידועה — ✅ מגודרת (FU-7, GAP-17):** אחרי `revise_draft`/`apply_user_edit` ה-DOCX
|
||||||
ל"מקור-האמת" לעריכות-Track-Changes העוקבות (`db.py:185-188`), ו**בלוקי-ה-DB אינם מתעדכנים
|
המסומן `active_draft_path` משמש כאנקור לעריכות-Track-Changes העוקבות, ובלוקי-ה-DB אינם
|
||||||
חזרה** — הנתון-הנגזר זוחל למקור-אמת בפועל בלי סנכרון לאחור. **יעד:** או re-sync מהבלוקים, או
|
מתעדכנים חזרה — סטייה אפשרית בין הבלוקים למסמך-החי. **התיקון שנבחר (חוזה מפורש, לא re-sync):**
|
||||||
חוזה מפורש ש-`active_draft_path` הוא רק אנקור-revision ולא מקור-תוכן → ראה [§5](#5-current-vs-target).
|
`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)
|
### 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
|
**מקורות:** NCSC/JTC — *Principles & Practices for AI Use in Courts* (controlled, auditable
|
||||||
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
|
output) · Council of Europe / CEPEJ (2018, under user control) · Federal Judicial Center —
|
||||||
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
*Judicial Writing Manual* (2d ed.) | סטטוס: verified
|
||||||
**אכיפה:** `export_blocked = critical_failures > 0` (`qa_validator.py:362`); **נאכף ברמת-
|
**אכיפה — ✅ hard-block בקוד (FU-6, GAP-15):** `export_docx` (`drafting.py:462`) **בודק לעצמו**
|
||||||
הזרימה/agent בלבד** — הסוכן `legal-exporter` מחויב להריץ `validate_decision` ולבדוק
|
לפני כל ייצוא — `db.qa_run_exists` (אם QA לא רץ כלל → חסום) ו-`db.get_critical_qa_failures`
|
||||||
כשלים-קריטיים לפני ייצוא (`.claude/agents/legal-exporter.md:71,149`).
|
(אם יש כשל-קריטי → חסום) — **לפני** הגישה ל-`docx_exporter.export_decision`. אלו `SELECT` זולים
|
||||||
**הפרה ידועה:** `export_docx` (`drafting.py:384`) **אינו** קורא ל-`validate_decision` בעצמו —
|
על ה-`qa_results` המאוחסנים (לא הרצת-LLM חוזרת). נוסף על `export_blocked = critical_failures > 0`
|
||||||
הוא ניגש ישירות ל-`docx_exporter.export_decision` בלי לבדוק `export_blocked`. החסימה תלויה
|
ברמת-הזרימה ועל משמעת-הסוכן `legal-exporter` (`.claude/agents/legal-exporter.md:71,149`) — כך
|
||||||
במשמעת-הסוכן ואינה hard-block בקוד-הייצוא → ראה [§5](#5-current-vs-target) (תואם
|
שאי-אפשר לעקוף את השער אפילו בקריאה ישירה ל-`export_docx`.
|
||||||
[05-qa-review §4](05-qa-review.md#4-current-vs-target--ממצאי-audit)).
|
**הפרה ידועה — ✅ נסגרה (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
|
## 5. Current vs Target
|
||||||
|
|
||||||
- **שער-ייצוא אכוף-זרימה ולא אכוף-קוד (INV-EX3 / INV-QA3).** אומת בקוד: `export_docx`
|
- **שער-ייצוא — ✅ hard-block בקוד (INV-EX3 / INV-QA3, FU-6 / GAP-15).** `export_docx`
|
||||||
(`drafting.py:384-419`) קורא ישירות ל-`docx_exporter.export_decision` (`:403`) ללא קריאה
|
(`drafting.py:462-494`) בודק `db.qa_run_exists` + `db.get_critical_qa_failures` ודוחה לפני
|
||||||
ל-`qa_validator.validate_decision` ובלי בדיקת `export_blocked`. החסימה מתקיימת רק כי הסוכן
|
הגישה ל-`docx_exporter.export_decision` — לא ניתן לעקוף בקריאה ישירה. מאומת ב-
|
||||||
`legal-exporter` מחויב להריץ QA קודם (`legal-exporter.md:71,149`) — אדם/סוכן שיקרא
|
`test_export_qa_gate.py` (3 מקרים: ללא-QA / כשל-קריטי / נקי).
|
||||||
ל-`export_docx` ישירות **יעקוף** את השער. **יעד:** hard-block בתוך `export_docx` — שליפת
|
- **`active_draft_path` כ-derived (INV-EX1, FU-7 / GAP-17) — ✅ מגודר בחוזה מפורש.** ה-DB נשאר
|
||||||
`qa_results`/`export_blocked` ודחייה לפני כתיבת ה-DOCX, כך שאי-אפשר לעקוף.
|
מקור-האמת; `active_draft_path` הוא אנקור-revision בלבד. ה-drift בין הבלוקים ל-DOCX-החי נלכד
|
||||||
- **`active_draft_path` כ-derived-שזוחל-למקור (INV-EX1).** ה-DOCX נגזר מהבלוקים בייצוא הראשון,
|
בדגל `cases.blocks_stale`: נדלק ב-`revise_draft`/`apply_user_edit` (`drafting.py:688,789`),
|
||||||
אך אחרי עריכה (`revise_draft`/`apply_user_edit`) ה-DOCX הופך ל"מקור-האמת" לעריכות הבאות
|
כובה ב-`export_docx` (`drafting.py:504`). **שארית (low-pri):** health-check שמתריע על
|
||||||
(`db.py:185-188`) בלי לעדכן את `decision_blocks` חזרה — סטייה אפשרית בין הבלוקים למסמך-החי.
|
`blocks_stale=true` עתיק — תיעוד-המשך, לא חוסם.
|
||||||
**יעד:** חוזה מפורש — או re-sync מהבלוקים, או הגדרת `active_draft_path` כאנקור-revision בלבד
|
|
||||||
(לא מקור-תוכן), עם בדיקת-בריאות לגילוי drift בין הבלוקים ל-DOCX הפעיל.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@
|
|||||||
|
|
||||||
**הקריאה:** טבלת `style_distance_history` (append-only) + `GET /api/learning/style-distance-history`. ירידה ב-`anti_pattern_total`/`change_percent` ככל ש-N גדל = **הוכחה מתגלגלת שהלמידה מכלילה** (INV-LRN4 — זהו משטח-המגמה של "ניגוד-האמת"). **אזהרת-פרשנות:** `change_percent` מערבב סגנון עם שלמות-תוכן (לפעמים היו"ר מכפילה אורך כי חסרה מהות, לפעמים חותכת) → `anti_pattern_total` הוא הסיגנל הנקי-יותר לסגנון. שימוש-חוזר בשירות `style_distance` ובבריכת `appeal_type_rules` — אין מסלול-מדד מקביל (G2).
|
**הקריאה:** טבלת `style_distance_history` (append-only) + `GET /api/learning/style-distance-history`. ירידה ב-`anti_pattern_total`/`change_percent` ככל ש-N גדל = **הוכחה מתגלגלת שהלמידה מכלילה** (INV-LRN4 — זהו משטח-המגמה של "ניגוד-האמת"). **אזהרת-פרשנות:** `change_percent` מערבב סגנון עם שלמות-תוכן (לפעמים היו"ר מכפילה אורך כי חסרה מהות, לפעמים חותכת) → `anti_pattern_total` הוא הסיגנל הנקי-יותר לסגנון. שימוש-חוזר בשירות `style_distance` ובבריכת `appeal_type_rules` — אין מסלול-מדד מקביל (G2).
|
||||||
|
|
||||||
|
> **המלצת-הכיול style-ranked (#213):** `recommend_effort` ב-`scripts/calibrate_effort.py` מדרג efforts לפי `anti_pattern_total` **ראשי** (סיגנל-סגנון נקי) → סטיית-יחסי-זהב → `distance` (tiebreak קלוש). `change_percent` **מדווח-לא-מדורג** מאותה אזהרת-פרשנות לעיל (מערבב סגנון עם שלמות-תוכן). ההמלצה נושאת דגל-`confidence` ("clear"/"weak") כך שבחירה שההובלה-ב-anti שלה בתוך-הרעש (epsilon=max(0.5, 0.20·spread)) לא מוצגת כוודאית. אין מסלול-מדד מקביל — שימוש-חוזר ב-`style_distance` (G2).
|
||||||
|
|
||||||
> **חלון נקי חד-פעמי:** תיק שכבר `lessons_folded` פספס את חלון ה-held-out שלו — אין backfill. הטבלה מתמלאת קדימה מהסופי הבא.
|
> **חלון נקי חד-פעמי:** תיק שכבר `lessons_folded` פספס את חלון ה-held-out שלו — אין backfill. הטבלה מתמלאת קדימה מהסופי הבא.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ Paperclip בקונפליקט (project-specific מנצח default), אך אינו
|
|||||||
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
ל-`SKILL.md`/`lessons.md` — מופע של [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant).
|
||||||
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
- **company_id פר-סוכן.** כל שורה בטבלה מיוצגת פעמיים (CMP + CMPA); ה-CEO לכל חברה שונה
|
||||||
([X2 §1](X2-multi-company.md)). הסוכן פועל רק בטווח-החברה שלו ([X2 §2](X2-multi-company.md)).
|
([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 מול הוראות
|
### 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).
|
**אכיפה:** בדיקת-עקביות 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א).
|
**הפרה ידועה:** — (טופל ב-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)
|
## 5. חיווט הספ לסוכנים — בוצע (FU-8b)
|
||||||
|
|||||||
@@ -105,13 +105,36 @@ TanStack Query — *Important Defaults* (staleTime/refetch) (https://tanstack.co
|
|||||||
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
|
**אכיפה:** `halacha_backlog` מרונדר ב-/operations (לא נזרק); `findings_approved` (review_status, צרכן אמיתי)
|
||||||
החליף את `findings_applied` (דגל מת); מוני-סוכנים מסמנים "חלקי" כשחברה לא-נטענה (גל-1 #130).
|
החליף את `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
|
## 3. כללי-עיצוב (Design Rules) — נגזרים מה-invariants
|
||||||
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
|
- **SSoT ל-enums/תוויות/tones:** כל enum (CaseStatus, PracticeArea, AppealSubtype, DocType, outcome) +
|
||||||
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע UI1/G2).
|
תוויותיו + צבעיו מוגדרים **פעם אחת** ונצרכים מיבוא — לא משוכפלים בין דפים/רכיבים (מופע 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)).
|
- **חוזי-טופס:** ראה INV-UI6 ([X8](X8-field-provenance.md)).
|
||||||
|
|
||||||
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, redundancy) ב-[ui-audit.md](ui-audit.md); התיקון — **FU-10**.
|
הממצאים הקונקרטיים (כפילויות, הגדרות-שגויות, 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) אחרי ייצוב-הספ.
|
אחרי FU-1. FU-11 (מילוי-שדות) עצמאי. FU-13 (סוכנים+skills) אחרי ייצוב-הספ.
|
||||||
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
|
**סיווג:** pure-code — FU-9/10/11/13/14; +data-migration קל — FU-12; +chair-decision — FU-15 (rotation סודות).
|
||||||
priority בפועל — של היו"ר.
|
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
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# Core case lifecycle — kept in sync with STATUS_ORDER in tools/cases.py and the
|
# Core case lifecycle. The canonical key set, order, labels, phases and (future)
|
||||||
# frontend SSoT web-ui/src/lib/api/case-status.ts. Trimmed from 17 → 10 (the
|
# per-status actions all live in ONE place — legal_mcp/case_status_model.py —
|
||||||
# decorative mid-stage markers that no pipeline code ever set were removed).
|
# which asserts this enum matches it. The frontend mirror is
|
||||||
|
# web-ui/src/lib/api/case-status.ts (generated against GET /api/status-model).
|
||||||
|
# The analyst/research intermediate states are first-class canonical statuses
|
||||||
|
# (the agents set them) — not legacy.
|
||||||
class CaseStatus(str, enum.Enum):
|
class CaseStatus(str, enum.Enum):
|
||||||
NEW = "new"
|
NEW = "new"
|
||||||
PROCESSING = "processing"
|
PROCESSING = "processing"
|
||||||
DOCUMENTS_READY = "documents_ready"
|
DOCUMENTS_READY = "documents_ready"
|
||||||
|
ANALYST_VERIFIED = "analyst_verified"
|
||||||
|
RESEARCH_COMPLETE = "research_complete"
|
||||||
OUTCOME_SET = "outcome_set"
|
OUTCOME_SET = "outcome_set"
|
||||||
DIRECTION_APPROVED = "direction_approved"
|
DIRECTION_APPROVED = "direction_approved"
|
||||||
QA_REVIEW = "qa_review"
|
QA_REVIEW = "qa_review"
|
||||||
|
|||||||
@@ -572,6 +572,18 @@ async def get_legal_arguments(
|
|||||||
return await la_tools.get_legal_arguments(case_number, party)
|
return await la_tools.get_legal_arguments(case_number, party)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def reanalyze_claims(
|
||||||
|
case_number: str,
|
||||||
|
reanalyze_all_primary: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""ניתוח-מחדש מאחד אחרי הוספת מסמך-עיקרי: מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד,
|
||||||
|
מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה
|
||||||
|
(diff טיעונים/המלצה לפני↔אחרי) ליו"ר. reanalyze_all_primary=True = כל המסמכים-העיקריים.
|
||||||
|
"""
|
||||||
|
return await la_tools.reanalyze_claims(case_number, reanalyze_all_primary)
|
||||||
|
|
||||||
|
|
||||||
# References
|
# References
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def extract_references(
|
async def extract_references(
|
||||||
@@ -747,13 +759,15 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def analyze_protocol(case_number: str) -> str:
|
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
||||||
|
|
||||||
מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' +
|
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
|
||||||
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent).
|
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
|
||||||
|
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
|
||||||
|
re-run מחליף (idempotent).
|
||||||
"""
|
"""
|
||||||
return await drafting.analyze_protocol(case_number)
|
return await drafting.analyze_protocol(case_number, document_id=document_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -835,6 +849,12 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
|||||||
return await drafting.export_interim_draft(case_number, output_path)
|
return await drafting.export_interim_draft(case_number, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
|
||||||
|
"""סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה נפרד מטיוטת-ההחלטה, להכנת היו"ר לדיון. נגזר מ-legal_arguments/claims (אותו מקור), נעוץ Opus 4.8, נשמר וניתן-לייצוא."""
|
||||||
|
return await drafting.summarize_party_claims(case_number, instructions)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||||
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
||||||
|
|||||||
@@ -182,16 +182,21 @@ def _add_runs_with_inline_bold(paragraph: Paragraph, text: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _clear_body(doc: DocumentT) -> None:
|
def _clear_body(doc: DocumentT) -> None:
|
||||||
"""Remove every paragraph currently in the document body.
|
"""Remove ALL body content (paragraphs, tables, stray bookmarks),
|
||||||
|
keeping only sectPr.
|
||||||
|
|
||||||
The template ships with example paragraphs ("רקע", "דיון והכרעה"…)
|
The template ships with example paragraphs ("רקע", "דיון והכרעה"…) *and
|
||||||
that we don't want in the output. Section properties (sectPr) are
|
three sample tables* (header / panel / signatures). Removing only ``w:p``
|
||||||
kept so page size / margins / RTL / footer remain intact.
|
left the tables behind — including the signatures table, which then floated
|
||||||
|
to the top against the header table. Mirror the decision exporter's
|
||||||
|
``_clear_body`` (G2 symmetry): drop everything except sectPr, which carries
|
||||||
|
page setup including bidi.
|
||||||
"""
|
"""
|
||||||
body = doc.element.body
|
body = doc.element.body
|
||||||
for p in list(body.findall(qn("w:p"))):
|
sectPr_tag = qn("w:sectPr")
|
||||||
body.remove(p)
|
for child in list(body):
|
||||||
# Leave sectPr alone — it carries page setup including bidi.
|
if child.tag != sectPr_tag:
|
||||||
|
body.remove(child)
|
||||||
|
|
||||||
|
|
||||||
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
|
def _add_paragraph(doc: DocumentT, text: str, style: str) -> Paragraph:
|
||||||
@@ -386,9 +391,9 @@ def _group_precedents(
|
|||||||
return case_level, by_section
|
return case_level, by_section
|
||||||
|
|
||||||
|
|
||||||
def _next_version(export_dir: Path) -> int:
|
def _next_version(export_dir: Path, prefix: str = "ניתוח-משפטי") -> int:
|
||||||
"""Return the next version number for ניתוח-משפטי-v{N}.docx."""
|
"""Return the next version number for {prefix}-v{N}.docx in export_dir."""
|
||||||
existing = sorted(export_dir.glob("ניתוח-משפטי-v*.docx"))
|
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
||||||
next_ver = 1
|
next_ver = 1
|
||||||
for p in existing:
|
for p in existing:
|
||||||
try:
|
try:
|
||||||
@@ -511,3 +516,77 @@ async def build_analysis_docx(case_number: str) -> Path:
|
|||||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
)
|
)
|
||||||
return out_path
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
# ── Generic markdown → DOCX (party-claims summary, #202) ───────────
|
||||||
|
|
||||||
|
# ATX-heading markers → template heading style. The party-claims summary is a
|
||||||
|
# plain markdown document (not the structured analysis-and-research.md), so it
|
||||||
|
# is rendered line-by-line through the same template machinery.
|
||||||
|
_ATX_HEADING_RE = re.compile(r"^(#{1,4})\s+(.+)$")
|
||||||
|
_HR_RE = re.compile(r"^\s*[-*_]{3,}\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_markdown_body(doc: DocumentT, markdown: str) -> None:
|
||||||
|
"""Render a markdown string into the doc using the template styles.
|
||||||
|
|
||||||
|
Handles ATX headings (# .. ####), horizontal rules (skipped), and delegates
|
||||||
|
every other line to _emit_content_line (bullets, numbered, bold-labels, plain).
|
||||||
|
"""
|
||||||
|
for raw in markdown.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
if _HR_RE.match(line):
|
||||||
|
continue
|
||||||
|
m = _ATX_HEADING_RE.match(line.strip())
|
||||||
|
if m:
|
||||||
|
level = len(m.group(1))
|
||||||
|
# # → Heading 1 (doc title), ## → Heading 1, ### / #### → Heading 2.
|
||||||
|
style = "Heading 1" if level <= 2 else "Heading 2"
|
||||||
|
_add_paragraph(doc, m.group(2).strip(), style)
|
||||||
|
continue
|
||||||
|
_emit_content_line(doc, line)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_party_claims_summary_docx(case_number: str) -> Path:
|
||||||
|
"""Build a DOCX of the party-claims executive summary using the template
|
||||||
|
styles, saved versioned under the case's exports folder.
|
||||||
|
|
||||||
|
Reads the saved party-claims-summary.md (produced by the summarize_party_claims
|
||||||
|
MCP tool — generation is local-only). Raises FileNotFoundError if the summary
|
||||||
|
or the template is missing.
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import party_claims_summary
|
||||||
|
|
||||||
|
if not TEMPLATE_PATH.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Template not found at {TEMPLATE_PATH}. "
|
||||||
|
"Run: python scripts/convert_decision_template.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_path = party_claims_summary.summary_file_path(case_number)
|
||||||
|
if not summary_path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Party-claims summary not found for case {case_number}. "
|
||||||
|
"Run summarize_party_claims first."
|
||||||
|
)
|
||||||
|
|
||||||
|
markdown = summary_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
doc = Document(str(TEMPLATE_PATH))
|
||||||
|
_clear_body(doc)
|
||||||
|
_render_markdown_body(doc, markdown)
|
||||||
|
|
||||||
|
export_dir = config.find_case_dir(case_number) / "exports"
|
||||||
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
_PREFIX = "סיכום-מנהלים-טענות"
|
||||||
|
out_path = export_dir / f"{_PREFIX}-v{_next_version(export_dir, _PREFIX)}.docx"
|
||||||
|
buf = io.BytesIO()
|
||||||
|
doc.save(buf)
|
||||||
|
await storage.put_bytes(
|
||||||
|
out_path.relative_to(config.DATA_DIR).as_posix(), buf.getvalue(),
|
||||||
|
bucket=storage.Bucket.DOCUMENTS,
|
||||||
|
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
|
return out_path
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Allowed enum values mirror the DB CHECK constraints.
|
# Allowed enum values mirror the DB CHECK constraints.
|
||||||
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
||||||
|
# Sides that may comprise multiple distinct litigants with potentially opposing
|
||||||
|
# positions — aggregated per source pleading (party_name) rather than collapsed
|
||||||
|
# into one bucket (#224). Appellant/committee speak with a single voice.
|
||||||
|
SPLIT_PARTIES = {"respondent", "permit_applicant"}
|
||||||
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
||||||
|
|
||||||
# Hebrew labels for the prompt (Claude needs context in the same
|
# Hebrew labels for the prompt (Claude needs context in the same
|
||||||
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(party: str, propositions: list[dict]) -> str:
|
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
|
||||||
"""Compose the per-party aggregation prompt."""
|
"""Compose the per-party aggregation prompt."""
|
||||||
n = len(propositions)
|
n = len(propositions)
|
||||||
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
||||||
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
|
|||||||
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
||||||
|
|
||||||
party_he = PARTY_LABELS_HE.get(party, party)
|
party_he = PARTY_LABELS_HE.get(party, party)
|
||||||
|
# For a split side (e.g. a specific respondent brief), name the brief so
|
||||||
|
# Claude aggregates only that litigant's position and does not conflate it
|
||||||
|
# with a co-respondent's separate — possibly opposing — pleading (#224).
|
||||||
|
if party_name:
|
||||||
|
party_he = f"{party_he} — {party_name}"
|
||||||
# Strip noise from propositions for the prompt — Claude only needs
|
# Strip noise from propositions for the prompt — Claude only needs
|
||||||
# the id and the text to do the grouping.
|
# the id and the text to do the grouping.
|
||||||
compact = [
|
compact = [
|
||||||
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _aggregate_party(
|
async def _aggregate_party(
|
||||||
party: str, propositions: list[dict],
|
party: str, propositions: list[dict], party_name: str = "",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Ask Claude to group one party's propositions; return normalized rows."""
|
"""Ask Claude to group one party's propositions; return normalized rows.
|
||||||
|
|
||||||
|
``party_name`` names the specific pleading when this is a split side
|
||||||
|
(respondent / permit_applicant brief), so the prompt scopes to that
|
||||||
|
litigant's position only (#224).
|
||||||
|
"""
|
||||||
if not propositions:
|
if not propositions:
|
||||||
return []
|
return []
|
||||||
prompt = _build_prompt(party, propositions)
|
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||||
@@ -221,14 +235,25 @@ async def aggregate_claims_to_arguments(
|
|||||||
"total": 0,
|
"total": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Group propositions by party.
|
# Group propositions by party — and, for the multi-party sides (respondent /
|
||||||
by_party: dict[str, list[dict]] = {}
|
# permit_applicant), further by their source pleading so opposing joint
|
||||||
|
# briefs (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") aggregate into
|
||||||
|
# SEPARATE argument sets instead of collapsing into one "respondent" bucket
|
||||||
|
# (#224). Appellant/committee stay single-group (party_name=""). Splitting
|
||||||
|
# the large respondent set per-brief also keeps each Claude call small enough
|
||||||
|
# to succeed — a single 100+ proposition call previously returned non-JSON
|
||||||
|
# and silently dropped the whole side.
|
||||||
|
by_group: dict[tuple[str, str], list[dict]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
party = r["party_role"]
|
party = r["party_role"]
|
||||||
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
# Map deprecated 'appraiser' or unknown labels to 'unknown'.
|
||||||
if party not in ALLOWED_PARTIES:
|
if party not in ALLOWED_PARTIES:
|
||||||
party = "unknown"
|
party = "unknown"
|
||||||
by_party.setdefault(party, []).append(dict(r))
|
party_name = (
|
||||||
|
(r["source_document"] or "").strip()
|
||||||
|
if party in SPLIT_PARTIES else ""
|
||||||
|
)
|
||||||
|
by_group.setdefault((party, party_name), []).append(dict(r))
|
||||||
|
|
||||||
# Valid claim_ids for this case == the ids of the claims we just fetched.
|
# Valid claim_ids for this case == the ids of the claims we just fetched.
|
||||||
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
|
# The LLM is asked to echo back supporting claim_ids, but it may hallucinate
|
||||||
@@ -243,9 +268,11 @@ async def aggregate_claims_to_arguments(
|
|||||||
inserted = 0
|
inserted = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
for party, props in by_party.items():
|
for (party, party_name), props in by_group.items():
|
||||||
|
# Display key for the per-side summary: keep opposing briefs distinct.
|
||||||
|
group_key = f"{party}·{party_name}" if party_name else party
|
||||||
try:
|
try:
|
||||||
arguments = await _aggregate_party(party, props)
|
arguments = await _aggregate_party(party, props, party_name=party_name)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
# Most likely cause: Claude CLI not installed (running from
|
# Most likely cause: Claude CLI not installed (running from
|
||||||
# the container). Don't crash — record the gap and continue.
|
# the container). Don't crash — record the gap and continue.
|
||||||
@@ -259,11 +286,11 @@ async def aggregate_claims_to_arguments(
|
|||||||
),
|
),
|
||||||
"total": 0,
|
"total": 0,
|
||||||
}
|
}
|
||||||
errors.append(f"{party}: {msg}")
|
errors.append(f"{group_key}: {msg}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not arguments:
|
if not arguments:
|
||||||
party_counts[party] = 0
|
party_counts[group_key] = 0
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -271,12 +298,13 @@ async def aggregate_claims_to_arguments(
|
|||||||
for idx, arg in enumerate(arguments):
|
for idx, arg in enumerate(arguments):
|
||||||
arg_id = await conn.fetchval(
|
arg_id = await conn.fetchval(
|
||||||
"""INSERT INTO legal_arguments
|
"""INSERT INTO legal_arguments
|
||||||
(case_id, party, argument_index, argument_title,
|
(case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority)
|
argument_title, argument_body, legal_topic, priority)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
case_id,
|
case_id,
|
||||||
party,
|
party,
|
||||||
|
party_name,
|
||||||
idx + 1,
|
idx + 1,
|
||||||
arg["title"],
|
arg["title"],
|
||||||
arg["body"],
|
arg["body"],
|
||||||
@@ -313,7 +341,7 @@ async def aggregate_claims_to_arguments(
|
|||||||
cid, arg_id, e,
|
cid, arg_id, e,
|
||||||
)
|
)
|
||||||
inserted += 1
|
inserted += 1
|
||||||
party_counts[party] = len(arguments)
|
party_counts[group_key] = len(arguments)
|
||||||
|
|
||||||
result: dict = {
|
result: dict = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -338,9 +366,9 @@ async def get_legal_arguments(
|
|||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
if party and party in ALLOWED_PARTIES:
|
if party and party in ALLOWED_PARTIES:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
"""SELECT id, case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority, cited_precedents,
|
argument_title, argument_body, legal_topic, priority,
|
||||||
created_at, updated_at
|
cited_precedents, created_at, updated_at
|
||||||
FROM legal_arguments
|
FROM legal_arguments
|
||||||
WHERE case_id = $1 AND party = $2
|
WHERE case_id = $1 AND party = $2
|
||||||
ORDER BY priority, argument_index""",
|
ORDER BY priority, argument_index""",
|
||||||
@@ -348,12 +376,12 @@ async def get_legal_arguments(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT id, case_id, party, argument_index, argument_title,
|
"""SELECT id, case_id, party, party_name, argument_index,
|
||||||
argument_body, legal_topic, priority, cited_precedents,
|
argument_title, argument_body, legal_topic, priority,
|
||||||
created_at, updated_at
|
cited_precedents, created_at, updated_at
|
||||||
FROM legal_arguments
|
FROM legal_arguments
|
||||||
WHERE case_id = $1
|
WHERE case_id = $1
|
||||||
ORDER BY party, priority, argument_index""",
|
ORDER BY party, party_name, priority, argument_index""",
|
||||||
case_id,
|
case_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db
|
||||||
@@ -38,7 +38,7 @@ async def log_action(
|
|||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
||||||
uuid4(), action, case_id, document_id,
|
uuid4(), action, case_id, document_id,
|
||||||
json.dumps(details or {}, ensure_ascii=False, default=str),
|
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,
|
logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user,
|
||||||
json.dumps(details or {}, ensure_ascii=False)[:200])
|
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
|
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
|
||||||
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
|
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
|
||||||
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
|
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
|
||||||
|
# 1M-context build of the SAME pinned model (#216). The default build runs a
|
||||||
|
# 200K-token context (~400K Hebrew chars at ~2 chars/token); large prompts —
|
||||||
|
# notably block-yod, which carries the full case as source-context — overflow it.
|
||||||
|
# Opus 4.8 offers a 1M-token window at standard pricing; `claude -p` exposes it
|
||||||
|
# via the `[1m]` model id directly (no beta header — verified CLI 2.1.196). We
|
||||||
|
# escalate to it only when the prompt is large, so small blocks stay on the
|
||||||
|
# cheaper/faster 200K build. NOT a parallel model (G2) — same model, wider window.
|
||||||
|
GENERATION_MODEL_1M = "claude-opus-4-8[1m]"
|
||||||
|
# Escalate to the 1M build above this prompt size. Set below the 200K-token wall
|
||||||
|
# (~400K chars) with headroom for output tokens (max_tokens up to 16K) + tokenizer
|
||||||
|
# variance, so we switch before the standard build can overflow.
|
||||||
|
_CTX_1M_THRESHOLD_CHARS = 350_000
|
||||||
|
|
||||||
BLOCK_CONFIG = {
|
BLOCK_CONFIG = {
|
||||||
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
|
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
|
||||||
@@ -65,7 +77,7 @@ BLOCK_CONFIG = {
|
|||||||
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
|
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
|
||||||
"block-dalet":{"index": 4, "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-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-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-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},
|
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||||
@@ -461,17 +473,21 @@ async def write_block(
|
|||||||
if not dir_doc.get("approved"):
|
if not dir_doc.get("approved"):
|
||||||
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.")
|
||||||
|
|
||||||
# Guard against context overflow before calling claude -p.
|
# Pick the model build by prompt size (#216). Above the 200K-token wall we
|
||||||
# Sonnet: 200K context → ~800K chars max; Opus: 200K context → same.
|
# escalate to the 1M-context build (`[1m]`) instead of failing the block —
|
||||||
# In practice the CLI has crashed on prompts above ~400K chars, so use
|
# block-yod legitimately carries the whole case as source-context. The 400K
|
||||||
# that as a conservative ceiling (well below the token limit).
|
# ceiling was an artifact of the old 200K-only build, NOT a model limit.
|
||||||
_MAX_PROMPT_CHARS = 400_000
|
gen_model = GENERATION_MODEL_1M if len(prompt) > _CTX_1M_THRESHOLD_CHARS else GENERATION_MODEL
|
||||||
|
|
||||||
|
# Final guard: even the 1M build is finite (~2M Hebrew chars of input). Cap at
|
||||||
|
# 1.5M chars (~750K tokens) to leave room for output + a safety margin under 1M.
|
||||||
|
_MAX_PROMPT_CHARS = 1_500_000
|
||||||
if len(prompt) > _MAX_PROMPT_CHARS:
|
if len(prompt) > _MAX_PROMPT_CHARS:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
f"Prompt too large for {block_id}: {len(prompt):,} chars "
|
||||||
f"(limit {_MAX_PROMPT_CHARS:,}). "
|
f"(limit {_MAX_PROMPT_CHARS:,}, even on the 1M-context build). "
|
||||||
f"source_context: {len(source_context):,} chars. "
|
f"source_context: {len(source_context):,} chars. "
|
||||||
f"Reduce documents or call extract_appraiser_facts first."
|
f"Reduce source-context (summaries / appraiser_facts / focused RAG)."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
||||||
@@ -483,14 +499,14 @@ async def write_block(
|
|||||||
content = await claude_session.query(
|
content = await claude_session.query(
|
||||||
prompt,
|
prompt,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
model=GENERATION_MODEL,
|
model=gen_model,
|
||||||
effort=effort,
|
effort=effort,
|
||||||
tools="", # prose gen — no tool_use → no error_max_turns
|
tools="", # prose gen — no tool_use → no error_max_turns
|
||||||
)
|
)
|
||||||
|
|
||||||
sources = await _collect_block_sources(case_id, block_id)
|
sources = await _collect_block_sources(case_id, block_id)
|
||||||
sources["case_law_ids"] = _precedent_case_law_ids
|
sources["case_law_ids"] = _precedent_case_law_ids
|
||||||
result = _build_result(block_id, content, block_cfg)
|
result = _build_result(block_id, content, block_cfg, model_used=gen_model)
|
||||||
# Record the EFFECTIVE effort (override wins) so the harness can attribute
|
# Record the EFFECTIVE effort (override wins) so the harness can attribute
|
||||||
# the measured distance to the effort that actually produced the text.
|
# the measured distance to the effort that actually produced the text.
|
||||||
if result.get("effort") is not None:
|
if result.get("effort") is not None:
|
||||||
@@ -499,7 +515,8 @@ async def write_block(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
def _build_result(block_id: str, content: str, block_cfg: dict,
|
||||||
|
model_used: str = GENERATION_MODEL) -> dict:
|
||||||
word_count = len(content.split())
|
word_count = len(content.split())
|
||||||
is_ai = block_cfg["model"] == "ai"
|
is_ai = block_cfg["model"] == "ai"
|
||||||
return {
|
return {
|
||||||
@@ -509,8 +526,9 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
|||||||
"content": content,
|
"content": content,
|
||||||
"word_count": word_count,
|
"word_count": word_count,
|
||||||
"generation_type": block_cfg["gen_type"],
|
"generation_type": block_cfg["gen_type"],
|
||||||
# AI blocks record the pinned model; template blocks record "script".
|
# AI blocks record the model build actually used (200K or [1m], #216);
|
||||||
"model_used": GENERATION_MODEL if is_ai else block_cfg["model"],
|
# template blocks record "script".
|
||||||
|
"model_used": model_used if is_ai else block_cfg["model"],
|
||||||
# The real generation knob (#204). None for template/script blocks.
|
# The real generation knob (#204). None for template/script blocks.
|
||||||
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
|
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
|
||||||
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
|
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ rows. Reuses the one corpus search + the one authority query + the one radar (G2
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
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
|
except Exception as e: # noqa: BLE001 — radar is best-effort
|
||||||
logger.warning("citation_verification radar failed for %s: %s", case_number, e)
|
logger.warning("citation_verification radar failed for %s: %s", case_number, e)
|
||||||
|
|
||||||
out_args: list[dict] = []
|
async def _fetch(a: dict) -> tuple[list[dict], dict]:
|
||||||
n_verified = 0
|
"""Per-argument corpus search + batched authority — run concurrently.
|
||||||
for a in args:
|
|
||||||
aid = str(a["id"])
|
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()
|
title = (a.get("argument_title") or "").strip()
|
||||||
topic = (a.get("legal_topic") or "").strip()
|
topic = (a.get("legal_topic") or "").strip()
|
||||||
query = f"{ctx} {title}. {topic}".strip()
|
query = f"{ctx} {title}. {topic}".strip()
|
||||||
|
hits: list[dict] = []
|
||||||
hits = []
|
|
||||||
try:
|
try:
|
||||||
hits = await precedent_library.search_library(
|
hits = await precedent_library.search_library(
|
||||||
query=query, limit=_SUGGEST_PER_ISSUE, include_halachot=True)
|
query=query, limit=_SUGGEST_PER_ISSUE, include_halachot=True)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
logger.warning("citation_verification search failed (%s): %s", title[:30], e)
|
||||||
|
|
||||||
# Resolve the authority breakdown for the hit set in one batched query.
|
# Resolve the authority breakdown for the hit set in one batched query.
|
||||||
clids = [UUID(str(h["case_law_id"])) for h in hits
|
clids = [UUID(str(h["case_law_id"])) for h in hits
|
||||||
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
|
if h.get("case_law_id") and float(h.get("score", 0) or 0) >= _SUGGEST_FLOOR]
|
||||||
authority = await db.citation_authority(clids) if clids else {}
|
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()
|
seen: set[str] = set()
|
||||||
supporting: list[dict] = []
|
supporting: list[dict] = []
|
||||||
|
|||||||
@@ -360,6 +360,10 @@ async def extract_and_store_claims(
|
|||||||
claims = await extract_claims_with_ai(text, doc_type, party_hint)
|
claims = await extract_claims_with_ai(text, doc_type, party_hint)
|
||||||
|
|
||||||
if not claims:
|
if not claims:
|
||||||
|
# Stamp the document as analysed even with zero claims, so it is not
|
||||||
|
# perpetually flagged "not-analysed" (#201 — the not-analysed flag is
|
||||||
|
# about whether extraction *ran*, not whether it found anything).
|
||||||
|
await db.mark_document_claims_extracted(document_id, status="no_claims")
|
||||||
return {"status": "no_claims", "total": 0, "source": source_name}
|
return {"status": "no_claims", "total": 0, "source": source_name}
|
||||||
|
|
||||||
# Determine claim_type from document type and title
|
# Determine claim_type from document type and title
|
||||||
@@ -368,6 +372,10 @@ async def extract_and_store_claims(
|
|||||||
c["claim_type"] = claim_type
|
c["claim_type"] = claim_type
|
||||||
|
|
||||||
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
stored = await db.store_claims(case_id, claims, source_document=source_name)
|
||||||
|
# Mark this document analysed (WS2 / #201). store_claims already replaced
|
||||||
|
# this document's prior claims by source_document — so re-running is a clean
|
||||||
|
# per-document refresh that does NOT touch other documents' claims (the merge).
|
||||||
|
await db.mark_document_claims_extracted(document_id, status="completed")
|
||||||
|
|
||||||
# Summarize by role
|
# Summarize by role
|
||||||
role_counts: dict[str, int] = {}
|
role_counts: dict[str, int] = {}
|
||||||
|
|||||||
@@ -146,8 +146,15 @@ async def query(
|
|||||||
"""
|
"""
|
||||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||||
|
|
||||||
if len(full_prompt) > 150_000:
|
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
|
||||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
# chars/token). Above it callers should pass the 1M-context model id
|
||||||
|
# (`claude-opus-4-8[1m]`) — block_writer escalates automatically (#216).
|
||||||
|
if len(full_prompt) > 350_000 and "[1m]" not in (model or ""):
|
||||||
|
logger.warning(
|
||||||
|
"Large prompt: %d chars on a non-[1m] model (%s) — may overflow the "
|
||||||
|
"200K-token context. Pass claude-opus-4-8[1m] for the 1M window.",
|
||||||
|
len(full_prompt), model or "CLI-default",
|
||||||
|
)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"claude", "-p",
|
"claude", "-p",
|
||||||
|
|||||||
@@ -1870,6 +1870,35 @@ CREATE INDEX IF NOT EXISTS idx_protocol_analysis_case ON protocol_analysis(case_
|
|||||||
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
|
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# V49 (WS2 / task #201): per-document "claims analysed" tracking — the "not-analysed"
|
||||||
|
# flag. `claims_extracted_at` is stamped by claims_extractor AFTER a document's claims
|
||||||
|
# are stored; `claims_extraction_status` records the outcome
|
||||||
|
# ('pending' | 'completed' | 'no_claims' | 'failed'). These describe the per-document
|
||||||
|
# extraction *event* (the single source of truth for "has this doc been analysed") —
|
||||||
|
# NOT derived from doc_type. They let us detect a PRIMARY document (is_primary, V47)
|
||||||
|
# added after the analysis ran and not yet included → "primary doc not yet analysed".
|
||||||
|
# The partial index pairs with idx_documents_primary so that query is cheap. Idempotent:
|
||||||
|
# ADD COLUMN IF NOT EXISTS; re-running is a no-op. (V48 = protocol_analysis, #357.)
|
||||||
|
SCHEMA_V49_SQL = """
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extracted_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extraction_status TEXT
|
||||||
|
NOT NULL DEFAULT 'pending';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
|
||||||
|
ON documents(case_id) WHERE is_primary AND claims_extracted_at IS NULL;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# 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);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
@@ -1941,6 +1970,8 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V46_SQL)
|
await conn.execute(SCHEMA_V46_SQL)
|
||||||
await conn.execute(SCHEMA_V47_SQL)
|
await conn.execute(SCHEMA_V47_SQL)
|
||||||
await conn.execute(SCHEMA_V48_SQL)
|
await conn.execute(SCHEMA_V48_SQL)
|
||||||
|
await conn.execute(SCHEMA_V49_SQL)
|
||||||
|
await conn.execute(SCHEMA_V50_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -2321,6 +2352,14 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
|||||||
else is_primary_doc_type(d.get("doc_type"))
|
else is_primary_doc_type(d.get("doc_type"))
|
||||||
d["is_primary"] = is_primary
|
d["is_primary"] = is_primary
|
||||||
d["doc_category"] = "primary" if is_primary else "secondary"
|
d["doc_category"] = "primary" if is_primary else "secondary"
|
||||||
|
# Per-document claims-analysis state (WS2 / #201). `claims_analyzed` is the
|
||||||
|
# human-facing "has this doc been included in the claims analysis" flag —
|
||||||
|
# True iff claims extraction has been stamped for it. Single source of truth:
|
||||||
|
# claims_extracted_at. Defaults keep pre-migration rows well-formed.
|
||||||
|
extracted_at = d.get("claims_extracted_at")
|
||||||
|
if "claims_extraction_status" not in d or d.get("claims_extraction_status") is None:
|
||||||
|
d["claims_extraction_status"] = "pending"
|
||||||
|
d["claims_analyzed"] = extracted_at is not None
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -2370,6 +2409,47 @@ async def get_claims(case_id: UUID, party_role: str | None = None) -> list[dict]
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Per-document claims-analysis tracking (WS2 / #201) ──────────────
|
||||||
|
|
||||||
|
async def mark_document_claims_extracted(
|
||||||
|
doc_id: UUID, status: str = "completed",
|
||||||
|
) -> None:
|
||||||
|
"""Stamp a document as having had its claims extracted (WS2 / #201).
|
||||||
|
|
||||||
|
Sets ``claims_extracted_at = now()`` and records the outcome in
|
||||||
|
``claims_extraction_status`` ('completed' | 'no_claims' | 'failed').
|
||||||
|
This is the single source of truth for the per-document "not-analysed"
|
||||||
|
flag — it is written at the point claims are stored, not derived later.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE documents SET claims_extracted_at = now(), "
|
||||||
|
"claims_extraction_status = $2 WHERE id = $1",
|
||||||
|
doc_id, status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def primary_docs_not_analyzed(case_id: UUID) -> list[dict]:
|
||||||
|
"""Return PRIMARY documents in a case that have NOT yet been analysed.
|
||||||
|
|
||||||
|
"Primary" = ``is_primary`` (the V47 generated column, single source of
|
||||||
|
truth = PRIMARY_DOC_TYPES). "Not analysed" = ``claims_extracted_at IS NULL``.
|
||||||
|
Backed by the partial index ``idx_documents_claims_pending``. Surfaces the
|
||||||
|
"there is a primary document not yet included in the analysis" condition
|
||||||
|
(#201). Returns the same shape as ``list_documents`` rows.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT * FROM documents "
|
||||||
|
"WHERE case_id = $1 AND is_primary AND claims_extracted_at IS NULL "
|
||||||
|
"ORDER BY created_at",
|
||||||
|
case_id,
|
||||||
|
)
|
||||||
|
return [_row_to_doc(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ── Decisions ──────────────────────────────────────────────────────
|
# ── Decisions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def create_decision(
|
async def create_decision(
|
||||||
|
|||||||
@@ -43,10 +43,16 @@ HEBREW_FONT = "David"
|
|||||||
|
|
||||||
|
|
||||||
def _mark_run_rtl(run) -> None:
|
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()
|
rPr = run._r.get_or_add_rPr()
|
||||||
if rPr.find(qn("w:rFonts")) is None:
|
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:ascii"), HEBREW_FONT)
|
||||||
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
|
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
|
||||||
fonts.set(qn("w:cs"), HEBREW_FONT)
|
fonts.set(qn("w:cs"), HEBREW_FONT)
|
||||||
@@ -192,14 +198,29 @@ def _apply_list_numbering(paragraph, num_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _clear_body(doc) -> None:
|
def _clear_body(doc) -> None:
|
||||||
"""Remove all paragraphs in the document body while keeping sectPr.
|
"""Remove ALL body content (paragraphs, tables, stray bookmarks) while
|
||||||
|
keeping only sectPr.
|
||||||
|
|
||||||
The template ships with sample paragraphs we don't want. Section
|
The template ships with sample paragraphs *and three sample tables*
|
||||||
properties (page size, margins, bidi) stay intact.
|
(institutional header, panel, and a **signatures** table at the end —
|
||||||
|
"ניתנה פה אחד" + יו"ר/מזכירה). Removing only ``w:p`` left those tables
|
||||||
|
behind: the leftover header/panel tables injected block-א–ד data that was
|
||||||
|
never extracted from the protocol (violates the empty-unless-extracted
|
||||||
|
rule), and the leftover signatures table collapsed up against the header
|
||||||
|
table at the **top** of the document (the chair-reported bug: חתימות צמודות
|
||||||
|
לבלוק-ד במקום בסוף). The decision is a derived artifact rebuilt purely from
|
||||||
|
``decision_blocks`` (INV-EX1) — signatures come from block-yod-bet, rendered
|
||||||
|
last in block order — so the template's sample tables are stale scaffolding
|
||||||
|
and must go. Section properties (page size, margins, bidi) stay intact.
|
||||||
"""
|
"""
|
||||||
body = doc.element.body
|
body = doc.element.body
|
||||||
for p in list(body.findall(qn("w:p"))):
|
sectPr_tag = qn("w:sectPr")
|
||||||
body.remove(p)
|
for child in list(body):
|
||||||
|
# Keep section properties (page setup / bidi). Drop everything else:
|
||||||
|
# sample paragraphs (w:p), sample tables (w:tbl), and any stray
|
||||||
|
# bookmark markers the template left dangling between them.
|
||||||
|
if child.tag != sectPr_tag:
|
||||||
|
body.remove(child)
|
||||||
|
|
||||||
|
|
||||||
# ── Bookmark helpers ──────────────────────────────────────────────
|
# ── Bookmark helpers ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -199,27 +199,86 @@ def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
|
|||||||
return 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 ─────────────────────────────────────────────
|
# ── Paragraph builders ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree._Element:
|
||||||
font: str = "David", size_half_pt: int | None = None) -> etree._Element:
|
"""Build a w:r (run): RTL marker + optional bold/italic + text.
|
||||||
"""Build a w:r (run) element with RTL/David defaults and given 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"))
|
r = etree.Element(_w("r"))
|
||||||
rPr = etree.SubElement(r, _w("rPr"))
|
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:
|
if bold:
|
||||||
etree.SubElement(rPr, _w("b"))
|
etree.SubElement(rPr, _w("b"))
|
||||||
etree.SubElement(rPr, _w("bCs"))
|
etree.SubElement(rPr, _w("bCs"))
|
||||||
@@ -235,28 +294,36 @@ def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
|
def _build_paragraph(text: str, *, style: StyleType = "body",
|
||||||
"""Build a w:p (paragraph) with RTL + David + given text."""
|
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"))
|
p = etree.Element(_w("p"))
|
||||||
pPr = etree.SubElement(p, _w("pPr"))
|
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 = etree.SubElement(pPr, _w("bidi"))
|
||||||
bidi.set(_w("val"), "1")
|
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 = etree.SubElement(pPr, _w("jc"))
|
||||||
jc.set(_w("val"), "right")
|
jc.set(_w("val"), "both")
|
||||||
|
|
||||||
rPr_p = etree.SubElement(pPr, _w("rPr"))
|
rPr_p = etree.SubElement(pPr, _w("rPr"))
|
||||||
etree.SubElement(rPr_p, _w("rtl"))
|
etree.SubElement(rPr_p, _w("rtl"))
|
||||||
|
|
||||||
bold = style in ("heading", "bold")
|
bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
|
||||||
italic = style == "quote"
|
run = _build_run(text, bold=bold, italic=italic)
|
||||||
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)
|
|
||||||
p.append(run)
|
p.append(run)
|
||||||
return p
|
return p
|
||||||
|
|
||||||
@@ -275,7 +342,7 @@ def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
|
|||||||
|
|
||||||
def _make_tracked_paragraph_insert(
|
def _make_tracked_paragraph_insert(
|
||||||
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
|
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:
|
) -> etree._Element:
|
||||||
"""Build a whole tracked-inserted paragraph.
|
"""Build a whole tracked-inserted paragraph.
|
||||||
|
|
||||||
@@ -286,7 +353,7 @@ def _make_tracked_paragraph_insert(
|
|||||||
"""
|
"""
|
||||||
if mark_id is None:
|
if mark_id is None:
|
||||||
mark_id = ins_id
|
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"))
|
pPr = p.find(_w("pPr"))
|
||||||
assert pPr is not None
|
assert pPr is not None
|
||||||
rPr = pPr.find(_w("rPr"))
|
rPr = pPr.find(_w("rPr"))
|
||||||
@@ -339,6 +406,7 @@ def _apply_insert(
|
|||||||
ins_id: int,
|
ins_id: int,
|
||||||
author: str,
|
author: str,
|
||||||
date_iso: str,
|
date_iso: str,
|
||||||
|
style_ids: dict[str, str] | None = None,
|
||||||
) -> RevisionResult:
|
) -> RevisionResult:
|
||||||
"""Apply insert_after / insert_before relative to a bookmark."""
|
"""Apply insert_after / insert_before relative to a bookmark."""
|
||||||
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
||||||
@@ -362,6 +430,7 @@ def _apply_insert(
|
|||||||
revision.content, style=revision.style,
|
revision.content, style=revision.style,
|
||||||
ins_id=ins_id, mark_id=ins_id + 1,
|
ins_id=ins_id, mark_id=ins_id + 1,
|
||||||
author=author, date_iso=date_iso,
|
author=author, date_iso=date_iso,
|
||||||
|
style_id=(style_ids or {}).get(revision.style),
|
||||||
)
|
)
|
||||||
|
|
||||||
parent = enclosing_p.getparent()
|
parent = enclosing_p.getparent()
|
||||||
@@ -407,6 +476,7 @@ def _apply_replace(
|
|||||||
del_id: int,
|
del_id: int,
|
||||||
author: str,
|
author: str,
|
||||||
date_iso: str,
|
date_iso: str,
|
||||||
|
style_ids: dict[str, str] | None = None,
|
||||||
) -> RevisionResult:
|
) -> RevisionResult:
|
||||||
"""Replace = delete the existing paragraph + insert new one after it."""
|
"""Replace = delete the existing paragraph + insert new one after it."""
|
||||||
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
||||||
@@ -428,6 +498,7 @@ def _apply_replace(
|
|||||||
revision.content, style=revision.style,
|
revision.content, style=revision.style,
|
||||||
ins_id=ins_id, mark_id=ins_id + 1,
|
ins_id=ins_id, mark_id=ins_id + 1,
|
||||||
author=author, date_iso=date_iso,
|
author=author, date_iso=date_iso,
|
||||||
|
style_id=(style_ids or {}).get(revision.style),
|
||||||
)
|
)
|
||||||
idx = list(parent).index(enclosing_p)
|
idx = list(parent).index(enclosing_p)
|
||||||
parent.insert(idx + 1, new_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)
|
members, doc_tree, settings_tree = _load_docx_xml(source_path)
|
||||||
_ensure_track_revisions(settings_tree)
|
_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)
|
next_id = _next_revision_id(doc_tree)
|
||||||
|
|
||||||
batch = RevisionBatchResult()
|
batch = RevisionBatchResult()
|
||||||
@@ -480,7 +555,8 @@ def apply_tracked_revisions(
|
|||||||
try:
|
try:
|
||||||
if rev.type in ("insert_after", "insert_before"):
|
if rev.type in ("insert_after", "insert_before"):
|
||||||
result = _apply_insert(doc_tree, rev, ins_id=next_id,
|
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
|
# insert consumes 2 IDs: run-wrapper + paragraph-mark
|
||||||
next_id += 2
|
next_id += 2
|
||||||
elif rev.type == "delete":
|
elif rev.type == "delete":
|
||||||
@@ -490,7 +566,8 @@ def apply_tracked_revisions(
|
|||||||
elif rev.type == "replace":
|
elif rev.type == "replace":
|
||||||
result = _apply_replace(doc_tree, rev,
|
result = _apply_replace(doc_tree, rev,
|
||||||
ins_id=next_id, del_id=next_id + 2,
|
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
|
# replace consumes 3 IDs: ins-run, ins-mark, del
|
||||||
next_id += 3
|
next_id += 3
|
||||||
else:
|
else:
|
||||||
|
|||||||
208
mcp-server/src/legal_mcp/services/party_claims_summary.py
Normal file
208
mcp-server/src/legal_mcp/services/party_claims_summary.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""סיכום-מנהלים של טענות הצדדים — מסמך פרוזה מזוקק להכנת היו"ר לדיון (WS3, #202).
|
||||||
|
|
||||||
|
מסמך זה הוא **נפרד ומובחן מטיוטת-הביניים** (החלטת-יו"ר, תוכנית workflow-redesign §WS3) —
|
||||||
|
תמצית מנהלים קצרה ומוקפדת של טענות הצדדים והתשובות זו-לזו, להכנה לדיון בעל-פה. אין הוא
|
||||||
|
משכפל את כתבי-הטענות המלאים: הוא מזקק.
|
||||||
|
|
||||||
|
מקור-האמת היחיד (G2): הוא נגזר מ-``legal_arguments`` (טיעונים מאוגדים) ומ-``claims``
|
||||||
|
הקיימים בתיק — לא מחלץ-מחדש ולא קורא לכתבי-הטענות הגולמיים. אם הטיעונים המאוגדים טרם
|
||||||
|
חושבו, הוא נופל-חזרה ל-claims הגולמיים (אותו מקור).
|
||||||
|
|
||||||
|
הייצור עובר ``claude_session.query`` → ``claude -p`` (CLI, מנוי claude.ai, עלות-שולית-אפס),
|
||||||
|
נעוץ ל-Opus 4.8 + effort=high — משימת זיקוק/סינתזה. **local-only**: לקונטיינר אין claude CLI;
|
||||||
|
לכן הכלי רץ רק מ-MCP-server המקומי, וה-endpoint ב-app.py רק מגיש את הקובץ השמור (לא מייצר).
|
||||||
|
|
||||||
|
ההזהרה נגד-הזיה (INV-AH): המסמך מתוחם לחלוטין לטענות-התיק עצמן. אסור להמציא טענה, הלכה
|
||||||
|
או עובדה שאינה מופיעה בקלט; טענה שאינה ברורה — מצוין במפורש, לא משלים.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp import config
|
||||||
|
from legal_mcp.services import (
|
||||||
|
argument_aggregator,
|
||||||
|
block_writer,
|
||||||
|
claude_session,
|
||||||
|
db,
|
||||||
|
git_sync,
|
||||||
|
storage,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Saved alongside the legal analysis (same research folder, same git+S3 mirror path).
|
||||||
|
_SUMMARY_FILENAME = "party-claims-summary.md"
|
||||||
|
|
||||||
|
_PARTY_HE = {
|
||||||
|
"appellant": "העוררים",
|
||||||
|
"respondent": "המשיבים",
|
||||||
|
"committee": "הוועדה המקומית",
|
||||||
|
"permit_applicant": "מבקשי ההיתר",
|
||||||
|
"unknown": "צד לא מזוהה",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summary_file_path(case_number: str) -> Path:
|
||||||
|
"""Resolve the saved party-claims-summary.md path for a case."""
|
||||||
|
return config.find_case_dir(case_number) / "documents" / "research" / _SUMMARY_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_arguments_context(case_id: UUID) -> tuple[str, int, str]:
|
||||||
|
"""Build the grounded source block from the case's OWN claims/arguments.
|
||||||
|
|
||||||
|
Preference order (single source of truth, no re-extraction — G2):
|
||||||
|
1. ``legal_arguments`` — the aggregated, de-duplicated arguments per party.
|
||||||
|
2. fallback to raw ``claims`` when arguments were never aggregated.
|
||||||
|
|
||||||
|
Returns ``(context_text, item_count, source_kind)`` where ``source_kind`` is
|
||||||
|
``"arguments"`` or ``"claims"`` (or ``"none"`` when neither exists).
|
||||||
|
"""
|
||||||
|
args = await argument_aggregator.get_legal_arguments(case_id)
|
||||||
|
if args:
|
||||||
|
by_party: dict[str, list[dict]] = {}
|
||||||
|
for a in args:
|
||||||
|
by_party.setdefault(a["party"], []).append(a)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
for party, items in by_party.items():
|
||||||
|
lines.append(f"\n## {_PARTY_HE.get(party, party)}")
|
||||||
|
for a in items:
|
||||||
|
title = (a.get("argument_title") or "").strip()
|
||||||
|
body = (a.get("argument_body") or "").strip()
|
||||||
|
topic = (a.get("legal_topic") or "").strip()
|
||||||
|
header = f"- **{title}**" if title else "-"
|
||||||
|
if topic:
|
||||||
|
header += f" [{topic}]"
|
||||||
|
lines.append(header)
|
||||||
|
if body:
|
||||||
|
lines.append(f" {body}")
|
||||||
|
return "\n".join(lines), len(args), "arguments"
|
||||||
|
|
||||||
|
# Fallback — raw claims, the same underlying source.
|
||||||
|
claims = await db.get_claims(case_id)
|
||||||
|
# Exclude block-zayin (decision-summary) claims — original pleadings only.
|
||||||
|
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
||||||
|
if not source_claims:
|
||||||
|
return "", 0, "none"
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
current_role = ""
|
||||||
|
role_he = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים",
|
||||||
|
"committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"}
|
||||||
|
n = 0
|
||||||
|
for c in source_claims:
|
||||||
|
if c["party_role"] != current_role:
|
||||||
|
current_role = c["party_role"]
|
||||||
|
lines.append(f"\n## {role_he.get(current_role, current_role)}")
|
||||||
|
n += 1
|
||||||
|
lines.append(f"- טענה #{n}: {c['claim_text']}")
|
||||||
|
return "\n".join(lines), len(source_claims), "claims"
|
||||||
|
|
||||||
|
|
||||||
|
_PROMPT_TEMPLATE = """אתה מכין עבור יו"ר ועדת הערר (עו"ד דפנה תמיר) **סיכום-מנהלים מזוקק של טענות הצדדים** — מסמך פרוזה קצר שמטרתו אחת: להכין את היו"ר לדיון בעל-פה. זהו מסמך **נפרד** מטיוטת-ההחלטה ואינו חלק ממנה.
|
||||||
|
|
||||||
|
## פרטי התיק:
|
||||||
|
{case_context}
|
||||||
|
|
||||||
|
## הקלט — טענות/טיעוני הצדדים (מקור-האמת היחיד):
|
||||||
|
{arguments_context}
|
||||||
|
|
||||||
|
## כללי-כתיבה מחייבים:
|
||||||
|
- **זיקוק, לא שכפול.** אל תעתיק את כתבי-הטענות. תמצת כל צד ל-2–5 משפטי-מפתח. היעד: עמוד אחד עד שניים, לא יותר.
|
||||||
|
- **מבוסס-קלט בלבד (חובה אנטי-הזיה).** הסתמך אך-ורק על הטענות שבקלט לעיל. אסור להמציא טענה, הלכה, פסק-דין, מספר או עובדה שאינם בקלט. טענה שאינה ברורה או חסרה — ציין זאת במפורש ("הטענה לא פורטה"), אל תשלים מדמיונך.
|
||||||
|
- **ניטרלי.** הצג את עמדת כל צד בנאמנות, בלי להעריך, בלי להכריע ובלי לרמוז על תוצאה. אין זו החלטה — זו הכנה.
|
||||||
|
- **מבנה קבוע:**
|
||||||
|
1. פסקת-פתיחה קצרה (משפט–שניים): במה עוסק הערר ומיהם הצדדים.
|
||||||
|
2. **טענות העוררים** — תמצית מזוקקת.
|
||||||
|
3. **טענות המשיבים / הוועדה / מבקשי ההיתר** — תמצית מזוקקת (כל צד שקיים בקלט).
|
||||||
|
4. **נקודות-המחלוקת המרכזיות** — 2–4 הסוגיות שעליהן ניצבת ההכרעה, כשאלות פתוחות (לא תשובות).
|
||||||
|
- **סגנון:** עברית משפטית בהירה, גוף-שלישי לתיאור הצדדים. בלי כותרת "החלטה". בלי חתימות.
|
||||||
|
|
||||||
|
## מדריך-סגנון (לטון בלבד — אל תיישם מבנה-החלטה):
|
||||||
|
{style_context}
|
||||||
|
|
||||||
|
החזר אך-ורק את גוף מסמך הסיכום (Markdown), בלי הקדמות ובלי הסברים על מה שעשית."""
|
||||||
|
|
||||||
|
|
||||||
|
async def summarize_party_claims(case_id: UUID, case_number: str,
|
||||||
|
instructions: str = "") -> dict:
|
||||||
|
"""Generate the distilled executive summary of party claims and save it.
|
||||||
|
|
||||||
|
Grounded strictly in the case's own claims/legal_arguments (INV-AH).
|
||||||
|
Generation is pinned to Opus 4.8 + effort=high (distillation/synthesis).
|
||||||
|
"""
|
||||||
|
case = await db.get_case(case_id)
|
||||||
|
if not case:
|
||||||
|
raise ValueError(f"Case {case_id} not found")
|
||||||
|
|
||||||
|
decision = await db.get_decision_by_case(case_id)
|
||||||
|
|
||||||
|
arguments_context, n_items, source_kind = await _build_arguments_context(case_id)
|
||||||
|
if source_kind == "none":
|
||||||
|
raise ValueError(
|
||||||
|
"אין טענות לסכם. הרץ extract_claims (ורצוי aggregate_claims_to_arguments) קודם."
|
||||||
|
)
|
||||||
|
|
||||||
|
case_context = block_writer._build_case_context(case, decision)
|
||||||
|
style_context = await block_writer._build_style_context(case.get("practice_area", ""))
|
||||||
|
|
||||||
|
prompt = _PROMPT_TEMPLATE.format(
|
||||||
|
case_context=case_context,
|
||||||
|
arguments_context=arguments_context,
|
||||||
|
style_context=style_context,
|
||||||
|
)
|
||||||
|
if instructions:
|
||||||
|
prompt += f"\n\n## הנחיות נוספות מהיו\"ר:\n{instructions}"
|
||||||
|
|
||||||
|
# Generation: claude_session → claude -p, pinned Opus 4.8 + high effort.
|
||||||
|
# tools="" — pure prose, no tool_use (avoids error_max_turns). LONG_TIMEOUT:
|
||||||
|
# full-case context can be large.
|
||||||
|
content = await claude_session.query(
|
||||||
|
prompt,
|
||||||
|
timeout=claude_session.LONG_TIMEOUT,
|
||||||
|
model="claude-opus-4-8",
|
||||||
|
effort="high",
|
||||||
|
tools="",
|
||||||
|
)
|
||||||
|
content = (content or "").strip()
|
||||||
|
if not content:
|
||||||
|
raise ValueError("הסיכום חזר ריק מ-claude -p.")
|
||||||
|
|
||||||
|
today = date.today().strftime("%d.%m.%Y")
|
||||||
|
header = (
|
||||||
|
f"# סיכום-מנהלים — טענות הצדדים\n\n"
|
||||||
|
f"**תיק:** {case['case_number']} \n"
|
||||||
|
f"**נושא:** {case.get('subject', '')} \n"
|
||||||
|
f"**הופק:** {today} · מסמך הכנה לדיון (נפרד מטיוטת-ההחלטה)\n\n"
|
||||||
|
f"---\n\n"
|
||||||
|
)
|
||||||
|
document = header + content + "\n"
|
||||||
|
|
||||||
|
# Save to data/cases/{n}/documents/research/ — same path as the legal analysis,
|
||||||
|
# so it is git-synced + S3-mirrored by the existing machinery.
|
||||||
|
out_path = summary_file_path(case_number)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(document, encoding="utf-8") # noqa: STG1 — mirrored below
|
||||||
|
try:
|
||||||
|
key = out_path.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
||||||
|
await storage.mirror(key, document.encode("utf-8"), bucket=storage.Bucket.DOCUMENTS)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
case_dir = config.find_case_dir(case_number)
|
||||||
|
if case_dir.exists():
|
||||||
|
git_sync.commit_and_push(case_dir, f"סיכום-מנהלים: טענות הצדדים ({case_number})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"case_number": case_number,
|
||||||
|
"path": str(out_path),
|
||||||
|
"source_kind": source_kind,
|
||||||
|
"source_items": n_items,
|
||||||
|
"word_count": len(content.split()),
|
||||||
|
"message": f"סיכום-מנהלים נוצר ({len(content.split())} מילים, מקור: {source_kind}).",
|
||||||
|
}
|
||||||
@@ -120,15 +120,47 @@ HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול די
|
|||||||
MAX_PROTOCOL_CHARS = 120_000
|
MAX_PROTOCOL_CHARS = 120_000
|
||||||
|
|
||||||
|
|
||||||
def _find_protocol(docs: list[dict]) -> dict | None:
|
def _protocol_scope(doc: dict) -> str:
|
||||||
"""The protocol document for the case, if present (doc_type or title)."""
|
"""Protocol scope from ``metadata.protocol_scope``.
|
||||||
for d in docs:
|
|
||||||
if d.get("doc_type") == "protocol":
|
Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
|
||||||
return d
|
document-type-editor convention where only the non-default ``'lower'``
|
||||||
for d in docs:
|
(ועדה מקומית/מחוזית) is persisted.
|
||||||
if "פרוטוקול" in (d.get("title") or ""):
|
"""
|
||||||
return d
|
meta = doc.get("metadata") or {}
|
||||||
|
return (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
|
return None
|
||||||
|
appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True)
|
||||||
|
return appeal_scoped[0]
|
||||||
|
|
||||||
|
|
||||||
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
||||||
@@ -239,9 +271,15 @@ async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def analyze_protocol(case_id: UUID) -> dict:
|
async def analyze_protocol(
|
||||||
|
case_id: UUID, target_document_id: UUID | None = None,
|
||||||
|
) -> dict:
|
||||||
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
|
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
|
||||||
|
|
||||||
|
``target_document_id`` pins the analysis to a specific protocol document —
|
||||||
|
required when a case holds several protocols (e.g. a lower-committee annex
|
||||||
|
plus the ועדת-הערר hearing) and the auto-pick would be ambiguous (#223).
|
||||||
|
|
||||||
1. Locates the protocol document.
|
1. Locates the protocol document.
|
||||||
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
|
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
|
||||||
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
|
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
|
||||||
@@ -252,11 +290,20 @@ async def analyze_protocol(case_id: UUID) -> dict:
|
|||||||
Returns a serializable summary dict.
|
Returns a serializable summary dict.
|
||||||
"""
|
"""
|
||||||
docs = await db.list_documents(case_id)
|
docs = await db.list_documents(case_id)
|
||||||
protocol = _find_protocol(docs)
|
protocol = _find_protocol(docs, document_id=target_document_id)
|
||||||
if not protocol:
|
if not protocol:
|
||||||
|
if target_document_id is not None:
|
||||||
return {
|
return {
|
||||||
"status": "no_protocol",
|
"status": "no_protocol",
|
||||||
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.",
|
"message": f"מסמך {target_document_id} לא נמצא בתיק.",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "no_protocol",
|
||||||
|
"message": (
|
||||||
|
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
|
||||||
|
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
|
||||||
|
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
document_id = UUID(protocol["id"])
|
document_id = UUID(protocol["id"])
|
||||||
|
|||||||
@@ -15,8 +15,14 @@ import os
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from typing import Any
|
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"
|
# Placeholder strings — any of these means "not yet filled"
|
||||||
CHAIR_POSITION_PLACEHOLDERS = (
|
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 ""
|
date_str = date_match.group(1) if date_match else ""
|
||||||
|
|
||||||
stat = file_path.stat()
|
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] = {
|
result: dict[str, Any] = {
|
||||||
"header": {
|
"header": {
|
||||||
@@ -354,7 +360,7 @@ def update_chair_position(
|
|||||||
"saved": True,
|
"saved": True,
|
||||||
"section_id": section_id,
|
"section_id": section_id,
|
||||||
"preview": preview,
|
"preview": preview,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now(IL_TZ).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from uuid import UUID
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
|
from legal_mcp.case_status_model import STATUS_ORDER # status SSoT
|
||||||
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
from legal_mcp.services import audit, db, extractor, git_sync, practice_area as pa
|
||||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
@@ -341,16 +342,8 @@ async def case_update(
|
|||||||
"""
|
"""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
# Ordered core lifecycle — regression protection (forward-only).
|
# Ordered core lifecycle (forward-only regression guard). Single source of
|
||||||
# Single source of truth, mirrored by web-ui/src/lib/api/case-status.ts and
|
# truth: legal_mcp/case_status_model.py — STATUS_ORDER/BY_KEY derive from it.
|
||||||
# models.CaseStatus. Trimmed from 17 → 10 (decorative statuses removed).
|
|
||||||
STATUS_ORDER = [
|
|
||||||
"new", "processing", "documents_ready",
|
|
||||||
"outcome_set", "direction_approved",
|
|
||||||
"qa_review", "drafted",
|
|
||||||
"exported", "reviewed", "final",
|
|
||||||
]
|
|
||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return err(f"תיק {case_number} לא נמצא.")
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
@@ -363,6 +356,9 @@ async def case_update(
|
|||||||
# Only update if advancing or status is unknown to the order
|
# Only update if advancing or status is unknown to the order
|
||||||
if new_idx >= cur_idx or new_idx == -1:
|
if new_idx >= cur_idx or new_idx == -1:
|
||||||
fields["status"] = status
|
fields["status"] = status
|
||||||
|
# per-status on_enter hook (case_status_model) — the single place a
|
||||||
|
# status's behaviour is declared. No dispatcher wired yet; when the
|
||||||
|
# first action is added, dispatch BY_KEY[status].on_enter here.
|
||||||
if title:
|
if title:
|
||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if subject:
|
if subject:
|
||||||
|
|||||||
@@ -433,17 +433,24 @@ ALLOWED_DOC_TYPES = {
|
|||||||
# Allowed appraiser_side values; '' (empty) clears the tag.
|
# Allowed appraiser_side values; '' (empty) clears the tag.
|
||||||
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
||||||
|
|
||||||
|
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
|
||||||
|
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
|
||||||
|
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||||
|
|
||||||
|
|
||||||
async def document_update(
|
async def document_update(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
doc_type: str = "",
|
doc_type: str = "",
|
||||||
appraiser_side: str = "",
|
appraiser_side: str = "",
|
||||||
|
is_post_hearing: bool | None = None,
|
||||||
|
protocol_scope: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
|
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
|
||||||
|
|
||||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
|
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
|
||||||
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
|
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
|
||||||
|
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר (לאישור שייכות)
|
case_number: מספר תיק הערר (לאישור שייכות)
|
||||||
@@ -452,6 +459,9 @@ async def document_update(
|
|||||||
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
||||||
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
||||||
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
||||||
|
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
|
||||||
|
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
|
||||||
|
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
|
||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
@@ -477,17 +487,37 @@ async def document_update(
|
|||||||
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
||||||
updates["doc_type"] = doc_type
|
updates["doc_type"] = doc_type
|
||||||
|
|
||||||
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
# appraiser_side / protocol_scope are optional. The MCP tool can't
|
||||||
# "set to empty string", so we use the convention: only update if non-empty.
|
# distinguish "skip" from "set to empty string", so we use the convention:
|
||||||
# To clear, the operator must edit metadata directly (rare).
|
# only update if non-empty. To clear, edit metadata directly (rare).
|
||||||
|
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
metadata_dirty = False
|
||||||
|
|
||||||
if appraiser_side:
|
if appraiser_side:
|
||||||
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||||
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
||||||
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
||||||
metadata = doc.get("metadata") or {}
|
|
||||||
if isinstance(metadata, str):
|
|
||||||
metadata = json.loads(metadata)
|
|
||||||
metadata["appraiser_side"] = appraiser_side
|
metadata["appraiser_side"] = appraiser_side
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if is_post_hearing is not None:
|
||||||
|
if is_post_hearing:
|
||||||
|
metadata["is_post_hearing"] = True
|
||||||
|
else:
|
||||||
|
metadata.pop("is_post_hearing", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if protocol_scope:
|
||||||
|
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
|
||||||
|
return err(f"protocol_scope לא תקין: {protocol_scope}",
|
||||||
|
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
|
||||||
|
metadata["protocol_scope"] = protocol_scope
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if metadata_dirty:
|
||||||
updates["metadata"] = metadata
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
return err(str(e))
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def analyze_protocol(case_number: str) -> str:
|
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
||||||
|
|
||||||
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
||||||
@@ -588,12 +588,17 @@ async def analyze_protocol(case_number: str) -> str:
|
|||||||
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
||||||
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
||||||
|
|
||||||
|
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
|
||||||
|
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
|
||||||
|
ציין document_id כדי לכוון לדיון המדויק (#223).
|
||||||
|
|
||||||
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
||||||
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
||||||
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
|
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
|
||||||
"""
|
"""
|
||||||
from legal_mcp.services import protocol_analyzer
|
from legal_mcp.services import protocol_analyzer
|
||||||
|
|
||||||
@@ -601,8 +606,16 @@ async def analyze_protocol(case_number: str) -> str:
|
|||||||
if not case:
|
if not case:
|
||||||
return err(f"תיק {case_number} לא נמצא.")
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
target_doc_id: UUID | None = None
|
||||||
|
if document_id.strip():
|
||||||
try:
|
try:
|
||||||
result = await protocol_analyzer.analyze_protocol(case_id)
|
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, target_document_id=target_doc_id,
|
||||||
|
)
|
||||||
await audit.log_action_safe(
|
await audit.log_action_safe(
|
||||||
"analyze_protocol", case_id=case_id,
|
"analyze_protocol", case_id=case_id,
|
||||||
details={"status": result.get("status"), "total": result.get("total", 0)},
|
details={"status": result.get("status"), "total": result.get("total", 0)},
|
||||||
@@ -734,6 +747,34 @@ async def export_interim_draft(case_number: str, output_path: str = "") -> str:
|
|||||||
return err(str(e))
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def summarize_party_claims(case_number: str, instructions: str = "") -> str:
|
||||||
|
"""הפקת סיכום-מנהלים מזוקק של טענות הצדדים — מסמך פרוזה **נפרד מטיוטת-הביניים**,
|
||||||
|
להכנת היו"ר לדיון בעל-פה. תמצית קצרה ומוקפדת (לא שכפול כתבי-הטענות), נגזרת
|
||||||
|
מהטיעונים המאוגדים (legal_arguments) או מ-claims הגולמיים — אותו מקור-אמת,
|
||||||
|
בלי חילוץ-מחדש. נשמר ל-data/cases/{n}/documents/research/ (git + S3) וניתן-לייצוא.
|
||||||
|
|
||||||
|
הייצור עובר claude_session (claude -p), נעוץ Opus 4.8 + effort=high. מתוחם
|
||||||
|
לטענות-התיק בלבד (anti-hallucination) — אינו ממציא טענה/הלכה/עובדה שאינה בקלט.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר
|
||||||
|
instructions: הנחיות נוספות מהיו"ר (אופציונלי)
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import party_claims_summary
|
||||||
|
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
try:
|
||||||
|
result = await party_claims_summary.summarize_party_claims(
|
||||||
|
case_id, case_number, instructions,
|
||||||
|
)
|
||||||
|
return ok(result)
|
||||||
|
except ValueError as e:
|
||||||
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||||
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
|
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from legal_mcp.services import argument_aggregator, db
|
from legal_mcp.services import argument_aggregator, claims_extractor, db
|
||||||
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
|
||||||
|
|
||||||
|
# Party labels reused across this module (display + impact diff).
|
||||||
|
_PARTY_HE = {
|
||||||
|
"appellant": "עוררים",
|
||||||
|
"respondent": "משיבים",
|
||||||
|
"committee": "ועדה מקומית",
|
||||||
|
"permit_applicant": "מבקשי היתר",
|
||||||
|
"unknown": "צד לא מזוהה",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def aggregate_claims_to_arguments(
|
async def aggregate_claims_to_arguments(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
@@ -55,16 +64,9 @@ async def get_legal_arguments(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Group by party for nicer display.
|
# Group by party for nicer display.
|
||||||
party_he = {
|
|
||||||
"appellant": "עוררים",
|
|
||||||
"respondent": "משיבים",
|
|
||||||
"committee": "ועדה מקומית",
|
|
||||||
"permit_applicant": "מבקשי היתר",
|
|
||||||
"unknown": "צד לא מזוהה",
|
|
||||||
}
|
|
||||||
by_party: dict[str, list[dict]] = {}
|
by_party: dict[str, list[dict]] = {}
|
||||||
for a in args:
|
for a in args:
|
||||||
label = party_he.get(a["party"], a["party"])
|
label = _PARTY_HE.get(a["party"], a["party"])
|
||||||
by_party.setdefault(label, []).append(a)
|
by_party.setdefault(label, []).append(a)
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
@@ -72,3 +74,147 @@ async def get_legal_arguments(
|
|||||||
"total": len(args),
|
"total": len(args),
|
||||||
"by_party": by_party,
|
"by_party": by_party,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Re-analysis with merge + impact diff (WS2 / #201) ───────────────
|
||||||
|
|
||||||
|
def _snapshot(args: list[dict]) -> dict:
|
||||||
|
"""Build a comparable snapshot of aggregated arguments for the impact diff.
|
||||||
|
|
||||||
|
Captures, per party: the set of argument titles and a priority histogram —
|
||||||
|
enough to show the chair what changed without dumping full bodies. The
|
||||||
|
priority mix is the deterministic "recommendation" signal: it summarises the
|
||||||
|
balance of threshold/substantive/procedural/relief arguments per side.
|
||||||
|
"""
|
||||||
|
by_party: dict[str, dict] = {}
|
||||||
|
for a in args:
|
||||||
|
party = a.get("party", "unknown")
|
||||||
|
bucket = by_party.setdefault(party, {"titles": [], "priorities": {}})
|
||||||
|
bucket["titles"].append((a.get("argument_title") or "").strip())
|
||||||
|
pr = a.get("priority", "substantive")
|
||||||
|
bucket["priorities"][pr] = bucket["priorities"].get(pr, 0) + 1
|
||||||
|
return {"total": len(args), "by_party": by_party}
|
||||||
|
|
||||||
|
|
||||||
|
def _impact_diff(before: dict, after: dict) -> dict:
|
||||||
|
"""Diff two argument snapshots → a chair-facing before↔after summary.
|
||||||
|
|
||||||
|
Reports, per party, which argument titles were added/removed and how the
|
||||||
|
priority mix (the "recommendation" balance) shifted. ``changed`` is a quick
|
||||||
|
boolean the UI/chair can gate on. Nothing here is auto-applied — it is purely
|
||||||
|
surfaced for the human gate (G10).
|
||||||
|
"""
|
||||||
|
parties = sorted(set(before["by_party"]) | set(after["by_party"]))
|
||||||
|
per_party: list[dict] = []
|
||||||
|
any_change = False
|
||||||
|
for party in parties:
|
||||||
|
b = before["by_party"].get(party, {"titles": [], "priorities": {}})
|
||||||
|
a = after["by_party"].get(party, {"titles": [], "priorities": {}})
|
||||||
|
b_titles, a_titles = set(b["titles"]), set(a["titles"])
|
||||||
|
added = sorted(a_titles - b_titles)
|
||||||
|
removed = sorted(b_titles - a_titles)
|
||||||
|
prio_before = dict(sorted(b["priorities"].items()))
|
||||||
|
prio_after = dict(sorted(a["priorities"].items()))
|
||||||
|
changed = bool(added or removed or prio_before != prio_after)
|
||||||
|
any_change = any_change or changed
|
||||||
|
per_party.append({
|
||||||
|
"party": party,
|
||||||
|
"party_he": _PARTY_HE.get(party, party),
|
||||||
|
"count_before": len(b["titles"]),
|
||||||
|
"count_after": len(a["titles"]),
|
||||||
|
"added_arguments": added,
|
||||||
|
"removed_arguments": removed,
|
||||||
|
"priority_before": prio_before,
|
||||||
|
"priority_after": prio_after,
|
||||||
|
"changed": changed,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"changed": any_change,
|
||||||
|
"total_before": before["total"],
|
||||||
|
"total_after": after["total"],
|
||||||
|
"by_party": per_party,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def reanalyze_claims(
|
||||||
|
case_number: str,
|
||||||
|
reanalyze_all_primary: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""ניתוח-מחדש מאחד של טענות — מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד,
|
||||||
|
מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה
|
||||||
|
(diff טיעונים/המלצה לפני↔אחרי) ליו"ר.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר.
|
||||||
|
reanalyze_all_primary: True = לחלץ מחדש מכל המסמכים-העיקריים (ולא רק
|
||||||
|
מאלה שטרם-נותחו). ברירת-מחדל False = רק מסמכים-עיקריים חדשים/לא-נותחו.
|
||||||
|
|
||||||
|
מנגנון האיחוד (לא force-delete): כל מסמך מחולץ דרך
|
||||||
|
``claims_extractor.extract_and_store_claims``, ש-``store_claims`` שלו מחליף רק
|
||||||
|
את טענות *אותו* מסמך (לפי ``source_document``) — כך טענות ממסמכים שכבר-נותחו
|
||||||
|
נשמרות. הצבירה-מחדש (``aggregate_claims_to_arguments(force=True)``) מחשבת את
|
||||||
|
הטיעונים מחדש מתוך **מערך-הטענות המאוחד השלם** — מסלול-החישוב הקנוני, לא מסלול
|
||||||
|
מקביל (G2).
|
||||||
|
"""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
|
||||||
|
# 1. BEFORE snapshot — current aggregated arguments (the impact baseline).
|
||||||
|
before_args = await argument_aggregator.get_legal_arguments(case_id)
|
||||||
|
before = _snapshot(before_args)
|
||||||
|
|
||||||
|
# 2. Select PRIMARY documents to (re)extract. Default: only those not yet
|
||||||
|
# analysed (the not-analysed flag). reanalyze_all_primary widens to every
|
||||||
|
# primary doc. Source of truth for "primary" = is_primary (V47).
|
||||||
|
if reanalyze_all_primary:
|
||||||
|
all_docs = await db.list_documents(case_id)
|
||||||
|
targets = [d for d in all_docs if d.get("is_primary")]
|
||||||
|
else:
|
||||||
|
targets = await db.primary_docs_not_analyzed(case_id)
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
return ok({
|
||||||
|
"case_number": case_number,
|
||||||
|
"status": "no_pending_documents",
|
||||||
|
"message": "אין מסמכים-עיקריים חדשים/לא-נותחו. לא בוצע ניתוח-מחדש.",
|
||||||
|
"documents_analyzed": [],
|
||||||
|
"impact": _impact_diff(before, before),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Extract+merge claims from the selected documents only. Each call replaces
|
||||||
|
# only that document's claims (merge), never the whole case (no force-delete).
|
||||||
|
analyzed: list[dict] = []
|
||||||
|
for doc in targets:
|
||||||
|
text = await db.get_document_text(UUID(doc["id"]))
|
||||||
|
if not text:
|
||||||
|
await db.mark_document_claims_extracted(UUID(doc["id"]), status="no_claims")
|
||||||
|
analyzed.append({"document": doc["title"], "status": "empty_text", "total": 0})
|
||||||
|
continue
|
||||||
|
res = await claims_extractor.extract_and_store_claims(
|
||||||
|
case_id=case_id,
|
||||||
|
document_id=UUID(doc["id"]),
|
||||||
|
text=text,
|
||||||
|
doc_type=doc["doc_type"],
|
||||||
|
)
|
||||||
|
analyzed.append({"document": doc["title"], **res})
|
||||||
|
|
||||||
|
# 4. Re-aggregate from the now-complete merged claim set. force=True is the
|
||||||
|
# canonical recompute (deletes only legal_arguments, NOT claims) — same
|
||||||
|
# path aggregate_claims_to_arguments always uses, not a fork.
|
||||||
|
agg = await argument_aggregator.aggregate_claims_to_arguments(case_id, force=True)
|
||||||
|
|
||||||
|
# 5. AFTER snapshot + impact diff for the chair.
|
||||||
|
after_args = await argument_aggregator.get_legal_arguments(case_id)
|
||||||
|
after = _snapshot(after_args)
|
||||||
|
impact = _impact_diff(before, after)
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"case_number": case_number,
|
||||||
|
"status": "completed",
|
||||||
|
"documents_analyzed": analyzed,
|
||||||
|
"aggregation": agg,
|
||||||
|
"impact": impact,
|
||||||
|
})
|
||||||
|
|||||||
@@ -46,8 +46,19 @@ async def workflow_status(case_number: str) -> str:
|
|||||||
"extraction": doc["extraction_status"],
|
"extraction": doc["extraction_status"],
|
||||||
"chunks": chunk_map.get(doc["id"], 0),
|
"chunks": chunk_map.get(doc["id"], 0),
|
||||||
"pages": doc.get("page_count"),
|
"pages": doc.get("page_count"),
|
||||||
|
# WS2 / #201: primary/secondary + whether claims were analysed.
|
||||||
|
"is_primary": doc.get("is_primary", False),
|
||||||
|
"claims_analyzed": doc.get("claims_analyzed", False),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# WS2 / #201: PRIMARY documents added after the analysis ran and not yet
|
||||||
|
# included → the "not-analysed" condition that should prompt a merging
|
||||||
|
# re-analysis (reanalyze_claims). Derived from is_primary + claims_extracted_at.
|
||||||
|
pending_primary = await db.primary_docs_not_analyzed(case_id)
|
||||||
|
primary_not_analyzed = [
|
||||||
|
{"title": d["title"], "type": d["doc_type"]} for d in pending_primary
|
||||||
|
]
|
||||||
|
|
||||||
# Check draft status
|
# Check draft status
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
@@ -64,15 +75,19 @@ async def workflow_status(case_number: str) -> str:
|
|||||||
"documents": doc_status,
|
"documents": doc_status,
|
||||||
"total_documents": len(docs),
|
"total_documents": len(docs),
|
||||||
"total_chunks": sum(chunk_map.values()),
|
"total_chunks": sum(chunk_map.values()),
|
||||||
|
"primary_docs_not_analyzed": primary_not_analyzed,
|
||||||
"has_draft": has_draft,
|
"has_draft": has_draft,
|
||||||
"draft_size_bytes": draft_size,
|
"draft_size_bytes": draft_size,
|
||||||
"next_steps": _suggest_next_steps(case, docs, has_draft),
|
"next_steps": _suggest_next_steps(case, docs, has_draft, primary_not_analyzed),
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok(status)
|
return ok(status)
|
||||||
|
|
||||||
|
|
||||||
def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
|
def _suggest_next_steps(
|
||||||
|
case: dict, docs: list, has_draft: bool,
|
||||||
|
primary_not_analyzed: list | None = None,
|
||||||
|
) -> list[str]:
|
||||||
"""Suggest next steps based on case state."""
|
"""Suggest next steps based on case state."""
|
||||||
steps = []
|
steps = []
|
||||||
doc_types = {d["doc_type"] for d in docs}
|
doc_types = {d["doc_type"] for d in docs}
|
||||||
@@ -85,6 +100,13 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
|
|||||||
if "response" not in doc_types:
|
if "response" not in doc_types:
|
||||||
steps.append("העלה תשובת ועדה/משיבים")
|
steps.append("העלה תשובת ועדה/משיבים")
|
||||||
|
|
||||||
|
# WS2 / #201: a primary document was added but not yet analysed.
|
||||||
|
if primary_not_analyzed:
|
||||||
|
titles = ", ".join(d["title"] for d in primary_not_analyzed)
|
||||||
|
steps.append(
|
||||||
|
f"מסמך-עיקרי לא-נותח ({titles}) — הרץ ניתוח-מחדש מאחד (reanalyze_claims)"
|
||||||
|
)
|
||||||
|
|
||||||
pending = [d for d in docs if d["extraction_status"] == "pending"]
|
pending = [d for d in docs if d["extraction_status"] == "pending"]
|
||||||
if pending:
|
if pending:
|
||||||
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")
|
steps.append(f"עיבוד {len(pending)} מסמכים ממתינים")
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ from lxml import etree
|
|||||||
from legal_mcp.services.docx_exporter import (
|
from legal_mcp.services.docx_exporter import (
|
||||||
_BOOKMARK_ID_START,
|
_BOOKMARK_ID_START,
|
||||||
HEBREW_FONT,
|
HEBREW_FONT,
|
||||||
|
TEMPLATE_PATH,
|
||||||
_add_styled_paragraph,
|
_add_styled_paragraph,
|
||||||
|
_clear_body,
|
||||||
_insert_bookmark_end,
|
_insert_bookmark_end,
|
||||||
_insert_bookmark_start,
|
_insert_bookmark_start,
|
||||||
_mark_paragraph_rtl,
|
_mark_paragraph_rtl,
|
||||||
@@ -168,6 +170,83 @@ def test_block_dalet_does_not_use_title_style() -> None:
|
|||||||
assert any("החלטה" in t for t in texts)
|
assert any("החלטה" in t for t in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_dalet_is_heading_only_ignores_db_content() -> None:
|
||||||
|
"""ת2 — block-ד is the 'החלטה' heading marker, NOT a data block.
|
||||||
|
|
||||||
|
Whatever content sits in decision_blocks for block-dalet must be ignored;
|
||||||
|
the only non-empty text line produced is the literal heading 'החלטה'.
|
||||||
|
"""
|
||||||
|
doc = Document()
|
||||||
|
_write_block_to_docx(
|
||||||
|
doc, "block-dalet", title="כותרת",
|
||||||
|
content="נתון ישן שצריך להיות מתועלם\nשורה שנייה",
|
||||||
|
)
|
||||||
|
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||||
|
assert text_lines == ["החלטה"], (
|
||||||
|
f"block-dalet must render ONLY the heading 'החלטה', got {text_lines}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _clear_body strips ALL scaffolding (ת1 + ת3 root cause) ────────
|
||||||
|
# The template ships 3 sample tables (header / panel / signatures). Removing
|
||||||
|
# only <w:p> left them behind: header/panel tables injected block-א–ד data
|
||||||
|
# never extracted from the protocol (ת1), and the signatures table floated to
|
||||||
|
# the top against the header (ת3 — חתימות צמודות לבלוק-ד במקום בסוף). The
|
||||||
|
# decision is rebuilt purely from decision_blocks (INV-EX1), so the template's
|
||||||
|
# sample tables are stale scaffolding and must be cleared.
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_body_removes_tables_and_leaves_only_sectpr() -> None:
|
||||||
|
doc = Document(str(TEMPLATE_PATH))
|
||||||
|
body = doc.element.body
|
||||||
|
# Sanity: the template really ships sample tables (else this test is moot).
|
||||||
|
assert len(body.findall(qn("w:tbl"))) > 0, "template expected to ship sample tables"
|
||||||
|
|
||||||
|
_clear_body(doc)
|
||||||
|
|
||||||
|
remaining = [c.tag.split("}")[-1] for c in list(body)]
|
||||||
|
assert remaining == ["sectPr"], (
|
||||||
|
f"_clear_body must leave only sectPr, got {remaining}"
|
||||||
|
)
|
||||||
|
assert len(body.findall(qn("w:tbl"))) == 0, "no template tables may survive"
|
||||||
|
assert len(body.findall(qn("w:p"))) == 0, "no template paragraphs may survive"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signatures_block_renders_after_body_not_at_top() -> None:
|
||||||
|
"""ת3 — block-yod-bet (חתימות) renders LAST, after a cleared body.
|
||||||
|
|
||||||
|
Regression for the bug where the leftover template signatures table sat at
|
||||||
|
the top against the header table. After the _clear_body fix, signatures
|
||||||
|
come only from block-yod-bet, which the export loop emits last in block
|
||||||
|
order.
|
||||||
|
"""
|
||||||
|
doc = Document(str(TEMPLATE_PATH))
|
||||||
|
_clear_body(doc)
|
||||||
|
|
||||||
|
# Emit a header block, a body block, then signatures last (canonical order).
|
||||||
|
_write_block_to_docx(doc, "block-alef", title="", content="מדינת ישראל")
|
||||||
|
_write_block_to_docx(doc, "block-vav", title="", content="רקע עובדתי\n1. המקרקעין.")
|
||||||
|
_write_block_to_docx(
|
||||||
|
doc, "block-yod-bet", title="",
|
||||||
|
content='ניתנה פה אחד היום.\nדפנה תמיר, עו"ד',
|
||||||
|
)
|
||||||
|
|
||||||
|
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||||
|
assert text_lines[0] == "מדינת ישראל", text_lines
|
||||||
|
# The signatures block (block-yod-bet) emits each line as its own paragraph,
|
||||||
|
# so its content must occupy the document's tail — after the body block.
|
||||||
|
body_idx = text_lines.index("רקע עובדתי")
|
||||||
|
sig_idx = next(i for i, t in enumerate(text_lines) if "ניתנה פה אחד" in t)
|
||||||
|
assert sig_idx > body_idx, (
|
||||||
|
f"signatures must come AFTER the body, got tail={text_lines[-3:]}"
|
||||||
|
)
|
||||||
|
assert any("דפנה תמיר" in t for t in text_lines[sig_idx:]), text_lines[sig_idx:]
|
||||||
|
# No template signatures table ("מזכירת ועדת ערר") leaked anywhere.
|
||||||
|
assert all(
|
||||||
|
"מזכירת ועדת ערר" not in (p.text or "") for p in doc.paragraphs
|
||||||
|
), "template signatures-table text leaked into the body"
|
||||||
|
|
||||||
|
|
||||||
# ── Heading overrides, numbered-list, dash strip ──────────────────
|
# ── Heading overrides, numbered-list, dash strip ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ def _find_ins_with_runs(tree: etree._Element) -> etree._Element | None:
|
|||||||
return 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:
|
def test_insert_after_ins_has_author_and_date(sample_docx: Path, tmp_path: Path) -> None:
|
||||||
out = tmp_path / "out.docx"
|
out = tmp_path / "out.docx"
|
||||||
rev = Revision(id="r1", type="insert_after",
|
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
|
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"
|
out = tmp_path / "out.docx"
|
||||||
rev = Revision(id="r1", type="insert_after",
|
rev = Revision(id="r1", type="insert_after",
|
||||||
anchor_bookmark="block-alef", content="מוסף")
|
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
|
assert run is not None
|
||||||
rPr = run.find(_w("rPr"))
|
rPr = run.find(_w("rPr"))
|
||||||
assert rPr is not None
|
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
|
assert rPr.find(_w("rtl")) is not None
|
||||||
rFonts = rPr.find(_w("rFonts"))
|
# …but NO hand-set font/size on the run (INV-EX4).
|
||||||
assert rFonts is not None
|
assert rPr.find(_w("rFonts")) is None
|
||||||
assert rFonts.get(_w("ascii")) == "David"
|
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 ────────────────────────
|
# ── 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)."
|
||||||
|
)
|
||||||
212
mcp-server/tests/test_party_claims_summary.py
Normal file
212
mcp-server/tests/test_party_claims_summary.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Tests for the party-claims executive summary (#202, WS3).
|
||||||
|
|
||||||
|
Cover the deterministic, offline parts:
|
||||||
|
- ``_build_arguments_context`` source selection: legal_arguments preferred,
|
||||||
|
raw claims fallback, "none" when empty (G2 — single source, no re-extract).
|
||||||
|
- ``summarize_party_claims`` pins Opus 4.8 + effort=high and grounds the
|
||||||
|
prompt strictly in the case's own claims (anti-hallucination / INV-AH).
|
||||||
|
- ``_render_markdown_body`` maps markdown headings/bullets onto template styles.
|
||||||
|
|
||||||
|
Generation itself (claude -p) is local-only and not exercised here — the
|
||||||
|
claude_session call is monkeypatched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from legal_mcp.services import party_claims_summary as pcs
|
||||||
|
|
||||||
|
|
||||||
|
def _run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
# ── source selection (G2: arguments preferred, claims fallback) ────
|
||||||
|
|
||||||
|
|
||||||
|
def test_arguments_preferred_over_claims(monkeypatch):
|
||||||
|
cid = uuid4()
|
||||||
|
|
||||||
|
async def _get_args(_case_id):
|
||||||
|
return [
|
||||||
|
{"party": "appellant", "argument_title": "חריגת בנייה",
|
||||||
|
"argument_body": "הבנייה חורגת מקו הבניין", "legal_topic": "קווי בניין"},
|
||||||
|
{"party": "respondent", "argument_title": "התיישנות",
|
||||||
|
"argument_body": "", "legal_topic": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _get_claims(_case_id, *a, **k): # must NOT be consulted
|
||||||
|
raise AssertionError("claims fallback used despite arguments existing")
|
||||||
|
|
||||||
|
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _get_args)
|
||||||
|
monkeypatch.setattr(pcs.db, "get_claims", _get_claims)
|
||||||
|
|
||||||
|
ctx, n, kind = _run(pcs._build_arguments_context(cid))
|
||||||
|
assert kind == "arguments"
|
||||||
|
assert n == 2
|
||||||
|
assert "העוררים" in ctx and "המשיבים" in ctx
|
||||||
|
assert "חריגת בנייה" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_claims_when_no_arguments(monkeypatch):
|
||||||
|
cid = uuid4()
|
||||||
|
|
||||||
|
async def _no_args(_case_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _claims(_case_id, *a, **k):
|
||||||
|
return [
|
||||||
|
{"party_role": "appellant", "claim_text": "טענה אחת", "source_document": "appeal"},
|
||||||
|
{"party_role": "respondent", "claim_text": "טענה שתיים", "source_document": "response"},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
|
||||||
|
monkeypatch.setattr(pcs.db, "get_claims", _claims)
|
||||||
|
|
||||||
|
ctx, n, kind = _run(pcs._build_arguments_context(cid))
|
||||||
|
assert kind == "claims"
|
||||||
|
assert n == 2
|
||||||
|
assert "טענה אחת" in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_zayin_claims_excluded(monkeypatch):
|
||||||
|
"""Decision-summary (block-zayin) claims are not original pleadings."""
|
||||||
|
cid = uuid4()
|
||||||
|
|
||||||
|
async def _no_args(_case_id):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _claims(_case_id, *a, **k):
|
||||||
|
return [
|
||||||
|
{"party_role": "appellant", "claim_text": "מקורית", "source_document": "appeal"},
|
||||||
|
{"party_role": "appellant", "claim_text": "מסיכום", "source_document": "block-zayin"},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _no_args)
|
||||||
|
monkeypatch.setattr(pcs.db, "get_claims", _claims)
|
||||||
|
|
||||||
|
ctx, n, kind = _run(pcs._build_arguments_context(cid))
|
||||||
|
assert kind == "claims"
|
||||||
|
assert n == 1
|
||||||
|
assert "מקורית" in ctx and "מסיכום" not in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_when_no_source(monkeypatch):
|
||||||
|
cid = uuid4()
|
||||||
|
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments",
|
||||||
|
lambda _c: _aw([]))
|
||||||
|
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
|
||||||
|
ctx, n, kind = _run(pcs._build_arguments_context(cid))
|
||||||
|
assert kind == "none" and n == 0 and ctx == ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _aw(v):
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ── generation pins Opus 4.8 + high effort, grounded in claims ─────
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_pins_opus_and_high_effort(monkeypatch, tmp_path):
|
||||||
|
cid = uuid4()
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
async def _get_case(_case_id):
|
||||||
|
return {"case_number": "8125-09-24", "title": "t", "appellants": ["א"],
|
||||||
|
"respondents": ["ב"], "subject": "היטל השבחה", "property_address": "",
|
||||||
|
"appeal_type": "betterment_levy", "practice_area": "betterment_levy"}
|
||||||
|
|
||||||
|
async def _get_decision(_case_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _args(_case_id):
|
||||||
|
return [{"party": "appellant", "argument_title": "X",
|
||||||
|
"argument_body": "Y", "legal_topic": ""}]
|
||||||
|
|
||||||
|
async def _fake_query(prompt, *, timeout=None, model=None, effort=None, tools=None):
|
||||||
|
captured["model"] = model
|
||||||
|
captured["effort"] = effort
|
||||||
|
captured["tools"] = tools
|
||||||
|
captured["prompt"] = prompt
|
||||||
|
return "## טענות העוררים\nתמצית."
|
||||||
|
|
||||||
|
async def _style(_practice_area=""):
|
||||||
|
return "(style)"
|
||||||
|
|
||||||
|
monkeypatch.setattr(pcs.db, "get_case", _get_case)
|
||||||
|
monkeypatch.setattr(pcs.db, "get_decision_by_case", _get_decision)
|
||||||
|
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", _args)
|
||||||
|
monkeypatch.setattr(pcs.block_writer, "_build_style_context", _style)
|
||||||
|
monkeypatch.setattr(pcs.claude_session, "query", _fake_query)
|
||||||
|
monkeypatch.setattr(pcs.config, "find_case_dir", lambda cn: tmp_path / cn)
|
||||||
|
monkeypatch.setattr(pcs.config, "DATA_DIR", tmp_path)
|
||||||
|
|
||||||
|
async def _mirror(*a, **k):
|
||||||
|
return None
|
||||||
|
monkeypatch.setattr(pcs.storage, "mirror", _mirror)
|
||||||
|
monkeypatch.setattr(pcs.git_sync, "commit_and_push", lambda *a, **k: True)
|
||||||
|
|
||||||
|
result = _run(pcs.summarize_party_claims(cid, "8125-09-24"))
|
||||||
|
|
||||||
|
# The whole point of #202's generation-path constraint:
|
||||||
|
assert captured["model"] == "claude-opus-4-8"
|
||||||
|
assert captured["effort"] == "high"
|
||||||
|
assert captured["tools"] == "" # prose, no tool_use
|
||||||
|
# Grounded strictly in the case's own arguments (anti-hallucination).
|
||||||
|
assert "אנטי-הזיה" in captured["prompt"]
|
||||||
|
assert "Y" in captured["prompt"] # the actual argument body is in-context
|
||||||
|
|
||||||
|
assert result["source_kind"] == "arguments"
|
||||||
|
out = (tmp_path / "8125-09-24" / "documents" / "research" / "party-claims-summary.md")
|
||||||
|
assert out.exists()
|
||||||
|
assert "סיכום-מנהלים" in out.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_raises_when_no_claims(monkeypatch, tmp_path):
|
||||||
|
cid = uuid4()
|
||||||
|
monkeypatch.setattr(pcs.db, "get_case", lambda _c: _aw({"case_number": "9000-01-25"}))
|
||||||
|
monkeypatch.setattr(pcs.db, "get_decision_by_case", lambda _c: _aw(None))
|
||||||
|
monkeypatch.setattr(pcs.argument_aggregator, "get_legal_arguments", lambda _c: _aw([]))
|
||||||
|
monkeypatch.setattr(pcs.db, "get_claims", lambda _c, *a, **k: _aw([]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_run(pcs.summarize_party_claims(cid, "9000-01-25"))
|
||||||
|
raise AssertionError("expected ValueError on empty source")
|
||||||
|
except ValueError as e:
|
||||||
|
assert "טענות" in str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# ── markdown → DOCX rendering uses template heading styles ─────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_markdown_body_maps_styles():
|
||||||
|
from docx import Document
|
||||||
|
from legal_mcp.services import analysis_docx_exporter as ax
|
||||||
|
|
||||||
|
doc = Document()
|
||||||
|
md = (
|
||||||
|
"# כותרת ראשית\n\n"
|
||||||
|
"---\n"
|
||||||
|
"## טענות העוררים\n"
|
||||||
|
"- טענה אחת\n"
|
||||||
|
"1. סעיף ממוספר\n"
|
||||||
|
"טקסט רגיל.\n"
|
||||||
|
)
|
||||||
|
ax._render_markdown_body(doc, md)
|
||||||
|
styles = [p.style.name for p in doc.paragraphs]
|
||||||
|
texts = [p.text for p in doc.paragraphs]
|
||||||
|
# HR is skipped; headings become Heading styles.
|
||||||
|
assert "כותרת ראשית" in texts
|
||||||
|
assert "Heading 1" in styles # # / ## → Heading 1
|
||||||
|
assert any("טענה אחת" in t for t in texts)
|
||||||
|
assert not any(t.strip() in {"---", "#"} for t in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_version_is_prefix_scoped(tmp_path):
|
||||||
|
from legal_mcp.services import analysis_docx_exporter as ax
|
||||||
|
(tmp_path / "ניתוח-משפטי-v3.docx").write_bytes(b"x")
|
||||||
|
# An analysis file at v3 must NOT bump the summary's version.
|
||||||
|
assert ax._next_version(tmp_path, "סיכום-מנהלים-טענות") == 1
|
||||||
|
assert ax._next_version(tmp_path, "ניתוח-משפטי") == 4
|
||||||
@@ -49,6 +49,65 @@ def test_find_protocol_none():
|
|||||||
assert pa._find_protocol(docs) is None
|
assert pa._find_protocol(docs) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_skips_lower_scope():
|
||||||
|
# A local/district-committee protocol (scope='lower') must NOT be picked for
|
||||||
|
# the hearing-vs-pleadings comparison; the ועדת-ערר one wins (#223).
|
||||||
|
docs = [
|
||||||
|
{"id": "lower", "doc_type": "protocol", "title": "נספח 18 — פרוטוקולי ועדה מקומית",
|
||||||
|
"metadata": {"protocol_scope": "lower"}, "created_at": "2026-01-01"},
|
||||||
|
{"id": "appeal", "doc_type": "protocol", "title": "פרוטוקול דיון 23.6.26",
|
||||||
|
"metadata": {}, "created_at": "2026-06-23"},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(docs)["id"] == "appeal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_all_lower_returns_none():
|
||||||
|
# If every protocol is lower-scoped there is no ערר-hearing to compare.
|
||||||
|
docs = [
|
||||||
|
{"id": "1", "doc_type": "protocol", "title": "פרוטוקול מקומי",
|
||||||
|
"metadata": {"protocol_scope": "lower"}},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(docs) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_prefers_most_recent_appeal():
|
||||||
|
docs = [
|
||||||
|
{"id": "old", "doc_type": "protocol", "title": "פרוטוקול א",
|
||||||
|
"metadata": {}, "created_at": "2026-03-01"},
|
||||||
|
{"id": "new", "doc_type": "protocol", "title": "פרוטוקול ב",
|
||||||
|
"metadata": {}, "created_at": "2026-06-23"},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(docs)["id"] == "new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_explicit_document_id_overrides():
|
||||||
|
# Explicit target wins even over scope/recency heuristics — including the
|
||||||
|
# ability to point at a lower-scoped doc if the caller insists.
|
||||||
|
docs = [
|
||||||
|
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
|
||||||
|
"title": "פרוטוקול ערר", "metadata": {}, "created_at": "2026-06-23"},
|
||||||
|
{"id": "aaaaaaaa-0000-0000-0000-000000000002", "doc_type": "protocol",
|
||||||
|
"title": "פרוטוקול מקומי", "metadata": {"protocol_scope": "lower"},
|
||||||
|
"created_at": "2026-01-01"},
|
||||||
|
]
|
||||||
|
from uuid import UUID
|
||||||
|
picked = pa._find_protocol(
|
||||||
|
docs, document_id=UUID("aaaaaaaa-0000-0000-0000-000000000002"),
|
||||||
|
)
|
||||||
|
assert picked["id"] == "aaaaaaaa-0000-0000-0000-000000000002"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_unknown_document_id_returns_none():
|
||||||
|
from uuid import UUID
|
||||||
|
docs = [
|
||||||
|
{"id": "aaaaaaaa-0000-0000-0000-000000000001", "doc_type": "protocol",
|
||||||
|
"title": "פרוטוקול", "metadata": {}},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(
|
||||||
|
docs, document_id=UUID("bbbbbbbb-0000-0000-0000-000000000009"),
|
||||||
|
) is None
|
||||||
|
|
||||||
|
|
||||||
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
|
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
|
||||||
|
|
||||||
_AID = "11111111-1111-1111-1111-111111111111"
|
_AID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|||||||
76
mcp-server/tests/test_reanalyze_impact_diff.py
Normal file
76
mcp-server/tests/test_reanalyze_impact_diff.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Unit tests for the re-analysis impact diff — WS2 / task #201.
|
||||||
|
|
||||||
|
`_snapshot` + `_impact_diff` are pure functions (no DB) that produce the
|
||||||
|
before↔after summary surfaced to the chair after a merging re-analysis. They
|
||||||
|
must report per-party argument titles added/removed and the shift in the
|
||||||
|
priority ("recommendation balance") mix, and set ``changed`` correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from legal_mcp.tools.legal_arguments import _impact_diff, _snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _arg(party, title, priority="substantive"):
|
||||||
|
return {"party": party, "argument_title": title, "priority": priority}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_change_is_flagged_false():
|
||||||
|
args = [_arg("appellant", "זכות עמידה"), _arg("committee", "שיהוי")]
|
||||||
|
snap = _snapshot(args)
|
||||||
|
diff = _impact_diff(snap, snap)
|
||||||
|
assert diff["changed"] is False
|
||||||
|
assert diff["total_before"] == diff["total_after"] == 2
|
||||||
|
# Every party entry reports changed=False.
|
||||||
|
assert all(p["changed"] is False for p in diff["by_party"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_argument_detected():
|
||||||
|
before = _snapshot([_arg("appellant", "זכות עמידה")])
|
||||||
|
after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("appellant", "תחולת תמא 38")])
|
||||||
|
diff = _impact_diff(before, after)
|
||||||
|
assert diff["changed"] is True
|
||||||
|
assert diff["total_before"] == 1
|
||||||
|
assert diff["total_after"] == 2
|
||||||
|
appellant = next(p for p in diff["by_party"] if p["party"] == "appellant")
|
||||||
|
assert appellant["added_arguments"] == ["תחולת תמא 38"]
|
||||||
|
assert appellant["removed_arguments"] == []
|
||||||
|
assert appellant["party_he"] == "עוררים"
|
||||||
|
|
||||||
|
|
||||||
|
def test_removed_argument_detected():
|
||||||
|
before = _snapshot([_arg("committee", "שיהוי"), _arg("committee", "סמכות")])
|
||||||
|
after = _snapshot([_arg("committee", "שיהוי")])
|
||||||
|
diff = _impact_diff(before, after)
|
||||||
|
committee = next(p for p in diff["by_party"] if p["party"] == "committee")
|
||||||
|
assert committee["removed_arguments"] == ["סמכות"]
|
||||||
|
assert committee["count_before"] == 2
|
||||||
|
assert committee["count_after"] == 1
|
||||||
|
assert diff["changed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_mix_shift_is_a_change():
|
||||||
|
# Same title, but priority moved threshold -> substantive: the recommendation
|
||||||
|
# balance shifted, so the chair must see a change even with identical titles.
|
||||||
|
before = _snapshot([_arg("appellant", "סמכות", priority="threshold")])
|
||||||
|
after = _snapshot([_arg("appellant", "סמכות", priority="substantive")])
|
||||||
|
diff = _impact_diff(before, after)
|
||||||
|
appellant = next(p for p in diff["by_party"] if p["party"] == "appellant")
|
||||||
|
assert appellant["added_arguments"] == []
|
||||||
|
assert appellant["removed_arguments"] == []
|
||||||
|
assert appellant["priority_before"] == {"threshold": 1}
|
||||||
|
assert appellant["priority_after"] == {"substantive": 1}
|
||||||
|
assert appellant["changed"] is True
|
||||||
|
assert diff["changed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_party_appears():
|
||||||
|
before = _snapshot([_arg("appellant", "זכות עמידה")])
|
||||||
|
after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("respondent", "מענה")])
|
||||||
|
diff = _impact_diff(before, after)
|
||||||
|
parties = {p["party"] for p in diff["by_party"]}
|
||||||
|
assert parties == {"appellant", "respondent"}
|
||||||
|
respondent = next(p for p in diff["by_party"] if p["party"] == "respondent")
|
||||||
|
assert respondent["count_before"] == 0
|
||||||
|
assert respondent["count_after"] == 1
|
||||||
|
assert respondent["added_arguments"] == ["מענה"]
|
||||||
@@ -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:
|
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
|
||||||
"""Inserted paragraph must have bidi + rtl + David font so it renders
|
"""Inserted paragraph must have bidi + an <w:rtl/> run marker so Hebrew
|
||||||
correctly in Word alongside the user's content."""
|
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"
|
user_file = tmp_path / "user.docx"
|
||||||
_make_user_edited_docx(user_file)
|
_make_user_edited_docx(user_file)
|
||||||
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
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):
|
if any("עברית RTL" in (t.text or "") for t in text_els):
|
||||||
rPr = r.find(_w("rPr"))
|
rPr = r.find(_w("rPr"))
|
||||||
assert rPr is not None
|
assert rPr is not None
|
||||||
|
# RTL marker preserved…
|
||||||
assert rPr.find(_w("rtl")) is not None
|
assert rPr.find(_w("rtl")) is not None
|
||||||
rFonts = rPr.find(_w("rFonts"))
|
# …but NO hand-set font/size on the run (INV-EX4).
|
||||||
assert rFonts is not None
|
assert rPr.find(_w("rFonts")) is None
|
||||||
assert rFonts.get(_w("ascii")) == "David"
|
assert rPr.find(_w("sz")) is None
|
||||||
return
|
return
|
||||||
pytest.fail("tracked insertion with 'עברית RTL' not found")
|
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) |
|
| `calibrate_halacha_dedup.py` | python | **#82.1** — כיול ספי ה-dedup הלקסיקלי (#82.3) מול gold-set הניקוי. קורא `halacha-cleanup-manifest-*.csv` (זוגות duplicate↔survivor מתויגי-אדם), טוען טקסט-survivor מה-DB, ו-sweep של (jaccard_min × levenshtein_min) עם P/R/F1, מסמן את נקודת-העבודה המוגדרת. אימת ש-(0.55, 0.70) → **precision 1.0** (אפס false-merge), recall 0.30 — מתאים לאיתות-משני שחוסם auto-approve. `--manifest <path>`. רץ עם venv של mcp-server | חד-פעמי — כיול (בוצע 2026-06-06) |
|
||||||
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
|
| `ab_halacha_opus48.py` | python | **A/B לא-הרסני לחילוץ הלכות (Claude)** — מריץ מחדש חילוץ הלכות על פסק-דין בודד דרך מודל/effort נבחרים (`AB_MODEL`/`AB_EFFORT`, ברירת-מחדל `claude-opus-4-8`/`xhigh`) ומשווה לסטטיסטיקות ההלכות הקיימות ב-DB **בלי למחוק/לכתוב כלום**. משכפל את `halacha_extractor.extract()` (אותם פרומפטים, בחירת-צ'אנקים, אימות-ציטוט) ומחליף רק את קריאת ה-LLM ב-`claude -p --model --effort`. מפיק `data/ab_halacha_<case>_<effort>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data .venv/bin/python scripts/ab_halacha_opus48.py <case_law_id>`. **ממצא 2026-05-31 (שטיין 1128-08-20):** Opus 4.8@xhigh חילץ 51 מול 124 בייצור (100% quote-verified מול 96%) אך ביטחון מכויל-נמוך יותר (חציון 0.75 מול 0.82) — ולכן **לא** מקטין את תור-האישור-הידני תחת sweep אוטו-אישור conf≥0.78 (26 מול 24). שיפור איכות, לא צמצום-תור. | ידני (החלטת מודל-חילוץ) |
|
||||||
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
|
| `ab_halacha_codex.py` | python | **A/B לא-הרסני לחילוץ הלכות (Codex/gpt-5.5)** — עמית ל-`ab_halacha_opus48` אך מחליף את `claude -p` ב-`codex exec --model gpt-5.5` (אימות ChatGPT, ללא OPENAI_API_KEY). אותם פרומפטים ואותו הסקת quote-verification. הפלט האחרון של הסוכן (`-o FILE`) נפענח כ-JSON. `AB_MODEL` (default `gpt-5.5`), `AB_REASONING` low/medium/high/xhigh (default `medium`), `AB_CONCURRENCY` (default 1), `CODEX_BIN`. מפיק `data/ab_halacha_codex_<case>_<model>_<reasoning>.json`. הרצה: `DOTENV_PATH=/home/chaim/.env DATA_DIR=.../data mcp-server/.venv/bin/python scripts/ab_halacha_codex.py <case_law_id>`. **ממצא 2026-06-17 (8181-21 האוניברסיטה העברית):** gpt-5.5@medium חילץ 27 מול 28 של Opus (quote-verified 100%/100%), ביטחון חציון 0.86 מול 0.78 — אך **0 פריטים מתחת ל-0.7** (לעומת 9/28 של Opus = 32%), דבר המצביע על over-confidence. holding↑ (12 מול 7), procedural↓ (4 מול 7). **מסקנה: ריאלי כ-fallback חירום; לא מוכן לייצור ללא כיול-ביטחון.** | ידני (בנצ'מרק מודל codex) |
|
||||||
| `calibrate_effort.py` | python | **#208 (WS5/Q1, INV-G8 eval-harness) — כיול model×effort של הכותב מול הסופיים.** A/B per-(תיק,בלוק,effort) על `draft_final_pairs` בעלי `final_text`: מייצר מחדש כל בלוק דרך מסלול-הייצור (`block_writer.write_block(effort_override=…)` → `claude_session.query` → `claude -p`, Opus 4.8 נעוץ, **מקומי-בלבד**) ומודד מול ה**סקשן** המתאים בסופי דרך `services/style_distance.block_distance_to_final` (מקור-מדידה יחיד, G2): `change_percent` (compute_diff_stats) · `anti_pattern_total` (`lessons.ANTI_PATTERNS`, הסיגנל הנקי-לסגנון) · `golden_ratio_deviation_pp` · `distance` מרוכב. **ממליץ** per-בלוק על ה-effort בעל ה-distance-הממוצע-הנמוך (לצד ברירת-המחדל מ-#204). מפיק `data/eval/effort-calibration-<ts>.{json,md}`. ⚠️ **גודל-מדגם מודפס בראש הדוח — עדות-כיוון, לא רגרסיה** (מעט סופיים-עלויים). `--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` (לתזמן) |
|
| `monitor_halacha_quality.py` | python | מנטר איכות חילוץ הלכות. בודק drift של `avg(confidence)` בין baseline היסטורי לחלון אחרון. מחזיר JSON מטריקות + alert ב-stderr אם drift > threshold (ברירת מחדל 5%). 2 סדרות: trusted (approved+published) ו-all_extracted. תומך `--window N` / `--threshold X` / `--min-sample N` / `--silent` / `--exit-on-alert`. רץ ב-container או מקומית עם `mcp-server/.venv` (אין תלות ב-LLM, רק SQL). **תזמון מומלץ**: `0 8 * * 1` (יום ראשון 08:00, שבועי) | `0 8 * * 1` (לתזמן) |
|
||||||
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
|
| `audit_training_corpus.py` | python | audit של `style_corpus` — לכל החלטה: שדות מטא-דאטה מאוכלסים (`summary`/`outcome`/`key_principles`/`appeal_subtype`/`subject_categories`), קישור ל-`documents` (FK + chunks + embeddings). מפיק `data/audit/corpus-YYYY-MM-DD.json` + summary בקונסול. דרוש `POSTGRES_URL` או POSTGRES_*. אין תלויות חיצוניות מלבד asyncpg. **רץ מהמכונה המקומית** (לא קונטיינר) — חיבור ישיר ל-Postgres :5433 | ידני / קדם-עבודה לפני enrichment של מטא-דאטה |
|
||||||
| `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`) |
|
| `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 |
|
| 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()` |
|
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
|
||||||
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
|
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
|
||||||
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
|
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ Mapping (removed → kept):
|
|||||||
|
|
||||||
uploading → processing
|
uploading → processing
|
||||||
in_progress → outcome_set
|
in_progress → outcome_set
|
||||||
analyst_verified → documents_ready
|
|
||||||
research_complete → documents_ready
|
|
||||||
brainstorming → outcome_set
|
brainstorming → outcome_set
|
||||||
analysis_enriched → direction_approved
|
analysis_enriched → direction_approved
|
||||||
ready_for_writing → direction_approved
|
ready_for_writing → direction_approved
|
||||||
@@ -50,12 +48,14 @@ from legal_mcp.services import db # noqa: E402
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
log = logging.getLogger("status-trim")
|
log = logging.getLogger("status-trim")
|
||||||
|
|
||||||
# removed status → nearest preceding kept status
|
# removed status → nearest preceding kept status.
|
||||||
|
# NOTE (2026-06-30): analyst_verified + research_complete were RE-CANONICALISED
|
||||||
|
# into the status model (legal_mcp/case_status_model.py, "thinking" phase) — they
|
||||||
|
# are valid statuses again, so they were removed from this map. Do NOT downgrade
|
||||||
|
# them; only genuinely-removed markers remain below.
|
||||||
STATUS_MAP = {
|
STATUS_MAP = {
|
||||||
"uploading": "processing",
|
"uploading": "processing",
|
||||||
"in_progress": "outcome_set",
|
"in_progress": "outcome_set",
|
||||||
"analyst_verified": "documents_ready",
|
|
||||||
"research_complete": "documents_ready",
|
|
||||||
"brainstorming": "outcome_set",
|
"brainstorming": "outcome_set",
|
||||||
"analysis_enriched": "direction_approved",
|
"analysis_enriched": "direction_approved",
|
||||||
"ready_for_writing": "direction_approved",
|
"ready_for_writing": "direction_approved",
|
||||||
|
|||||||
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)
|
• distance — normalized composite (lower = closer to Dafna)
|
||||||
No parallel metric path: it reuses style_distance + learning_loop (G2).
|
No parallel metric path: it reuses style_distance + learning_loop (G2).
|
||||||
|
|
||||||
RECOMMENDATION: for each block, the effort with the lowest MEAN composite distance
|
RECOMMENDATION (#213 — STYLE-CLEAN, anti-primary): for each block, the effort is
|
||||||
across cases (ties → fewer anti-patterns → lower change_percent). Reported next to
|
ranked by `anti_pattern_total` FIRST (the clean Dafna-style-rule-violation count),
|
||||||
the #204 current default so a regression/improvement is visible.
|
then `golden_ratio_deviation_pp` (structural-style), and `distance` only as a faint
|
||||||
|
final tiebreak. `change_percent` is REPORTED-NOT-RANKED: per 07-learning §0.7 it
|
||||||
|
mixes style with content-completeness (a case's missing facts the model can't know)
|
||||||
|
→ noise for STYLE. The pick carries a `confidence` flag ("clear"/"weak"): a pick
|
||||||
|
whose anti-pattern lead over the runner-up is within noise is flagged "weak" so an
|
||||||
|
over-claimed pick (the old block-zayin "low") is never shown as confident. Reported
|
||||||
|
next to the #204 current default so a regression/improvement is visible.
|
||||||
|
|
||||||
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded final
|
⚠️ SAMPLE-SIZE CAVEAT (honored, not hidden): very few cases have an uploaded final
|
||||||
(draft_final_pairs.final_text non-empty). The report prints n_finals PROMINENTLY and
|
(draft_final_pairs.final_text non-empty). The report prints n_finals PROMINENTLY and
|
||||||
@@ -55,12 +61,16 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from statistics import mean
|
from statistics import mean
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(REPO_ROOT / "mcp-server" / "src"))
|
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
|
# 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
|
# final's matching section (split_final_by_section). Template blocks (א-ד, יב) are
|
||||||
# deterministic template-fill — no effort knob. block-yod (discussion) is out of
|
# deterministic template-fill — no effort knob. block-yod (discussion) is EXCLUDED
|
||||||
# WS5's interim scope but mappable, so it's included when present.
|
# from the defaults: it requires an approved direction (brainstorm → approve_direction)
|
||||||
CALIBRATABLE_BLOCKS = ["block-he", "block-vav", "block-zayin", "block-chet", "block-tet", "block-yod", "block-yod-alef"]
|
# 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"]
|
DEFAULT_EFFORTS = ["low", "medium", "high", "xhigh"]
|
||||||
VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
|
VALID_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
|
||||||
|
|
||||||
|
|
||||||
# ── pure helpers (offline-testable) ──────────────────────────────────────────
|
# ── pure helpers (offline-testable) ──────────────────────────────────────────
|
||||||
def recommend_effort(cells: list[dict]) -> dict | None:
|
# Style-clean ranking epsilon (#213). Confidence in a pick = how far the best
|
||||||
"""Pick the best effort for ONE block from its scored cells.
|
# effort's anti_pattern_total beats the 2nd-best. We call a pick "clear" only when
|
||||||
|
# that margin clears a floor that is itself the LARGER of:
|
||||||
|
# • an absolute floor (≥0.5 avg anti-patterns — a half-violation per case), and
|
||||||
|
# • a fraction of the spread across efforts (best can't claim a lead that is mere
|
||||||
|
# noise on a block whose efforts barely differ).
|
||||||
|
# Below that → "weak" (within noise; nominal pick still reported, but flagged so a
|
||||||
|
# within-noise pick is never shown as confident). Documented in the docstring.
|
||||||
|
CONFIDENCE_ABS_FLOOR = 0.5 # avg anti-patterns (a half-violation per case)
|
||||||
|
CONFIDENCE_SPREAD_FRACTION = 0.20 # of the across-effort anti spread
|
||||||
|
|
||||||
cells: [{"effort","distance","anti_pattern_total","change_percent","n"}].
|
|
||||||
Lowest mean composite distance wins; ties broken by fewer anti-patterns,
|
def _anti_confidence(rows: list[dict]) -> tuple[str, float, float]:
|
||||||
then lower change_percent. Pure → unit-tested in --self-test.
|
"""Confidence that the anti-primary pick is real, not within-noise.
|
||||||
|
|
||||||
|
Returns (confidence, margin, epsilon) where confidence ∈ {"clear","weak"}.
|
||||||
|
margin = (2nd-best anti) − (best anti); epsilon = the bar margin must clear.
|
||||||
|
A single effort (no rival) is trivially "clear". Pure → unit-tested.
|
||||||
|
"""
|
||||||
|
if len(rows) < 2:
|
||||||
|
return "clear", float("inf"), 0.0
|
||||||
|
antis = sorted(r["anti_pattern_total"] for r in rows)
|
||||||
|
best, second = antis[0], antis[1]
|
||||||
|
margin = second - best
|
||||||
|
spread = antis[-1] - antis[0]
|
||||||
|
epsilon = max(CONFIDENCE_ABS_FLOOR, CONFIDENCE_SPREAD_FRACTION * spread)
|
||||||
|
return ("clear" if margin >= epsilon else "weak"), round(margin, 4), round(epsilon, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_effort(cells: list[dict]) -> dict | None:
|
||||||
|
"""Pick the STYLE-CLEANEST effort for ONE block from its scored cells (#213).
|
||||||
|
|
||||||
|
cells: [{"effort","distance","anti_pattern_total","change_percent",
|
||||||
|
"golden_ratio_deviation_pp","n"}].
|
||||||
|
|
||||||
|
Ranking key (style-clean, G8 / 07-learning §0.7) — change_percent is EXCLUDED
|
||||||
|
from the ranking (it mixes style with content-completeness; reported only):
|
||||||
|
1. PRIMARY anti_pattern_total (↑ fewer Dafna-style violations = better)
|
||||||
|
2. SECONDARY golden_ratio_deviation_pp (↑ structural-style; None → +inf worst)
|
||||||
|
3. TIEBREAK distance (↑ faint final tiebreak only)
|
||||||
|
|
||||||
|
Returns the winning row dict (so callers reading ["effort"] keep working) with
|
||||||
|
two added keys: ``confidence`` ∈ {"clear","weak"} and ``confidence_margin`` —
|
||||||
|
so a within-noise pick is never presented as confident. Pure → unit-tested.
|
||||||
"""
|
"""
|
||||||
if not cells:
|
if not cells:
|
||||||
return None
|
return None
|
||||||
ranked = sorted(
|
ranked = sorted(
|
||||||
cells,
|
cells,
|
||||||
key=lambda c: (c["distance"], c["anti_pattern_total"], c["change_percent"]),
|
key=lambda c: (
|
||||||
|
c["anti_pattern_total"],
|
||||||
|
c["golden_ratio_deviation_pp"] if c.get("golden_ratio_deviation_pp") is not None else float("inf"),
|
||||||
|
c["distance"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return ranked[0]
|
best = dict(ranked[0])
|
||||||
|
conf, margin, _eps = _anti_confidence(cells)
|
||||||
|
best["confidence"] = conf
|
||||||
|
best["confidence_margin"] = margin
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
def aggregate_cell(per_run: list[dict]) -> dict:
|
def aggregate_cell(per_run: list[dict]) -> dict:
|
||||||
@@ -124,6 +185,88 @@ def _current_default(block_id: str) -> str | None:
|
|||||||
return cfg.get("effort", DEFAULT_EFFORT)
|
return cfg.get("effort", DEFAULT_EFFORT)
|
||||||
|
|
||||||
|
|
||||||
|
# ── re-rank a saved report (offline — NO LLM, NO DB) ─────────────────────────
|
||||||
|
def rerank_saved(saved: dict) -> dict:
|
||||||
|
"""Re-apply the CURRENT (#213) recommend_effort to a saved calibration report.
|
||||||
|
|
||||||
|
Reads the per-(block,effort) rows that the run already persisted
|
||||||
|
(distance/anti/change%/ratioΔ/n) and recomputes recommended + confidence — no
|
||||||
|
regeneration, no model, no DB. Lets a past run be re-ranked under the new
|
||||||
|
style-clean key. Returns {"block_id": {old, new, confidence, confidence_margin,
|
||||||
|
current_default, efforts}}. Pure → unit-tested in --self-test.
|
||||||
|
"""
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for block_id, bd in (saved.get("by_block") or {}).items():
|
||||||
|
rows = bd.get("efforts") or []
|
||||||
|
rec = recommend_effort(rows)
|
||||||
|
out[block_id] = {
|
||||||
|
"section": bd.get("section"),
|
||||||
|
"current_default": bd.get("current_default"),
|
||||||
|
"old_recommended": bd.get("recommended"),
|
||||||
|
"new_recommended": rec["effort"] if rec else None,
|
||||||
|
"confidence": rec["confidence"] if rec else None,
|
||||||
|
"confidence_margin": rec.get("confidence_margin") if rec else None,
|
||||||
|
"efforts": rows,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _render_rerank_md(saved: dict, reranked: dict, src: Path) -> str:
|
||||||
|
g = saved.get("grid", {})
|
||||||
|
finals = g.get("finals", [])
|
||||||
|
lines = [
|
||||||
|
f"# #213 — re-rank של {src.name} (style-clean, anti-primary · OFFLINE)\n",
|
||||||
|
f"> מקור: `{src.name}` · {g.get('n_finals','?')} סופיים "
|
||||||
|
f"({', '.join(finals) or '—'}) · ללא LLM/DB — דירוג-מחדש בלבד.\n",
|
||||||
|
"## old pick → new pick (+confidence)\n",
|
||||||
|
"| block | section | current | old pick | **new pick** | confidence | n |",
|
||||||
|
"|---|---|---|---|---|---|---|",
|
||||||
|
]
|
||||||
|
for b, rd in reranked.items():
|
||||||
|
old = rd.get("old_recommended") or "—"
|
||||||
|
new = rd.get("new_recommended") or "—"
|
||||||
|
mark = "" if new == old else " ⬅︎"
|
||||||
|
conf = rd.get("confidence")
|
||||||
|
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "—")
|
||||||
|
n_b = rd["efforts"][0]["n"] if rd.get("efforts") else 0
|
||||||
|
lines.append(
|
||||||
|
f"| {b} | {rd.get('section') or '—'} | {rd.get('current_default') or '—'} | "
|
||||||
|
f"{old} | **{new}**{mark} | {conf_cell} | {n_b} |")
|
||||||
|
lines.append("")
|
||||||
|
for b, rd in reranked.items():
|
||||||
|
if not rd.get("efforts"):
|
||||||
|
continue
|
||||||
|
lines += [f"### {b} ({rd.get('section') or '—'})\n",
|
||||||
|
"| effort | distance | anti_total | change% (reported) | ratioΔpp | n |",
|
||||||
|
"|---|---|---|---|---|---|"]
|
||||||
|
for r in rd["efforts"]:
|
||||||
|
star = " ⭐" if r["effort"] == rd.get("new_recommended") else ""
|
||||||
|
ratio = r.get("golden_ratio_deviation_pp")
|
||||||
|
lines.append(
|
||||||
|
f"| {r['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
||||||
|
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("> דירוג **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak); "
|
||||||
|
"**change% מדווח-לא-מדורג** (07-learning §0.7); "
|
||||||
|
"confidence=⚠️weak ⇒ הובלת-anti < epsilon = max(0.5, 0.20·spread).\n")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _do_rerank(path: Path) -> int:
|
||||||
|
"""--rerank entry: load a saved report, re-rank offline, print + write *-reranked.md."""
|
||||||
|
if not path.exists():
|
||||||
|
print(f"rerank: file not found: {path}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
reranked = rerank_saved(saved)
|
||||||
|
md = _render_rerank_md(saved, reranked, path)
|
||||||
|
print(md)
|
||||||
|
out = path.with_name(path.stem + "-reranked.md")
|
||||||
|
out.write_text(md, encoding="utf-8")
|
||||||
|
print(f" reranked report written: {out}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
# ── self-test (no DB, no model) ──────────────────────────────────────────────
|
# ── self-test (no DB, no model) ──────────────────────────────────────────────
|
||||||
def _self_test() -> int:
|
def _self_test() -> int:
|
||||||
ok = True
|
ok = True
|
||||||
@@ -171,16 +314,66 @@ def _self_test() -> int:
|
|||||||
{"background", "claims", "discussion", "summary"}))
|
{"background", "claims", "discussion", "summary"}))
|
||||||
chk("split found ≥1 section", len(secs) >= 1)
|
chk("split found ≥1 section", len(secs) >= 1)
|
||||||
|
|
||||||
# recommend_effort: lowest distance wins; tie → fewer anti-patterns.
|
# recommend_effort (#213, style-clean): anti_pattern_total is PRIMARY — the
|
||||||
|
# effort with the FEWEST style violations wins, even if its distance is higher.
|
||||||
rec = recommend_effort([
|
rec = recommend_effort([
|
||||||
{"effort": "low", "distance": 0.40, "anti_pattern_total": 5, "change_percent": 40, "n": 1},
|
{"effort": "low", "distance": 0.20, "anti_pattern_total": 5, "change_percent": 22,
|
||||||
{"effort": "high", "distance": 0.20, "anti_pattern_total": 2, "change_percent": 25, "n": 1},
|
"golden_ratio_deviation_pp": 3.0, "n": 1},
|
||||||
{"effort": "xhigh", "distance": 0.20, "anti_pattern_total": 1, "change_percent": 22, "n": 1},
|
{"effort": "high", "distance": 0.40, "anti_pattern_total": 2, "change_percent": 40,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||||
|
{"effort": "xhigh", "distance": 0.50, "anti_pattern_total": 1, "change_percent": 50,
|
||||||
|
"golden_ratio_deviation_pp": 9.0, "n": 1},
|
||||||
])
|
])
|
||||||
chk("recommend picks lowest distance", rec["distance"] == 0.20)
|
chk("recommend: anti-primary (not distance)", rec["effort"] == "xhigh")
|
||||||
chk("recommend tie → fewer anti-patterns", rec["effort"] == "xhigh")
|
chk("recommend: change_percent NOT ranked", rec["distance"] == 0.50) # worst distance still won
|
||||||
chk("recommend empty ⇒ None", recommend_effort([]) is None)
|
chk("recommend empty ⇒ None", recommend_effort([]) is None)
|
||||||
|
|
||||||
|
# SECONDARY = golden_ratio_deviation_pp when anti ties; distance only as last tiebreak.
|
||||||
|
rec_tie = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 10,
|
||||||
|
"golden_ratio_deviation_pp": 8.0, "n": 1},
|
||||||
|
{"effort": "high", "distance": 0.90, "anti_pattern_total": 3, "change_percent": 90,
|
||||||
|
"golden_ratio_deviation_pp": 2.0, "n": 1},
|
||||||
|
])
|
||||||
|
chk("recommend: ratio breaks anti-tie", rec_tie["effort"] == "high")
|
||||||
|
rec_ratio_tie = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.30, "anti_pattern_total": 3, "change_percent": 10,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||||
|
{"effort": "high", "distance": 0.10, "anti_pattern_total": 3, "change_percent": 90,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 1},
|
||||||
|
])
|
||||||
|
chk("recommend: distance breaks final tie", rec_ratio_tie["effort"] == "high")
|
||||||
|
# None ratio is treated as worst (never beats a real deviation on the secondary key).
|
||||||
|
rec_none = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.10, "anti_pattern_total": 2, "change_percent": 10,
|
||||||
|
"golden_ratio_deviation_pp": None, "n": 1},
|
||||||
|
{"effort": "high", "distance": 0.90, "anti_pattern_total": 2, "change_percent": 90,
|
||||||
|
"golden_ratio_deviation_pp": 4.0, "n": 1},
|
||||||
|
])
|
||||||
|
chk("recommend: None ratio is worst", rec_none["effort"] == "high")
|
||||||
|
|
||||||
|
# confidence flag (#213): a clear anti lead ⇒ "clear"; within-noise ⇒ "weak".
|
||||||
|
rec_clear = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.5, "anti_pattern_total": 1.0, "change_percent": 50,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 5},
|
||||||
|
{"effort": "high", "distance": 0.5, "anti_pattern_total": 4.0, "change_percent": 50,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 5},
|
||||||
|
])
|
||||||
|
chk("confidence: big anti lead ⇒ clear", rec_clear["confidence"] == "clear")
|
||||||
|
rec_weak = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.4, "anti_pattern_total": 3.6, "change_percent": 93,
|
||||||
|
"golden_ratio_deviation_pp": 6.7, "n": 5},
|
||||||
|
{"effort": "xhigh", "distance": 0.5, "anti_pattern_total": 3.6, "change_percent": 102,
|
||||||
|
"golden_ratio_deviation_pp": 6.6, "n": 5},
|
||||||
|
{"effort": "high", "distance": 0.53, "anti_pattern_total": 3.8, "change_percent": 104,
|
||||||
|
"golden_ratio_deviation_pp": 5.7, "n": 5},
|
||||||
|
])
|
||||||
|
chk("confidence: anti tie ⇒ weak", rec_weak["confidence"] == "weak")
|
||||||
|
rec_single = recommend_effort([
|
||||||
|
{"effort": "low", "distance": 0.4, "anti_pattern_total": 2.0, "change_percent": 50,
|
||||||
|
"golden_ratio_deviation_pp": 5.0, "n": 5}])
|
||||||
|
chk("confidence: single effort ⇒ clear", rec_single["confidence"] == "clear")
|
||||||
|
|
||||||
# aggregate_cell: means + ratio drops Nones, keeps n.
|
# aggregate_cell: means + ratio drops Nones, keeps n.
|
||||||
agg = aggregate_cell([
|
agg = aggregate_cell([
|
||||||
{"distance": 0.2, "anti_pattern_total": 2, "change_percent": 20, "golden_ratio_deviation_pp": 3.0},
|
{"distance": 0.2, "anti_pattern_total": 2, "change_percent": 20, "golden_ratio_deviation_pp": 3.0},
|
||||||
@@ -190,6 +383,23 @@ def _self_test() -> int:
|
|||||||
chk("aggregate n counted", agg["n"] == 2)
|
chk("aggregate n counted", agg["n"] == 2)
|
||||||
chk("aggregate ratio skips None", agg["golden_ratio_deviation_pp"] == 3.0)
|
chk("aggregate ratio skips None", agg["golden_ratio_deviation_pp"] == 3.0)
|
||||||
|
|
||||||
|
# rerank_saved: re-applies the new key to a saved report's stored rows, no I/O.
|
||||||
|
# Mimics the real 8-final block-vav rows: low has fewest anti by a clear margin.
|
||||||
|
saved = {"grid": {"n_finals": 7, "finals": ["x"]}, "by_block": {
|
||||||
|
"block-vav": {"section": "background", "current_default": "medium",
|
||||||
|
"recommended": "low", "efforts": [
|
||||||
|
{"effort": "low", "distance": 0.4454, "anti_pattern_total": 1.57, "change_percent": 87.39, "golden_ratio_deviation_pp": 6.33, "n": 7},
|
||||||
|
{"effort": "medium", "distance": 0.4749, "anti_pattern_total": 2.29, "change_percent": 87.76, "golden_ratio_deviation_pp": 6.37, "n": 7},
|
||||||
|
{"effort": "high", "distance": 0.5381, "anti_pattern_total": 4.14, "change_percent": 90.69, "golden_ratio_deviation_pp": 5.99, "n": 7},
|
||||||
|
{"effort": "xhigh", "distance": 0.5413, "anti_pattern_total": 4.57, "change_percent": 89.53, "golden_ratio_deviation_pp": 6.41, "n": 7},
|
||||||
|
]},
|
||||||
|
}}
|
||||||
|
rr = rerank_saved(saved)
|
||||||
|
chk("rerank: block-vav stays low", rr["block-vav"]["new_recommended"] == "low")
|
||||||
|
chk("rerank: block-vav clear (anti lead)", rr["block-vav"]["confidence"] == "clear")
|
||||||
|
chk("rerank: keeps old pick for diff", rr["block-vav"]["old_recommended"] == "low")
|
||||||
|
chk("rerank: renders markdown", "new pick" in _render_rerank_md(saved, rr, Path("x.json")))
|
||||||
|
|
||||||
print("ALL PASS" if ok else "*** FAILURES ***")
|
print("ALL PASS" if ok else "*** FAILURES ***")
|
||||||
return 0 if ok else 1
|
return 0 if ok else 1
|
||||||
|
|
||||||
@@ -228,7 +438,7 @@ async def _score_cell(case_id, block_id: str, effort: str, final_section: str,
|
|||||||
return agg
|
return agg
|
||||||
|
|
||||||
|
|
||||||
async def _run(args) -> dict:
|
async def _run(args, ts: str) -> dict:
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from legal_mcp.services import db
|
from legal_mcp.services import db
|
||||||
from legal_mcp.services.lessons import canonical_outcome
|
from legal_mcp.services.lessons import canonical_outcome
|
||||||
@@ -279,10 +489,21 @@ async def _run(args) -> dict:
|
|||||||
final_section = c["sections"][section]
|
final_section = c["sections"][section]
|
||||||
case_cells = []
|
case_cells = []
|
||||||
for effort in efforts:
|
for effort in efforts:
|
||||||
|
# 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(
|
cell = await _score_cell(
|
||||||
UUID(c["case_id"]), block_id, effort, final_section,
|
UUID(c["case_id"]), block_id, effort, final_section,
|
||||||
c["final_total_words"], c["outcome"], args.repeats,
|
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)
|
per_effort_runs[effort].append(cell)
|
||||||
case_cells.append({k: cell[k] for k in
|
case_cells.append({k: cell[k] for k in
|
||||||
("effort", "distance", "anti_pattern_total",
|
("effort", "distance", "anti_pattern_total",
|
||||||
@@ -309,15 +530,35 @@ async def _run(args) -> dict:
|
|||||||
"section": section,
|
"section": section,
|
||||||
"current_default": _current_default(block_id),
|
"current_default": _current_default(block_id),
|
||||||
"recommended": rec["effort"] if rec else None,
|
"recommended": rec["effort"] if rec else None,
|
||||||
|
"confidence": rec["confidence"] if rec else None,
|
||||||
|
"confidence_margin": rec.get("confidence_margin") if rec else None,
|
||||||
"efforts": effort_rows,
|
"efforts": effort_rows,
|
||||||
"per_case": per_case,
|
"per_case": per_case,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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}
|
return {"dry_run": False, "grid": grid_summary, "by_block": by_block}
|
||||||
|
|
||||||
|
|
||||||
|
IL_TZ = ZoneInfo("Asia/Jerusalem")
|
||||||
|
|
||||||
|
|
||||||
def _ts() -> str:
|
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]:
|
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"]
|
g = result["grid"]
|
||||||
n = g["n_finals"]
|
n = g["n_finals"]
|
||||||
|
il_now = datetime.now(IL_TZ).strftime("%Y-%m-%d %H:%M")
|
||||||
lines = [
|
lines = [
|
||||||
f"# #208 — כיול model×effort מול הסופיים — {ts}\n",
|
f"# #208 — כיול model×effort מול הסופיים — {ts}\n",
|
||||||
|
f"> נוצר: {il_now} (שעון ישראל · Asia/Jerusalem)\n",
|
||||||
f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or '—'}). "
|
f"> ⚠️ **גודל-מדגם: {n} סופיים** ({', '.join(g['finals']) or '—'}). "
|
||||||
"זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. "
|
"זוהי **עדות-כיוון, לא רגרסיה** — מעט תיקים בעלי סופי-עלוי. "
|
||||||
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\n",
|
"ההמלצה אדוויזורית; ההכרעה בידי היו\"ר/המפעיל.\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")
|
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
return jp, mp
|
return jp, mp
|
||||||
|
|
||||||
lines += ["## המלצה per-בלוק (distance נמוך = קרוב יותר לדפנה)\n",
|
lines += ["## המלצה per-בלוק (style-clean: anti_pattern_total ראשי — #213)\n",
|
||||||
"| block | section | current | **recommended** | n |", "|---|---|---|---|---|"]
|
"| block | section | current | **recommended** | confidence | n |",
|
||||||
|
"|---|---|---|---|---|---|"]
|
||||||
for b, bd in result["by_block"].items():
|
for b, bd in result["by_block"].items():
|
||||||
rec = bd.get("recommended") or "—"
|
rec = bd.get("recommended") or "—"
|
||||||
mark = "" if rec == bd.get("current_default") else " ⬅︎"
|
mark = "" if rec == bd.get("current_default") else " ⬅︎"
|
||||||
|
conf = bd.get("confidence")
|
||||||
|
conf_cell = {"clear": "clear", "weak": "⚠️ weak (within-noise)"}.get(conf, "—")
|
||||||
n_b = bd["efforts"][0]["n"] if bd.get("efforts") else 0
|
n_b = bd["efforts"][0]["n"] if bd.get("efforts") else 0
|
||||||
lines.append(f"| {b} | {bd.get('section','')} | {bd.get('current_default') or '—'} | **{rec}**{mark} | {n_b} |")
|
lines.append(
|
||||||
|
f"| {b} | {bd.get('section','')} | {bd.get('current_default') or '—'} | "
|
||||||
|
f"**{rec}**{mark} | {conf_cell} | {n_b} |")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
for b, bd in result["by_block"].items():
|
for b, bd in result["by_block"].items():
|
||||||
lines += [f"### {b} ({bd.get('section','')})\n",
|
lines += [f"### {b} ({bd.get('section','')})\n",
|
||||||
@@ -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['effort']}{star} | {r['distance']:.4f} | {r['anti_pattern_total']} | "
|
||||||
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
f"{r['change_percent']} | {ratio if ratio is not None else '—'} | {r['n']} |")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("> change% מערבב סגנון עם שלמות-תוכן (07-learning §0.7); "
|
lines.append("> דירוג-ההמלצה **style-clean** (#213): anti_total ראשי → ratioΔ → distance (tiebreak). "
|
||||||
"anti_total הוא הסיגנל הנקי-יותר לסגנון.\n")
|
"**change% מדווח-לא-מדורג** — מערבב סגנון עם שלמות-תוכן (07-learning §0.7), "
|
||||||
|
"anti_total הוא הסיגנל הנקי-לסגנון. confidence=⚠️weak ⇒ הבחירה בתוך-הרעש "
|
||||||
|
"(ההובלה ב-anti קטנה מ-epsilon = max(0.5, 0.20·spread)).\n")
|
||||||
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
mp.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
return jp, mp
|
return jp, mp
|
||||||
|
|
||||||
@@ -374,6 +624,8 @@ def _write_report(result: dict, ts: str) -> tuple[Path, Path]:
|
|||||||
async def main() -> int:
|
async def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description="#208 model/effort calibration harness")
|
ap = argparse.ArgumentParser(description="#208 model/effort calibration harness")
|
||||||
ap.add_argument("--self-test", action="store_true", help="offline measurement-logic proof (no DB/CLI)")
|
ap.add_argument("--self-test", action="store_true", help="offline measurement-logic proof (no DB/CLI)")
|
||||||
|
ap.add_argument("--rerank", metavar="REPORT.json", default=None,
|
||||||
|
help="re-rank a saved calibration JSON under the current style-clean key (no LLM/DB)")
|
||||||
ap.add_argument("--dry-run", action="store_true", help="plan the A/B grid over existing finals, no model calls")
|
ap.add_argument("--dry-run", action="store_true", help="plan the A/B grid over existing finals, no model calls")
|
||||||
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
|
ap.add_argument("--efforts", default=",".join(DEFAULT_EFFORTS),
|
||||||
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
|
help=f"comma effort grid (default {','.join(DEFAULT_EFFORTS)})")
|
||||||
@@ -383,22 +635,27 @@ async def main() -> int:
|
|||||||
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
|
ap.add_argument("--repeats", type=int, default=1, help="generations per cell (avg out gen noise)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
|
||||||
if args.self_test:
|
if args.self_test:
|
||||||
return _self_test()
|
return _self_test()
|
||||||
|
|
||||||
|
if args.rerank:
|
||||||
|
return _do_rerank(Path(args.rerank))
|
||||||
|
|
||||||
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
|
args.efforts = [e.strip() for e in args.efforts.split(",") if e.strip()]
|
||||||
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
|
bad = [e for e in args.efforts if e not in VALID_EFFORTS]
|
||||||
if bad:
|
if bad:
|
||||||
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
|
print(f"invalid effort(s): {bad}. valid: {sorted(VALID_EFFORTS)}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
args.blocks = [b.strip() for b in args.blocks.split(",") if b.strip()]
|
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:
|
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
|
return 2
|
||||||
|
|
||||||
result = await _run(args)
|
|
||||||
ts = _ts()
|
ts = _ts()
|
||||||
|
result = await _run(args, ts)
|
||||||
jp, mp = _write_report(result, ts)
|
jp, mp = _write_report(result, ts)
|
||||||
|
|
||||||
g = result["grid"]
|
g = result["grid"]
|
||||||
@@ -410,7 +667,8 @@ async def main() -> int:
|
|||||||
else:
|
else:
|
||||||
for b, bd in result["by_block"].items():
|
for b, bd in result["by_block"].items():
|
||||||
print(f" {b:16} current={bd.get('current_default') or '—':6} "
|
print(f" {b:16} current={bd.get('current_default') or '—':6} "
|
||||||
f"→ recommended={bd.get('recommended') or '—'}")
|
f"→ recommended={bd.get('recommended') or '—':6} "
|
||||||
|
f"[{bd.get('confidence') or '—'}]")
|
||||||
print(f" report: {mp}")
|
print(f" report: {mp}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ description: This skill should be used when writing legal decisions (החלטו
|
|||||||
|
|
||||||
**ערר היטל השבחה — הכרעה מפוצלת (שמאי מייעץ):**
|
**ערר היטל השבחה — הכרעה מפוצלת (שמאי מייעץ):**
|
||||||
תת-מסלול חוזר: הוועדה מאשרת את עצם החבות אך אינה קובעת את גובה ההיטל — ממנה שמאי מייעץ. פתיחה: זהה למסלול הכללי — "עניינו של ערר זה בדרישת תשלום היטל השבחה...". ללא סיפור תכנוני רחב. דיון — שלב משפטי: הכרעה בשאלת עצם החבות. ניתוח הטענה המרכזית (בד"כ "זכות מוקנית") מול ההקלה שהתבקשה. ציטוטי פסיקה inline — הפניה להחלטות ועדת ערר קודמות ללא בלוק ציטוט מלא. ביטויי מפתח: "אנו סבורים כי...", "בניגוד לעמדת העורר...", "לא ניתן לטעון כי...". מעבר לשלב השמאי — 3 ביטויי גישור: (א) "בכל הנוגע לטענות לגבי מקדמים... וכל טענה בעלת אופי שמאי **ניתן יהיה לעלות בפני השמאי המייעץ**." (ב) "על כן, לאור האמור **אנו ממנים שמאי מייעץ** אשר יערוך שומה להערכת ההשבחה במקרקעין כתוצאה מאישור ההקלה..." (ג) "השמאי המייעץ ינהל את הדיון **בהתאם לתקנות התכנון והבניה (סדרי דין בבקשה להכרעה לפני שמאי מכריע או שמאי מייעץ), התשס"ט-2008**." — נוסחה קבועה. הוראות המשך: "לאחר קבלת השומה המייעצת יהיו רשאים הצדדים להגיש את השגותיהם בתוך 30 יום לוועדת הערר ולאחר מכן תתקבל החלטה באשר לאופן קידום ההליך." סיום: **ללא** כותרת "סיכום" / "סוף דבר" — זורם ישירות מהוראות המינוי לחתימה "ניתנה פה אחד היום...". הוצאות: לא מוזכרות (ההליך טרם הסתיים). ראה: נווה יעקב 8070/25.
|
תת-מסלול חוזר: הוועדה מאשרת את עצם החבות אך אינה קובעת את גובה ההיטל — ממנה שמאי מייעץ. פתיחה: זהה למסלול הכללי — "עניינו של ערר זה בדרישת תשלום היטל השבחה...". ללא סיפור תכנוני רחב. דיון — שלב משפטי: הכרעה בשאלת עצם החבות. ניתוח הטענה המרכזית (בד"כ "זכות מוקנית") מול ההקלה שהתבקשה. ציטוטי פסיקה inline — הפניה להחלטות ועדת ערר קודמות ללא בלוק ציטוט מלא. ביטויי מפתח: "אנו סבורים כי...", "בניגוד לעמדת העורר...", "לא ניתן לטעון כי...". מעבר לשלב השמאי — 3 ביטויי גישור: (א) "בכל הנוגע לטענות לגבי מקדמים... וכל טענה בעלת אופי שמאי **ניתן יהיה לעלות בפני השמאי המייעץ**." (ב) "על כן, לאור האמור **אנו ממנים שמאי מייעץ** אשר יערוך שומה להערכת ההשבחה במקרקעין כתוצאה מאישור ההקלה..." (ג) "השמאי המייעץ ינהל את הדיון **בהתאם לתקנות התכנון והבניה (סדרי דין בבקשה להכרעה לפני שמאי מכריע או שמאי מייעץ), התשס"ט-2008**." — נוסחה קבועה. הוראות המשך: "לאחר קבלת השומה המייעצת יהיו רשאים הצדדים להגיש את השגותיהם בתוך 30 יום לוועדת הערר ולאחר מכן תתקבל החלטה באשר לאופן קידום ההליך." סיום: **ללא** כותרת "סיכום" / "סוף דבר" — זורם ישירות מהוראות המינוי לחתימה "ניתנה פה אחד היום...". הוצאות: לא מוזכרות (ההליך טרם הסתיים). ראה: נווה יעקב 8070/25.
|
||||||
|
*הכרעות-מהות בתת-מסלול זה (מחיר-למשתכן / שומה סמוכה — 8125-09-24, ראה `docs/legal-decision-lessons.md` #47–#50):* (א) "הסבת שטחים" ממחיר-למשתכן לשוק-חופשי בעקבות הקלת תוספת יח"ד = **השבחה תכנונית בת-חיוב** — להכריע בחיוב, לא לגדֵר כשאלה פתוחה או להטיל ספק מכוח קשר-סיבתי (בית הכרם/פמיני). (ב) שומה מכרעת חלוטה סמוכה = **משכנעת, לא מחייבת**; שומה מנומקת רשאית לסטות ("לא נוכל להיאחז בשומה מכרעת אחרת רק מכיוון והיא קיימת") — לא להעמיס דוקטרינת שוויון לטובת העוררת. (ג) שמאי מכריע פרש → מינוי **שמאי מייעץ חדש** (סמכות אשקלוני/גנדליס).
|
||||||
|
|
||||||
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
|
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
|
||||||
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:
|
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ApprovalCategory,
|
type ApprovalCategory,
|
||||||
type ApprovalSeverity,
|
type ApprovalSeverity,
|
||||||
} from "@/lib/api/chair";
|
} from "@/lib/api/chair";
|
||||||
|
import { formatDateLong } from "@/lib/format-date";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* מרכז אישורים — דפנה (INV-G10).
|
* מרכז אישורים — דפנה (INV-G10).
|
||||||
@@ -35,18 +36,7 @@ const SEVERITY_RAIL: Record<ApprovalSeverity, string> = {
|
|||||||
ok: "border-s-success",
|
ok: "border-s-success",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(iso?: string | null): string {
|
const formatDate = (iso?: string | null) => formatDateLong(iso);
|
||||||
if (!iso) return "";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("he-IL", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
|
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
|
||||||
const cleared = cat.count === 0;
|
const cleared = cat.count === 0;
|
||||||
|
|||||||
@@ -28,19 +28,7 @@ import {
|
|||||||
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
|
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
|
||||||
import { subtypeOf } from "@/components/cases/appeal-type-bars";
|
import { subtypeOf } from "@/components/cases/appeal-type-bars";
|
||||||
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
|
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
|
||||||
|
import { formatDate, getIsraelYear } from "@/lib/format-date";
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
|
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
|
||||||
const TYPE_CHIP: Record<string, string> = {
|
const TYPE_CHIP: Record<string, string> = {
|
||||||
@@ -177,8 +165,8 @@ export default function ArchivePage() {
|
|||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const c of rows) {
|
for (const c of rows) {
|
||||||
if (!c.archived_at) continue;
|
if (!c.archived_at) continue;
|
||||||
const y = new Date(c.archived_at).getFullYear();
|
const y = getIsraelYear(c.archived_at);
|
||||||
if (!Number.isNaN(y)) set.add(String(y));
|
if (y !== null) set.add(String(y));
|
||||||
}
|
}
|
||||||
return [...set].sort((a, b) => Number(b) - Number(a));
|
return [...set].sort((a, b) => Number(b) - Number(a));
|
||||||
}, [rows]);
|
}, [rows]);
|
||||||
@@ -209,7 +197,7 @@ export default function ArchivePage() {
|
|||||||
if (yearFilter !== "all") {
|
if (yearFilter !== "all") {
|
||||||
all = all.filter((r) => {
|
all = all.filter((r) => {
|
||||||
const iso = r.original.archived_at;
|
const iso = r.original.archived_at;
|
||||||
return iso != null && String(new Date(iso).getFullYear()) === yearFilter;
|
return iso != null && String(getIsraelYear(iso)) === yearFilter;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return all;
|
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,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use } from "react";
|
import { use, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { CaseHeader } from "@/components/cases/case-header";
|
import { CaseHeader } from "@/components/cases/case-header";
|
||||||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
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 { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||||||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||||||
|
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||||||
|
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
||||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||||
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
||||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
|
||||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
|
||||||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Play, Loader2 } from "lucide-react";
|
import {
|
||||||
|
Play, Loader2, LayoutGrid, Scale, BadgeCheck, FileText, MessageSquare, Users,
|
||||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
type LucideIcon,
|
||||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
} from "lucide-react";
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Next 16 breaking change: route params are now a Promise.
|
* Next 16 breaking change: route params are now a Promise.
|
||||||
@@ -39,12 +38,10 @@ export default function CaseDetailPage({
|
|||||||
params: Promise<{ caseNumber: string }>;
|
params: Promise<{ caseNumber: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { caseNumber } = use(params);
|
const { caseNumber } = use(params);
|
||||||
|
const [tab, setTab] = useState("overview");
|
||||||
const { data, isPending, error, refetch } = useCase(caseNumber);
|
const { data, isPending, error, refetch } = useCase(caseNumber);
|
||||||
const startWorkflow = useStartWorkflow(caseNumber);
|
const startWorkflow = useStartWorkflow(caseNumber);
|
||||||
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
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
|
// 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.
|
// 5xx on the 5s background refetch must not blow away an already-loaded page.
|
||||||
@@ -71,23 +68,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 = (
|
const tabsList = (
|
||||||
<TabsList
|
<TabsList
|
||||||
variant="line"
|
variant="line"
|
||||||
className="gap-6 h-auto p-0 rounded-none -mb-px"
|
className="!h-auto w-full gap-2 p-0 justify-between"
|
||||||
>
|
>
|
||||||
{[
|
{tabDefs.map(([value, label, Icon]) => (
|
||||||
["overview", "סקירה"],
|
|
||||||
["arguments", "טיעונים"],
|
|
||||||
["decision", "ההחלטה"],
|
|
||||||
["drafts", "טיוטות והערות"],
|
|
||||||
["agents", "סוכנים"],
|
|
||||||
].map(([value, label]) => (
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={value}
|
key={value}
|
||||||
value={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}
|
{label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
@@ -98,9 +104,6 @@ export default function CaseDetailPage({
|
|||||||
<>
|
<>
|
||||||
{data && <CaseEditDialog data={data} />}
|
{data && <CaseEditDialog data={data} />}
|
||||||
<UploadSheet caseNumber={caseNumber} />
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
<Button asChild className="bg-gold text-white hover:bg-gold-deep border-transparent">
|
|
||||||
<Link href={`/cases/${caseNumber}/compose`}>פתח עורך החלטה</Link>
|
|
||||||
</Button>
|
|
||||||
{canStartWorkflow && (
|
{canStartWorkflow && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
||||||
@@ -126,7 +129,7 @@ export default function CaseDetailPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Tabs defaultValue="overview" dir="rtl">
|
<Tabs value={tab} onValueChange={setTab} dir="rtl">
|
||||||
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
|
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
|
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
|
||||||
@@ -135,23 +138,57 @@ export default function CaseDetailPage({
|
|||||||
<Skeleton className="h-6 w-96" />
|
<Skeleton className="h-6 w-96" />
|
||||||
</div>
|
</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) */}
|
{/* full-width tab content — status now lives in the always-visible band
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start mt-6">
|
StatusRail (banner A, X17), so every tab keeps the status context. */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 mt-6">
|
||||||
<TabsContent value="overview" className="mt-0 space-y-5">
|
<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} />
|
<DocumentsPanel data={data} />
|
||||||
|
|
||||||
<AgentActivityPreview caseNumber={caseNumber} />
|
<AgentActivityPreview caseNumber={caseNumber} />
|
||||||
{/* decision-editor CTA moved to the band actions (visible on all tabs) */}
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="arguments" className="mt-0">
|
{/* טיעונים ועמדות — 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">
|
||||||
<Card className="bg-surface border-rule shadow-sm">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5">
|
||||||
|
<PositionsPanel caseNumber={caseNumber} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<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} />
|
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* אימות פסיקה — 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -180,32 +217,6 @@ export default function CaseDetailPage({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</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 />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type ChairFeedback,
|
type ChairFeedback,
|
||||||
type FeedbackCategory,
|
type FeedbackCategory,
|
||||||
} from "@/lib/api/feedback";
|
} 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",
|
other: "bg-rule-soft text-ink-soft",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(iso?: string | null): string {
|
const formatDate = (iso?: string | null) => formatDateLong(iso);
|
||||||
if (!iso) return "";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("he-IL", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
|
function FeedbackCard({ fb }: { fb: ChairFeedback }) {
|
||||||
const resolve = useResolveFeedback();
|
const resolve = useResolveFeedback();
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
type SubscriptionUsage,
|
type SubscriptionUsage,
|
||||||
type UsageWindow,
|
type UsageWindow,
|
||||||
} from "@/lib/api/operations";
|
} from "@/lib/api/operations";
|
||||||
|
import { formatTime, israelParts } from "@/lib/format-date";
|
||||||
|
|
||||||
function mb(bytes: number): string {
|
function mb(bytes: number): string {
|
||||||
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
|
||||||
@@ -46,8 +47,7 @@ function mb(bytes: number): string {
|
|||||||
// and resumes on its own when a window resets.
|
// and resumes on its own when a window resets.
|
||||||
function usageResetLabel(iso: string | null): string {
|
function usageResetLabel(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
return `איפוס ${formatTime(iso)}`;
|
||||||
return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
|
function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) {
|
||||||
@@ -195,8 +195,9 @@ function nextSaturday18(): Date {
|
|||||||
}
|
}
|
||||||
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
|
const HE_DAYS = ["א׳", "ב׳", "ג׳", "ד׳", "ה׳", "ו׳", "ש׳"];
|
||||||
function fmtDeadline(iso: string): string {
|
function fmtDeadline(iso: string): string {
|
||||||
const d = new Date(iso);
|
const p = israelParts(iso);
|
||||||
return `${HE_DAYS[d.getDay()]} ${pad2(d.getDate())}/${pad2(d.getMonth() + 1)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
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 }) {
|
function BurstControl({ s }: { s: OpsService }) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
useRunScript,
|
useRunScript,
|
||||||
type ScriptRunResult,
|
type ScriptRunResult,
|
||||||
} from "@/lib/api/scripts";
|
} from "@/lib/api/scripts";
|
||||||
|
import { formatDateLong } from "@/lib/format-date";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* /scripts — catalog of everything under scripts/, rendered as the
|
* /scripts — catalog of everything under scripts/, rendered as the
|
||||||
@@ -354,11 +355,7 @@ export default function ScriptsPage() {
|
|||||||
|
|
||||||
const lastModified =
|
const lastModified =
|
||||||
data?.last_modified != null
|
data?.last_modified != null
|
||||||
? new Date(data.last_modified * 1000).toLocaleDateString("he-IL", {
|
? formatDateLong(data.last_modified * 1000)
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const giteaBase = data?.gitea_url ?? null;
|
const giteaBase = data?.gitea_url ?? null;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type DriftEntry,
|
type DriftEntry,
|
||||||
type PaperclipAgent,
|
type PaperclipAgent,
|
||||||
} from "@/lib/api/paperclip-agents";
|
} from "@/lib/api/paperclip-agents";
|
||||||
|
import { formatDateShort } from "@/lib/format-date";
|
||||||
|
|
||||||
const ROLE_LABEL: Record<string, string> = {
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
ceo: "CEO",
|
ceo: "CEO",
|
||||||
@@ -211,7 +212,7 @@ function PairCard({ pair }: { pair: AgentPair }) {
|
|||||||
</Button>
|
</Button>
|
||||||
{pair.master?.updated_at && (
|
{pair.master?.updated_at && (
|
||||||
<span className="text-[0.7rem] text-ink-light">
|
<span className="text-[0.7rem] text-ink-light">
|
||||||
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
|
עודכן: {formatDateShort(pair.master.updated_at)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useSkills, type Skill } from "@/lib/api/skills";
|
import { useSkills, type Skill } from "@/lib/api/skills";
|
||||||
|
import { formatIsoDate } from "@/lib/format-date";
|
||||||
|
|
||||||
function formatChars(skill: Skill): string {
|
function formatChars(skill: Skill): string {
|
||||||
// Mockup column = "גודל (תווים)" — the DB markdown char count, grouped.
|
// Mockup column = "גודל (תווים)" — the DB markdown char count, grouped.
|
||||||
@@ -21,13 +22,8 @@ function formatChars(skill: Skill): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatUpdated(iso: string | null): string {
|
function formatUpdated(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
// ISO-style date to match the mockup (2026-06-09), tabular — Israel-time day.
|
||||||
try {
|
return formatIsoDate(iso);
|
||||||
// ISO-style date to match the mockup (2026-06-09), tabular.
|
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
|
||||||
} catch {
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useAgentActivity,
|
useAgentActivity,
|
||||||
useSendComment,
|
useSendComment,
|
||||||
useSubmitInteraction,
|
useSubmitInteraction,
|
||||||
|
useDismissInteraction,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
import type {
|
import type {
|
||||||
Interaction,
|
Interaction,
|
||||||
@@ -16,7 +17,10 @@ import type {
|
|||||||
InteractionQuestion,
|
InteractionQuestion,
|
||||||
InteractionTask,
|
InteractionTask,
|
||||||
PaperclipComment,
|
PaperclipComment,
|
||||||
|
PaperclipAgentStatus,
|
||||||
|
PaperclipIssue,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
|
import { formatRelative } from "@/lib/format-date";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
@@ -28,6 +32,8 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
ChevronDown,
|
||||||
|
Ban,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ── Role → color mapping ────────────────────────────────────── */
|
/* ── Role → color mapping ────────────────────────────────────── */
|
||||||
@@ -80,18 +86,83 @@ function issueStatusLabel(status: string) {
|
|||||||
return ISSUE_STATUS_LABELS[status] ?? status;
|
return ISSUE_STATUS_LABELS[status] ?? status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Agent roster (mockup 18i) — who's doing what right now ────── */
|
||||||
|
|
||||||
|
const AGENT_STATUS_LABEL: Record<string, string> = {
|
||||||
|
active: "פעיל",
|
||||||
|
running: "פעיל",
|
||||||
|
idle: "ממתין",
|
||||||
|
error: "שגיאה",
|
||||||
|
};
|
||||||
|
|
||||||
|
function agentIsActive(s: string) {
|
||||||
|
return s === "active" || s === "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentStatusDot(s: string) {
|
||||||
|
return agentIsActive(s)
|
||||||
|
? "bg-success shadow-[0_0_6px_rgba(74,124,89,0.6)]"
|
||||||
|
: s === "error"
|
||||||
|
? "bg-danger"
|
||||||
|
: "bg-rule";
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentStatusTone(s: string) {
|
||||||
|
return agentIsActive(s)
|
||||||
|
? "bg-success-bg text-success"
|
||||||
|
: s === "error"
|
||||||
|
? "bg-danger-bg text-danger"
|
||||||
|
: "bg-rule-soft text-ink-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
|
||||||
|
if (!agents.length) return null;
|
||||||
|
|
||||||
|
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
|
<Bot className="w-4 h-4 text-gold-deep shrink-0" />
|
||||||
|
<h3 className="text-[0.82rem] font-bold text-navy">סוכני התיק</h3>
|
||||||
|
<span className="text-[0.72rem] text-ink-muted">
|
||||||
|
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Compact roster — all agents on one row (mockup 18i v2): status · name ·
|
||||||
|
role only. The per-agent "current activity" line was removed: it
|
||||||
|
matched comments by role-fallback, so agents sharing a role showed the
|
||||||
|
same text. */}
|
||||||
|
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
|
||||||
|
{agents.map((a) => {
|
||||||
|
const active = agentIsActive(a.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
||||||
|
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
|
||||||
|
<span
|
||||||
|
className={`inline-block mt-1 text-[0.55rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
|
||||||
|
>
|
||||||
|
{AGENT_STATUS_LABEL[a.status] ?? a.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Time formatting ─────────────────────────────────────────── */
|
/* ── Time formatting ─────────────────────────────────────────── */
|
||||||
|
|
||||||
function timeAgo(iso: string | null): string {
|
function timeAgo(iso: string | null): string {
|
||||||
if (!iso) return "";
|
return formatRelative(iso, { units: "short", fallback: "" });
|
||||||
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} ימים`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Issue identifier → find matching identifier ─────────────── */
|
/* ── Issue identifier → find matching identifier ─────────────── */
|
||||||
@@ -513,15 +584,21 @@ function InteractionCard({
|
|||||||
interaction,
|
interaction,
|
||||||
caseNumber,
|
caseNumber,
|
||||||
issueMap,
|
issueMap,
|
||||||
|
defaultOpen = true,
|
||||||
}: {
|
}: {
|
||||||
interaction: Interaction;
|
interaction: Interaction;
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
issueMap: Map<string, string>;
|
issueMap: Map<string, string>;
|
||||||
|
defaultOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const submit = useSubmitInteraction(caseNumber);
|
const submit = useSubmitInteraction(caseNumber);
|
||||||
|
const dismiss = useDismissInteraction(caseNumber);
|
||||||
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
||||||
const isPending = interaction.status === "pending";
|
const isPending = interaction.status === "pending";
|
||||||
const summary = summaryAnswer(interaction);
|
const summary = summaryAnswer(interaction);
|
||||||
|
// Pending interactions collapse into an accordion (mockup 18i v2); resolved
|
||||||
|
// ones always render their one-line summary.
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
|
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
|
||||||
submit.mutate(
|
submit.mutate(
|
||||||
@@ -538,6 +615,16 @@ function InteractionCard({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
dismiss.mutate(
|
||||||
|
{ issue_id: interaction.issue_id, interaction_id: interaction.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("הבקשה בוטלה"),
|
||||||
|
onError: () => toast.error("שגיאה בביטול הבקשה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
||||||
@@ -560,15 +647,30 @@ function InteractionCard({
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
{isPending ? (
|
||||||
|
<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">
|
<span className="text-sm font-semibold text-navy">
|
||||||
{interaction.title || "שאלה לסוכן"}
|
{interaction.title || "שאלה לסוכן"}
|
||||||
</span>
|
</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 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>
|
</span>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-semibold text-navy">
|
||||||
|
{interaction.title || "שאלה לסוכן"}
|
||||||
|
</span>
|
||||||
<ResolvedBadge status={interaction.status} />
|
<ResolvedBadge status={interaction.status} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{identifier && (
|
{identifier && (
|
||||||
<Badge variant="outline" className="text-[10px] font-mono">
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
@@ -579,13 +681,31 @@ function InteractionCard({
|
|||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
{isPending && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
disabled={dismiss.isPending}
|
||||||
|
className="h-7 px-2 text-ink-muted hover:text-danger"
|
||||||
|
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
|
||||||
|
>
|
||||||
|
{dismiss.isPending ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Ban className="w-3.5 h-3.5 me-1" />
|
||||||
|
)}
|
||||||
|
התעלם
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{interaction.summary && (
|
{interaction.summary && (!isPending || open) && (
|
||||||
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
open ? (
|
||||||
interaction.kind === "ask_user_questions" ? (
|
interaction.kind === "ask_user_questions" ? (
|
||||||
<AskUserQuestionsForm
|
<AskUserQuestionsForm
|
||||||
interaction={interaction}
|
interaction={interaction}
|
||||||
@@ -613,6 +733,7 @@ function InteractionCard({
|
|||||||
pending={submit.isPending}
|
pending={submit.isPending}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
|
) : null
|
||||||
) : summary ? (
|
) : summary ? (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<Markdown content={summary} />
|
<Markdown content={summary} />
|
||||||
@@ -623,6 +744,80 @@ 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 closed = issue.status === "done" || issue.status === "cancelled";
|
||||||
|
return (
|
||||||
|
<div className="border-b border-rule-soft last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant={closed ? "secondary" : "default"}
|
||||||
|
className="text-[10px] font-mono shrink-0"
|
||||||
|
>
|
||||||
|
{issue.identifier}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[0.78rem] font-semibold text-navy truncate">
|
||||||
|
{shortIssueTitle(issue.title)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||||||
|
{issueStatusLabel(issue.status)} · {items.length}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function AgentActivityFeed({
|
export function AgentActivityFeed({
|
||||||
@@ -696,24 +891,46 @@ export function AgentActivityFeed({
|
|||||||
|
|
||||||
const comments = data.comments ?? [];
|
const comments = data.comments ?? [];
|
||||||
const interactions = data.interactions ?? [];
|
const interactions = data.interactions ?? [];
|
||||||
|
// Pending interactions are surfaced at the top (awaiting-you, mockup 18i) and
|
||||||
|
// NOT repeated in the timeline below.
|
||||||
|
const pending = interactions.filter((i) => i.status === "pending");
|
||||||
|
|
||||||
// Unified, time-sorted feed: comments + interactions interleaved.
|
// Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
|
||||||
type FeedItem =
|
// resolved interactions; pending ones are surfaced above and not repeated.
|
||||||
| { kind: "comment"; at: number; comment: PaperclipComment }
|
const itemsByIssue = new Map<string, FeedItem[]>();
|
||||||
| { kind: "interaction"; at: number; interaction: Interaction };
|
const pushItem = (issueId: string, item: FeedItem) => {
|
||||||
|
const arr = itemsByIssue.get(issueId);
|
||||||
const feed: FeedItem[] = [
|
if (arr) arr.push(item);
|
||||||
...comments.map<FeedItem>((c) => ({
|
else itemsByIssue.set(issueId, [item]);
|
||||||
|
};
|
||||||
|
for (const c of comments) {
|
||||||
|
pushItem(c.issue_id, {
|
||||||
kind: "comment",
|
kind: "comment",
|
||||||
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
||||||
comment: c,
|
comment: c,
|
||||||
})),
|
});
|
||||||
...interactions.map<FeedItem>((i) => ({
|
}
|
||||||
|
for (const i of interactions) {
|
||||||
|
if (i.status === "pending") continue;
|
||||||
|
pushItem(i.issue_id, {
|
||||||
kind: "interaction",
|
kind: "interaction",
|
||||||
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
||||||
interaction: i,
|
interaction: i,
|
||||||
})),
|
});
|
||||||
].sort((a, b) => a.at - b.at);
|
}
|
||||||
|
for (const arr of itemsByIssue.values()) arr.sort((a, b) => a.at - b.at);
|
||||||
|
|
||||||
|
// Order groups by most-recent activity so live work floats to the top.
|
||||||
|
const issueGroups = data.issues
|
||||||
|
.map((iss) => {
|
||||||
|
const items = itemsByIssue.get(iss.id) ?? [];
|
||||||
|
const maxAt = items.reduce((m, it) => Math.max(m, it.at), 0);
|
||||||
|
return { iss, items, maxAt };
|
||||||
|
})
|
||||||
|
.filter((g) => g.items.length > 0)
|
||||||
|
.sort((a, b) => b.maxAt - a.maxAt);
|
||||||
|
|
||||||
|
const totalFeedItems = issueGroups.reduce((n, g) => n + g.items.length, 0);
|
||||||
|
|
||||||
// An issue is "active" if it's not done/cancelled. When everything is closed
|
// An issue is "active" if it's not done/cancelled. When everything is closed
|
||||||
// we should NOT show the "agents are working, waiting for report" spinner.
|
// we should NOT show the "agents are working, waiting for report" spinner.
|
||||||
@@ -723,22 +940,43 @@ export function AgentActivityFeed({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Issue summary bar */}
|
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
|
<AgentRoster agents={data.agents ?? []} />
|
||||||
{data.issues.map((iss) => (
|
|
||||||
<Badge
|
{/* pending interactions surfaced to the top — each an accordion, first
|
||||||
key={iss.id}
|
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
|
||||||
variant={iss.status === "done" ? "secondary" : "default"}
|
{pending.length > 0 && (
|
||||||
className="text-[11px] font-mono"
|
<div className="mb-3 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">
|
||||||
{iss.identifier} — {issueStatusLabel(iss.status)}
|
<Clock className="w-4 h-4" />
|
||||||
</Badge>
|
ממתין לתשובתך
|
||||||
|
<span className="text-[0.7rem] font-semibold bg-amber-100 rounded-full px-2 py-0.5">
|
||||||
|
{pending.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{pending.map((i, idx) => (
|
||||||
|
<InteractionCard
|
||||||
|
key={`pending-${i.id}`}
|
||||||
|
interaction={i}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
issueMap={issueMap}
|
||||||
|
defaultOpen={idx === 0}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Comments + interactions stream */}
|
{/* Timeline header */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
|
||||||
{feed.length === 0 ? (
|
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||||
|
<span className="text-[0.72rem] text-ink-muted">
|
||||||
|
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-issue accordions */}
|
||||||
|
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||||
|
{totalFeedItems === 0 ? (
|
||||||
hasActiveIssue ? (
|
hasActiveIssue ? (
|
||||||
<div className="text-center py-8 text-ink-faint text-sm">
|
<div className="text-center py-8 text-ink-faint text-sm">
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
@@ -751,22 +989,19 @@ export function AgentActivityFeed({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
feed.map((item) =>
|
issueGroups.map(({ iss, items }, idx) => (
|
||||||
item.kind === "comment" ? (
|
<IssueGroup
|
||||||
<CommentCard
|
key={iss.id}
|
||||||
key={`c-${item.comment.id}`}
|
issue={iss}
|
||||||
comment={item.comment}
|
items={items}
|
||||||
issueMap={issueMap}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<InteractionCard
|
|
||||||
key={`i-${item.interaction.id}`}
|
|
||||||
interaction={item.interaction}
|
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
issueMap={issueMap}
|
issueMap={issueMap}
|
||||||
|
defaultOpen={
|
||||||
|
idx === 0 ||
|
||||||
|
(iss.status !== "done" && iss.status !== "cancelled")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
))
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
<div ref={endRef} />
|
<div ref={endRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { Loader2, CheckCircle2, Clock } from "lucide-react";
|
import { Loader2, CheckCircle2, Clock } from "lucide-react";
|
||||||
import { useAgentActivity } from "@/lib/api/agents";
|
import { useAgentActivity } from "@/lib/api/agents";
|
||||||
import type { PaperclipIssue } from "@/lib/api/agents";
|
import type { PaperclipIssue } from "@/lib/api/agents";
|
||||||
|
import { formatRelative } from "@/lib/format-date";
|
||||||
|
|
||||||
const STATUS_DOT: Record<string, string> = {
|
const STATUS_DOT: Record<string, string> = {
|
||||||
in_progress: "bg-gold",
|
in_progress: "bg-gold",
|
||||||
@@ -22,13 +23,7 @@ const STATUS_LABEL: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function timeAgo(iso: string | null): string {
|
function timeAgo(iso: string | null): string {
|
||||||
if (!iso) return "—";
|
return formatRelative(iso, { units: "tight", fallback: "—" });
|
||||||
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)} ימים`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueRow({ issue }: { issue: PaperclipIssue }) {
|
function IssueRow({ issue }: { issue: PaperclipIssue }) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { StatusBadge } from "@/components/cases/status-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 { CaseArchiveAction } from "@/components/cases/case-archive-action";
|
||||||
import { CreateRepoButton } from "@/components/cases/create-repo-button";
|
import { CreateRepoButton } from "@/components/cases/create-repo-button";
|
||||||
import {
|
import {
|
||||||
@@ -10,30 +10,14 @@ import {
|
|||||||
APPEAL_SUBTYPE_LABELS,
|
APPEAL_SUBTYPE_LABELS,
|
||||||
isBlamSubtype,
|
isBlamSubtype,
|
||||||
} from "@/lib/practice-area";
|
} from "@/lib/practice-area";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
import type { CaseDetail } from "@/lib/api/cases";
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
function 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
|
* Case header — parchment band (IA-redesign mockup 17): full-bleed band with
|
||||||
* the case title + status/type chips inline, a parties line, the case actions
|
* the case title + status/type chips inline, a parties line, the case actions
|
||||||
@@ -41,15 +25,19 @@ function partiesLine(data?: CaseDetail): string | null {
|
|||||||
* the tab strip inside the band, anchored to its bottom edge.
|
* the tab strip inside the band, anchored to its bottom edge.
|
||||||
*/
|
*/
|
||||||
export function CaseHeader({
|
export function CaseHeader({
|
||||||
|
caseNumber,
|
||||||
data,
|
data,
|
||||||
actions,
|
actions,
|
||||||
tabs,
|
tabs,
|
||||||
}: {
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
data?: CaseDetail;
|
data?: CaseDetail;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
tabs?: ReactNode;
|
tabs?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const parties = partiesLine(data);
|
const appellants = data?.appellants?.filter(Boolean) ?? [];
|
||||||
|
const respondents = data?.respondents?.filter(Boolean) ?? [];
|
||||||
|
const hasParties = appellants.length > 0 || respondents.length > 0;
|
||||||
const isBlam =
|
const isBlam =
|
||||||
data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype);
|
data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype);
|
||||||
|
|
||||||
@@ -72,6 +60,12 @@ export function CaseHeader({
|
|||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
|
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
|
{/* case subject on the same line, smaller font (chair request) */}
|
||||||
|
{data?.title && (
|
||||||
|
<span className="text-navy/75 text-lg font-semibold leading-snug">
|
||||||
|
{data.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{data?.status && <StatusBadge status={data.status} />}
|
{data?.status && <StatusBadge status={data.status} />}
|
||||||
{data?.archived_at && (
|
{data?.archived_at && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -103,27 +97,44 @@ export function CaseHeader({
|
|||||||
)}
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* case title / subject under the heading */}
|
{/* case actions — parties now live in a popover (chair request) so
|
||||||
{data?.title && (
|
they don't push the tab content down the page. */}
|
||||||
<p className="text-navy/90 text-base font-semibold mt-2 max-w-3xl leading-snug">
|
|
||||||
{data.title}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{/* parties line (mockup .parties) — clamped to 2 lines so a long list
|
|
||||||
of appellants/respondents doesn't grow the band; full text on hover. */}
|
|
||||||
{parties ? (
|
|
||||||
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl line-clamp-2" title={parties}>
|
|
||||||
{parties}
|
|
||||||
</p>
|
|
||||||
) : data?.subject ? (
|
|
||||||
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl leading-relaxed line-clamp-2"
|
|
||||||
title={data.subject}>
|
|
||||||
{data.subject}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* case actions — kept verbatim, moved into the band */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap mt-3">
|
<div className="flex items-center gap-2 flex-wrap mt-3">
|
||||||
|
{hasParties && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-rule bg-surface px-3 py-1.5 text-[0.82rem] font-semibold text-ink-soft hover:bg-parchment transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
צדדים
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-72 p-3 space-y-2.5 text-sm">
|
||||||
|
{appellants.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] font-bold text-gold-deep uppercase tracking-wider mb-1">
|
||||||
|
עוררים
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5 text-ink-soft">
|
||||||
|
{appellants.map((a, i) => <li key={`a${i}`}>{a}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{respondents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] font-bold text-gold-deep uppercase tracking-wider mb-1">
|
||||||
|
משיבים
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5 text-ink-soft">
|
||||||
|
{respondents.map((r, i) => <li key={`r${i}`}>{r}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
{data?.case_number && (
|
{data?.case_number && (
|
||||||
<CaseArchiveAction
|
<CaseArchiveAction
|
||||||
caseNumber={data.case_number}
|
caseNumber={data.case_number}
|
||||||
@@ -135,25 +146,13 @@ export function CaseHeader({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* tab strip anchored to band bottom (mockup .tabs) */}
|
{/* ★ integrated status rail — visible on every tab (banner A, X17) */}
|
||||||
{tabs ? <div className="mt-5">{tabs}</div> : null}
|
<StatusRail caseNumber={caseNumber} data={data} />
|
||||||
|
|
||||||
|
{/* tab strip anchored to band bottom */}
|
||||||
|
{tabs ? <div className="mt-4">{tabs}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,32 +24,16 @@ import {
|
|||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
import { isBlamSubtype } from "@/lib/practice-area";
|
import { isBlamSubtype } from "@/lib/practice-area";
|
||||||
import type { Case } from "@/lib/api/cases";
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
import { formatDate, israelMidnightMs, todayIsraelMidnightMs } from "@/lib/format-date";
|
||||||
|
|
||||||
function formatDate(iso?: string) {
|
/** Midnight today in Israel time, in ms — pivot between an upcoming and a past hearing. */
|
||||||
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. */
|
|
||||||
function startOfToday() {
|
function startOfToday() {
|
||||||
const d = new Date();
|
return todayIsraelMidnightMs();
|
||||||
d.setHours(0, 0, 0, 0);
|
|
||||||
return d.getTime();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
function hearingMs(iso?: string | null): number | null {
|
||||||
if (!iso) return null;
|
return israelMidnightMs(iso);
|
||||||
const t = new Date(iso).setHours(0, 0, 0, 0);
|
|
||||||
return Number.isNaN(t) ? null : t;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HearingClass = "soon" | "upcoming" | "past" | "none";
|
type HearingClass = "soon" | "upcoming" | "past" | "none";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type BlockStatus,
|
type BlockStatus,
|
||||||
} from "@/lib/api/decision-blocks";
|
} from "@/lib/api/decision-blocks";
|
||||||
import { BLOCK_LABELS } from "@/lib/api/feedback";
|
import { BLOCK_LABELS } from "@/lib/api/feedback";
|
||||||
|
import { formatTime } from "@/lib/format-date";
|
||||||
import { AlertTriangle, Pencil, FileText } from "lucide-react";
|
import { AlertTriangle, Pencil, FileText } from "lucide-react";
|
||||||
|
|
||||||
/* ── status badge styling ─────────────────────────────── */
|
/* ── status badge styling ─────────────────────────────── */
|
||||||
@@ -233,10 +234,7 @@ function SaveIndicator({ state }: { state: SaveState }) {
|
|||||||
return <span className="text-[0.72rem] text-ink-muted">⏳ שומר…</span>;
|
return <span className="text-[0.72rem] text-ink-muted">⏳ שומר…</span>;
|
||||||
}
|
}
|
||||||
if (state.kind === "saved") {
|
if (state.kind === "saved") {
|
||||||
const time = state.at.toLocaleTimeString("he-IL", {
|
const time = formatTime(state.at);
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
||||||
}
|
}
|
||||||
return <span className="text-[0.72rem] text-danger">⚠ {state.message}</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 { CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
APPRAISER_SIDE_LABELS,
|
APPRAISER_SIDE_LABELS,
|
||||||
APPRAISER_SIDE_OPTIONS,
|
APPRAISER_SIDE_OPTIONS,
|
||||||
DOC_TYPE_OPTIONS,
|
DOC_TYPE_OPTIONS,
|
||||||
|
PROTOCOL_SCOPE_OPTIONS,
|
||||||
appraiserSideLabel,
|
appraiserSideLabel,
|
||||||
doctypeLabel,
|
doctypeLabel,
|
||||||
doctypeTone,
|
doctypeTone,
|
||||||
@@ -29,14 +31,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
useExtractAppraiserFacts,
|
useExtractAppraiserFacts,
|
||||||
usePatchDocument,
|
usePatchDocument,
|
||||||
|
type DocumentPatch,
|
||||||
type ExtractAppraiserFactsResponse,
|
type ExtractAppraiserFactsResponse,
|
||||||
} from "@/lib/api/documents";
|
} from "@/lib/api/documents";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
||||||
* Popover with two Selects:
|
* Popover with:
|
||||||
* 1. doc_type (always shown)
|
* 1. doc_type (always shown)
|
||||||
* 2. appraiser_side (only when doc_type === "appraisal")
|
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||||
|
* 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית
|
||||||
|
* 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4
|
||||||
*
|
*
|
||||||
* After a successful save we swap the Popover body to a confirmation view
|
* After a successful save we swap the Popover body to a confirmation view
|
||||||
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
|
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
|
||||||
@@ -49,15 +54,25 @@ export function DocumentTypeEditor({
|
|||||||
docId,
|
docId,
|
||||||
docType,
|
docType,
|
||||||
appraiserSide,
|
appraiserSide,
|
||||||
|
isPostHearing,
|
||||||
|
protocolScope,
|
||||||
}: {
|
}: {
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
docType: string;
|
docType: string;
|
||||||
appraiserSide?: string;
|
appraiserSide?: string;
|
||||||
|
isPostHearing?: boolean;
|
||||||
|
protocolScope?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// Effective stored scope: absent === "appeal" (ועדת הערר) by convention.
|
||||||
|
const storedScope = protocolScope === "lower" ? "lower" : "appeal";
|
||||||
|
const storedPost = isPostHearing === true;
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [draftType, setDraftType] = useState<string>(docType || "");
|
const [draftType, setDraftType] = useState<string>(docType || "");
|
||||||
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
||||||
|
const [draftScope, setDraftScope] = useState<string>(storedScope);
|
||||||
|
const [draftPost, setDraftPost] = useState<boolean>(storedPost);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [extractResult, setExtractResult] =
|
const [extractResult, setExtractResult] =
|
||||||
useState<ExtractAppraiserFactsResponse | null>(null);
|
useState<ExtractAppraiserFactsResponse | null>(null);
|
||||||
@@ -67,6 +82,8 @@ export function DocumentTypeEditor({
|
|||||||
function reset() {
|
function reset() {
|
||||||
setDraftType(docType || "");
|
setDraftType(docType || "");
|
||||||
setDraftSide(appraiserSide || "");
|
setDraftSide(appraiserSide || "");
|
||||||
|
setDraftScope(storedScope);
|
||||||
|
setDraftPost(storedPost);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
setExtractResult(null);
|
setExtractResult(null);
|
||||||
patch.reset();
|
patch.reset();
|
||||||
@@ -74,14 +91,17 @@ export function DocumentTypeEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAppraisal = draftType === "appraisal";
|
const isAppraisal = draftType === "appraisal";
|
||||||
|
const isProtocol = draftType === "protocol";
|
||||||
const sideMissing = isAppraisal && !draftSide;
|
const sideMissing = isAppraisal && !draftSide;
|
||||||
const dirty =
|
const dirty =
|
||||||
draftType !== docType ||
|
draftType !== docType ||
|
||||||
(isAppraisal && draftSide !== (appraiserSide || ""));
|
(isAppraisal && draftSide !== (appraiserSide || "")) ||
|
||||||
|
(isProtocol && draftScope !== storedScope) ||
|
||||||
|
draftPost !== storedPost;
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (sideMissing || !dirty) return;
|
if (sideMissing || !dirty) return;
|
||||||
const body: { doc_type?: string; appraiser_side?: string } = {};
|
const body: DocumentPatch = {};
|
||||||
if (draftType !== docType) body.doc_type = draftType;
|
if (draftType !== docType) body.doc_type = draftType;
|
||||||
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
||||||
body.appraiser_side = draftSide;
|
body.appraiser_side = draftSide;
|
||||||
@@ -90,6 +110,16 @@ export function DocumentTypeEditor({
|
|||||||
// clear it so it doesn't dangle confusingly in metadata.
|
// clear it so it doesn't dangle confusingly in metadata.
|
||||||
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
||||||
|
|
||||||
|
// protocol_scope: store only the non-default "lower"; anything else clears
|
||||||
|
// back to the implicit ועדת-הערר default. Also clear when leaving protocol.
|
||||||
|
if (isProtocol && draftScope !== storedScope) {
|
||||||
|
body.protocol_scope = draftScope === "lower" ? "lower" : "";
|
||||||
|
} else if (!isProtocol && protocolScope) {
|
||||||
|
body.protocol_scope = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftPost !== storedPost) body.is_post_hearing = draftPost;
|
||||||
|
|
||||||
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
||||||
// rejection from this async click handler would otherwise leak.
|
// rejection from this async click handler would otherwise leak.
|
||||||
try {
|
try {
|
||||||
@@ -190,6 +220,46 @@ export function DocumentTypeEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isProtocol && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-ink-muted">פרוטוקול של</label>
|
||||||
|
<Select value={draftScope} onValueChange={setDraftScope} dir="rtl">
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROTOCOL_SCOPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||||
|
ועדת הערר → קבוצת הפרוטוקול · מקומית/מחוזית → מסמכים נלווים
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-3 rounded-md border border-rule bg-parchment/40 px-3 py-2.5">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<label
|
||||||
|
htmlFor={`post-${docId}`}
|
||||||
|
className="text-xs font-medium text-ink block"
|
||||||
|
>
|
||||||
|
התקבל אחרי הדיון
|
||||||
|
</label>
|
||||||
|
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||||
|
מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`post-${docId}`}
|
||||||
|
checked={draftPost}
|
||||||
|
onCheckedChange={setDraftPost}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{patch.isError && (
|
{patch.isError && (
|
||||||
<p className="text-[0.7rem] text-danger">
|
<p className="text-[0.7rem] text-danger">
|
||||||
שמירה נכשלה. נסה שוב.
|
שמירה נכשלה. נסה שוב.
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ import { apiRequest } from "@/lib/api/client";
|
|||||||
import { casesKeys } from "@/lib/api/cases";
|
import { casesKeys } from "@/lib/api/cases";
|
||||||
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
||||||
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
||||||
|
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||||
|
import {
|
||||||
|
documentGroup,
|
||||||
|
DOC_GROUP_ORDER,
|
||||||
|
DOC_GROUP_LABELS,
|
||||||
|
DOC_GROUP_HINTS,
|
||||||
|
type DocGroup,
|
||||||
|
} from "@/lib/doc-types";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||||
@@ -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 {
|
function filenameFromPath(path: string): string {
|
||||||
const parts = path.split("/");
|
const parts = path.split("/");
|
||||||
return parts[parts.length - 1] || path;
|
return parts[parts.length - 1] || path;
|
||||||
@@ -232,6 +231,13 @@ function DocumentRow({
|
|||||||
const canPreview =
|
const canPreview =
|
||||||
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
||||||
|
|
||||||
|
const meta = doc.metadata as
|
||||||
|
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
|
||||||
|
| undefined;
|
||||||
|
const isPostHearing = meta?.is_post_hearing === true;
|
||||||
|
const protocolScope = meta?.protocol_scope;
|
||||||
|
const isProtocol = doc.doc_type === "protocol";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
||||||
@@ -254,15 +260,24 @@ function DocumentRow({
|
|||||||
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{isProtocol && (
|
||||||
|
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
|
||||||
|
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPostHearing && (
|
||||||
|
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
|
||||||
|
לאחר הדיון
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{doc.doc_type && (
|
{doc.doc_type && (
|
||||||
<DocumentTypeEditor
|
<DocumentTypeEditor
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
docId={doc.id}
|
docId={doc.id}
|
||||||
docType={doc.doc_type}
|
docType={doc.doc_type}
|
||||||
appraiserSide={
|
appraiserSide={meta?.appraiser_side}
|
||||||
(doc.metadata as { appraiser_side?: string } | undefined)
|
isPostHearing={isPostHearing}
|
||||||
?.appraiser_side
|
protocolScope={protocolScope}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -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({
|
export function DocumentsPanel({
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
@@ -337,6 +376,16 @@ export function DocumentsPanel({
|
|||||||
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
|
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bucket into the four overview groups. Iterating `sorted` preserves the
|
||||||
|
// within-group status ordering. Grouping is display-only — see documentGroup.
|
||||||
|
const grouped = new Map<DocGroup, CaseDocument[]>();
|
||||||
|
for (const doc of sorted) {
|
||||||
|
const g = documentGroup(doc.doc_type, doc.metadata);
|
||||||
|
const bucket = grouped.get(g);
|
||||||
|
if (bucket) bucket.push(doc);
|
||||||
|
else grouped.set(g, [doc]);
|
||||||
|
}
|
||||||
|
|
||||||
const done = docs.filter(
|
const done = docs.filter(
|
||||||
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
|
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
|
||||||
).length;
|
).length;
|
||||||
@@ -386,12 +435,21 @@ export function DocumentsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden" dir="rtl">
|
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden space-y-2" dir="rtl">
|
||||||
|
{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">
|
<ul className="divide-y divide-rule" dir="rtl">
|
||||||
{sorted.map((doc) => (
|
{groupDocs.map((doc) => (
|
||||||
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocumentsShell>
|
</DocumentsShell>
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ import {
|
|||||||
useUploadFinalDecision,
|
useUploadFinalDecision,
|
||||||
useRunFinalLearning,
|
useRunFinalLearning,
|
||||||
useRunFinalHalacha,
|
useRunFinalHalacha,
|
||||||
|
type ExportFile,
|
||||||
} from "@/lib/api/exports";
|
} from "@/lib/api/exports";
|
||||||
|
import {
|
||||||
|
usePartyClaimsSummary,
|
||||||
|
useGeneratePartyClaimsSummary,
|
||||||
|
useGenerateInterimDraft,
|
||||||
|
} from "@/lib/api/generate";
|
||||||
import {
|
import {
|
||||||
useCaseFeedback,
|
useCaseFeedback,
|
||||||
useCreateFeedback,
|
useCreateFeedback,
|
||||||
@@ -38,6 +44,7 @@ import {
|
|||||||
import { useCaseCitations } from "@/lib/api/citations";
|
import { useCaseCitations } from "@/lib/api/citations";
|
||||||
import { learningKeys } from "@/lib/api/learning";
|
import { learningKeys } from "@/lib/api/learning";
|
||||||
import { LearningStatusBadges } from "@/components/cases/learning-status-badges";
|
import { LearningStatusBadges } from "@/components/cases/learning-status-badges";
|
||||||
|
import { formatDateTime, formatDateShort } from "@/lib/format-date";
|
||||||
import type { CaseStatus } from "@/lib/api/cases";
|
import type { CaseStatus } from "@/lib/api/cases";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +61,10 @@ import {
|
|||||||
Stamp,
|
Stamp,
|
||||||
Link2,
|
Link2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Zap,
|
||||||
|
ClipboardList,
|
||||||
|
Files,
|
||||||
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* Statuses at which a draft is considered ready */
|
/* Statuses at which a draft is considered ready */
|
||||||
@@ -72,13 +83,29 @@ function formatSize(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(epoch: number): string {
|
function formatDate(epoch: number): string {
|
||||||
return new Date(epoch * 1000).toLocaleDateString("he-IL", {
|
return formatDateTime(epoch * 1000);
|
||||||
day: "numeric",
|
}
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
/**
|
||||||
hour: "2-digit",
|
* Pick the newest export file whose name starts with `prefix`, preferring the
|
||||||
minute: "2-digit",
|
* 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) => {
|
||||||
|
const m = f.filename.match(/v(\d+)/);
|
||||||
|
return { ...f, version: m ? parseInt(m[1], 10) : null };
|
||||||
});
|
});
|
||||||
|
withVer.sort((a, b) => {
|
||||||
|
if (a.version != null && b.version != null) return b.version - a.version;
|
||||||
|
return b.created_at - a.created_at;
|
||||||
|
});
|
||||||
|
return withVer[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Main component ─────────────────────────────────── */
|
/* ── Main component ─────────────────────────────────── */
|
||||||
@@ -102,6 +129,10 @@ export function DraftsPanel({
|
|||||||
const uploadFinal = useUploadFinalDecision(caseNumber);
|
const uploadFinal = useUploadFinalDecision(caseNumber);
|
||||||
const runLearning = useRunFinalLearning(caseNumber);
|
const runLearning = useRunFinalLearning(caseNumber);
|
||||||
const runHalacha = useRunFinalHalacha(caseNumber);
|
const runHalacha = useRunFinalHalacha(caseNumber);
|
||||||
|
// ── Document generation (#214) — the "הפקת מסמכים" card ──
|
||||||
|
const { data: summary } = usePartyClaimsSummary(caseNumber);
|
||||||
|
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
|
||||||
|
const genInterim = useGenerateInterimDraft(caseNumber);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -134,6 +165,38 @@ export function DraftsPanel({
|
|||||||
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
|
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// ── "הפקת מסמכים" derived state (#214) ──
|
||||||
|
// Latest interim ("party-claims") partial draft in the exports table — the poll
|
||||||
|
// target for button 2. The CEO writes it as טיוטת-ביניים-{case}-vN.docx.
|
||||||
|
const latestInterim = pickLatestVersioned(exports, "טיוטת-ביניים-");
|
||||||
|
const summaryReady = Boolean(summary?.markdown);
|
||||||
|
|
||||||
|
function handleGenerateSummary() {
|
||||||
|
genSummary.mutate(undefined, {
|
||||||
|
onSuccess: (d) => {
|
||||||
|
if (d.status === "ok") {
|
||||||
|
toast.success("הופעלה הפקת סיכום-המנהלים — רצה ברקע (אופוס)");
|
||||||
|
} else {
|
||||||
|
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה בהפעלת הפקת סיכום-המנהלים"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGenerateInterim() {
|
||||||
|
genInterim.mutate(undefined, {
|
||||||
|
onSuccess: (d) => {
|
||||||
|
if (d.status === "ok") {
|
||||||
|
toast.success("הופעלה הפקת טיוטת טענות-הצדדים — רצה ברקע (אופוס)");
|
||||||
|
} else {
|
||||||
|
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה בהפעלת הפקת הטיוטה החלקית"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleUpload(file: File) {
|
function handleUpload(file: File) {
|
||||||
uploadDraft.mutate(file, {
|
uploadDraft.mutate(file, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -238,34 +301,184 @@ export function DraftsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
// flex + per-card `order` so the visual order matches mockup 26:
|
||||||
{/* ── Banner ── */}
|
// generate-card → banner → active-draft → drafts → final+learning → feedback.
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */}
|
||||||
|
<section className="order-0 rounded-lg border border-rule bg-surface overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft bg-parchment">
|
||||||
|
<Zap className="w-[18px] h-[18px] text-gold-deep shrink-0" />
|
||||||
|
<h3 className="text-navy text-base font-semibold">הפקת מסמכים</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-xs text-ink-muted leading-relaxed mb-1">
|
||||||
|
הפקה דטרמיניסטית בלחיצה — בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת,
|
||||||
|
והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 1 — executive summary of party claims */}
|
||||||
|
<div className="flex items-start gap-3 py-3 border-t border-rule-soft first-of-type:border-t-0">
|
||||||
|
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
|
||||||
|
<ClipboardList className="w-[18px] h-[18px]" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-navy">הפק סיכום מנהלים</div>
|
||||||
|
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
|
||||||
|
תמצית מזוקקת של טענות הצדדים לקראת הדיון — מסמך נפרד מהטיוטה.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 mt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGenerateSummary}
|
||||||
|
disabled={genSummary.isPending}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{genSummary.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||||
|
מייצר…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"הפק"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{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>
|
||||||
|
{latestInterim && (
|
||||||
|
<span className="text-[0.7rem] text-ink-muted">
|
||||||
|
{latestInterim.version
|
||||||
|
? `v${latestInterim.version} · `
|
||||||
|
: ""}
|
||||||
|
{formatDate(latestInterim.created_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */}
|
||||||
|
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
|
||||||
|
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
|
||||||
|
<Files className="w-[18px] h-[18px]" />
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-navy">
|
||||||
|
הפק טיוטת החלטה מלאה
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
|
||||||
|
ההחלטה המלאה — כל 12 הבלוקים, כולל דיון והכרעה. נקבעת
|
||||||
|
כמקור-האמת ונשמרת בטבלת-הטיוטות.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5 mt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={exportDocx.isPending || !isDraftReady}
|
||||||
|
title={!isDraftReady ? "זמין כשיש בלוקים כתובים" : undefined}
|
||||||
|
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||||
|
>
|
||||||
|
{exportDocx.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
|
||||||
|
מייצר…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileOutput className="w-3.5 h-3.5 me-1.5" />
|
||||||
|
הפק
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{!isDraftReady && (
|
||||||
|
<span className="rounded bg-warn-bg px-2 py-0.5 text-[0.65rem] text-warn">
|
||||||
|
זמין כשיש בלוקים כתובים
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */}
|
||||||
{isDraftReady && (
|
{isDraftReady && (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
<div className="order-1 flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
||||||
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
|
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
|
||||||
<span className="text-sm font-medium text-gold-deep">
|
<span className="text-sm font-medium text-gold-deep">
|
||||||
{draftLabel}
|
{draftLabel}
|
||||||
</span>
|
</span>
|
||||||
<div className="me-auto" />
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={exportDocx.isPending}
|
|
||||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
|
||||||
>
|
|
||||||
{exportDocx.isPending ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
|
||||||
) : (
|
|
||||||
<FileOutput className="w-4 h-4 me-1.5" />
|
|
||||||
)}
|
|
||||||
הפק DOCX
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
|
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
|
||||||
{activeDraft?.filename && (
|
{activeDraft?.filename && (
|
||||||
<div className="flex items-center gap-2 text-xs text-ink-muted">
|
<div className="order-2 flex items-center gap-2 text-xs text-ink-muted">
|
||||||
<span>מקור האמת:</span>
|
<span>מקור האמת:</span>
|
||||||
<Badge variant="outline" className="bg-surface">
|
<Badge variant="outline" className="bg-surface">
|
||||||
{activeDraft.filename}
|
{activeDraft.filename}
|
||||||
@@ -274,7 +487,7 @@ export function DraftsPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
|
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
|
||||||
<section className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
|
<section className="order-4 rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
|
||||||
<input
|
<input
|
||||||
ref={finalFileRef}
|
ref={finalFileRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -352,13 +565,13 @@ export function DraftsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasFinal && <LearningStatusBadges caseNumber={caseNumber} />}
|
{hasFinal && <LearningStatusBadges caseNumber={caseNumber} />}
|
||||||
|
{/* precedents cited inside the signed decision — sub-section of the
|
||||||
|
final card (mockup 18h), collapsible inside CitationsSection itself */}
|
||||||
|
<CitationsSection caseNumber={caseNumber} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Precedents cited inside the signed decision ── */}
|
{/* ── Card: draft files (mockup 18h) ── */}
|
||||||
<CitationsSection caseNumber={caseNumber} />
|
<section className="order-3 rounded-lg border border-rule bg-surface p-4">
|
||||||
|
|
||||||
{/* ── Exports list ── */}
|
|
||||||
<section>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-navy text-base">קבצי טיוטה</h3>
|
<h3 className="text-navy text-base">קבצי טיוטה</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -401,9 +614,9 @@ export function DraftsPanel({
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
|
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
|
||||||
<th className="text-start px-4 py-2 font-medium">File</th>
|
<th className="text-start px-4 py-2 font-medium">קובץ</th>
|
||||||
<th className="text-start px-4 py-2 font-medium">Size</th>
|
<th className="text-start px-4 py-2 font-medium">גודל</th>
|
||||||
<th className="text-start px-4 py-2 font-medium">Date</th>
|
<th className="text-start px-4 py-2 font-medium">תאריך</th>
|
||||||
<th className="px-4 py-2" />
|
<th className="px-4 py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -526,8 +739,8 @@ export function DraftsPanel({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Chair feedback ── */}
|
{/* ── Card: chair feedback (mockup 18h) ── */}
|
||||||
<section>
|
<section className="order-5 rounded-lg border border-rule bg-surface p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-navy text-base">
|
<h3 className="text-navy text-base">
|
||||||
הערות יו״ר
|
הערות יו״ר
|
||||||
@@ -568,9 +781,7 @@ export function DraftsPanel({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="text-[0.65rem] text-ink-muted me-auto">
|
<span className="text-[0.65rem] text-ink-muted me-auto">
|
||||||
{fb.created_at
|
{fb.created_at ? formatDateShort(fb.created_at) : ""}
|
||||||
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
|
||||||
: ""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
||||||
|
|||||||
@@ -36,6 +36,23 @@ const PRIORITY_BADGE_TONE: Record<LegalArgumentPriority, string> = {
|
|||||||
relief: "bg-emerald-50 text-emerald-900 border-emerald-200",
|
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(
|
function groupByPriority(
|
||||||
args: LegalArgument[],
|
args: LegalArgument[],
|
||||||
): Record<LegalArgumentPriority, LegalArgument[]> {
|
): Record<LegalArgumentPriority, LegalArgument[]> {
|
||||||
@@ -55,22 +72,57 @@ function groupByPriority(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PartySectionProps = {
|
type PartySectionProps = {
|
||||||
party: LegalArgumentParty;
|
|
||||||
args: LegalArgument[];
|
args: LegalArgument[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function PartySection({ party, args }: PartySectionProps) {
|
/**
|
||||||
const grouped = useMemo(() => groupByPriority(args), [args]);
|
* 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-5">
|
||||||
<div className="flex items-baseline justify-between border-b border-rule pb-2">
|
{briefs.map(([brief, briefArgs]) => (
|
||||||
<h3 className="text-navy text-base font-semibold">
|
<div key={brief || "—"} className="space-y-2">
|
||||||
{PARTY_LABELS_HE[party] ?? party}
|
<div className="flex items-center gap-2 border-s-2 border-gold-deep ps-2">
|
||||||
</h3>
|
<span className="text-navy text-sm font-semibold">
|
||||||
|
{brief || "ללא שיוך לכתב"}
|
||||||
|
</span>
|
||||||
<span className="text-ink-muted text-xs">
|
<span className="text-ink-muted text-xs">
|
||||||
{args.length} טיעונים
|
{briefArgs.length} טיעונים
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<PriorityGroups args={briefArgs} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority-grouped argument accordion for one coherent set of arguments. */
|
||||||
|
function PriorityGroups({ args }: PartySectionProps) {
|
||||||
|
const grouped = useMemo(() => groupByPriority(args), [args]);
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
{PRIORITY_ORDER.map((priority) => {
|
{PRIORITY_ORDER.map((priority) => {
|
||||||
const list = grouped[priority];
|
const list = grouped[priority];
|
||||||
if (!list?.length) return null;
|
if (!list?.length) return null;
|
||||||
@@ -253,15 +305,47 @@ export function LegalArgumentsPanel({ caseNumber }: LegalArgumentsPanelProps) {
|
|||||||
החישוב למנתח המשפטי.
|
החישוב למנתח המשפטי.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
// Top-level party accordion — each party is its own collapsible section,
|
||||||
{parties.map((party) => (
|
// closed by default (empty defaultValue). The count badge on every
|
||||||
<PartySection
|
// 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}
|
key={party}
|
||||||
party={party}
|
value={party}
|
||||||
args={data.by_party[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">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
351
web-ui/src/components/cases/positions-panel.tsx
Normal file
351
web-ui/src/components/cases/positions-panel.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"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 {
|
||||||
|
useCaseLearningStatus,
|
||||||
|
type CaseLearningStatus,
|
||||||
|
} from "@/lib/api/learning";
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Staged-pipeline indicator text — derived from the live learning-status,
|
||||||
|
// same source as the drafts-panel LearningStatusBadges. ──────────────────────
|
||||||
|
function voiceLearningText(s?: CaseLearningStatus): string {
|
||||||
|
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
|
||||||
|
const v = s.voice_learning;
|
||||||
|
if (v.outcome === "succeeded") {
|
||||||
|
const bits = [`${v.lessons_count} לקחים הופקו`];
|
||||||
|
if (v.lessons_proposed > 0) bits.push(`${v.lessons_proposed} הוצעו לאישור`);
|
||||||
|
return `✓ הושלם · ${bits.join(" · ")}`;
|
||||||
|
}
|
||||||
|
if (v.outcome === "failed") return v.error ? `✗ נכשל — ${v.error}` : "✗ נכשל";
|
||||||
|
return "ממתין להרצה";
|
||||||
|
}
|
||||||
|
|
||||||
|
function halachaExtractionText(s?: CaseLearningStatus): string {
|
||||||
|
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
|
||||||
|
const h = s.halacha_extraction;
|
||||||
|
if (!h.enrolled_in_corpus)
|
||||||
|
return h.not_enrolled_reason ?? "לא נכנס לקורפוס-הפסיקה";
|
||||||
|
switch (h.status) {
|
||||||
|
case "completed":
|
||||||
|
return `✓ הושלם · חולצו ${h.halachot_count} · ${h.approved} אושרו · ${h.rejected} נדחו`;
|
||||||
|
case "processing":
|
||||||
|
return "רץ עכשיו…";
|
||||||
|
case "pending":
|
||||||
|
case "busy":
|
||||||
|
return "בתור";
|
||||||
|
case "partial":
|
||||||
|
return `חלקי · חולצו ${h.halachot_count}`;
|
||||||
|
case "failed":
|
||||||
|
case "extraction_failed":
|
||||||
|
return "✗ נכשל";
|
||||||
|
case "no_chunks":
|
||||||
|
return "אין טקסט לחילוץ";
|
||||||
|
default:
|
||||||
|
return "ממתין להרצה";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||||
|
if (!content?.trim()) return null;
|
||||||
|
return (
|
||||||
|
<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, analysis upload/download (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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* stage indicators — informational pointers, not actions */}
|
||||||
|
<div className="mt-3 space-y-0">
|
||||||
|
<div className="text-[0.78rem] text-ink-muted pt-2 border-t border-rule-soft">
|
||||||
|
<b className="text-navy">הרץ למידת-קול</b> — {voiceLearningText(learning.data)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.78rem] text-ink-muted pt-2 mt-2 border-t border-rule-soft">
|
||||||
|
<b className="text-navy">הרץ אימות-הלכות</b> — {halachaExtractionText(learning.data)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<p className="text-ink-muted text-[0.78rem]">
|
||||||
|
סוגיות-המחלוקת ועמדות הצדדים. עורך עמדת-היו״ר נשמר אוטומטית ומזין את בלוק י׳ (דיון והכרעה).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{analysis.isPending ? (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-6 py-5 space-y-3">
|
||||||
|
<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 פסיקה מצורפת + finish/export gates (INV-IA3: not removed) */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 items-start pt-2">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<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,
|
new: FilePlus2,
|
||||||
processing: Loader2,
|
processing: Loader2,
|
||||||
documents_ready: FileCheck,
|
documents_ready: FileCheck,
|
||||||
|
analyst_verified: SearchCheck,
|
||||||
|
research_complete: Compass,
|
||||||
outcome_set: Target,
|
outcome_set: Target,
|
||||||
direction_approved: Compass,
|
direction_approved: Compass,
|
||||||
qa_review: SearchCheck,
|
qa_review: SearchCheck,
|
||||||
@@ -36,6 +38,8 @@ const STATUS_TONE: Record<CaseStatus, string> = {
|
|||||||
new: "bg-rule-soft text-ink-muted border-rule",
|
new: "bg-rule-soft text-ink-muted border-rule",
|
||||||
processing: "bg-info-bg text-info border-info/30",
|
processing: "bg-info-bg text-info border-info/30",
|
||||||
documents_ready: "bg-info-bg text-info border-info/40",
|
documents_ready: "bg-info-bg text-info border-info/40",
|
||||||
|
analyst_verified: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
|
research_complete: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
|
outcome_set: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
|
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
|
||||||
qa_review: "bg-warn-bg text-warn border-warn/40",
|
qa_review: "bg-warn-bg text-warn border-warn/40",
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ const ALL_STATUSES: readonly CaseStatus[] = CASE_STATUSES;
|
|||||||
export function StatusChanger({
|
export function StatusChanger({
|
||||||
caseNumber,
|
caseNumber,
|
||||||
currentStatus,
|
currentStatus,
|
||||||
|
inline = false,
|
||||||
}: {
|
}: {
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
currentStatus?: CaseStatus;
|
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
|
// `null` = untouched → the dropdown tracks the live `currentStatus` (which
|
||||||
// arrives async and changes on the 5s poll / external updates). Only an
|
// arrives async and changes on the 5s poll / external updates). Only an
|
||||||
@@ -45,8 +49,10 @@ export function StatusChanger({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 border-t border-rule pt-3 space-y-2">
|
<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>
|
<label className="text-[0.72rem] text-ink-muted block">שינוי סטטוס ידני</label>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={effective || "__current__"}
|
value={effective || "__current__"}
|
||||||
@@ -77,7 +83,7 @@ export function StatusChanger({
|
|||||||
disabled={!canSave || mutate.isPending}
|
disabled={!canSave || mutate.isPending}
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
>
|
>
|
||||||
{mutate.isPending ? "שומר…" : "עדכן"}
|
{mutate.isPending ? "שומר…" : inline ? "עדכן סטטוס" : "עדכן"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 { useGitStatus } from "@/lib/api/cases";
|
||||||
import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react";
|
import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react";
|
||||||
|
import { formatRelative } from "@/lib/format-date";
|
||||||
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} ימים`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SyncIndicator({ caseNumber }: { caseNumber?: string }) {
|
export function SyncIndicator({ caseNumber }: { caseNumber?: string }) {
|
||||||
const { data, isLoading } = useGitStatus(caseNumber);
|
const { data, isLoading } = useGitStatus(caseNumber);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { STATUS_LABELS, STATUS_ICONS, STATUS_DESCRIPTIONS } from "@/components/cases/status-badge";
|
||||||
import {
|
import {
|
||||||
FolderInput, ClipboardList, Brain, PenLine, CheckCircle2,
|
FolderInput, ClipboardList, Brain, PenLine, CheckCircle2,
|
||||||
@@ -21,11 +21,6 @@ const PHASE_ICONS: Record<PhaseKey, LucideIcon> = {
|
|||||||
done: CheckCircle2,
|
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 }) {
|
export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
|
||||||
const currentIdx = phaseIndexOf(status);
|
const currentIdx = phaseIndexOf(status);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useSaveChairPosition } from "@/lib/api/research";
|
import { useSaveChairPosition } from "@/lib/api/research";
|
||||||
|
import { formatTime } from "@/lib/format-date";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Chair-position editor for a single threshold claim or issue.
|
* 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>;
|
return <span className="text-[0.72rem] text-ink-muted">⏳ שומר…</span>;
|
||||||
}
|
}
|
||||||
if (state.kind === "saved") {
|
if (state.kind === "saved") {
|
||||||
const time = state.at.toLocaleTimeString("he-IL", {
|
const time = formatTime(state.at);
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
||||||
}
|
}
|
||||||
return <span className="text-[0.72rem] text-danger">⚠ {state.message}</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
|
* supporting precedents with the cumulative authority signal (cited_by), a verify
|
||||||
* gate + chair note, and per-issue radar (unlinked digests). The writer cites only
|
* 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).
|
* 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 { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
import {
|
import {
|
||||||
useCitationVerification,
|
useCitationVerification,
|
||||||
useVerifyCitation,
|
useVerifyCitation,
|
||||||
@@ -33,10 +43,34 @@ const RADAR_LABEL: Record<string, string> = {
|
|||||||
available_link: "בקורפוס — לקשר",
|
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 }) {
|
export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }) {
|
||||||
const { data, isPending, isError } = useCitationVerification(caseNumber);
|
const { data, isPending, isError } = useCitationVerification(caseNumber);
|
||||||
const verify = useVerifyCitation(caseNumber);
|
const verify = useVerifyCitation(caseNumber);
|
||||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
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) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
@@ -111,76 +145,178 @@ export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sum = data.summary;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* summary */}
|
{/* summary + progress */}
|
||||||
<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">
|
<div className="rounded-xl border border-rule bg-surface shadow-sm px-5 py-4">
|
||||||
<span>סוגיות עם פסיקה: <b className="text-navy tabular-nums">{sum.arguments_with_support}/{sum.arguments_total}</b></span>
|
<div className="flex items-center gap-6 flex-wrap">
|
||||||
<span>אומתו: <b className="text-success tabular-nums">{sum.verified}</b></span>
|
<Stat k="סוגיות עם פסיקה" v={`${sum.arguments_with_support}/${sum.arguments_total}`} />
|
||||||
<span>לידֵי רדאר: <b className="text-warn tabular-nums">{sum.radar_leads}</b></span>
|
<Stat k="אומתו" v={String(sum.verified)} tone="text-success" />
|
||||||
<span className="text-ink-muted text-[0.78rem] ms-auto">ה-writer מצטט רק מאומתים (INV-AH) · היומון אינו מצוטט (INV-DIG1)</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{data.arguments.map((a) => (
|
{/* filters */}
|
||||||
<Card key={a.argument_id} className="bg-surface border-rule shadow-sm overflow-hidden">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<CardContent className="p-0">
|
{FILTERS.map((f) => (
|
||||||
{/* issue header */}
|
<button
|
||||||
<div className="flex items-start gap-3 px-5 py-3.5 border-b border-rule-soft bg-parchment/60">
|
key={f.key}
|
||||||
<div className="min-w-0 flex-1">
|
type="button"
|
||||||
<div className="text-navy font-bold text-[0.98rem]">{a.title}</div>
|
onClick={() => setFilter(f.key)}
|
||||||
<div className="text-ink-muted text-xs mt-0.5">
|
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}` : ""}
|
סוגיה{a.legal_topic ? ` · ${a.legal_topic}` : ""}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
{a.priority && (
|
{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">
|
<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}
|
{PRIORITY_LABEL[a.priority] ?? a.priority}
|
||||||
</span>
|
</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>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 pb-3 pt-0">
|
||||||
|
<div className="text-[0.72rem] font-bold text-ink-muted mb-1">
|
||||||
|
פסיקה תומכת בקורפוס
|
||||||
</div>
|
</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 ? (
|
{a.supporting.length === 0 ? (
|
||||||
<p className="text-ink-muted text-sm py-2">לא נמצאה פסיקה תומכת בקורפוס לסוגיה זו.</p>
|
<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 divide-y divide-rule-soft">
|
<ul className="list-none p-0 m-0">
|
||||||
{a.supporting.map((s) => (
|
{supShown.map((s) => {
|
||||||
<li key={s.case_law_id} className="py-3 first:pt-1">
|
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"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={`/precedents/${s.case_law_id}`}
|
href={`/precedents/${s.case_law_id}`}
|
||||||
className="font-bold text-gold-deep text-[0.86rem] hover:text-navy"
|
className="font-bold text-gold-deep text-[0.85rem] hover:text-navy"
|
||||||
>
|
>
|
||||||
{s.case_number || s.case_name}
|
{s.case_number || s.case_name}
|
||||||
</a>
|
</a>
|
||||||
{s.cited_by.positive > 0 && (
|
{s.cited_by.positive > 0 && (
|
||||||
<span className="rounded-full bg-success-bg text-success text-[0.68rem] font-semibold px-2 py-0.5">
|
<span className="rounded-full bg-success-bg text-success text-[0.66rem] font-semibold px-2 py-0.5">
|
||||||
אומץ ×{s.cited_by.positive}
|
אומץ ×{s.cited_by.positive}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{s.cited_by.negative > 0 && (
|
{s.cited_by.negative > 0 && (
|
||||||
<span className="rounded-full bg-danger-bg text-danger text-[0.68rem] font-semibold px-2 py-0.5">
|
<span className="rounded-full bg-danger-bg text-danger text-[0.66rem] font-semibold px-2 py-0.5">
|
||||||
אובחן ×{s.cited_by.negative}
|
אובחן ×{s.cited_by.negative}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<span className="ms-auto flex items-center gap-1.5">
|
||||||
{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 ? (
|
{s.verified ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-success text-xs font-semibold">✓ אומת ע״י היו״ר</span>
|
<span className="text-success text-[0.72rem] font-semibold">
|
||||||
|
✓ אומת ע״י היו״ר
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={verify.isPending}
|
disabled={verify.isPending}
|
||||||
onClick={() => runVerify(a, s, false)}
|
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"
|
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>
|
||||||
@@ -191,7 +327,7 @@ export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={verify.isPending}
|
disabled={verify.isPending}
|
||||||
onClick={() => runVerify(a, s, true)}
|
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"
|
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>
|
||||||
@@ -199,49 +335,84 @@ export function CitationVerificationPanel({ caseNumber }: { caseNumber: string }
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={verify.isPending}
|
disabled={verify.isPending}
|
||||||
onClick={() => runVerify(a, s, false)}
|
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"
|
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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{s.cited_by.negative > 0 && !s.verified && (
|
</span>
|
||||||
<span className="text-warn text-[0.72rem] ms-1">⚠ אובחן — בדוק הקשר</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* chair note */}
|
{s.quote && (
|
||||||
<div className="mt-2 flex items-start gap-2">
|
<blockquote
|
||||||
<label className="text-[0.72rem] font-bold text-gold-deep whitespace-nowrap pt-1.5">
|
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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
defaultValue={s.chair_note}
|
defaultValue={s.chair_note}
|
||||||
placeholder="הוסף הערה לציטוט — מדוע תומך / הסתייגות / איך לשלב…"
|
placeholder="הוסף הערה לציטוט — מדוע תומך / הסתייגות / איך לשלב…"
|
||||||
onChange={(e) => setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))}
|
onChange={(e) =>
|
||||||
|
setNotes((n) => ({ ...n, [s.case_law_id]: e.target.value }))
|
||||||
|
}
|
||||||
onBlur={() => saveNote(a, s)}
|
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"
|
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>
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSet(setOpenNotes, s.case_law_id)}
|
||||||
|
className="mt-2 text-[0.72rem] text-gold-deep font-semibold hover:underline"
|
||||||
|
>
|
||||||
|
+ הוסף הערת יו״ר
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* radar — unlinked digests for this issue (INV-DIG1: pointer, not citation) */}
|
|
||||||
<RadarStrip radar={a.radar} />
|
<RadarStrip radar={a.radar} />
|
||||||
</CardContent>
|
</AccordionContent>
|
||||||
</Card>
|
</AccordionItem>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
</div>
|
</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[] }) {
|
function RadarStrip({ radar }: { radar: RadarLead[] }) {
|
||||||
if (!radar.length) return null;
|
if (!radar.length) return null;
|
||||||
return (
|
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 className="text-[0.72rem] font-bold text-gold-deep mb-1">
|
||||||
📡 רדאר — פסיקה שאין לנו בקורפוס (מצביע, לא מצוטט)
|
📡 רדאר — פסיקה שאין לנו בקורפוס (מצביע, לא מצוטט)
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,15 +5,7 @@ import type { ReactNode } from "react";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { practiceAreaLabel } from "@/components/precedents/practice-area";
|
import { practiceAreaLabel } from "@/components/precedents/practice-area";
|
||||||
import type { Digest } from "@/lib/api/digests";
|
import type { Digest } from "@/lib/api/digests";
|
||||||
|
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||||
function formatDate(iso: string | null) {
|
|
||||||
if (!iso) return "—";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("he-IL");
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presentational card for a single digest ("כל יום" radar entry).
|
* Presentational card for a single digest ("כל יום" radar entry).
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import type { GraphFacets } from "@/lib/api/graph";
|
import type { GraphFacets } from "@/lib/api/graph";
|
||||||
import type { ColorBy, SizeBy } from "@/components/graph/graph-canvas";
|
import type { ColorBy, SizeBy } from "@/components/graph/graph-canvas";
|
||||||
|
import { getIsraelYear } from "@/lib/format-date";
|
||||||
|
|
||||||
export type GraphControls = {
|
export type GraphControls = {
|
||||||
practiceArea: string;
|
practiceArea: string;
|
||||||
@@ -51,7 +52,7 @@ const ALL = "__all__";
|
|||||||
// Floor covers the oldest dated precedent in the corpus (currently ע"א 725/81 →
|
// Floor covers the oldest dated precedent in the corpus (currently ע"א 725/81 →
|
||||||
// 1982); ceiling tracks the current year so the range never ages out.
|
// 1982); ceiling tracks the current year so the range never ages out.
|
||||||
const YEAR_FLOOR = 1980;
|
const YEAR_FLOOR = 1980;
|
||||||
const CURRENT_YEAR = new Date().getFullYear();
|
const CURRENT_YEAR = getIsraelYear(new Date()) ?? new Date().getFullYear();
|
||||||
const YEARS = Array.from(
|
const YEARS = Array.from(
|
||||||
{ length: CURRENT_YEAR - YEAR_FLOOR + 1 },
|
{ length: CURRENT_YEAR - YEAR_FLOOR + 1 },
|
||||||
(_, i) => CURRENT_YEAR - i,
|
(_, i) => CURRENT_YEAR - i,
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
* header. Reflects the current section so the user always sees where they
|
* header. Reflects the current section so the user always sees where they
|
||||||
* are without scanning the nav row.
|
* are without scanning the nav row.
|
||||||
*
|
*
|
||||||
* Special-cases case routes (`/cases/{caseNumber}` and `/compose`) so the
|
* Special-cases case routes (`/cases/{caseNumber}`) so the subtitle includes
|
||||||
* subtitle includes the case number — the most useful piece of context
|
* the case number — the most useful piece of context during decision drafting.
|
||||||
* during decision drafting.
|
|
||||||
*/
|
*/
|
||||||
export function headerSubtitle(pathname: string): string {
|
export function headerSubtitle(pathname: string): string {
|
||||||
if (pathname === "/") return "בית";
|
if (pathname === "/") return "בית";
|
||||||
@@ -13,9 +12,8 @@ export function headerSubtitle(pathname: string): string {
|
|||||||
if (pathname.startsWith("/cases/")) {
|
if (pathname.startsWith("/cases/")) {
|
||||||
const [, , slug] = pathname.split("/");
|
const [, , slug] = pathname.split("/");
|
||||||
if (!slug || slug === "new") return "תיק חדש";
|
if (!slug || slug === "new") return "תיק חדש";
|
||||||
const isCompose = pathname.includes("/compose");
|
|
||||||
const decoded = decodeURIComponent(slug);
|
const decoded = decodeURIComponent(slug);
|
||||||
return isCompose ? `ערר ${decoded} · ניסוח` : `ערר ${decoded}`;
|
return `ערר ${decoded}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/archive")) return "ארכיון";
|
if (pathname.startsWith("/archive")) return "ארכיון";
|
||||||
|
|||||||
@@ -20,15 +20,7 @@ import {
|
|||||||
type MissingPrecedentStatus,
|
type MissingPrecedentStatus,
|
||||||
} from "@/lib/api/missing-precedents";
|
} from "@/lib/api/missing-precedents";
|
||||||
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
|
import { MissingPrecedentDetailDrawer } from "./missing-precedent-detail-drawer";
|
||||||
|
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||||
function formatDate(iso: string | null) {
|
|
||||||
if (!iso) return "—";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("he-IL");
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Status chip — mockup 09 tones (open=warn, uploaded=info, closed=success,
|
/** Status chip — mockup 09 tones (open=warn, uploaded=info, closed=success,
|
||||||
* irrelevant=muted). Pill-shaped, whitespace-nowrap. */
|
* irrelevant=muted). Pill-shaped, whitespace-nowrap. */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useDiagnostics, type DiagDoc } from "@/lib/api/system";
|
import { useDiagnostics, type DiagDoc } from "@/lib/api/system";
|
||||||
|
import { formatDateShort, formatRelative } from "@/lib/format-date";
|
||||||
|
|
||||||
const TABLE_LABELS: Record<string, string> = {
|
const TABLE_LABELS: Record<string, string> = {
|
||||||
cases: "תיקים",
|
cases: "תיקים",
|
||||||
@@ -25,16 +26,10 @@ const TABLE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
function formatRelativeTime(iso: string | null) {
|
function formatRelativeTime(iso: string | null) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const then = new Date(iso);
|
const min = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
|
||||||
const diffMs = Date.now() - then.getTime();
|
// Past 30 days, fall back to an absolute Israel-time date.
|
||||||
const min = Math.floor(diffMs / 60000);
|
if (min >= 30 * 24 * 60) return formatDateShort(iso);
|
||||||
if (min < 1) return "עכשיו";
|
return formatRelative(iso, { units: "long" });
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) {
|
function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type Halacha, type CanonicalInstance,
|
type Halacha, type CanonicalInstance,
|
||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
|
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. */
|
/** #81 strict-rubric flags — why an item was held back from auto-approval. */
|
||||||
const QUALITY_FLAG_LABELS: Record<string, string> = {
|
const QUALITY_FLAG_LABELS: Record<string, string> = {
|
||||||
@@ -28,15 +29,6 @@ const QUALITY_FLAG_LABELS: Record<string, string> = {
|
|||||||
nevo_preamble_leak: "דליפת רציו נבו",
|
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. */
|
/* Strip Unicode bidi marks that render as zero-width but shift visual position. */
|
||||||
function cleanCitation(s: string | null | undefined): string {
|
function cleanCitation(s: string | null | undefined): string {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
|
|||||||
@@ -31,15 +31,7 @@ import {
|
|||||||
FormattedCitation,
|
FormattedCitation,
|
||||||
CitationCopyButton,
|
CitationCopyButton,
|
||||||
} from "./formatted-citation";
|
} from "./formatted-citation";
|
||||||
|
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||||
function formatDate(iso: string | null) {
|
|
||||||
if (!iso) return "—";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("he-IL");
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanCitation(s: string | null | undefined): string {
|
function cleanCitation(s: string | null | undefined): string {
|
||||||
if (!s) return "—";
|
if (!s) return "—";
|
||||||
|
|||||||
@@ -14,15 +14,7 @@ import {
|
|||||||
} from "@/lib/api/precedent-library";
|
} from "@/lib/api/precedent-library";
|
||||||
import { PRACTICE_AREAS, PRECEDENT_LEVELS } from "./practice-area";
|
import { PRACTICE_AREAS, PRECEDENT_LEVELS } from "./practice-area";
|
||||||
import { AuthorityBadge } from "./halacha-meta";
|
import { AuthorityBadge } from "./halacha-meta";
|
||||||
|
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||||
function formatDate(iso: string | null) {
|
|
||||||
if (!iso) return "—";
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleDateString("he-IL");
|
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Score chip — boxed, gold-deep, tabular (mockup 07 `.score`). Sits at the
|
/** 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. */
|
* 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";
|
} from "@/lib/api/precedent-library";
|
||||||
import { ApiError } from "@/lib/api/client";
|
import { ApiError } from "@/lib/api/client";
|
||||||
import { useProgress } from "@/lib/api/documents";
|
import { useProgress } from "@/lib/api/documents";
|
||||||
|
import { formatDateShort } from "@/lib/format-date";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
PRACTICE_AREAS, PRECEDENT_LEVELS, SOURCE_TYPES,
|
||||||
} from "./practice-area";
|
} from "./practice-area";
|
||||||
@@ -192,7 +193,7 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
|
|||||||
{conflict.citation}
|
{conflict.citation}
|
||||||
{conflict.case_name && ` — ${conflict.case_name}`}
|
{conflict.case_name && ` — ${conflict.case_name}`}
|
||||||
{conflict.court && ` · ${conflict.court}`}
|
{conflict.court && ` · ${conflict.court}`}
|
||||||
{conflict.date && ` · ${new Date(conflict.date).toLocaleDateString("he-IL")}`}
|
{conflict.date && ` · ${formatDateShort(conflict.date)}`}
|
||||||
</p>
|
</p>
|
||||||
{conflict.halacha_extraction_status && (
|
{conflict.halacha_extraction_status && (
|
||||||
<p className="text-danger/60 text-[0.72rem]">
|
<p className="text-danger/60 text-[0.72rem]">
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
useDeleteChat,
|
useDeleteChat,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "@/lib/api/training";
|
} from "@/lib/api/training";
|
||||||
|
import { formatDateShort } from "@/lib/format-date";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export function ChatPanel() {
|
export function ChatPanel() {
|
||||||
@@ -200,7 +201,7 @@ function ConversationsSidebar({
|
|||||||
<span className="tabular-nums">{c.message_count}</span>
|
<span className="tabular-nums">{c.message_count}</span>
|
||||||
<MessageSquare className="w-3 h-3" />
|
<MessageSquare className="w-3 h-3" />
|
||||||
<span className="grow text-end">
|
<span className="grow text-end">
|
||||||
{new Date(c.last_message_at).toLocaleDateString("he-IL")}
|
{formatDateShort(c.last_message_at)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(c.id); }}
|
onClick={(e) => { e.stopPropagation(); onDelete(c.id); }}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useCorpus, useDeleteCorpusEntry, type CorpusDecision } from "@/lib/api/training";
|
import { useCorpus, useDeleteCorpusEntry, type CorpusDecision } from "@/lib/api/training";
|
||||||
import { CorpusDetailDrawer } from "./corpus-detail-drawer";
|
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.
|
* 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`;
|
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({
|
function Row({
|
||||||
item, onOpen,
|
item, onOpen,
|
||||||
}: { item: CorpusDecision; onOpen: () => void }) {
|
}: { item: CorpusDecision; onOpen: () => void }) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
|
import { formatDateShort, formatDateTimeFull } from "@/lib/format-date";
|
||||||
import {
|
import {
|
||||||
useCuratorPrompt,
|
useCuratorPrompt,
|
||||||
useCuratorStats,
|
useCuratorStats,
|
||||||
@@ -256,7 +257,7 @@ function RecentFindings() {
|
|||||||
)}
|
)}
|
||||||
<span className="grow text-ink-muted text-end">
|
<span className="grow text-ink-muted text-end">
|
||||||
<Clock className="w-3 h-3 inline me-1" />
|
<Clock className="w-3 h-3 inline me-1" />
|
||||||
{new Date(f.created_at).toLocaleDateString("he-IL")}
|
{formatDateShort(f.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-ink leading-relaxed">{f.lesson_text}</p>
|
<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>
|
<h3 className="text-navy font-semibold">{data.filename}</h3>
|
||||||
<p className="text-[0.72rem] text-ink-muted">
|
<p className="text-[0.72rem] text-ink-muted">
|
||||||
{data.bytes.toLocaleString("he-IL")} בייטים ·
|
{data.bytes.toLocaleString("he-IL")} בייטים ·
|
||||||
עודכן: {new Date(data.last_modified * 1000).toLocaleString("he-IL")}
|
עודכן: {formatDateTimeFull(data.last_modified * 1000)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning,
|
useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning,
|
||||||
type DraftFinalPair,
|
type DraftFinalPair,
|
||||||
} from "@/lib/api/learning";
|
} from "@/lib/api/learning";
|
||||||
|
import { formatDateShort as fmtDate } from "@/lib/format-date";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /training "למידה" tab (T6/T13) — the reconciliation ledger (INV-LRN4): every
|
* /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",
|
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 }) {
|
function StyleDistanceDetail({ caseNumber }: { caseNumber: string }) {
|
||||||
const { data, isPending, error } = useStyleDistance(caseNumber);
|
const { data, isPending, error } = useStyleDistance(caseNumber);
|
||||||
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
|
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
usePatchLesson,
|
usePatchLesson,
|
||||||
type DecisionLesson,
|
type DecisionLesson,
|
||||||
} from "@/lib/api/training";
|
} from "@/lib/api/training";
|
||||||
|
import { formatDateShort } from "@/lib/format-date";
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ value: "general", label: "כללי" },
|
{ value: "general", label: "כללי" },
|
||||||
@@ -224,7 +225,7 @@ function LessonItem({
|
|||||||
{reviewBadge.label}
|
{reviewBadge.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="grow text-ink-muted tabular-nums">
|
<span className="grow text-ink-muted tabular-nums">
|
||||||
{new Date(lesson.created_at).toLocaleDateString("he-IL")}
|
{formatDateShort(lesson.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,26 @@ 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) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentResetResult = {
|
export type AgentResetResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
reassigned_issues: { id: string; identifier: string }[];
|
reassigned_issues: { id: string; identifier: string }[];
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Single source of truth for the case-status lifecycle (UI-B1 / G2).
|
* Frontend mirror of the canonical case-status lifecycle.
|
||||||
*
|
*
|
||||||
* The 17-status manual menu was trimmed to the **10 core statuses** that the
|
* The ONE source of truth is the backend status model
|
||||||
* pipeline actually sets or that gate real logic. Decorative mid-stage markers
|
* (`mcp-server/src/legal_mcp/case_status_model.py`), exposed at
|
||||||
* (uploading, analyst_verified, research_complete, brainstorming,
|
* `GET /api/status-model`; this file mirrors it (order / labels / phases) so the
|
||||||
* analysis_enriched, ready_for_writing, drafting) plus the legacy/dead
|
* UI keeps compile-time `CaseStatus` typing. Keep the two in sync — the backend
|
||||||
* `in_progress` and `qa_failed` were removed — no pipeline code ever set them.
|
* registry is authoritative (it also declares per-status `on_enter` actions).
|
||||||
*
|
*
|
||||||
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards,
|
* The analyst/research intermediate states (`analyst_verified`,
|
||||||
* compose chip) imports the list / labels / phases from here instead of
|
* `research_complete`) are first-class canonical statuses — the agents set them,
|
||||||
* re-declaring its own array. Keep this file in sync with the backend
|
* so the chip, stepper and manual changer all agree instead of the status
|
||||||
* STATUS_ORDER in `mcp-server/src/legal_mcp/tools/cases.py`.
|
* falling between the canonical set and a legacy bucket.
|
||||||
|
*
|
||||||
|
* Every status consumer (badge, changer, timeline, guide, donut, KPI cards)
|
||||||
|
* imports the list / labels / phases from here instead of re-declaring its own.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Ordered lifecycle — also the order shown in the manual status dropdown. */
|
/** Ordered lifecycle — also the order shown in the manual status dropdown. */
|
||||||
@@ -18,6 +21,8 @@ export const CASE_STATUSES = [
|
|||||||
"new",
|
"new",
|
||||||
"processing",
|
"processing",
|
||||||
"documents_ready",
|
"documents_ready",
|
||||||
|
"analyst_verified",
|
||||||
|
"research_complete",
|
||||||
"outcome_set",
|
"outcome_set",
|
||||||
"direction_approved",
|
"direction_approved",
|
||||||
"qa_review",
|
"qa_review",
|
||||||
@@ -35,21 +40,49 @@ export type PhaseKey = "intake" | "prep" | "thinking" | "writing" | "done";
|
|||||||
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
|
export const PHASES: { key: PhaseKey; label: string; statuses: CaseStatus[] }[] = [
|
||||||
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
|
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "processing"] },
|
||||||
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] },
|
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready"] },
|
||||||
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["outcome_set", "direction_approved"] },
|
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["analyst_verified", "research_complete", "outcome_set", "direction_approved"] },
|
||||||
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
|
{ key: "writing", label: "כתיבת טיוטה", statuses: ["qa_review", "drafted"] },
|
||||||
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 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 {
|
export function phaseOf(status?: CaseStatus | string): PhaseKey | undefined {
|
||||||
if (!status) return 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> = {
|
export const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||||
new: "חדש",
|
new: "חדש",
|
||||||
processing: "בעיבוד",
|
processing: "בעיבוד",
|
||||||
documents_ready: "מסמכים מוכנים",
|
documents_ready: "מסמכים מוכנים",
|
||||||
|
analyst_verified: "ניתוח אומת",
|
||||||
|
research_complete: "מחקר הושלם",
|
||||||
outcome_set: "תוצאה נקבעה",
|
outcome_set: "תוצאה נקבעה",
|
||||||
direction_approved: "כיוון אושר",
|
direction_approved: "כיוון אושר",
|
||||||
qa_review: "בדיקת איכות",
|
qa_review: "בדיקת איכות",
|
||||||
@@ -67,8 +100,6 @@ export const STATUS_LABELS: Record<CaseStatus, string> = {
|
|||||||
const LEGACY_STATUS_LABELS: Record<string, string> = {
|
const LEGACY_STATUS_LABELS: Record<string, string> = {
|
||||||
in_progress: "בעבודה",
|
in_progress: "בעבודה",
|
||||||
uploading: "מעלה",
|
uploading: "מעלה",
|
||||||
analyst_verified: "ניתוח אומת",
|
|
||||||
research_complete: "מחקר הושלם",
|
|
||||||
brainstorming: "סיעור מוחות",
|
brainstorming: "סיעור מוחות",
|
||||||
analysis_enriched: "ניתוח הועמק",
|
analysis_enriched: "ניתוח הועמק",
|
||||||
ready_for_writing: "מוכן לכתיבה",
|
ready_for_writing: "מוכן לכתיבה",
|
||||||
@@ -94,6 +125,8 @@ export const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
|
|||||||
new: "התיק נוצר וממתין להעלאת מסמכים",
|
new: "התיק נוצר וממתין להעלאת מסמכים",
|
||||||
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
||||||
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
||||||
|
analyst_verified: "המנתח סיים ואימת את הניתוח — ממתין להכרעת תוצאה",
|
||||||
|
research_complete: "חקר התקדימים הושלם (מסלול נפרד מהמנתח)",
|
||||||
outcome_set: "נקבעה תוצאה צפויה לערר",
|
outcome_set: "נקבעה תוצאה צפויה לערר",
|
||||||
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
|
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח וכתיבה",
|
||||||
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
||||||
|
|||||||
@@ -84,7 +84,13 @@ export function useCitationVerification(caseNumber: string | undefined) {
|
|||||||
{ signal },
|
{ signal },
|
||||||
),
|
),
|
||||||
enabled: Boolean(caseNumber),
|
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 = {
|
export type DocumentPatch = {
|
||||||
doc_type?: string;
|
doc_type?: string;
|
||||||
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
|
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
|
||||||
|
is_post_hearing?: boolean; // true = received after the hearing (→ group 4); false clears
|
||||||
|
protocol_scope?: string; // "" clears; "appeal" | "lower" (protocol docs only)
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PatchDocumentResponse = {
|
export type PatchDocumentResponse = {
|
||||||
|
|||||||
92
web-ui/src/lib/api/generate.ts
Normal file
92
web-ui/src/lib/api/generate.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 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}-vN.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateKeys = {
|
||||||
|
all: ["generate"] as const,
|
||||||
|
partyClaimsSummary: (caseNumber: string) =>
|
||||||
|
[...generateKeys.all, "party-claims-summary", caseNumber] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── 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.
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: generateKeys.partyClaimsSummary(caseNumber),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Trigger: interim ("party-claims") partial draft (button 2) ── */
|
||||||
|
export function useGenerateInterimDraft(caseNumber: string) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiRequest<GenerateTriggerResult>(
|
||||||
|
`/api/cases/${caseNumber}/generate/interim-draft`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
// The result lands in the exports list (טיוטת-ביניים-*.docx); useExports
|
||||||
|
// already polls on a 5s interval, so no extra invalidation is needed here.
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ export type LegalArgument = {
|
|||||||
id: string;
|
id: string;
|
||||||
case_id: string;
|
case_id: string;
|
||||||
party: LegalArgumentParty;
|
party: LegalArgumentParty;
|
||||||
|
/**
|
||||||
|
* The specific pleading this argument belongs to, for sides that may comprise
|
||||||
|
* several litigants with opposing positions (respondent / permit_applicant
|
||||||
|
* briefs). Empty for single-voice sides (appellant / committee). (#224)
|
||||||
|
*/
|
||||||
|
party_name?: string;
|
||||||
argument_index: number;
|
argument_index: number;
|
||||||
argument_title: string;
|
argument_title: string;
|
||||||
argument_body: string;
|
argument_body: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,79 @@ export function doctypeTone(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Overview groups (display-only convenience buckets) ─────────────
|
||||||
|
//
|
||||||
|
// The case-overview document list is split into four sections purely for
|
||||||
|
// the chair's convenience — grouping NEVER changes a document's stored
|
||||||
|
// doc_type. Classification is derived on the client from doc_type plus two
|
||||||
|
// metadata flags (is_post_hearing, protocol_scope).
|
||||||
|
|
||||||
|
export type DocGroup = "primary" | "ancillary" | "hearing_protocol" | "post_hearing";
|
||||||
|
|
||||||
|
export const DOC_GROUP_LABELS: Record<DocGroup, string> = {
|
||||||
|
primary: "מסמכים עיקריים",
|
||||||
|
ancillary: "מסמכים נלווים",
|
||||||
|
hearing_protocol: "פרוטוקול דיון ועדת הערר",
|
||||||
|
post_hearing: "מסמכים לאחר הדיון",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOC_GROUP_HINTS: Record<DocGroup, string> = {
|
||||||
|
primary: "כתבי ערר, תשובות ושומות שהוגשו לפני הדיון",
|
||||||
|
ancillary: "פרוטוקולי/החלטות ועדה מקומית-מחוזית, תכניות, וכל השאר",
|
||||||
|
hearing_protocol: "הדיון בפני הוועדה",
|
||||||
|
post_hearing: "השלמות ותגובות שהתקבלו לאחר הדיון",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fixed render order of the groups in the overview panel. */
|
||||||
|
export const DOC_GROUP_ORDER: DocGroup[] = [
|
||||||
|
"primary",
|
||||||
|
"ancillary",
|
||||||
|
"hearing_protocol",
|
||||||
|
"post_hearing",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** doc_types that count as "primary" pre-hearing filings (group 1). */
|
||||||
|
const PRIMARY_DOC_TYPES = new Set<DocType>(["appeal", "response", "appraisal"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a document into one of the four overview groups.
|
||||||
|
*
|
||||||
|
* Priority order matters:
|
||||||
|
* 1. is_post_hearing wins over everything — a post-hearing כתב תשובה
|
||||||
|
* belongs in group 4, not group 1.
|
||||||
|
* 2. A protocol goes to group 3 (ועדת הערר) by default, or group 2 when
|
||||||
|
* explicitly tagged as a lower-committee protocol (protocol_scope==="lower").
|
||||||
|
* 3. appeal/response/appraisal → group 1; everything else → group 2.
|
||||||
|
*/
|
||||||
|
export function documentGroup(
|
||||||
|
docType: string,
|
||||||
|
metadata?: Record<string, unknown> | null,
|
||||||
|
): DocGroup {
|
||||||
|
if (metadata?.is_post_hearing === true) return "post_hearing";
|
||||||
|
if (docType === "protocol") {
|
||||||
|
return metadata?.protocol_scope === "lower" ? "ancillary" : "hearing_protocol";
|
||||||
|
}
|
||||||
|
if (PRIMARY_DOC_TYPES.has(docType as DocType)) return "primary";
|
||||||
|
return "ancillary";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Protocol scope (only relevant when doc_type === "protocol") ────
|
||||||
|
// Distinguishes the appeal-committee hearing protocol (group 3) from a
|
||||||
|
// lower (local/district) committee protocol (group 2). Stored in
|
||||||
|
// documents.metadata.protocol_scope; absent === "appeal" (the default).
|
||||||
|
|
||||||
|
export type ProtocolScope = "appeal" | "lower";
|
||||||
|
|
||||||
|
export const PROTOCOL_SCOPE_LABELS: Record<ProtocolScope, string> = {
|
||||||
|
appeal: "ועדת הערר",
|
||||||
|
lower: "ועדה מקומית/מחוזית",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROTOCOL_SCOPE_OPTIONS: { value: ProtocolScope; label: string }[] = [
|
||||||
|
{ value: "appeal", label: PROTOCOL_SCOPE_LABELS.appeal },
|
||||||
|
{ value: "lower", label: PROTOCOL_SCOPE_LABELS.lower },
|
||||||
|
];
|
||||||
|
|
||||||
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
|
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
|
||||||
|
|
||||||
export type AppraiserSide = "committee" | "appellant" | "deciding";
|
export type AppraiserSide = "committee" | "appellant" | "deciding";
|
||||||
|
|||||||
296
web-ui/src/lib/format-date.ts
Normal file
296
web-ui/src/lib/format-date.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* Central date/time formatting — pinned to Israel time (Asia/Jerusalem).
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS (INV-UI9 / G2):
|
||||||
|
* Storage stays UTC end-to-end (Postgres TIMESTAMPTZ, API returns ISO-UTC).
|
||||||
|
* Only the *display* layer is localized, and it is localized **deterministically**:
|
||||||
|
* every human-facing timestamp renders in `Asia/Jerusalem`, regardless of the
|
||||||
|
* host/container/browser timezone. The legal-ai container runs in UTC, so an
|
||||||
|
* ad-hoc `new Date(iso).toLocaleDateString("he-IL")` shows UTC under SSR (and
|
||||||
|
* UTC again on any non-Israel runtime) — that drift is exactly what this module
|
||||||
|
* removes. Pinning `timeZone: "Asia/Jerusalem"` makes SSR and the browser agree.
|
||||||
|
*
|
||||||
|
* This is the ONE date formatter for the UI. Do not format timestamps ad-hoc
|
||||||
|
* with `new Date(...).toLocaleDateString(...)` / `.toLocaleString(...)` /
|
||||||
|
* `.getHours()` rendering elsewhere — route through these helpers so the tz is
|
||||||
|
* always Israel and nothing drifts.
|
||||||
|
*
|
||||||
|
* Number formatting (e.g. `n.toLocaleString("he-IL")`) is NOT a timestamp and is
|
||||||
|
* intentionally out of scope here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TZ = "Asia/Jerusalem";
|
||||||
|
const LOCALE = "he-IL";
|
||||||
|
|
||||||
|
/** Coerce an ISO string / epoch-ms / Date into a valid Date, or null. */
|
||||||
|
function toDate(value: string | number | Date | null | undefined): Date | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const d = value instanceof Date ? value : new Date(value);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date only — `dd/MM/yyyy` in Israel time (e.g. "30/06/2026").
|
||||||
|
* Matches the prior `toLocaleDateString("he-IL", {day,month:"2-digit",year:"numeric"})`.
|
||||||
|
*/
|
||||||
|
export function formatDate(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
fallback = "—",
|
||||||
|
): string {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return fallback;
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
timeZone: TZ,
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date only — long month form (e.g. "30 ביוני 2026") in Israel time.
|
||||||
|
* Matches the prior `toLocaleDateString("he-IL", {day:"numeric",month:"long",year:"numeric"})`.
|
||||||
|
*/
|
||||||
|
export function formatDateLong(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
fallback = "",
|
||||||
|
): string {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return fallback;
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
timeZone: TZ,
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date only — short form, locale default (e.g. "30.6.2026") in Israel time.
|
||||||
|
* Matches the prior bare `toLocaleDateString("he-IL")`.
|
||||||
|
*/
|
||||||
|
export function formatDateShort(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
fallback = "—",
|
||||||
|
): string {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return fallback;
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, { timeZone: TZ }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date + time — `d בMMM yyyy, HH:mm` style in Israel time.
|
||||||
|
* Matches the prior `{day:"numeric",month:"short",year:"numeric",hour,minute}`.
|
||||||
|
*/
|
||||||
|
export function formatDateTime(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
fallback = "—",
|
||||||
|
): string {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return fallback;
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
timeZone: TZ,
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full date + time — locale default with time, Israel time.
|
||||||
|
* Matches the prior bare `toLocaleString("he-IL")`.
|
||||||
|
*/
|
||||||
|
export function formatDateTimeFull(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
fallback = "—",
|
||||||
|
): string {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return fallback;
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
timeZone: TZ,
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time only — `HH:mm` in Israel time (e.g. "14:05").
|
||||||
|
* Matches the prior `toLocaleTimeString("he-IL", {hour,minute})`.
|
||||||
|
*/
|
||||||
|
export function formatTime(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
fallback = "—",
|
||||||
|
): string {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return fallback;
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
timeZone: TZ,
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO calendar date `YYYY-MM-DD` for the Israel-time day of the instant.
|
||||||
|
* Deterministic replacement for `new Date(iso).toISOString().slice(0,10)`,
|
||||||
|
* which yields the UTC day (off-by-one near midnight Israel time).
|
||||||
|
*/
|
||||||
|
export function formatIsoDate(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
fallback = "—",
|
||||||
|
): string {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return fallback;
|
||||||
|
// en-CA's short date is ISO-shaped (YYYY-MM-DD); pin the tz so it's the Israel day.
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: TZ,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar year (number) of the Israel-time day for an instant.
|
||||||
|
* Deterministic replacement for `new Date(iso).getFullYear()` (which uses the
|
||||||
|
* runtime tz). Returns null when the value is absent/unparseable.
|
||||||
|
*/
|
||||||
|
export function getIsraelYear(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return null;
|
||||||
|
const y = new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric" }).format(d);
|
||||||
|
const n = Number(y);
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Israel-time calendar day of an instant as a sortable integer YYYYMMDD,
|
||||||
|
* or null when absent/unparseable. Use this to compare two dates at *day*
|
||||||
|
* precision in Israel time (e.g. hearing-date vs. today) without runtime-tz drift.
|
||||||
|
*/
|
||||||
|
export function israelDayKey(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
const iso = formatIsoDate(value, ""); // "YYYY-MM-DD" or ""
|
||||||
|
if (!iso) return null;
|
||||||
|
const n = Number(iso.replace(/-/g, ""));
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's Israel-time calendar day as a sortable integer YYYYMMDD. */
|
||||||
|
export function todayIsraelDayKey(): number {
|
||||||
|
return israelDayKey(new Date()) as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UTC-ms of midnight (00:00) of the instant's *Israel* calendar day.
|
||||||
|
* Day-precision pivot for ms-based comparisons/diffs (e.g. hearing date vs.
|
||||||
|
* today) that must not drift with the runtime timezone. Returns null when the
|
||||||
|
* value is absent/unparseable. Built from the en-CA YYYY-MM-DD of the Israel day
|
||||||
|
* combined with the Israel UTC offset at that instant, so two values from the
|
||||||
|
* same Israel day always reduce to the same ms.
|
||||||
|
*/
|
||||||
|
export function israelMidnightMs(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
): number | null {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return null;
|
||||||
|
const iso = formatIsoDate(d, ""); // YYYY-MM-DD of the Israel day
|
||||||
|
if (!iso) return null;
|
||||||
|
const [y, m, day] = iso.split("-").map(Number);
|
||||||
|
// Israel wall-clock midnight as a UTC instant: take naive UTC midnight of that
|
||||||
|
// calendar day and subtract Israel's offset (Israel is ahead of UTC).
|
||||||
|
const naiveUtcMidnight = Date.UTC(y, m - 1, day);
|
||||||
|
const utc = new Date(d.toLocaleString("en-US", { timeZone: "UTC" }));
|
||||||
|
const isr = new Date(d.toLocaleString("en-US", { timeZone: TZ }));
|
||||||
|
const offsetMs = isr.getTime() - utc.getTime();
|
||||||
|
return naiveUtcMidnight - offsetMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC-ms of midnight of *today's* Israel calendar day. */
|
||||||
|
export function todayIsraelMidnightMs(): number {
|
||||||
|
return israelMidnightMs(new Date()) as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numeric date/time components of an instant in Israel time. For the rare call
|
||||||
|
* site that builds a bespoke string from parts (e.g. a custom weekday + dd/MM
|
||||||
|
* HH:mm deadline label) and must do so in Israel time rather than the runtime
|
||||||
|
* tz. `weekday` is 0=Sunday … 6=Saturday (matching `Date.getDay()`).
|
||||||
|
* Returns null when the value is absent/unparseable.
|
||||||
|
*/
|
||||||
|
export function israelParts(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
): { year: number; month: number; day: number; hour: number; minute: number; weekday: number } | null {
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return null;
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: TZ,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
weekday: "short",
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(d);
|
||||||
|
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
||||||
|
const WD: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||||
|
let hour = Number(get("hour"));
|
||||||
|
if (hour === 24) hour = 0; // some engines emit "24" for midnight under hour12:false
|
||||||
|
return {
|
||||||
|
year: Number(get("year")),
|
||||||
|
month: Number(get("month")),
|
||||||
|
day: Number(get("day")),
|
||||||
|
hour,
|
||||||
|
minute: Number(get("minute")),
|
||||||
|
weekday: WD[get("weekday")] ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative "time ago" in Hebrew. Timezone-agnostic (it's a diff between two
|
||||||
|
* absolute instants), but centralized here so all relative rendering lives in
|
||||||
|
* one place. `units` selects the existing wording per call site so visible text
|
||||||
|
* is unchanged:
|
||||||
|
* - "long": עכשיו / לפני N דקות / לפני N שעות / לפני N ימים
|
||||||
|
* - "short": עכשיו / לפני N דק׳ / לפני N שע׳ / לפני N ימים
|
||||||
|
* - "tight": לפני Nד' / לפני Nש' / לפני N ימים (no "now" bucket)
|
||||||
|
*/
|
||||||
|
export function formatRelative(
|
||||||
|
value: string | number | Date | null | undefined,
|
||||||
|
opts: { units?: "long" | "short" | "tight"; fallback?: string } = {},
|
||||||
|
): string {
|
||||||
|
const { units = "short", fallback = "" } = opts;
|
||||||
|
const d = toDate(value);
|
||||||
|
if (!d) return fallback;
|
||||||
|
|
||||||
|
const diffMs = Date.now() - d.getTime();
|
||||||
|
const mins = Math.floor(diffMs / 60_000);
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (units === "tight") {
|
||||||
|
if (mins < 60) return `לפני ${mins}ד'`;
|
||||||
|
if (hours < 24) return `לפני ${hours}ש'`;
|
||||||
|
return `לפני ${days} ימים`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = "עכשיו";
|
||||||
|
if (units === "long") {
|
||||||
|
if (mins < 1) return now;
|
||||||
|
if (mins < 60) return `לפני ${mins} דקות`;
|
||||||
|
if (hours < 24) return `לפני ${hours} שעות`;
|
||||||
|
return `לפני ${days} ימים`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "short"
|
||||||
|
if (mins < 1) return now;
|
||||||
|
if (mins < 60) return `לפני ${mins} דק׳`;
|
||||||
|
if (hours < 24) return `לפני ${hours} שע׳`;
|
||||||
|
return `לפני ${days} ימים`;
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ from web.paperclip_client import (
|
|||||||
COMPANIES as PAPERCLIP_COMPANIES,
|
COMPANIES as PAPERCLIP_COMPANIES,
|
||||||
accept_interaction as pc_accept_interaction,
|
accept_interaction as pc_accept_interaction,
|
||||||
archive_project as pc_archive_project,
|
archive_project as pc_archive_project,
|
||||||
|
cancel_interaction as pc_cancel_interaction,
|
||||||
cancel_run as pc_cancel_run,
|
cancel_run as pc_cancel_run,
|
||||||
create_project as pc_create_project,
|
create_project as pc_create_project,
|
||||||
create_workflow_issue as pc_create_workflow_issue,
|
create_workflow_issue as pc_create_workflow_issue,
|
||||||
@@ -50,6 +51,7 @@ from web.paperclip_client import (
|
|||||||
get_run_log as pc_get_run_log,
|
get_run_log as pc_get_run_log,
|
||||||
list_live_runs as pc_list_live_runs,
|
list_live_runs as pc_list_live_runs,
|
||||||
post_comment as pc_post_comment,
|
post_comment as pc_post_comment,
|
||||||
|
reap_stale_interactions as pc_reap_stale_interactions,
|
||||||
reject_interaction as pc_reject_interaction,
|
reject_interaction as pc_reject_interaction,
|
||||||
reset_agent_session as pc_reset_agent_session,
|
reset_agent_session as pc_reset_agent_session,
|
||||||
reset_case_agents as pc_reset_case_agents,
|
reset_case_agents as pc_reset_case_agents,
|
||||||
@@ -59,6 +61,7 @@ from web.paperclip_client import (
|
|||||||
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
wake_analyst_for_appraiser_facts as pc_wake_analyst_for_appraiser_facts,
|
||||||
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
|
wake_analyst_for_argument_aggregation as pc_wake_analyst_for_argument_aggregation,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
|
wake_ceo_for_action as pc_wake_ceo_for_action,
|
||||||
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
wake_ceo_for_feedback_fold as pc_wake_ceo_for_feedback_fold,
|
||||||
wake_curator_for_final as pc_wake_curator_for_final,
|
wake_curator_for_final as pc_wake_curator_for_final,
|
||||||
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
wake_for_precedent_extraction as pc_wake_for_precedent_extraction,
|
||||||
@@ -97,6 +100,7 @@ __all__ = [
|
|||||||
"pc_get_agents_for_case",
|
"pc_get_agents_for_case",
|
||||||
"pc_get_agents",
|
"pc_get_agents",
|
||||||
"pc_wake_ceo",
|
"pc_wake_ceo",
|
||||||
|
"pc_wake_ceo_for_action",
|
||||||
"pc_wake_ceo_for_feedback_fold",
|
"pc_wake_ceo_for_feedback_fold",
|
||||||
"pc_wake_curator_for_final",
|
"pc_wake_curator_for_final",
|
||||||
"pc_wake_for_precedent_extraction",
|
"pc_wake_for_precedent_extraction",
|
||||||
@@ -109,6 +113,8 @@ __all__ = [
|
|||||||
"pc_accept_interaction",
|
"pc_accept_interaction",
|
||||||
"pc_reject_interaction",
|
"pc_reject_interaction",
|
||||||
"pc_respond_to_interaction",
|
"pc_respond_to_interaction",
|
||||||
|
"pc_cancel_interaction",
|
||||||
|
"pc_reap_stale_interactions",
|
||||||
# agent-run observability + control (live view + smart management)
|
# agent-run observability + control (live view + smart management)
|
||||||
"pc_list_live_runs",
|
"pc_list_live_runs",
|
||||||
"pc_get_run_log",
|
"pc_get_run_log",
|
||||||
|
|||||||
199
web/app.py
199
web/app.py
@@ -57,6 +57,7 @@ from web.agent_platform_port import (
|
|||||||
get_project_url,
|
get_project_url,
|
||||||
pc_accept_interaction,
|
pc_accept_interaction,
|
||||||
pc_archive_project,
|
pc_archive_project,
|
||||||
|
pc_cancel_interaction,
|
||||||
pc_cancel_run,
|
pc_cancel_run,
|
||||||
pc_create_project,
|
pc_create_project,
|
||||||
pc_create_workflow_issue,
|
pc_create_workflow_issue,
|
||||||
@@ -69,6 +70,7 @@ from web.agent_platform_port import (
|
|||||||
pc_get_run_log,
|
pc_get_run_log,
|
||||||
pc_list_live_runs,
|
pc_list_live_runs,
|
||||||
pc_post_comment,
|
pc_post_comment,
|
||||||
|
pc_reap_stale_interactions,
|
||||||
pc_reject_interaction,
|
pc_reject_interaction,
|
||||||
pc_request,
|
pc_request,
|
||||||
pc_reset_agent_session,
|
pc_reset_agent_session,
|
||||||
@@ -79,6 +81,7 @@ from web.agent_platform_port import (
|
|||||||
pc_wake_analyst_for_argument_aggregation,
|
pc_wake_analyst_for_argument_aggregation,
|
||||||
rename_case_project,
|
rename_case_project,
|
||||||
pc_wake_ceo,
|
pc_wake_ceo,
|
||||||
|
pc_wake_ceo_for_action,
|
||||||
pc_wake_ceo_for_feedback_fold,
|
pc_wake_ceo_for_feedback_fold,
|
||||||
pc_wake_curator_for_final,
|
pc_wake_curator_for_final,
|
||||||
pc_wake_for_precedent_extraction,
|
pc_wake_for_precedent_extraction,
|
||||||
@@ -100,17 +103,36 @@ PROGRESS_TTL_SECONDS = 300
|
|||||||
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
|
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _interaction_reaper_loop():
|
||||||
|
"""Safety-net reaper for stranded pending interactions (TaskMaster #215).
|
||||||
|
|
||||||
|
Every 15 min, cancel pending interactions on closed (done/cancelled) issues
|
||||||
|
so the chair's "awaiting you" count never accumulates dead questions.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(900)
|
||||||
|
await pc_reap_stale_interactions()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.warning("interaction reaper sweep failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
await db.init_schema()
|
await db.init_schema()
|
||||||
sync_task = asyncio.create_task(git_sync.sweep_loop())
|
sync_task = asyncio.create_task(git_sync.sweep_loop())
|
||||||
|
reaper_task = asyncio.create_task(_interaction_reaper_loop())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
sync_task.cancel()
|
sync_task.cancel()
|
||||||
|
reaper_task.cancel()
|
||||||
|
for _t in (sync_task, reaper_task):
|
||||||
try:
|
try:
|
||||||
await sync_task
|
await _t
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
await db.close_pool()
|
await db.close_pool()
|
||||||
@@ -1828,6 +1850,16 @@ async def health():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/status-model")
|
||||||
|
async def api_status_model():
|
||||||
|
"""Canonical case-status model — ordered statuses (key/label/description/
|
||||||
|
phase/selectable/terminal/on_enter) + the 5 phases. Single source of truth
|
||||||
|
(legal_mcp/case_status_model.py); the frontend case-status.ts mirrors it."""
|
||||||
|
from legal_mcp.case_status_model import to_dict
|
||||||
|
|
||||||
|
return to_dict()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases")
|
@app.get("/api/cases")
|
||||||
async def list_cases(
|
async def list_cases(
|
||||||
detail: bool = False,
|
detail: bool = False,
|
||||||
@@ -3063,6 +3095,103 @@ async def api_research_analysis_export_docx(case_number: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Party-claims executive summary (party-claims-summary.md) — #202 ─
|
||||||
|
# A distilled prose document of the parties' claims, SEPARATE from the interim
|
||||||
|
# draft, to prepare the chair for the hearing. Generation is local-only (the
|
||||||
|
# summarize_party_claims MCP tool via claude_session); these endpoints only
|
||||||
|
# SERVE the saved file — they never generate (the container has no claude CLI).
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/research/party-claims-summary")
|
||||||
|
async def api_party_claims_summary(case_number: str):
|
||||||
|
"""Return the raw markdown of the party-claims executive summary."""
|
||||||
|
from legal_mcp.services import party_claims_summary as pcs
|
||||||
|
path = pcs.summary_file_path(case_number)
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(404, "טרם הופק סיכום-מנהלים לטענות הצדדים לתיק זה")
|
||||||
|
return {"markdown": path.read_text(encoding="utf-8")}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Deterministic document generation triggers (TaskMaster #214) ─────
|
||||||
|
# The "הפקת מסמכים" buttons in the case "טיוטות והערות" tab. Generation for the
|
||||||
|
# executive summary + interim draft runs HOST-SIDE (claude_session → claude -p,
|
||||||
|
# not in the container), so these endpoints fire a *structured-action* CEO wakeup
|
||||||
|
# (the CEO reads payload.action and routes to שלב H2 / שלב H — legal-ceo.md).
|
||||||
|
# Wakeup goes through the Paperclip API helper (pc_wake_ceo_for_action → the
|
||||||
|
# platform Port → POST /api/agents/{id}/wakeup), NEVER a direct DB insert.
|
||||||
|
# Polling for completion uses the existing read endpoints (research/party-claims-
|
||||||
|
# summary for the summary; the exports list for the טיוטת-ביניים-*.docx file).
|
||||||
|
|
||||||
|
|
||||||
|
async def _wake_ceo_action(case_number: str, action: str) -> dict:
|
||||||
|
"""Resolve the case's company + fire a structured-action CEO wakeup (#214)."""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
prefix = case_number[:1]
|
||||||
|
company_id = (
|
||||||
|
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||||
|
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await pc_wake_ceo_for_action(case_number, action, company_id=company_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CEO action %s wakeup failed for %s: %s", action, case_number, e)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/generate/party-claims-summary", status_code=202)
|
||||||
|
async def api_generate_party_claims_summary(case_number: str):
|
||||||
|
"""Fire-and-accept: wake the CEO to generate the party-claims executive summary
|
||||||
|
(שלב H2 → summarize_party_claims). Runs host-side; poll
|
||||||
|
GET /api/cases/{n}/research/party-claims-summary for completion."""
|
||||||
|
return await _wake_ceo_action(case_number, "party_claims_summary")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/generate/interim-draft", status_code=202)
|
||||||
|
async def api_generate_interim_draft(case_number: str):
|
||||||
|
"""Fire-and-accept: wake the CEO to generate the partial "party-claims" draft
|
||||||
|
(שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
|
||||||
|
exports list for the new טיוטת-ביניים-{case}-vN.docx."""
|
||||||
|
return await _wake_ceo_action(case_number, "interim_draft")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
|
||||||
|
async def api_party_claims_summary_download(case_number: str):
|
||||||
|
"""Download the raw party-claims-summary.md file."""
|
||||||
|
from legal_mcp.services import party_claims_summary as pcs
|
||||||
|
path = pcs.summary_file_path(case_number)
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(404, "טרם הופק סיכום-מנהלים לטענות הצדדים לתיק זה")
|
||||||
|
return await serve_blob(
|
||||||
|
path,
|
||||||
|
media_type="text/markdown; charset=utf-8",
|
||||||
|
filename=f"party-claims-summary-{case_number}.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/research/party-claims-summary/export-docx")
|
||||||
|
async def api_party_claims_summary_export_docx(case_number: str):
|
||||||
|
"""Export the party-claims summary as a DOCX using דפנה's template styles."""
|
||||||
|
from legal_mcp.services.analysis_docx_exporter import build_party_claims_summary_docx
|
||||||
|
try:
|
||||||
|
path = await build_party_claims_summary_docx(case_number)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to export party-claims summary DOCX for %s", case_number)
|
||||||
|
raise HTTPException(500, f"שגיאה בייצוא: {e}")
|
||||||
|
case_dir = config.find_case_dir(case_number)
|
||||||
|
if case_dir.exists():
|
||||||
|
commit_and_push(case_dir, f"סיכום-מנהלים: {path.name}")
|
||||||
|
return await serve_blob(
|
||||||
|
path,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
filename=path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/cases/{case_number}/research/analysis/upload")
|
@app.put("/api/cases/{case_number}/research/analysis/upload")
|
||||||
async def api_research_analysis_upload(
|
async def api_research_analysis_upload(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
@@ -4303,6 +4432,30 @@ async def api_post_interaction_response(
|
|||||||
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionDismissRequest(BaseModel):
|
||||||
|
issue_id: str
|
||||||
|
interaction_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/agents/interaction-dismiss")
|
||||||
|
async def api_dismiss_interaction(
|
||||||
|
case_number: str, req: InteractionDismissRequest,
|
||||||
|
):
|
||||||
|
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
|
||||||
|
|
||||||
|
For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
|
||||||
|
this does not resolve via Paperclip's wake_assignee path — it cancels the row
|
||||||
|
so the chair can clear noise without triggering an agent run.
|
||||||
|
"""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
|
result = await pc_cancel_interaction(req.issue_id, req.interaction_id)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(404, result.get("error", "ביטול הבקשה נכשל"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/agents/reset")
|
@app.post("/api/cases/{case_number}/agents/reset")
|
||||||
async def api_reset_case_agents(case_number: str):
|
async def api_reset_case_agents(case_number: str):
|
||||||
"""Reset stuck agents for a case.
|
"""Reset stuck agents for a case.
|
||||||
@@ -5569,13 +5722,21 @@ async def api_reprocess_document(case_number: str, doc_id: str):
|
|||||||
|
|
||||||
|
|
||||||
_ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
|
_ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
|
||||||
|
# metadata.protocol_scope — only meaningful when doc_type == "protocol".
|
||||||
|
# "appeal" = ועדת הערר (default when absent), "lower" = ועדה מקומית/מחוזית.
|
||||||
|
# Used by the overview panel to sort a protocol into group 3 vs group 2.
|
||||||
|
_ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||||
|
|
||||||
|
|
||||||
class DocumentPatchRequest(BaseModel):
|
class DocumentPatchRequest(BaseModel):
|
||||||
"""Patch payload for a single document. Both fields are optional."""
|
"""Patch payload for a single document. All fields are optional; only the
|
||||||
|
ones present are applied. The metadata.* flags are display/processing hints
|
||||||
|
that never change the stored doc_type."""
|
||||||
|
|
||||||
doc_type: str | None = None
|
doc_type: str | None = None
|
||||||
appraiser_side: str | None = None # committee | appellant | deciding | "" to clear
|
appraiser_side: str | None = None # committee | appellant | deciding | "" to clear
|
||||||
|
is_post_hearing: bool | None = None # material submitted after the hearing (→ group 4, block-chet)
|
||||||
|
protocol_scope: str | None = None # appeal | lower | "" to clear (protocol docs only)
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/cases/{case_number}/documents/{doc_id}")
|
@app.patch("/api/cases/{case_number}/documents/{doc_id}")
|
||||||
@@ -5600,6 +5761,13 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
|
|||||||
|
|
||||||
updates: dict = {}
|
updates: dict = {}
|
||||||
|
|
||||||
|
# Load metadata once — appraiser_side, is_post_hearing and protocol_scope
|
||||||
|
# all live in the same JSONB blob, so a single patch may touch several.
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
metadata_dirty = False
|
||||||
|
|
||||||
if req.doc_type is not None:
|
if req.doc_type is not None:
|
||||||
if req.doc_type not in DOC_TYPE_NAMES:
|
if req.doc_type not in DOC_TYPE_NAMES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -5616,13 +5784,34 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
|
|||||||
f"appraiser_side לא תקין: {req.appraiser_side}. ערכים מותרים: "
|
f"appraiser_side לא תקין: {req.appraiser_side}. ערכים מותרים: "
|
||||||
f"{', '.join(sorted(_ALLOWED_APPRAISER_SIDES))}",
|
f"{', '.join(sorted(_ALLOWED_APPRAISER_SIDES))}",
|
||||||
)
|
)
|
||||||
metadata = doc.get("metadata") or {}
|
|
||||||
if isinstance(metadata, str):
|
|
||||||
metadata = json.loads(metadata)
|
|
||||||
if req.appraiser_side:
|
if req.appraiser_side:
|
||||||
metadata["appraiser_side"] = req.appraiser_side
|
metadata["appraiser_side"] = req.appraiser_side
|
||||||
else:
|
else:
|
||||||
metadata.pop("appraiser_side", None)
|
metadata.pop("appraiser_side", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if req.is_post_hearing is not None:
|
||||||
|
# Store True; drop the key entirely when False so absence == "before".
|
||||||
|
if req.is_post_hearing:
|
||||||
|
metadata["is_post_hearing"] = True
|
||||||
|
else:
|
||||||
|
metadata.pop("is_post_hearing", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if req.protocol_scope is not None:
|
||||||
|
if req.protocol_scope and req.protocol_scope not in _ALLOWED_PROTOCOL_SCOPES:
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
f"protocol_scope לא תקין: {req.protocol_scope}. ערכים מותרים: "
|
||||||
|
f"{', '.join(sorted(_ALLOWED_PROTOCOL_SCOPES))}",
|
||||||
|
)
|
||||||
|
if req.protocol_scope:
|
||||||
|
metadata["protocol_scope"] = req.protocol_scope
|
||||||
|
else:
|
||||||
|
metadata.pop("protocol_scope", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if metadata_dirty:
|
||||||
updates["metadata"] = metadata
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user