Compare commits
23 Commits
d1ca1152b1
...
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 |
@@ -222,18 +222,22 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §7. סטטוסי תיק תקפים (case status flow)
|
## §7. סטטוסי תיק (case status flow)
|
||||||
|
|
||||||
הסטטוסים שאתה עשוי לראות ב-`case.status` (לפי `legal-ceo.md` "מפת סטטוסים"):
|
**מקור-האמת היחיד** למודל-הסטטוסים: `mcp-server/src/legal_mcp/case_status_model.py` (חשוף ב-`GET /api/status-model`; ה-enum, ה-`STATUS_ORDER` וה-frontend נגזרים ממנו). 12 הסטטוסים הקנוניים, לפי 5 השלבים:
|
||||||
|
|
||||||
```
|
```
|
||||||
new → proofread → documents_ready → analyst_verified → research_complete*
|
קליטה : new · processing
|
||||||
|
הכנה : documents_ready
|
||||||
|
ניתוח וכיוון : analyst_verified · research_complete · outcome_set · direction_approved
|
||||||
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
|
→ [שער שטן-מליץ: red-team אוטומטי → עצירת-אישור לידים ע"י היו"ר]
|
||||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
כתיבת טיוטה : qa_review · drafted
|
||||||
→ drafted → qa_passed / qa_failed → exported
|
סגירה : exported · reviewed · final
|
||||||
```
|
```
|
||||||
|
|
||||||
`research_complete` — **valid status** (לא legacy מחוסר תוקף). מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח (תרחיש מתקדם). ה-CEO יודע לטפל בו כאילו זה `analyst_verified` (ראה `legal-ceo.md` "מפת סטטוסים").
|
`analyst_verified` ו-`research_complete` הם **סטטוסים קנוניים מהמעלה הראשונה** (לא legacy) — המנתח/חוקר מציבים אותם, ומקומם בשלב "ניתוח וכיוון". `research_complete` מנותב ע"י `legal-researcher.md` שלב 5 כשמחקר תקדימים רץ בנפרד מהמנתח; ה-CEO מטפל בו כמו `analyst_verified`.
|
||||||
|
|
||||||
|
> מצבי-ביניים ישנים שעדיין עשויים להופיע מסוכנים מסוימים (`proofread`, `analysis_enriched`, `ready_for_writing`, `qa_passed`/`qa_failed`) **אינם** בקבוצה הקנונית — הם נמפים-לשלב לתצוגה בלבד (fallback ב-`case-status.ts`) ואינם ניתנים-לבחירה ידנית. אם נדרש לקבע אחד מהם — להוסיף ל-`case_status_model.py` (המקור-היחיד).
|
||||||
|
|
||||||
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.
|
> **שער שטן-מליץ (red-team) — שער-קבע אחרי הניתוח (ל-CEO).** ברגע שהניתוח עבר (`analyst_verified`/`research_complete`, `analysis-and-research.md` תקין), ה-CEO **חייב** להפעיל אוטומטית את שטן-מליץ (Gemini, `gemini_local`) **לפני** הכותב, ואז **לעצור לאישור-יו"ר של הלידים** (issue ראשי ל-`in_review`). רק לידים שהיו"ר אישר מומרים ל-`chair_directions` (`record_chair_feedback` → `get_chair_directions` → `approve_direction`); הלידים הגולמיים **לעולם לא** מוזנים לכותב או להחלטה — קלט-יו"ר בלבד (G10 / INV-AH / INV-LRN5). זרימה מלאה: `legal-ceo.md` "שלב A2"/"שלב A3". זה שער-קבע, לא on-demand.
|
||||||
|
|
||||||
|
|||||||
@@ -806,7 +806,7 @@ ls data/cases/$CASE_NUMBER/documents/research/analysis-and-research.md
|
|||||||
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
| `proofread` | מגיה | → צור issue למנתח משפטי (ראה תבנית למטה) |
|
||||||
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
|
| `documents_ready` | מנתח | → שלב A (בדיקות שלמות + שליליות + מתודולוגיה). אם עובר → עדכן ל-`analyst_verified`, ואז **שלב A2 (שער שטן-מליץ, אוטומטי)** — אל תקפוץ לשלב B לפני שהשער רץ והיו"ר אישר לידים (A3) |
|
||||||
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
| `analyst_verified` | CEO (אחרי שלב A) | → **קודם שער שטן-מליץ:** אם אין `critique-gemini.md` → שלב A2 (הפעל שטן-מליץ אוטומטית). אם יש `critique-gemini.md` שטרם הוצג ליו"ר → שלב A3 (עצירת-אישור לידים). **רק אחרי שהיו"ר אישר/דחה לידים** → שלב B (סיכום + שאלת תוצאה לחיים). המנתח כבר ביצע את המחקר כחלק מהניתוח — אין ליצור issue לחוקר. |
|
||||||
| `research_complete` | מנתח / חוקר תקדימים (valid status — legacy + תרחישים מתקדמים) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
|
| `research_complete` | מנתח / חוקר תקדימים (**סטטוס קנוני**, שלב "ניתוח וכיוון" — מקור-אמת: `case_status_model.py`) | → **קודם שער שטן-מליץ (A2/A3)** כמו ב-`analyst_verified`, ורק אחרי אישור-יו"ר ללידים → שלב B. **זה סטטוס תקף**, לא שגיאה. בזרימה הרגילה המנתח מגדיר `documents_ready`, אבל אם החוקר רץ בנפרד (`legal-researcher.md` שלב 5) הוא מעדכן ל-`research_complete`. אם תראה סטטוס זה, בדוק שגם `analysis-and-research.md` וגם `precedent-research.md` קיימים, ואז המשך לשער שטן-מליץ ואז §B. |
|
||||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||||
|
|||||||
@@ -179,10 +179,11 @@ tools:
|
|||||||
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
בדוק `case_get(case_number).status` — הוא צריך להיות בערכים תקפים. הזרימה הכוללת:
|
||||||
|
|
||||||
```
|
```
|
||||||
new → proofread → documents_ready → analyst_verified → research_complete (legacy/optional)
|
new → processing → documents_ready → analyst_verified → research_complete
|
||||||
→ outcome_set → direction_approved → analysis_enriched → ready_for_writing
|
→ outcome_set → direction_approved → qa_review → drafted (אתה כאן!)
|
||||||
→ drafted (אתה כאן!) → qa_passed / qa_failed → exported
|
→ exported → reviewed → final
|
||||||
```
|
```
|
||||||
|
(מקור-אמת יחיד: `case_status_model.py` / `GET /api/status-model`. `analyst_verified`+`research_complete` קנוניים, שלב "ניתוח וכיוון". מצבי-ביניים ישנים כמו `proofread`/`analysis_enriched`/`qa_passed` נמפים-לשלב לתצוגה בלבד.)
|
||||||
|
|
||||||
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
⚠️ **`research_complete` הוא valid status** (לא bug, לא legacy ערומה). ב-`legal-researcher.md` שלב 5 הוא הסטטוס שהחוקר מגדיר בסיום מחקר. אם תיק במצב זה נשלח אליך לפני `drafted` — דווח, אל תכשיל.
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg
|
|||||||
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
||||||
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
||||||
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
||||||
|
**חלון-הקשר (#216):** הבנייה הסטנדרטית רצה 200K-טוקן (~400K תווים עברית). פרומפט גדול
|
||||||
|
(בעיקר בלוק-י, שנושא את התיק המלא כ-source-context) מוסלם אוטומטית לבניית-ה-1M של *אותו*
|
||||||
|
מודל — `claude-opus-4-8[1m]` (1M-טוקן, מחיר רגיל; `claude -p` חושף אותה ישירות כ-model-id,
|
||||||
|
בלי beta-header). **עידון של אותו pin, לא מסלול-מודל מקביל (G2).** הסף ב-`block_writer`:
|
||||||
|
פרומפט > ~350K תווים → `[1m]`; אחרת הבנייה הסטנדרטית. תקרת-קשיחה: 1.5M תווים.
|
||||||
|
|
||||||
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
||||||
|
|
||||||
|
|||||||
@@ -759,13 +759,15 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def analyze_protocol(case_number: str) -> str:
|
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
||||||
|
|
||||||
מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' +
|
בוחר אוטומטית פרוטוקול ועדת-ערר (scope שאינו 'lower'); document_id מכוון לדיון
|
||||||
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent).
|
מדויק כשיש כמה פרוטוקולים. מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול
|
||||||
|
doc_type='protocol' + טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high;
|
||||||
|
re-run מחליף (idempotent).
|
||||||
"""
|
"""
|
||||||
return await drafting.analyze_protocol(case_number)
|
return await drafting.analyze_protocol(case_number, document_id=document_id)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Allowed enum values mirror the DB CHECK constraints.
|
# Allowed enum values mirror the DB CHECK constraints.
|
||||||
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
ALLOWED_PARTIES = {"appellant", "respondent", "committee", "permit_applicant", "unknown"}
|
||||||
|
# Sides that may comprise multiple distinct litigants with potentially opposing
|
||||||
|
# positions — aggregated per source pleading (party_name) rather than collapsed
|
||||||
|
# into one bucket (#224). Appellant/committee speak with a single voice.
|
||||||
|
SPLIT_PARTIES = {"respondent", "permit_applicant"}
|
||||||
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
ALLOWED_PRIORITIES = {"threshold", "substantive", "procedural", "relief"}
|
||||||
|
|
||||||
# Hebrew labels for the prompt (Claude needs context in the same
|
# Hebrew labels for the prompt (Claude needs context in the same
|
||||||
@@ -81,7 +85,7 @@ AGGREGATE_PROMPT_TEMPLATE = """אתה מנתח כתבי טענות בתחום ת
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(party: str, propositions: list[dict]) -> str:
|
def _build_prompt(party: str, propositions: list[dict], party_name: str = "") -> str:
|
||||||
"""Compose the per-party aggregation prompt."""
|
"""Compose the per-party aggregation prompt."""
|
||||||
n = len(propositions)
|
n = len(propositions)
|
||||||
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
# Conservative target: ~1 argument per 2-3 propositions, clamped 4-12.
|
||||||
@@ -89,6 +93,11 @@ def _build_prompt(party: str, propositions: list[dict]) -> str:
|
|||||||
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
target_max = max(target_min + 1, min(12, n // 2 + 1))
|
||||||
|
|
||||||
party_he = PARTY_LABELS_HE.get(party, party)
|
party_he = PARTY_LABELS_HE.get(party, party)
|
||||||
|
# For a split side (e.g. a specific respondent brief), name the brief so
|
||||||
|
# Claude aggregates only that litigant's position and does not conflate it
|
||||||
|
# with a co-respondent's separate — possibly opposing — pleading (#224).
|
||||||
|
if party_name:
|
||||||
|
party_he = f"{party_he} — {party_name}"
|
||||||
# Strip noise from propositions for the prompt — Claude only needs
|
# Strip noise from propositions for the prompt — Claude only needs
|
||||||
# the id and the text to do the grouping.
|
# the id and the text to do the grouping.
|
||||||
compact = [
|
compact = [
|
||||||
@@ -139,12 +148,17 @@ def _normalize_argument(raw: dict, fallback_topic: str = "") -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
async def _aggregate_party(
|
async def _aggregate_party(
|
||||||
party: str, propositions: list[dict],
|
party: str, propositions: list[dict], party_name: str = "",
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Ask Claude to group one party's propositions; return normalized rows."""
|
"""Ask Claude to group one party's propositions; return normalized rows.
|
||||||
|
|
||||||
|
``party_name`` names the specific pleading when this is a split side
|
||||||
|
(respondent / permit_applicant brief), so the prompt scopes to that
|
||||||
|
litigant's position only (#224).
|
||||||
|
"""
|
||||||
if not propositions:
|
if not propositions:
|
||||||
return []
|
return []
|
||||||
prompt = _build_prompt(party, propositions)
|
prompt = _build_prompt(party, propositions, party_name=party_name)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
raw_result = await claude_session.query_json(prompt, tools="") # no tool_use → no error_max_turns
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -146,8 +146,15 @@ async def query(
|
|||||||
"""
|
"""
|
||||||
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
full_prompt = f"{system}\n\n{prompt}" if system else prompt
|
||||||
|
|
||||||
if len(full_prompt) > 150_000:
|
# ~350K chars ≈ the 200K-token wall of the default Opus build (~2 Hebrew
|
||||||
logger.warning("Large prompt: %d chars — may hit context limits", len(full_prompt))
|
# chars/token). Above it callers should pass the 1M-context model id
|
||||||
|
# (`claude-opus-4-8[1m]`) — block_writer escalates automatically (#216).
|
||||||
|
if len(full_prompt) > 350_000 and "[1m]" not in (model or ""):
|
||||||
|
logger.warning(
|
||||||
|
"Large prompt: %d chars on a non-[1m] model (%s) — may overflow the "
|
||||||
|
"200K-token context. Pass claude-opus-4-8[1m] for the 1M window.",
|
||||||
|
len(full_prompt), model or "CLI-default",
|
||||||
|
)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"claude", "-p",
|
"claude", "-p",
|
||||||
|
|||||||
@@ -1888,6 +1888,18 @@ CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# V50 (#224): per-respondent-brief separation. The aggregator groups respondent /
|
||||||
|
# permit_applicant claims by their source pleading — each joint response brief
|
||||||
|
# (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") is one coherent litigation
|
||||||
|
# position, so opposing briefs no longer collapse into a single "respondent"
|
||||||
|
# bucket. ``party_name`` carries the brief label; appellant/committee stay ''.
|
||||||
|
SCHEMA_V50_SQL = """
|
||||||
|
ALTER TABLE legal_arguments ADD COLUMN IF NOT EXISTS party_name TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name
|
||||||
|
ON legal_arguments(case_id, party, party_name);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
# re-runs the idempotent migrations on startup; without this lock two processes
|
# re-runs the idempotent migrations on startup; without this lock two processes
|
||||||
@@ -1959,6 +1971,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V47_SQL)
|
await conn.execute(SCHEMA_V47_SQL)
|
||||||
await conn.execute(SCHEMA_V48_SQL)
|
await conn.execute(SCHEMA_V48_SQL)
|
||||||
await conn.execute(SCHEMA_V49_SQL)
|
await conn.execute(SCHEMA_V49_SQL)
|
||||||
|
await conn.execute(SCHEMA_V50_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
|
|||||||
@@ -120,15 +120,47 @@ HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול די
|
|||||||
MAX_PROTOCOL_CHARS = 120_000
|
MAX_PROTOCOL_CHARS = 120_000
|
||||||
|
|
||||||
|
|
||||||
def _find_protocol(docs: list[dict]) -> dict | None:
|
def _protocol_scope(doc: dict) -> str:
|
||||||
"""The protocol document for the case, if present (doc_type or title)."""
|
"""Protocol scope from ``metadata.protocol_scope``.
|
||||||
for d in docs:
|
|
||||||
if d.get("doc_type") == "protocol":
|
Absent/'' defaults to ``'appeal'`` (ועדת הערר), matching the
|
||||||
return d
|
document-type-editor convention where only the non-default ``'lower'``
|
||||||
for d in docs:
|
(ועדה מקומית/מחוזית) is persisted.
|
||||||
if "פרוטוקול" in (d.get("title") or ""):
|
"""
|
||||||
return d
|
meta = doc.get("metadata") or {}
|
||||||
return None
|
return (meta.get("protocol_scope") or "").strip() or "appeal"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_protocol(
|
||||||
|
docs: list[dict], document_id: UUID | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
"""The ועדת-הערר hearing protocol to compare against the pleadings.
|
||||||
|
|
||||||
|
- ``document_id`` given → return exactly that document (explicit target),
|
||||||
|
so a case with several protocols can be pointed at the right hearing.
|
||||||
|
- Otherwise prefer a protocol scoped to the appeals committee
|
||||||
|
(``scope != 'lower'``). A ``'lower'`` protocol is the local/district
|
||||||
|
committee's proceedings — it feeds background (block ו) only and must NOT
|
||||||
|
drive the hearing-vs-pleadings comparison (#223). If every protocol is
|
||||||
|
lower-scoped there is no ערר-hearing to compare → return None.
|
||||||
|
- Among appeal-scoped protocols the most recent (by ``created_at``) wins —
|
||||||
|
the operative hearing; ties fall back to list order.
|
||||||
|
"""
|
||||||
|
if document_id is not None:
|
||||||
|
return next(
|
||||||
|
(d for d in docs if str(d.get("id")) == str(document_id)), None,
|
||||||
|
)
|
||||||
|
|
||||||
|
protocols = [d for d in docs if d.get("doc_type") == "protocol"]
|
||||||
|
if not protocols:
|
||||||
|
# Legacy fallback: untyped docs whose title says "פרוטוקול".
|
||||||
|
protocols = [d for d in docs if "פרוטוקול" in (d.get("title") or "")]
|
||||||
|
|
||||||
|
appeal_scoped = [d for d in protocols if _protocol_scope(d) != "lower"]
|
||||||
|
if not appeal_scoped:
|
||||||
|
return None
|
||||||
|
appeal_scoped.sort(key=lambda d: d.get("created_at") or "", reverse=True)
|
||||||
|
return appeal_scoped[0]
|
||||||
|
|
||||||
|
|
||||||
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
||||||
@@ -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 {
|
||||||
|
"status": "no_protocol",
|
||||||
|
"message": f"מסמך {target_document_id} לא נמצא בתיק.",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"status": "no_protocol",
|
"status": "no_protocol",
|
||||||
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.",
|
"message": (
|
||||||
|
"לא נמצא פרוטוקול ועדת-ערר בתיק (doc_type='protocol' עם "
|
||||||
|
"protocol_scope שאינו 'lower'). העלה פרוטוקול-דיון, או תייג את "
|
||||||
|
"הפרוטוקול הקיים כ-appeal, והרץ שוב."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
document_id = UUID(protocol["id"])
|
document_id = UUID(protocol["id"])
|
||||||
|
|||||||
@@ -433,17 +433,24 @@ ALLOWED_DOC_TYPES = {
|
|||||||
# Allowed appraiser_side values; '' (empty) clears the tag.
|
# Allowed appraiser_side values; '' (empty) clears the tag.
|
||||||
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
||||||
|
|
||||||
|
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
|
||||||
|
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
|
||||||
|
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||||
|
|
||||||
|
|
||||||
async def document_update(
|
async def document_update(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
doc_type: str = "",
|
doc_type: str = "",
|
||||||
appraiser_side: str = "",
|
appraiser_side: str = "",
|
||||||
|
is_post_hearing: bool | None = None,
|
||||||
|
protocol_scope: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
|
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
|
||||||
|
|
||||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
|
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
|
||||||
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
|
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
|
||||||
|
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר (לאישור שייכות)
|
case_number: מספר תיק הערר (לאישור שייכות)
|
||||||
@@ -452,6 +459,9 @@ async def document_update(
|
|||||||
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
||||||
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
||||||
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
||||||
|
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
|
||||||
|
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
|
||||||
|
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
|
||||||
"""
|
"""
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
@@ -477,17 +487,37 @@ async def document_update(
|
|||||||
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
||||||
updates["doc_type"] = doc_type
|
updates["doc_type"] = doc_type
|
||||||
|
|
||||||
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
# appraiser_side / protocol_scope are optional. The MCP tool can't
|
||||||
# "set to empty string", so we use the convention: only update if non-empty.
|
# distinguish "skip" from "set to empty string", so we use the convention:
|
||||||
# To clear, the operator must edit metadata directly (rare).
|
# only update if non-empty. To clear, edit metadata directly (rare).
|
||||||
|
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
metadata_dirty = False
|
||||||
|
|
||||||
if appraiser_side:
|
if appraiser_side:
|
||||||
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||||
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
||||||
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
||||||
metadata = doc.get("metadata") or {}
|
|
||||||
if isinstance(metadata, str):
|
|
||||||
metadata = json.loads(metadata)
|
|
||||||
metadata["appraiser_side"] = appraiser_side
|
metadata["appraiser_side"] = appraiser_side
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if is_post_hearing is not None:
|
||||||
|
if is_post_hearing:
|
||||||
|
metadata["is_post_hearing"] = True
|
||||||
|
else:
|
||||||
|
metadata.pop("is_post_hearing", None)
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if protocol_scope:
|
||||||
|
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
|
||||||
|
return err(f"protocol_scope לא תקין: {protocol_scope}",
|
||||||
|
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
|
||||||
|
metadata["protocol_scope"] = protocol_scope
|
||||||
|
metadata_dirty = True
|
||||||
|
|
||||||
|
if metadata_dirty:
|
||||||
updates["metadata"] = metadata
|
updates["metadata"] = metadata
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
return err(str(e))
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def analyze_protocol(case_number: str) -> str:
|
async def analyze_protocol(case_number: str, document_id: str = "") -> str:
|
||||||
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
||||||
|
|
||||||
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
||||||
@@ -588,12 +588,17 @@ async def analyze_protocol(case_number: str) -> str:
|
|||||||
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
||||||
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
||||||
|
|
||||||
|
בוחר אוטומטית את פרוטוקול **ועדת-הערר** (protocol_scope שאינו 'lower');
|
||||||
|
פרוטוקול ועדה-מקומית ('lower') אינו נכנס להשוואה. כשיש כמה פרוטוקולי-ערר —
|
||||||
|
ציין document_id כדי לכוון לדיון המדויק (#223).
|
||||||
|
|
||||||
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
||||||
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
||||||
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
|
document_id: מזהה מסמך-הפרוטוקול לכיוון מדויק (ריק = בחירה אוטומטית)
|
||||||
"""
|
"""
|
||||||
from legal_mcp.services import protocol_analyzer
|
from legal_mcp.services import protocol_analyzer
|
||||||
|
|
||||||
@@ -601,8 +606,16 @@ async def analyze_protocol(case_number: str) -> str:
|
|||||||
if not case:
|
if not case:
|
||||||
return err(f"תיק {case_number} לא נמצא.")
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
case_id = UUID(case["id"])
|
case_id = UUID(case["id"])
|
||||||
|
target_doc_id: UUID | None = None
|
||||||
|
if document_id.strip():
|
||||||
|
try:
|
||||||
|
target_doc_id = UUID(document_id.strip())
|
||||||
|
except ValueError:
|
||||||
|
return err(f"document_id לא תקין: {document_id}")
|
||||||
try:
|
try:
|
||||||
result = await protocol_analyzer.analyze_protocol(case_id)
|
result = await protocol_analyzer.analyze_protocol(
|
||||||
|
case_id, target_document_id=target_doc_id,
|
||||||
|
)
|
||||||
await audit.log_action_safe(
|
await audit.log_action_safe(
|
||||||
"analyze_protocol", case_id=case_id,
|
"analyze_protocol", case_id=case_id,
|
||||||
details={"status": result.get("status"), "total": result.get("total", 0)},
|
details={"status": result.get("status"), "total": result.get("total", 0)},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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):* (א) "הסבת שטחים" ממחיר-למשתכן לשוק-חופשי בעקבות הקלת תוספת יח"ד = **השבחה תכנונית בת-חיוב** — להכריע בחיוב, לא לגדֵר כשאלה פתוחה או להטיל ספק מכוח קשר-סיבתי (בית הכרם/פמיני). (ב) שומה מכרעת חלוטה סמוכה = **משכנעת, לא מחייבת**; שומה מנומקת רשאית לסטות ("לא נוכל להיאחז בשומה מכרעת אחרת רק מכיוון והיא קיימת") — לא להעמיס דוקטרינת שוויון לטובת העוררת. (ג) שמאי מכריע פרש → מינוי **שמאי מייעץ חדש** (סמכות אשקלוני/גנדליס).
|
||||||
|
|
||||||
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
|
**ערר היטל השבחה — מסגרת תלת-שכבתית לניתוח "תכנית צל":**
|
||||||
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:
|
כשעורר טוען ש"תכנית צל" מאושרת הופכת זכויות להקלה לזכויות מוקנות, הניתוח מתבצע בשלוש שכבות נפרדות:
|
||||||
|
|||||||
@@ -104,12 +104,6 @@ export default function CaseDetailPage({
|
|||||||
<>
|
<>
|
||||||
{data && <CaseEditDialog data={data} />}
|
{data && <CaseEditDialog data={data} />}
|
||||||
<UploadSheet caseNumber={caseNumber} />
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
<Button
|
|
||||||
className="bg-gold text-white hover:bg-gold-deep border-transparent"
|
|
||||||
onClick={() => setTab("decision")}
|
|
||||||
>
|
|
||||||
פתח עורך החלטה
|
|
||||||
</Button>
|
|
||||||
{canStartWorkflow && (
|
{canStartWorkflow && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Markdown } from "@/components/ui/markdown";
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
|
||||||
import {
|
import {
|
||||||
useAgentActivity,
|
useAgentActivity,
|
||||||
useSendComment,
|
useSendComment,
|
||||||
useSubmitInteraction,
|
useSubmitInteraction,
|
||||||
|
useDismissInteraction,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
import type {
|
import type {
|
||||||
Interaction,
|
Interaction,
|
||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
InteractionQuestion,
|
InteractionQuestion,
|
||||||
InteractionTask,
|
InteractionTask,
|
||||||
PaperclipComment,
|
PaperclipComment,
|
||||||
|
PaperclipAgentStatus,
|
||||||
|
PaperclipIssue,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
import { formatRelative } from "@/lib/format-date";
|
import { formatRelative } from "@/lib/format-date";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -30,6 +32,8 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
ChevronDown,
|
||||||
|
Ban,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ── Role → color mapping ────────────────────────────────────── */
|
/* ── Role → color mapping ────────────────────────────────────── */
|
||||||
@@ -82,6 +86,79 @@ function issueStatusLabel(status: string) {
|
|||||||
return ISSUE_STATUS_LABELS[status] ?? status;
|
return ISSUE_STATUS_LABELS[status] ?? status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Agent roster (mockup 18i) — who's doing what right now ────── */
|
||||||
|
|
||||||
|
const AGENT_STATUS_LABEL: Record<string, string> = {
|
||||||
|
active: "פעיל",
|
||||||
|
running: "פעיל",
|
||||||
|
idle: "ממתין",
|
||||||
|
error: "שגיאה",
|
||||||
|
};
|
||||||
|
|
||||||
|
function agentIsActive(s: string) {
|
||||||
|
return s === "active" || s === "running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentStatusDot(s: string) {
|
||||||
|
return agentIsActive(s)
|
||||||
|
? "bg-success shadow-[0_0_6px_rgba(74,124,89,0.6)]"
|
||||||
|
: s === "error"
|
||||||
|
? "bg-danger"
|
||||||
|
: "bg-rule";
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentStatusTone(s: string) {
|
||||||
|
return agentIsActive(s)
|
||||||
|
? "bg-success-bg text-success"
|
||||||
|
: s === "error"
|
||||||
|
? "bg-danger-bg text-danger"
|
||||||
|
: "bg-rule-soft text-ink-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
|
||||||
|
if (!agents.length) return null;
|
||||||
|
|
||||||
|
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
|
<Bot className="w-4 h-4 text-gold-deep shrink-0" />
|
||||||
|
<h3 className="text-[0.82rem] font-bold text-navy">סוכני התיק</h3>
|
||||||
|
<span className="text-[0.72rem] text-ink-muted">
|
||||||
|
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Compact roster — all agents on one row (mockup 18i v2): status · name ·
|
||||||
|
role only. The per-agent "current activity" line was removed: it
|
||||||
|
matched comments by role-fallback, so agents sharing a role showed the
|
||||||
|
same text. */}
|
||||||
|
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
|
||||||
|
{agents.map((a) => {
|
||||||
|
const active = agentIsActive(a.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
||||||
|
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
|
||||||
|
<span
|
||||||
|
className={`inline-block mt-1 text-[0.55rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
|
||||||
|
>
|
||||||
|
{AGENT_STATUS_LABEL[a.status] ?? a.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Time formatting ─────────────────────────────────────────── */
|
/* ── Time formatting ─────────────────────────────────────────── */
|
||||||
|
|
||||||
function timeAgo(iso: string | null): string {
|
function timeAgo(iso: string | null): string {
|
||||||
@@ -507,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(
|
||||||
@@ -532,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 ${
|
||||||
@@ -554,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">
|
||||||
<span className="text-sm font-semibold text-navy">
|
|
||||||
{interaction.title || "שאלה לסוכן"}
|
|
||||||
</span>
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
<button
|
||||||
ממתין לתשובה
|
type="button"
|
||||||
</span>
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex items-center gap-2 flex-wrap text-start min-w-0"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-navy">
|
||||||
|
{interaction.title || "שאלה לסוכן"}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
|
||||||
|
ממתין לתשובה
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<ResolvedBadge status={interaction.status} />
|
<>
|
||||||
|
<span className="text-sm font-semibold text-navy">
|
||||||
|
{interaction.title || "שאלה לסוכן"}
|
||||||
|
</span>
|
||||||
|
<ResolvedBadge status={interaction.status} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{identifier && (
|
{identifier && (
|
||||||
<Badge variant="outline" className="text-[10px] font-mono">
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
@@ -573,39 +681,58 @@ function InteractionCard({
|
|||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
{isPending && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
disabled={dismiss.isPending}
|
||||||
|
className="h-7 px-2 text-ink-muted hover:text-danger"
|
||||||
|
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
|
||||||
|
>
|
||||||
|
{dismiss.isPending ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Ban className="w-3.5 h-3.5 me-1" />
|
||||||
|
)}
|
||||||
|
התעלם
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{interaction.summary && (
|
{interaction.summary && (!isPending || open) && (
|
||||||
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
interaction.kind === "ask_user_questions" ? (
|
open ? (
|
||||||
<AskUserQuestionsForm
|
interaction.kind === "ask_user_questions" ? (
|
||||||
interaction={interaction}
|
<AskUserQuestionsForm
|
||||||
onSubmit={(answers) => send("respond", { answers })}
|
interaction={interaction}
|
||||||
pending={submit.isPending}
|
onSubmit={(answers) => send("respond", { answers })}
|
||||||
/>
|
pending={submit.isPending}
|
||||||
) : interaction.kind === "request_confirmation" ? (
|
/>
|
||||||
<RequestConfirmationForm
|
) : interaction.kind === "request_confirmation" ? (
|
||||||
interaction={interaction}
|
<RequestConfirmationForm
|
||||||
onAccept={() => send("accept", {})}
|
interaction={interaction}
|
||||||
onReject={(reason) =>
|
onAccept={() => send("accept", {})}
|
||||||
send("reject", reason ? { reason } : {})
|
onReject={(reason) =>
|
||||||
}
|
send("reject", reason ? { reason } : {})
|
||||||
pending={submit.isPending}
|
}
|
||||||
/>
|
pending={submit.isPending}
|
||||||
) : interaction.kind === "suggest_tasks" ? (
|
/>
|
||||||
<SuggestTasksForm
|
) : interaction.kind === "suggest_tasks" ? (
|
||||||
interaction={interaction}
|
<SuggestTasksForm
|
||||||
onAccept={(keys) =>
|
interaction={interaction}
|
||||||
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
onAccept={(keys) =>
|
||||||
}
|
send("accept", keys.length ? { selectedClientKeys: keys } : {})
|
||||||
onReject={(reason) =>
|
}
|
||||||
send("reject", reason ? { reason } : {})
|
onReject={(reason) =>
|
||||||
}
|
send("reject", reason ? { reason } : {})
|
||||||
pending={submit.isPending}
|
}
|
||||||
/>
|
pending={submit.isPending}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
) : null
|
) : null
|
||||||
) : summary ? (
|
) : summary ? (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
@@ -617,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({
|
||||||
@@ -690,25 +891,46 @@ export function AgentActivityFeed({
|
|||||||
|
|
||||||
const comments = data.comments ?? [];
|
const comments = data.comments ?? [];
|
||||||
const interactions = data.interactions ?? [];
|
const interactions = data.interactions ?? [];
|
||||||
const pendingCount = interactions.filter((i) => i.status === "pending").length;
|
// 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.
|
||||||
@@ -718,35 +940,43 @@ export function AgentActivityFeed({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* agent roster — who's doing what right now (mockup 18i) */}
|
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||||
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 px-3 py-2.5">
|
<AgentRoster agents={data.agents ?? []} />
|
||||||
<AgentStatusWidget caseNumber={caseNumber} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* pending interactions surfaced to the top (mockup 18i — awaiting you) */}
|
{/* pending interactions surfaced to the top — each an accordion, first
|
||||||
{pendingCount > 0 && (
|
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
|
||||||
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2.5 text-[0.82rem] font-semibold text-amber-800 flex items-center gap-2">
|
{pending.length > 0 && (
|
||||||
<Clock className="w-4 h-4" />
|
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||||
ממתין לתשובתך — {pendingCount} {pendingCount === 1 ? "בקשה" : "בקשות"} בהמשך הזרם
|
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
ממתין לתשובתך
|
||||||
|
<span className="text-[0.7rem] font-semibold bg-amber-100 rounded-full px-2 py-0.5">
|
||||||
|
{pending.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{pending.map((i, idx) => (
|
||||||
|
<InteractionCard
|
||||||
|
key={`pending-${i.id}`}
|
||||||
|
interaction={i}
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
issueMap={issueMap}
|
||||||
|
defaultOpen={idx === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Issue summary bar */}
|
{/* Timeline header */}
|
||||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
|
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
|
||||||
{data.issues.map((iss) => (
|
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||||
<Badge
|
<span className="text-[0.72rem] text-ink-muted">
|
||||||
key={iss.id}
|
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||||
variant={iss.status === "done" ? "secondary" : "default"}
|
</span>
|
||||||
className="text-[11px] font-mono"
|
|
||||||
>
|
|
||||||
{iss.identifier} — {issueStatusLabel(iss.status)}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments + interactions stream */}
|
{/* Per-issue accordions */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||||
{feed.length === 0 ? (
|
{totalFeedItems === 0 ? (
|
||||||
hasActiveIssue ? (
|
hasActiveIssue ? (
|
||||||
<div className="text-center py-8 text-ink-faint text-sm">
|
<div className="text-center py-8 text-ink-faint text-sm">
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
@@ -759,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}
|
caseNumber={caseNumber}
|
||||||
/>
|
issueMap={issueMap}
|
||||||
) : (
|
defaultOpen={
|
||||||
<InteractionCard
|
idx === 0 ||
|
||||||
key={`i-${item.interaction.id}`}
|
(iss.status !== "done" && iss.status !== "cancelled")
|
||||||
interaction={item.interaction}
|
}
|
||||||
caseNumber={caseNumber}
|
/>
|
||||||
issueMap={issueMap}
|
))
|
||||||
/>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
<div ref={endRef} />
|
<div ref={endRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
APPRAISER_SIDE_LABELS,
|
APPRAISER_SIDE_LABELS,
|
||||||
APPRAISER_SIDE_OPTIONS,
|
APPRAISER_SIDE_OPTIONS,
|
||||||
DOC_TYPE_OPTIONS,
|
DOC_TYPE_OPTIONS,
|
||||||
|
PROTOCOL_SCOPE_OPTIONS,
|
||||||
appraiserSideLabel,
|
appraiserSideLabel,
|
||||||
doctypeLabel,
|
doctypeLabel,
|
||||||
doctypeTone,
|
doctypeTone,
|
||||||
@@ -29,14 +31,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
useExtractAppraiserFacts,
|
useExtractAppraiserFacts,
|
||||||
usePatchDocument,
|
usePatchDocument,
|
||||||
|
type DocumentPatch,
|
||||||
type ExtractAppraiserFactsResponse,
|
type ExtractAppraiserFactsResponse,
|
||||||
} from "@/lib/api/documents";
|
} from "@/lib/api/documents";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
||||||
* Popover with two Selects:
|
* Popover with:
|
||||||
* 1. doc_type (always shown)
|
* 1. doc_type (always shown)
|
||||||
* 2. appraiser_side (only when doc_type === "appraisal")
|
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||||
|
* 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית
|
||||||
|
* 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4
|
||||||
*
|
*
|
||||||
* After a successful save we swap the Popover body to a confirmation view
|
* After a successful save we swap the Popover body to a confirmation view
|
||||||
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
|
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
|
||||||
@@ -49,15 +54,25 @@ export function DocumentTypeEditor({
|
|||||||
docId,
|
docId,
|
||||||
docType,
|
docType,
|
||||||
appraiserSide,
|
appraiserSide,
|
||||||
|
isPostHearing,
|
||||||
|
protocolScope,
|
||||||
}: {
|
}: {
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
docType: string;
|
docType: string;
|
||||||
appraiserSide?: string;
|
appraiserSide?: string;
|
||||||
|
isPostHearing?: boolean;
|
||||||
|
protocolScope?: string;
|
||||||
}) {
|
}) {
|
||||||
|
// Effective stored scope: absent === "appeal" (ועדת הערר) by convention.
|
||||||
|
const storedScope = protocolScope === "lower" ? "lower" : "appeal";
|
||||||
|
const storedPost = isPostHearing === true;
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [draftType, setDraftType] = useState<string>(docType || "");
|
const [draftType, setDraftType] = useState<string>(docType || "");
|
||||||
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
||||||
|
const [draftScope, setDraftScope] = useState<string>(storedScope);
|
||||||
|
const [draftPost, setDraftPost] = useState<boolean>(storedPost);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [extractResult, setExtractResult] =
|
const [extractResult, setExtractResult] =
|
||||||
useState<ExtractAppraiserFactsResponse | null>(null);
|
useState<ExtractAppraiserFactsResponse | null>(null);
|
||||||
@@ -67,6 +82,8 @@ export function DocumentTypeEditor({
|
|||||||
function reset() {
|
function reset() {
|
||||||
setDraftType(docType || "");
|
setDraftType(docType || "");
|
||||||
setDraftSide(appraiserSide || "");
|
setDraftSide(appraiserSide || "");
|
||||||
|
setDraftScope(storedScope);
|
||||||
|
setDraftPost(storedPost);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
setExtractResult(null);
|
setExtractResult(null);
|
||||||
patch.reset();
|
patch.reset();
|
||||||
@@ -74,14 +91,17 @@ export function DocumentTypeEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAppraisal = draftType === "appraisal";
|
const isAppraisal = draftType === "appraisal";
|
||||||
|
const isProtocol = draftType === "protocol";
|
||||||
const sideMissing = isAppraisal && !draftSide;
|
const sideMissing = isAppraisal && !draftSide;
|
||||||
const dirty =
|
const dirty =
|
||||||
draftType !== docType ||
|
draftType !== docType ||
|
||||||
(isAppraisal && draftSide !== (appraiserSide || ""));
|
(isAppraisal && draftSide !== (appraiserSide || "")) ||
|
||||||
|
(isProtocol && draftScope !== storedScope) ||
|
||||||
|
draftPost !== storedPost;
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (sideMissing || !dirty) return;
|
if (sideMissing || !dirty) return;
|
||||||
const body: { doc_type?: string; appraiser_side?: string } = {};
|
const body: DocumentPatch = {};
|
||||||
if (draftType !== docType) body.doc_type = draftType;
|
if (draftType !== docType) body.doc_type = draftType;
|
||||||
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
||||||
body.appraiser_side = draftSide;
|
body.appraiser_side = draftSide;
|
||||||
@@ -90,6 +110,16 @@ export function DocumentTypeEditor({
|
|||||||
// clear it so it doesn't dangle confusingly in metadata.
|
// clear it so it doesn't dangle confusingly in metadata.
|
||||||
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
||||||
|
|
||||||
|
// protocol_scope: store only the non-default "lower"; anything else clears
|
||||||
|
// back to the implicit ועדת-הערר default. Also clear when leaving protocol.
|
||||||
|
if (isProtocol && draftScope !== storedScope) {
|
||||||
|
body.protocol_scope = draftScope === "lower" ? "lower" : "";
|
||||||
|
} else if (!isProtocol && protocolScope) {
|
||||||
|
body.protocol_scope = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draftPost !== storedPost) body.is_post_hearing = draftPost;
|
||||||
|
|
||||||
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
||||||
// rejection from this async click handler would otherwise leak.
|
// rejection from this async click handler would otherwise leak.
|
||||||
try {
|
try {
|
||||||
@@ -190,6 +220,46 @@ export function DocumentTypeEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isProtocol && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-ink-muted">פרוטוקול של</label>
|
||||||
|
<Select value={draftScope} onValueChange={setDraftScope} dir="rtl">
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROTOCOL_SCOPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||||
|
ועדת הערר → קבוצת הפרוטוקול · מקומית/מחוזית → מסמכים נלווים
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-3 rounded-md border border-rule bg-parchment/40 px-3 py-2.5">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<label
|
||||||
|
htmlFor={`post-${docId}`}
|
||||||
|
className="text-xs font-medium text-ink block"
|
||||||
|
>
|
||||||
|
התקבל אחרי הדיון
|
||||||
|
</label>
|
||||||
|
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||||
|
מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`post-${docId}`}
|
||||||
|
checked={draftPost}
|
||||||
|
onCheckedChange={setDraftPost}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{patch.isError && (
|
{patch.isError && (
|
||||||
<p className="text-[0.7rem] text-danger">
|
<p className="text-[0.7rem] text-danger">
|
||||||
שמירה נכשלה. נסה שוב.
|
שמירה נכשלה. נסה שוב.
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import { casesKeys } from "@/lib/api/cases";
|
|||||||
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
||||||
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
||||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||||
|
import {
|
||||||
|
documentGroup,
|
||||||
|
DOC_GROUP_ORDER,
|
||||||
|
DOC_GROUP_LABELS,
|
||||||
|
DOC_GROUP_HINTS,
|
||||||
|
type DocGroup,
|
||||||
|
} from "@/lib/doc-types";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||||
@@ -224,6 +231,13 @@ function DocumentRow({
|
|||||||
const canPreview =
|
const canPreview =
|
||||||
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
||||||
|
|
||||||
|
const meta = doc.metadata as
|
||||||
|
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
|
||||||
|
| undefined;
|
||||||
|
const isPostHearing = meta?.is_post_hearing === true;
|
||||||
|
const protocolScope = meta?.protocol_scope;
|
||||||
|
const isProtocol = doc.doc_type === "protocol";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
||||||
@@ -246,15 +260,24 @@ function DocumentRow({
|
|||||||
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{isProtocol && (
|
||||||
|
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
|
||||||
|
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isPostHearing && (
|
||||||
|
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
|
||||||
|
לאחר הדיון
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{doc.doc_type && (
|
{doc.doc_type && (
|
||||||
<DocumentTypeEditor
|
<DocumentTypeEditor
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
docId={doc.id}
|
docId={doc.id}
|
||||||
docType={doc.doc_type}
|
docType={doc.doc_type}
|
||||||
appraiserSide={
|
appraiserSide={meta?.appraiser_side}
|
||||||
(doc.metadata as { appraiser_side?: string } | undefined)
|
isPostHearing={isPostHearing}
|
||||||
?.appraiser_side
|
protocolScope={protocolScope}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -306,6 +329,30 @@ function DocumentsShell({ count, children }: { count: number; children: ReactNod
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Group section header ──────────────────────────────────────────
|
||||||
|
* The list is split into four convenience buckets (see documentGroup).
|
||||||
|
* The number is the fixed 1-4 position in DOC_GROUP_ORDER so a group keeps
|
||||||
|
* its identity even when earlier groups are empty and hidden. */
|
||||||
|
function GroupHeader({ group, count }: { group: DocGroup; count: number }) {
|
||||||
|
const index = DOC_GROUP_ORDER.indexOf(group) + 1;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5 px-2 -mx-2 py-2 border-b border-rule-soft bg-parchment/50">
|
||||||
|
<span className="w-[19px] h-[19px] shrink-0 rounded-full bg-navy text-cream text-[0.66rem] font-bold flex items-center justify-center tabular-nums">
|
||||||
|
{index}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.8rem] font-bold text-navy shrink-0">
|
||||||
|
{DOC_GROUP_LABELS[group]}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[0.62rem] font-bold text-gold-deep bg-gold-wash border border-rule rounded-full px-1.5 tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.66rem] text-ink-muted font-medium truncate">
|
||||||
|
{DOC_GROUP_HINTS[group]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DocumentsPanel({
|
export function DocumentsPanel({
|
||||||
data,
|
data,
|
||||||
}: {
|
}: {
|
||||||
@@ -329,6 +376,16 @@ export function DocumentsPanel({
|
|||||||
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
|
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bucket into the four overview groups. Iterating `sorted` preserves the
|
||||||
|
// within-group status ordering. Grouping is display-only — see documentGroup.
|
||||||
|
const grouped = new Map<DocGroup, CaseDocument[]>();
|
||||||
|
for (const doc of sorted) {
|
||||||
|
const g = documentGroup(doc.doc_type, doc.metadata);
|
||||||
|
const bucket = grouped.get(g);
|
||||||
|
if (bucket) bucket.push(doc);
|
||||||
|
else grouped.set(g, [doc]);
|
||||||
|
}
|
||||||
|
|
||||||
const done = docs.filter(
|
const done = docs.filter(
|
||||||
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
|
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
|
||||||
).length;
|
).length;
|
||||||
@@ -378,12 +435,21 @@ export function DocumentsPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden" dir="rtl">
|
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden space-y-2" dir="rtl">
|
||||||
<ul className="divide-y divide-rule" dir="rtl">
|
{DOC_GROUP_ORDER.map((group) => {
|
||||||
{sorted.map((doc) => (
|
const groupDocs = grouped.get(group);
|
||||||
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
if (!groupDocs || groupDocs.length === 0) return null;
|
||||||
))}
|
return (
|
||||||
</ul>
|
<section key={group}>
|
||||||
|
<GroupHeader group={group} count={groupDocs.length} />
|
||||||
|
<ul className="divide-y divide-rule" dir="rtl">
|
||||||
|
{groupDocs.map((doc) => (
|
||||||
|
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocumentsShell>
|
</DocumentsShell>
|
||||||
|
|||||||
@@ -25,7 +25,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,
|
||||||
@@ -55,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 */
|
||||||
@@ -76,6 +86,28 @@ function formatDate(epoch: number): string {
|
|||||||
return formatDateTime(epoch * 1000);
|
return formatDateTime(epoch * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the newest export file whose name starts with `prefix`, preferring the
|
||||||
|
* highest v-number and falling back to the latest created_at. Used by the
|
||||||
|
* "הפקת מסמכים" card to surface the most recent טיוטת-ביניים-* file (#214).
|
||||||
|
*/
|
||||||
|
function pickLatestVersioned(
|
||||||
|
files: ExportFile[] | undefined,
|
||||||
|
prefix: string,
|
||||||
|
): (ExportFile & { version: number | null }) | null {
|
||||||
|
const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix));
|
||||||
|
if (!matches.length) return null;
|
||||||
|
const withVer = matches.map((f) => {
|
||||||
|
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 ─────────────────────────────────── */
|
||||||
|
|
||||||
export function DraftsPanel({
|
export function DraftsPanel({
|
||||||
@@ -97,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);
|
||||||
@@ -129,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) => {
|
||||||
@@ -233,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}
|
||||||
@@ -269,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"
|
||||||
@@ -353,7 +571,7 @@ export function DraftsPanel({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Card: draft files (mockup 18h) ── */}
|
{/* ── Card: draft files (mockup 18h) ── */}
|
||||||
<section className="rounded-lg border border-rule bg-surface p-4">
|
<section className="order-3 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>
|
<h3 className="text-navy text-base">קבצי טיוטה</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -522,7 +740,7 @@ export function DraftsPanel({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Card: chair feedback (mockup 18h) ── */}
|
{/* ── Card: chair feedback (mockup 18h) ── */}
|
||||||
<section className="rounded-lg border border-rule bg-surface p-4">
|
<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">
|
||||||
הערות יו״ר
|
הערות יו״ר
|
||||||
|
|||||||
@@ -76,11 +76,50 @@ type PartySectionProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inner body of a single party — the priority-grouping + per-argument inner
|
* Inner body of a single party. For a multi-litigant side (respondent /
|
||||||
* accordion. The party header (name / sub / count) now lives on the enclosing
|
* permit_applicant) whose arguments carry distinct ``party_name`` brief labels,
|
||||||
* party-accordion trigger (mockup 25b), so this renders the groups only.
|
* we sub-group by brief first — so opposing joint briefs (e.g. "משיבות 2-3" vs
|
||||||
|
* "משיבים 4-6") read as separate positions rather than one merged list (#224).
|
||||||
|
* Single-voice sides (one empty brief) render the priority groups directly.
|
||||||
*/
|
*/
|
||||||
function PartySection({ args }: PartySectionProps) {
|
function PartySection({ args }: PartySectionProps) {
|
||||||
|
const briefs = useMemo(() => {
|
||||||
|
const byBrief = new Map<string, LegalArgument[]>();
|
||||||
|
for (const a of args) {
|
||||||
|
const key = a.party_name?.trim() ?? "";
|
||||||
|
const list = byBrief.get(key);
|
||||||
|
if (list) list.push(a);
|
||||||
|
else byBrief.set(key, [a]);
|
||||||
|
}
|
||||||
|
return [...byBrief.entries()];
|
||||||
|
}, [args]);
|
||||||
|
|
||||||
|
// No real per-brief split (single, unnamed group) → render groups directly.
|
||||||
|
if (briefs.length <= 1) {
|
||||||
|
return <PriorityGroups args={briefs[0]?.[1] ?? args} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{briefs.map(([brief, briefArgs]) => (
|
||||||
|
<div key={brief || "—"} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 border-s-2 border-gold-deep ps-2">
|
||||||
|
<span className="text-navy text-sm font-semibold">
|
||||||
|
{brief || "ללא שיוך לכתב"}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted text-xs">
|
||||||
|
{briefArgs.length} טיעונים
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<PriorityGroups args={briefArgs} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority-grouped argument accordion for one coherent set of arguments. */
|
||||||
|
function PriorityGroups({ args }: PartySectionProps) {
|
||||||
const grouped = useMemo(() => groupByPriority(args), [args]);
|
const grouped = useMemo(() => groupByPriority(args), [args]);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -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 }[];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -692,6 +692,28 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/status-model": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Api Status Model
|
||||||
|
* @description Canonical case-status model — ordered statuses (key/label/description/
|
||||||
|
* phase/selectable/terminal/on_enter) + the 5 phases. Single source of truth
|
||||||
|
* (legal_mcp/case_status_model.py); the frontend case-status.ts mirrors it.
|
||||||
|
*/
|
||||||
|
get: operations["api_status_model_api_status_model_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/cases": {
|
"/api/cases": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1353,6 +1375,50 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/cases/{case_number}/generate/party-claims-summary": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Generate Party Claims Summary
|
||||||
|
* @description Fire-and-accept: wake the CEO to generate the party-claims executive summary
|
||||||
|
* (שלב H2 → summarize_party_claims). Runs host-side; poll
|
||||||
|
* GET /api/cases/{n}/research/party-claims-summary for completion.
|
||||||
|
*/
|
||||||
|
post: operations["api_generate_party_claims_summary_api_cases__case_number__generate_party_claims_summary_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/cases/{case_number}/generate/interim-draft": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Generate Interim Draft
|
||||||
|
* @description Fire-and-accept: wake the CEO to generate the partial "party-claims" draft
|
||||||
|
* (שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
|
||||||
|
* exports list for the new טיוטת-ביניים-{case}-vN.docx.
|
||||||
|
*/
|
||||||
|
post: operations["api_generate_interim_draft_api_cases__case_number__generate_interim_draft_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/cases/{case_number}/research/party-claims-summary/download": {
|
"/api/cases/{case_number}/research/party-claims-summary/download": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2061,6 +2127,30 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/cases/{case_number}/agents/interaction-dismiss": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Api Dismiss Interaction
|
||||||
|
* @description Dismiss a pending interaction WITHOUT waking the issue assignee.
|
||||||
|
*
|
||||||
|
* For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
|
||||||
|
* this does not resolve via Paperclip's wake_assignee path — it cancels the row
|
||||||
|
* so the chair can clear noise without triggering an agent run.
|
||||||
|
*/
|
||||||
|
post: operations["api_dismiss_interaction_api_cases__case_number__agents_interaction_dismiss_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/cases/{case_number}/agents/reset": {
|
"/api/cases/{case_number}/agents/reset": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4587,13 +4677,19 @@ export interface components {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* DocumentPatchRequest
|
* DocumentPatchRequest
|
||||||
* @description Patch payload for a single document. Both fields are optional.
|
* @description Patch payload for a single document. All fields are optional; only the
|
||||||
|
* ones present are applied. The metadata.* flags are display/processing hints
|
||||||
|
* that never change the stored doc_type.
|
||||||
*/
|
*/
|
||||||
DocumentPatchRequest: {
|
DocumentPatchRequest: {
|
||||||
/** Doc Type */
|
/** Doc Type */
|
||||||
doc_type?: string | null;
|
doc_type?: string | null;
|
||||||
/** Appraiser Side */
|
/** Appraiser Side */
|
||||||
appraiser_side?: string | null;
|
appraiser_side?: string | null;
|
||||||
|
/** Is Post Hearing */
|
||||||
|
is_post_hearing?: boolean | null;
|
||||||
|
/** Protocol Scope */
|
||||||
|
protocol_scope?: string | null;
|
||||||
};
|
};
|
||||||
/** EquivalentLinkRequest */
|
/** EquivalentLinkRequest */
|
||||||
EquivalentLinkRequest: {
|
EquivalentLinkRequest: {
|
||||||
@@ -4758,6 +4854,13 @@ export interface components {
|
|||||||
/** Canonical Statement */
|
/** Canonical Statement */
|
||||||
canonical_statement?: string | null;
|
canonical_statement?: string | null;
|
||||||
};
|
};
|
||||||
|
/** InteractionDismissRequest */
|
||||||
|
InteractionDismissRequest: {
|
||||||
|
/** Issue Id */
|
||||||
|
issue_id: string;
|
||||||
|
/** Interaction Id */
|
||||||
|
interaction_id: string;
|
||||||
|
};
|
||||||
/** InteractionResponseRequest */
|
/** InteractionResponseRequest */
|
||||||
InteractionResponseRequest: {
|
InteractionResponseRequest: {
|
||||||
/** Issue Id */
|
/** Issue Id */
|
||||||
@@ -6147,6 +6250,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_status_model_api_status_model_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_cases_api_cases_get: {
|
list_cases_api_cases_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -7151,6 +7274,68 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_generate_party_claims_summary_api_cases__case_number__generate_party_claims_summary_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_number: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
api_generate_interim_draft_api_cases__case_number__generate_interim_draft_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_number: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_party_claims_summary_download_api_cases__case_number__research_party_claims_summary_download_get: {
|
api_party_claims_summary_download_api_cases__case_number__research_party_claims_summary_download_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8224,6 +8409,41 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
api_dismiss_interaction_api_cases__case_number__agents_interaction_dismiss_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
case_number: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["InteractionDismissRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
api_reset_case_agents_api_cases__case_number__agents_reset_post: {
|
api_reset_case_agents_api_cases__case_number__agents_reset_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -64,6 +64,79 @@ export function doctypeTone(value: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Overview groups (display-only convenience buckets) ─────────────
|
||||||
|
//
|
||||||
|
// The case-overview document list is split into four sections purely for
|
||||||
|
// the chair's convenience — grouping NEVER changes a document's stored
|
||||||
|
// doc_type. Classification is derived on the client from doc_type plus two
|
||||||
|
// metadata flags (is_post_hearing, protocol_scope).
|
||||||
|
|
||||||
|
export type DocGroup = "primary" | "ancillary" | "hearing_protocol" | "post_hearing";
|
||||||
|
|
||||||
|
export const DOC_GROUP_LABELS: Record<DocGroup, string> = {
|
||||||
|
primary: "מסמכים עיקריים",
|
||||||
|
ancillary: "מסמכים נלווים",
|
||||||
|
hearing_protocol: "פרוטוקול דיון ועדת הערר",
|
||||||
|
post_hearing: "מסמכים לאחר הדיון",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOC_GROUP_HINTS: Record<DocGroup, string> = {
|
||||||
|
primary: "כתבי ערר, תשובות ושומות שהוגשו לפני הדיון",
|
||||||
|
ancillary: "פרוטוקולי/החלטות ועדה מקומית-מחוזית, תכניות, וכל השאר",
|
||||||
|
hearing_protocol: "הדיון בפני הוועדה",
|
||||||
|
post_hearing: "השלמות ותגובות שהתקבלו לאחר הדיון",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fixed render order of the groups in the overview panel. */
|
||||||
|
export const DOC_GROUP_ORDER: DocGroup[] = [
|
||||||
|
"primary",
|
||||||
|
"ancillary",
|
||||||
|
"hearing_protocol",
|
||||||
|
"post_hearing",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** doc_types that count as "primary" pre-hearing filings (group 1). */
|
||||||
|
const PRIMARY_DOC_TYPES = new Set<DocType>(["appeal", "response", "appraisal"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a document into one of the four overview groups.
|
||||||
|
*
|
||||||
|
* Priority order matters:
|
||||||
|
* 1. is_post_hearing wins over everything — a post-hearing כתב תשובה
|
||||||
|
* belongs in group 4, not group 1.
|
||||||
|
* 2. A protocol goes to group 3 (ועדת הערר) by default, or group 2 when
|
||||||
|
* explicitly tagged as a lower-committee protocol (protocol_scope==="lower").
|
||||||
|
* 3. appeal/response/appraisal → group 1; everything else → group 2.
|
||||||
|
*/
|
||||||
|
export function documentGroup(
|
||||||
|
docType: string,
|
||||||
|
metadata?: Record<string, unknown> | null,
|
||||||
|
): DocGroup {
|
||||||
|
if (metadata?.is_post_hearing === true) return "post_hearing";
|
||||||
|
if (docType === "protocol") {
|
||||||
|
return metadata?.protocol_scope === "lower" ? "ancillary" : "hearing_protocol";
|
||||||
|
}
|
||||||
|
if (PRIMARY_DOC_TYPES.has(docType as DocType)) return "primary";
|
||||||
|
return "ancillary";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Protocol scope (only relevant when doc_type === "protocol") ────
|
||||||
|
// Distinguishes the appeal-committee hearing protocol (group 3) from a
|
||||||
|
// lower (local/district) committee protocol (group 2). Stored in
|
||||||
|
// documents.metadata.protocol_scope; absent === "appeal" (the default).
|
||||||
|
|
||||||
|
export type ProtocolScope = "appeal" | "lower";
|
||||||
|
|
||||||
|
export const PROTOCOL_SCOPE_LABELS: Record<ProtocolScope, string> = {
|
||||||
|
appeal: "ועדת הערר",
|
||||||
|
lower: "ועדה מקומית/מחוזית",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROTOCOL_SCOPE_OPTIONS: { value: ProtocolScope; label: string }[] = [
|
||||||
|
{ value: "appeal", label: PROTOCOL_SCOPE_LABELS.appeal },
|
||||||
|
{ value: "lower", label: PROTOCOL_SCOPE_LABELS.lower },
|
||||||
|
];
|
||||||
|
|
||||||
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
|
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
|
||||||
|
|
||||||
export type AppraiserSide = "committee" | "appellant" | "deciding";
|
export type AppraiserSide = "committee" | "appellant" | "deciding";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
143
web/app.py
143
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,19 +103,38 @@ 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()
|
||||||
try:
|
reaper_task.cancel()
|
||||||
await sync_task
|
for _t in (sync_task, reaper_task):
|
||||||
except asyncio.CancelledError:
|
try:
|
||||||
pass
|
await _t
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
await db.close_pool()
|
await db.close_pool()
|
||||||
await _progress.close()
|
await _progress.close()
|
||||||
|
|
||||||
@@ -3090,6 +3112,51 @@ async def api_party_claims_summary(case_number: str):
|
|||||||
return {"markdown": path.read_text(encoding="utf-8")}
|
return {"markdown": path.read_text(encoding="utf-8")}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Deterministic document generation triggers (TaskMaster #214) ─────
|
||||||
|
# The "הפקת מסמכים" buttons in the case "טיוטות והערות" tab. Generation for the
|
||||||
|
# executive summary + interim draft runs HOST-SIDE (claude_session → claude -p,
|
||||||
|
# not in the container), so these endpoints fire a *structured-action* CEO wakeup
|
||||||
|
# (the CEO reads payload.action and routes to שלב H2 / שלב H — legal-ceo.md).
|
||||||
|
# Wakeup goes through the Paperclip API helper (pc_wake_ceo_for_action → the
|
||||||
|
# platform Port → POST /api/agents/{id}/wakeup), NEVER a direct DB insert.
|
||||||
|
# Polling for completion uses the existing read endpoints (research/party-claims-
|
||||||
|
# summary for the summary; the exports list for the טיוטת-ביניים-*.docx file).
|
||||||
|
|
||||||
|
|
||||||
|
async def _wake_ceo_action(case_number: str, action: str) -> dict:
|
||||||
|
"""Resolve the case's company + fire a structured-action CEO wakeup (#214)."""
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||||
|
prefix = case_number[:1]
|
||||||
|
company_id = (
|
||||||
|
PAPERCLIP_COMPANIES["licensing"] if prefix == "1"
|
||||||
|
else PAPERCLIP_COMPANIES["betterment"] if prefix in ("8", "9")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return await pc_wake_ceo_for_action(case_number, action, company_id=company_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CEO action %s wakeup failed for %s: %s", action, case_number, e)
|
||||||
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/generate/party-claims-summary", status_code=202)
|
||||||
|
async def api_generate_party_claims_summary(case_number: str):
|
||||||
|
"""Fire-and-accept: wake the CEO to generate the party-claims executive summary
|
||||||
|
(שלב H2 → summarize_party_claims). Runs host-side; poll
|
||||||
|
GET /api/cases/{n}/research/party-claims-summary for completion."""
|
||||||
|
return await _wake_ceo_action(case_number, "party_claims_summary")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/generate/interim-draft", status_code=202)
|
||||||
|
async def api_generate_interim_draft(case_number: str):
|
||||||
|
"""Fire-and-accept: wake the CEO to generate the partial "party-claims" draft
|
||||||
|
(שלב H → write_interim_draft + export_interim_draft). Runs host-side; poll the
|
||||||
|
exports list for the new טיוטת-ביניים-{case}-vN.docx."""
|
||||||
|
return await _wake_ceo_action(case_number, "interim_draft")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
|
@app.get("/api/cases/{case_number}/research/party-claims-summary/download")
|
||||||
async def api_party_claims_summary_download(case_number: str):
|
async def api_party_claims_summary_download(case_number: str):
|
||||||
"""Download the raw party-claims-summary.md file."""
|
"""Download the raw party-claims-summary.md file."""
|
||||||
@@ -4365,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.
|
||||||
@@ -5631,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}")
|
||||||
@@ -5662,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(
|
||||||
@@ -5678,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:
|
||||||
|
|||||||
@@ -899,6 +899,69 @@ async def reject_interaction(
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_interaction(issue_id: str, interaction_id: str) -> dict:
|
||||||
|
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
|
||||||
|
|
||||||
|
For stale/duplicate questions (TaskMaster #215). We flip the row to
|
||||||
|
``cancelled`` directly rather than calling Paperclip's resolve endpoints:
|
||||||
|
those carry the interaction's ``wake_assignee`` continuation policy and would
|
||||||
|
re-wake the agent. A direct status flip has no side effects — the table has
|
||||||
|
no triggers (verified) and nothing reaps ``cancelled`` rows.
|
||||||
|
"""
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE issue_thread_interactions
|
||||||
|
SET status='cancelled', resolved_at=now(),
|
||||||
|
resolved_by_user_id=$3, updated_at=now()
|
||||||
|
WHERE id=$1::uuid AND issue_id=$2::uuid AND status='pending'
|
||||||
|
RETURNING id, status""",
|
||||||
|
interaction_id, issue_id, CHAIM_USER_ID,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"cancelled": False,
|
||||||
|
"error": "interaction not found or already resolved",
|
||||||
|
}
|
||||||
|
return {"ok": True, "cancelled": True, "id": str(row["id"]), "status": row["status"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def reap_stale_interactions() -> dict:
|
||||||
|
"""Cancel pending interactions stranded on closed issues (done/cancelled).
|
||||||
|
|
||||||
|
Safety-net reaper (TaskMaster #215): a question on a closed issue can never
|
||||||
|
be answered and only inflates the chair's "awaiting you" count — this was the
|
||||||
|
dominant source of the pending pile-up. Scoped to the two legal-ai companies.
|
||||||
|
Direct status flip — no wakeups, no side effects.
|
||||||
|
"""
|
||||||
|
company_ids = list(COMPANIES.values())
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""UPDATE issue_thread_interactions it
|
||||||
|
SET status='cancelled', resolved_at=now(),
|
||||||
|
resolved_by_user_id=$2, updated_at=now()
|
||||||
|
FROM issues i
|
||||||
|
WHERE i.id = it.issue_id
|
||||||
|
AND it.status='pending'
|
||||||
|
AND it.company_id = ANY($1::uuid[])
|
||||||
|
AND i.status IN ('done', 'cancelled')
|
||||||
|
RETURNING it.id""",
|
||||||
|
company_ids, CHAIM_USER_ID,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
if rows:
|
||||||
|
logger.info(
|
||||||
|
"reap_stale_interactions: cancelled %d stranded pending interactions",
|
||||||
|
len(rows),
|
||||||
|
)
|
||||||
|
return {"ok": True, "cancelled": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
# Singleton project for the precedent-library extraction queue. One issue per
|
# Singleton project for the precedent-library extraction queue. One issue per
|
||||||
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
||||||
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
||||||
@@ -1195,6 +1258,73 @@ async def wake_ceo_agent(issue_id: str, case_number: str, company_id: str = "")
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def wake_ceo_for_action(
|
||||||
|
case_number: str,
|
||||||
|
action: str,
|
||||||
|
company_id: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Wake the CEO with a deterministic *structured action* on the case's MAIN issue.
|
||||||
|
|
||||||
|
Drives the UI "הפקת מסמכים" buttons (TaskMaster #214): the CEO reads
|
||||||
|
``payload.action`` and routes deterministically to its שלב H / שלב H2
|
||||||
|
side-quests (legal-ceo.md §"פעולות סטרוקטורליות") — no free-text parsing.
|
||||||
|
|
||||||
|
action="party_claims_summary" → שלב H2 (summarize_party_claims)
|
||||||
|
action="interim_draft" → שלב H (write_interim_draft + export_interim_draft)
|
||||||
|
|
||||||
|
These are side-quests on the EXISTING ``[ערר {case_number}]`` issue — no child
|
||||||
|
issue is created (the CEO must not change ``cases.status`` or spawn sub-agents).
|
||||||
|
Wakeup goes through the Paperclip API (``POST /api/agents/{id}/wakeup``), never a
|
||||||
|
direct DB insert (CLAUDE.md hard rule). Returns
|
||||||
|
``{"status": "ok"|"skipped", ...}``.
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO action wakeup")
|
||||||
|
return {"status": "skipped", "reason": "no_api_key"}
|
||||||
|
|
||||||
|
issues = await get_case_issues(case_number)
|
||||||
|
if not issues:
|
||||||
|
logger.warning("No Paperclip issues found for case %s — skipping CEO action", case_number)
|
||||||
|
return {"status": "skipped", "reason": "no_issue"}
|
||||||
|
|
||||||
|
# The main case issue — prefer the in-progress one, else the canonical
|
||||||
|
# "[ערר {case_number}]" issue, else the oldest. (Same selection spirit as
|
||||||
|
# wake_curator_for_final, but we never spawn a child — it's a side-quest.)
|
||||||
|
main_issue = (
|
||||||
|
next((i for i in issues if i.get("status") == "in_progress"), None)
|
||||||
|
or next((i for i in issues if f"[ערר {case_number}]" in (i.get("title") or "")), None)
|
||||||
|
or issues[0]
|
||||||
|
)
|
||||||
|
main_issue_id = main_issue["id"]
|
||||||
|
|
||||||
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
|
wake_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
|
json={
|
||||||
|
"source": "on_demand",
|
||||||
|
"triggerDetail": "manual",
|
||||||
|
"reason": f"generate_{action}_{case_number}",
|
||||||
|
"payload": {
|
||||||
|
"issueId": main_issue_id,
|
||||||
|
"action": action,
|
||||||
|
"case_number": case_number,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"CEO action wakeup for case %s: action=%s issue=%s ceo=%s status=%s",
|
||||||
|
case_number, action, main_issue_id, ceo_id, wake_resp.status_code,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"action": action,
|
||||||
|
"issue_id": main_issue_id,
|
||||||
|
"ceo_id": ceo_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]:
|
def _curator_task_brief(task: str, case_number: str, final_filename: str) -> tuple[str, str]:
|
||||||
"""Build the (sub-issue title, description) for a staged final-decision task.
|
"""Build the (sub-issue title, description) for a staged final-decision task.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user