Compare commits
15 Commits
10540a38b4
...
ui-rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8502d46b | |||
| 0fef20e272 | |||
| ca6ec48580 | |||
| 4e418787cf | |||
| fdd12c6726 | |||
| e34d217345 | |||
| 6b8f002596 | |||
| aa0e608a4a | |||
| 916360e9b2 | |||
| cbe9d60901 | |||
| fb1f73fa25 | |||
| ac0a5ee30b | |||
| e483eba1a9 | |||
| d8a537e7aa | |||
| 75ea6825b2 |
@@ -834,13 +834,13 @@
|
||||
"description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||
"testStrategy": "Users can create a new case via the multi-step wizard (case appears in Gitea + Paperclip), upload documents with live SSE progress, and edit case fields inline.",
|
||||
"status": "in-progress",
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
"85"
|
||||
],
|
||||
"priority": "medium",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-11T16:18:28.714Z"
|
||||
"updatedAt": "2026-04-11T16:25:55.569Z"
|
||||
},
|
||||
{
|
||||
"id": "87",
|
||||
@@ -848,12 +848,13 @@
|
||||
"description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||
"testStrategy": "Feature parity with old legal-ai/web/static/index.html across all 10 views.",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
"86"
|
||||
],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-11T17:33:42.976Z"
|
||||
},
|
||||
{
|
||||
"id": "88",
|
||||
@@ -861,12 +862,13 @@
|
||||
"description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
|
||||
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||
"testStrategy": "Lighthouse a11y score > 90, all loading states visible, errors show toasts, README has documented smoke test steps.",
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
"87"
|
||||
],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-11T17:44:08.337Z"
|
||||
},
|
||||
{
|
||||
"id": "89",
|
||||
@@ -880,13 +882,39 @@
|
||||
],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "90",
|
||||
"title": "Phase 4.5 — Practice area integration",
|
||||
"description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
"86"
|
||||
],
|
||||
"priority": "high",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-11T17:15:57.831Z"
|
||||
},
|
||||
{
|
||||
"id": "91",
|
||||
"title": "Precedent attachment in compose screen",
|
||||
"description": "Add case_precedents table + FastAPI endpoints + MCP tools + Next.js compose UI for attaching legal precedents (quote + citation + optional archived PDF) to threshold_claims/issues and to the case as a whole. Plan: ~/.claude/plans/woolly-cooking-graham.md",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-11T19:20:56.040Z"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-04-11T16:18:28.714Z",
|
||||
"taskCount": 58,
|
||||
"completedCount": 52,
|
||||
"lastModified": "2026-04-11T19:20:56.040Z",
|
||||
"taskCount": 60,
|
||||
"completedCount": 57,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -42,6 +42,7 @@
|
||||
| [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** |
|
||||
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
||||
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
|
||||
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
||||
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
|
||||
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||
|
||||
@@ -107,6 +108,16 @@
|
||||
3. **"ללא כפילות"** — בלוק י (דיון) מפנה לבלוקים קודמים, לא חוזר עליהם
|
||||
4. **"טענות מקוריות בלבד"** — בלוק ז = מכתבי טענות מקוריים בלבד. השלמות → בלוק ח
|
||||
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
|
||||
6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`)
|
||||
|
||||
## הערות יו"ר (Chair Feedback)
|
||||
|
||||
מנגנון לתיעוד הערות דפנה על טיוטות:
|
||||
- **DB**: טבלת `chair_feedback` (case_id, block_id, feedback_text, category, lesson_extracted)
|
||||
- **API**: `GET/POST /api/feedback`, `PATCH /api/feedback/{id}/resolve`
|
||||
- **MCP tools**: `record_chair_feedback`, `list_chair_feedback`
|
||||
- **UI**: דף ניהול ב-`/feedback` (ב-Next.js)
|
||||
- **קטגוריות**: missing_content, wrong_tone, wrong_structure, factual_error, style, other
|
||||
|
||||
## יו"ר: עו"ד דפנה תמיר
|
||||
- מדריך סגנון מלא: `skills/decision/SKILL.md`
|
||||
|
||||
@@ -371,6 +371,7 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
||||
- MUST: מסקנה בפתיחת הדיון (לא בסוף)
|
||||
- MUST: מענה לכל טענה שהוצגה בבלוק ז
|
||||
- MUST: ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
|
||||
- MUST: **צ'קליסט תוכן** — הפרומפט מזריק `{content_checklist}` אוטומטית לפי סוג הערר (מתוך `lessons.py: CONTENT_CHECKLISTS`). ראה `docs/corpus-analysis.md` לדפוסי תוכן לפי סוג.
|
||||
- ⚠️ **MUST NOT ("ללא כפילות"):** חזרה על עובדות/טענות מבלוקים קודמים. השתמש בהפניות: "כאמור בסעיף X לעיל", "כפי שפורט", "כפי שציינו"
|
||||
- MUST NOT: כותרות משנה (חריג: נושאים נפרדים לחלוטין)
|
||||
- Dependencies: **ALL** previous blocks (ה-ט)
|
||||
|
||||
240
docs/corpus-analysis.md
Normal file
240
docs/corpus-analysis.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# ניתוח שיטתי של קורפוס ההחלטות — מפת תוכן
|
||||
|
||||
> נוצר: 2026-04-12
|
||||
> מקור: ניתוח 24 החלטות מתוך `/data/training/proofread/`
|
||||
> מטרה: לחלץ דפוסי תוכן בפרק הדיון וההכרעה לפי סוג תיק
|
||||
|
||||
---
|
||||
|
||||
## 1. סקירה כללית של הקורפוס
|
||||
|
||||
### הרכב הקורפוס
|
||||
| סוג | כמות | החלטות |
|
||||
|-----|------|--------|
|
||||
| רישוי ובנייה (1xxx) | 22 | כל ההחלטות מלבד גבאי ובית הכרם |
|
||||
| תמ"א 38 | 1 | בית הכרם 1126+1141 |
|
||||
| סמכות/סף בלבד | 1 | גבאי 1105 |
|
||||
| **היטל השבחה (8xxx)** | **0** | **פער קריטי — אין אף החלטה בקורפוס** |
|
||||
|
||||
### תוצאות
|
||||
| תוצאה | כמות | דוגמאות |
|
||||
|-------|------|---------|
|
||||
| דחייה | 12 | עמית, פרומר, זעיתר, בית שמש, אנשין (חניה), אהרן, שטרית, גבאי, ירושלים שקופה, לבנון, יפה |
|
||||
| קבלה | 5 | טלי-אביב, הראל 1043+1054, הראל 1071+1077, מינץ, לוי |
|
||||
| קבלה חלקית | 4 | אמיתי, בר-און, אנשין (1096), בית הכרם, אואקנין |
|
||||
|
||||
### אורך פרק הדיון
|
||||
- טווח: 465 — 12,000 מילים
|
||||
- ממוצע: ~5,000 מילים
|
||||
- הקצר ביותר: גבאי (465, סמכות בלבד)
|
||||
- הארוך ביותר: תורן/1015 (~11,000, שימוש חורג), מינץ/1071 (~12,000, סבב שני)
|
||||
|
||||
---
|
||||
|
||||
## 2. נושאים שנמצאו בפרקי הדיון — מפת תוכן מלאה
|
||||
|
||||
### 2.1 נושאים תכנוניים (Planning Content)
|
||||
|
||||
| נושא | מופיע ב-X החלטות | עומק טיפוסי | דוגמאות בולטות |
|
||||
|------|-----------------|-------------|----------------|
|
||||
| **ניתוח הוראות תכנית** (ציטוט ישיר, פרשנות) | 18/24 | 3-15 סעיפים | פרומר (MI/200), לבנון (Hal/435), בית הכרם (10038, 16000) |
|
||||
| **חניה** (חישוב, נספח תנועה, חלופות) | 8/24 | 5-15 סעיפים | אנשין-1096 (הרחבה), בית הכרם (הרחבה), אנשין-1109, לוי |
|
||||
| **קווי בניין ומרווחים** | 7/24 | 3-10 סעיפים | אמיתי, אואקנין, שטרית, בר-און |
|
||||
| **גובה/קומות** | 4/24 | 3-6 סעיפים | לבנון (הרחבה), בר-און, לוי |
|
||||
| **סביבה ואופי שכונה** | 6/24 | 2-5 סעיפים | זעיתר (טיפולוגיה), פרומר (חקלאי), בית הכרם |
|
||||
| **שימושים מותרים/שימוש חורג** | 2/24 | 5-20 סעיפים | תורן (הרחבה חריגה), יפה |
|
||||
| **שימור** | 2/24 | 2-5 סעיפים | בית הכרם, בר-און (עצים) |
|
||||
| **טופוגרפיה/טיפולוגיה** | 2/24 | 3-8 סעיפים | זעיתר (הרחבה), לבנון |
|
||||
| **תכנית אב כמסגרת** | 2/24 | 2-3 סעיפים | בית הכרם (16000), תורן (צור הדסה) |
|
||||
| **אינטרס ציבורי (חיזוק/התחדשות)** | 2/24 | 3-8 סעיפים | בית הכרם (תמ"א 38), מינץ |
|
||||
| **היררכיית תכניות** (ארצית→מחוזית→מקומית) | 3/24 | 5-12 סעיפים | פרומר (הרחבה), לבנון, תורן |
|
||||
| **נספח בינוי** (ניתוח פרטני) | 5/24 | 3-8 סעיפים | לבנון, לוי, מינץ, בר-און, בית שמש |
|
||||
| **פגיעה בשכנים** (צל, פרטיות, רעש) | 5/24 | 2-5 סעיפים | אמיתי, שטרית, בית הכרם, אואקנין, זעיתר |
|
||||
| **עצים/נוף** | 3/24 | 1-3 סעיפים | בר-און, בית שמש, בית הכרם |
|
||||
|
||||
### 2.2 נושאים משפטיים (Legal Content)
|
||||
|
||||
| נושא | מופיע ב-X החלטות | עומק טיפוסי |
|
||||
|------|-----------------|-------------|
|
||||
| **סמכות/זכות ערר** (ס' 152, 12ב) | 10/24 | 3-12 סעיפים |
|
||||
| **הלכת שפר** (ערר על היתר תואם תכנית) | 8/24 | 2-5 סעיפים |
|
||||
| **תימוכין קנייניים** (property feasibility) | 6/24 | 5-15 סעיפים |
|
||||
| **סטייה ניכרת** (תקנה 2) | 5/24 | 3-8 סעיפים |
|
||||
| **שיהוי** (delay/laches) | 5/24 | 2-5 סעיפים |
|
||||
| **מידתיות** (proportionality) | 4/24 | 2-5 סעיפים |
|
||||
| **עבריינות בנייה** (building violations) | 6/24 | 2-5 סעיפים |
|
||||
| **שיקול דעת הוועדה המקומית** | 8/24 | 2-5 סעיפים |
|
||||
| **קניין vs. תכנון** (הפרדת סמכויות) | 7/24 | 3-10 סעיפים |
|
||||
| **הכשרת בנייה קיימת** (regularization) | 3/24 | 5-12 סעיפים |
|
||||
|
||||
---
|
||||
|
||||
## 3. דפוסי "דיון תכנוני" שזוהו
|
||||
|
||||
### 3.1 מתי דפנה מקיימת דיון תכנוני מקיף?
|
||||
|
||||
**תמיד** כאשר:
|
||||
- הערר עוסק בהתאמה לתכנית (ייעוד, שימוש, גובה, בנייה)
|
||||
- יש שאלה של סטייה מהוראות תכנית
|
||||
- הנושא הוא חניה/תשתיות
|
||||
- התיק מערב תמ"א 38 או התחדשות עירונית
|
||||
|
||||
**לעולם לא** כאשר:
|
||||
- התיק הוא סף/סמכות בלבד (גבאי)
|
||||
- השאלה היא קניינית טהורה (טלי-אביב/1043+1054, בית שמש/1180+1181)
|
||||
|
||||
**עומק משתנה** כאשר:
|
||||
- יש מספר נושאים שחלקם תכנוניים — הדיון התכנוני מוגבל לנושאים הרלוונטיים
|
||||
|
||||
### 3.2 איך דפנה בונה דיון תכנוני — הדפוס
|
||||
|
||||
**שלב 1: הקשר תכנוני רחב (2-8 סעיפים)**
|
||||
- תכניות חלות ברמה הרלוונטית (מקומית, מחוזית, ארצית)
|
||||
- ייעוד הקרקע, שימושים מותרים
|
||||
- אופי הסביבה, מרקם בנוי
|
||||
- *דוגמה*: פרומר — 12 סעיפים על MI/200, TAMA 35, TAMAM 30/1
|
||||
|
||||
**שלב 2: ציטוט ישיר מהוראות תכנית (3-15 סעיפים)**
|
||||
- בלוקים ארוכים (200-600 מילים) של הוראות תכנית
|
||||
- הדגשות בולד על המילים הרלוונטיות
|
||||
- "הדגשת הח"מ" / "הדגשת הח.מ."
|
||||
- *דוגמה*: בית הכרם — 400+ מילים מהוראות חניה של תכנית 5166ב
|
||||
|
||||
**שלב 3: יישום על המקרה הספציפי (3-8 סעיפים)**
|
||||
- הוראה → עובדה → מסקנה
|
||||
- "הנה מה שאומרת התכנית, הנה מה שקורה בפועל, הנה המסקנה"
|
||||
- *דוגמה*: לבנון — השוואת חתכים של נספח בינוי עם הבקשה
|
||||
|
||||
**שלב 4: מסקנה תכנונית (1-3 סעיפים)**
|
||||
- האם הבקשה תואמת/סוטה
|
||||
- האם הסטייה מוצדקת
|
||||
- מה צריך לתקן
|
||||
|
||||
### 3.3 הדפוס של "דיון תכנוני" לפי סוג נושא
|
||||
|
||||
| נושא | סדר ניתוח טיפוסי | רמת עומק |
|
||||
|------|-----------------|----------|
|
||||
| **חניה** | הוראות תכנית → תקנות חניה → נספח תנועה → חישוב → חלופות (קרן חניה, חפיפה, תחבורה ציבורית) | עמוק מאוד (8-15 סעיפים) |
|
||||
| **קווי בניין** | הוראת תכנית → סטייה ניכרת? (תקנה 2(19)) → מידתיות → פגיעה בשכנים | בינוני-עמוק (5-10 סעיפים) |
|
||||
| **גובה** | הוראת תכנית → נספח בינוי → מטרת ההגבלה → סטייה ניכרת? | בינוני (4-8 סעיפים) |
|
||||
| **ייעוד/שימוש** | פרשנות תכנית → היררכיית תכניות → פרשנות מהותית → יישום | עמוק מאוד (10-20 סעיפים) |
|
||||
| **שכנות** | עובדות (סיור) → השפעה (צל, פרטיות, רעש) → מידתיות | בינוני (3-6 סעיפים) |
|
||||
| **סביבה** | תכנית אב → אופי שכונה → מרקם → השתלבות | בינוני (3-5 סעיפים) |
|
||||
|
||||
---
|
||||
|
||||
## 4. דפוסים חוצי-החלטות
|
||||
|
||||
### 4.1 מבנה הדיון — סדר הנושאים
|
||||
1. **שאלות סף** (אם יש) — סמכות, זכות ערר, שיהוי
|
||||
2. **הקשר תכנוני רחב** — תכניות, ייעוד, סביבה
|
||||
3. **ניתוח ענייני** — נושא אחר נושא, כל אחד ב-CREAC
|
||||
4. **מענה לטענות ספציפיות** — עובר על כל טענה מבלוק ז
|
||||
5. **מסקנה** — תוצאה + הוראות אופרטיביות
|
||||
|
||||
### 4.2 כמה תכנון יש בכל החלטה?
|
||||
|
||||
| דרגה | תיאור | החלטות |
|
||||
|------|-------|--------|
|
||||
| **כבד** (>50% תכנון) | הדיון הוא בעיקר תכנוני | פרומר, זעיתר, בית הכרם, תורן, לבנון |
|
||||
| **מאוזן** (30-50%) | שילוב תכנון + משפט | עמית, אמיתי, בר-און, אנשין-1096, אואקנין, לוי, שטרית |
|
||||
| **קל** (<30%) | בעיקר משפטי, תכנון מינימלי | בית שמש, אנשין-1109, יפה |
|
||||
| **אין** (0%) | רק משפטי/סמכות | טלי-אביב, הראל 1043+1054, גבאי, ירושלים שקופה |
|
||||
|
||||
### 4.3 פסיקה חוזרת (Recurring Case Law)
|
||||
| פסיקה | נושא | מופיעה ב-X החלטות |
|
||||
|-------|------|-------------------|
|
||||
| הלכת שפר (עע"מ 317/10) | ערר על היתר תואם תכנית | 8 |
|
||||
| הלכת עייזן (בג"ץ 1578/90) | תימוכין קנייניים | 6 |
|
||||
| הלכת בן-יקר-גת | סטייה ניכרת | 4 |
|
||||
| ערר אדלר 1181/22 | שיקול דעת תמ"א 38 | 2 |
|
||||
| עע"מ 3975/22 קרן נכסים | קניין vs. תכנון | 4 |
|
||||
|
||||
### 4.4 טכניקות ניתוח ייחודיות
|
||||
1. **פרשנות הרמונית** — כשיש מספר תכניות, דפנה מפרשת אותן ביחד (תורן)
|
||||
2. **בדיקת תקדימים עובדתית** — הוועדה בדקה בעצמה 3 נכסים שנטענו כתקדים (לבנון)
|
||||
3. **ציטוט מהחלטה מרכזת** — במקום לצטט 7 פס"ד, מצטטת אחד שריכז את כולם
|
||||
4. **מבחן "המגרש הריק"** — להכשרת בנייה קיימת (אמיתי)
|
||||
5. **מיפוי מתחים** — רשימת 3-6 מתחים לפני הניתוח (בית הכרם)
|
||||
6. **"למעלה מן הצורך"** — דיון obiter אחרי הכרעה בסף (עמית, בית שמש)
|
||||
|
||||
---
|
||||
|
||||
## 5. פערים שזוהו
|
||||
|
||||
### 5.1 פער קריטי: אין החלטות היטל השבחה בקורפוס
|
||||
למרות שהמערכת מגדירה 3 סוגי עררים (רישוי, היטל השבחה, פיצויים) — **כל 24 ההחלטות הן רישוי ובנייה**. אין לנו אף מודל לכתיבת החלטה בהיטל השבחה.
|
||||
|
||||
### 5.2 פער: לא כל נושא תכנוני מכוסה
|
||||
נושאים שמופיעים רק בהחלטה אחת-שתיים:
|
||||
- שימור → רק בית הכרם
|
||||
- תמ"א 38 → רק בית הכרם
|
||||
- שימוש חורג → רק תורן
|
||||
- טיפולוגיה/טופוגרפיה → רק זעיתר
|
||||
- תכנית אב כמסגרת → רק בית הכרם + תורן
|
||||
|
||||
### 5.3 פער: הפרומפט הנוכחי לא מכיל "צ'קליסט תוכן"
|
||||
הפרומפט של block-yod (שורות 198-234 ב-block_writer.py) אומר:
|
||||
- ✅ CREAC methodology
|
||||
- ✅ ענה על כל טענה
|
||||
- ✅ צטט פסיקה
|
||||
- ❌ **אין**: "בתיק רישוי, כסה את הנושאים התכנוניים הרלוונטיים"
|
||||
- ❌ **אין**: צ'קליסט תוכן לפי סוג ערר
|
||||
- ❌ **אין**: "הקשר תכנוני רחב" כמרכיב חובה
|
||||
|
||||
### 5.4 פער: הבחנה לא מספיקה בין תת-סוגי רישוי
|
||||
תיקי רישוי שונים מאוד זה מזה:
|
||||
- **סמכות/סף** — דיון משפטי טהור, אין צורך בתכנון
|
||||
- **קנייני** — תימוכין קנייניים, אין צורך בתכנון
|
||||
- **תכנוני מובהק** — ייעוד, חניה, גובה — דיון תכנוני מקיף
|
||||
- **שימוש חורג** — פרשנות תכניות, דיון תכנוני עמוק
|
||||
- **הקלה** — מידתיות + תכנון
|
||||
- **תמ"א 38** — איזון אינטרסים + תכנון
|
||||
|
||||
---
|
||||
|
||||
## 6. המלצות לשלב הבא
|
||||
|
||||
### 6.1 צ'קליסט תוכן מוצע לערר רישוי ובנייה
|
||||
```
|
||||
בהתאם לנושא הערר, הדיון צריך לכלול:
|
||||
|
||||
□ הקשר תכנוני רחב (תמיד כשהערר מגיע למריט):
|
||||
- תכניות חלות (מקומית, מחוזית, ארצית — לפי הצורך)
|
||||
- ייעוד הקרקע
|
||||
- אופי הסביבה
|
||||
|
||||
□ ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה):
|
||||
- ציטוט ישיר מהוראות רלוונטיות
|
||||
- פרשנות — תכלית ההוראה
|
||||
- יישום על המקרה
|
||||
|
||||
□ חניה (כשרלוונטי):
|
||||
- הוראות תכנית + נספח תנועה
|
||||
- חישוב מקומות נדרשים vs. מסופקים
|
||||
- חלופות (קרן חניה, חפיפה, תח"צ)
|
||||
|
||||
□ שכנות/פגיעה (כשרלוונטי):
|
||||
- ממצאי סיור
|
||||
- צל, פרטיות, רעש, נוף
|
||||
- מידתיות
|
||||
|
||||
□ קווי בניין (כשרלוונטי):
|
||||
- הוראת תכנית
|
||||
- סטייה ניכרת — תקנה 2(19)
|
||||
- הצדקה/מידתיות
|
||||
|
||||
□ גובה/קומות (כשרלוונטי):
|
||||
- הוראת תכנית + נספח בינוי
|
||||
- מטרת ההגבלה
|
||||
- סטייה ניכרת — תקנה 2(10)
|
||||
```
|
||||
|
||||
### 6.2 הבחנה בין תת-סוגים
|
||||
הפרומפט צריך לזהות את סוג הערר ולהתאים את הצ'קליסט:
|
||||
- **ערר סמכות/סף** → ללא דיון תכנוני
|
||||
- **ערר קנייני** → דיון משפטי, ללא תכנון
|
||||
- **ערר מהותי** → דיון תכנוני מקיף + משפטי
|
||||
|
||||
### 6.3 צורך דחוף: החלטות היטל השבחה
|
||||
צריך להוסיף לקורפוס לפחות 5-10 החלטות של היטל השבחה לפני שהמערכת יכולה לכתוב החלטות בתחום הזה.
|
||||
@@ -161,3 +161,44 @@ Our skill was "over-indexed" on one case type (הכט = rejected appeal). The co
|
||||
- Created `create-decision-structure.cjs` script for generating structure DOCX
|
||||
- Key innovation from Arieli: "ההליכים בפני ועדת הערר" as separate section (Block ח)
|
||||
- "Judge Test": every block written as if administrative court judge reads cold
|
||||
|
||||
---
|
||||
|
||||
## Lessons from Systematic Corpus Analysis (24 decisions, April 2026)
|
||||
|
||||
### Source
|
||||
- All 24 proofread decisions in `/data/training/proofread/`
|
||||
- Full analysis: [`docs/corpus-analysis.md`](corpus-analysis.md)
|
||||
- Date: April 2026
|
||||
|
||||
### 12. System Learned Style but Not Substantive Content
|
||||
- **Problem:** Dafna reviewed Kiryat Yearim draft and noted missing planning discussion in block-yod
|
||||
- **Root cause:** The block-yod prompt taught CREAC methodology and "answer all claims" but never said "in licensing cases, include comprehensive planning discussion"
|
||||
- **Fix:** Content checklists added to `lessons.py` (`CONTENT_CHECKLISTS`), injected into block-yod prompt via `{content_checklist}`
|
||||
- **Applied to:** `lessons.py`, `block_writer.py`
|
||||
|
||||
### 13. Corpus Composition — All Licensing, No Betterment Levy
|
||||
- All 24 training decisions are licensing/construction (1xxx)
|
||||
- Zero betterment levy (8xxx) decisions in corpus
|
||||
- Not a current priority gap — focusing on licensing first
|
||||
|
||||
### 14. Planning Discussion Patterns in Licensing Decisions
|
||||
- **Always present** when the appeal reaches substantive planning questions
|
||||
- **Never present** when the appeal is purely jurisdictional or property-based
|
||||
- **Structure**: broad planning context → direct plan provision citations (200-600 words) → application to specific case → planning conclusion
|
||||
- **Deepest planning**: פרומר (pure plan interpretation), לבנון (height/building appendix), בית הכרם (multi-plan TAMA 38)
|
||||
- **No planning**: טלי-אביב (property only), גבאי (jurisdiction only)
|
||||
|
||||
### 15. Five Appeal Subtypes Identified (Not Just Three)
|
||||
Licensing appeals are not homogeneous — the discussion structure varies significantly:
|
||||
1. **Substantive licensing** — full planning discussion + legal analysis (majority of cases)
|
||||
2. **Threshold/jurisdiction** — legal analysis only, no planning
|
||||
3. **Property-focused** — תימוכין קנייניים, minimal planning
|
||||
4. **TAMA 38** — balancing public interest + planning + neighbor impact
|
||||
5. **Deviant use (שימוש חורג)** — deep plan interpretation across multiple plans
|
||||
|
||||
### 16. Chair Feedback System Established
|
||||
- DB table `chair_feedback` records Dafna's comments on drafts
|
||||
- Categories: missing_content, wrong_tone, wrong_structure, factual_error, style, other
|
||||
- MCP tools + UI page for recording and reviewing feedback
|
||||
- First entry: Kiryat Yearim — missing planning discussion (2026-04-12)
|
||||
|
||||
@@ -346,6 +346,29 @@ async def ingest_final_version(
|
||||
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def record_chair_feedback(
|
||||
case_number: str,
|
||||
feedback_text: str,
|
||||
block_id: str = "block-yod",
|
||||
category: str = "missing_content",
|
||||
lesson_extracted: str = "",
|
||||
) -> str:
|
||||
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה — חסר, שגיאה, סגנון."""
|
||||
return await workflow.record_chair_feedback(
|
||||
case_number, feedback_text, block_id, category, lesson_extracted,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_chair_feedback(
|
||||
case_number: str = "",
|
||||
category: str = "",
|
||||
unresolved_only: bool = True,
|
||||
) -> str:
|
||||
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
|
||||
return await workflow.list_chair_feedback(case_number, category, unresolved_only)
|
||||
|
||||
|
||||
def main():
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
@@ -20,6 +20,7 @@ from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import db, embeddings, claude_session
|
||||
from legal_mcp.services.lessons import get_content_checklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -215,6 +216,8 @@ BLOCK_PROMPTS = {
|
||||
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
|
||||
- מספור רציף
|
||||
|
||||
{content_checklist}
|
||||
|
||||
## כיוון מאושר (חובה):
|
||||
{direction_context}
|
||||
|
||||
@@ -310,6 +313,15 @@ async def write_block(
|
||||
outcome = (decision or {}).get("outcome", "rejected")
|
||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||
|
||||
# Content checklist — tells block-yod WHAT topics to cover
|
||||
content_checklist = ""
|
||||
if block_id == "block-yod":
|
||||
content_checklist = get_content_checklist(
|
||||
appeal_type=case.get("appeal_type", ""),
|
||||
subject=case.get("subject", ""),
|
||||
subject_categories=case.get("subject_categories", []),
|
||||
)
|
||||
|
||||
# Format prompt — per Anthropic long-context best practices:
|
||||
# Place source documents FIRST (top of prompt), instructions LAST.
|
||||
# "Queries at the end can improve response quality by up to 30%"
|
||||
@@ -323,6 +335,7 @@ async def write_block(
|
||||
style_context=style_context,
|
||||
discussion_context=discussion_context,
|
||||
structure_guidance=structure_guidance,
|
||||
content_checklist=content_checklist,
|
||||
)
|
||||
|
||||
# Restructure: sources first, then instructions
|
||||
|
||||
@@ -358,6 +358,22 @@ CREATE TABLE IF NOT EXISTS case_law_embeddings (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- Chair Feedback (הערות דפנה על טיוטות)
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chair_feedback (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
|
||||
block_id TEXT DEFAULT '', -- block-yod, block-vav, etc.
|
||||
feedback_text TEXT NOT NULL, -- ההערה של דפנה
|
||||
category TEXT DEFAULT 'other', -- missing_content/wrong_tone/wrong_structure/factual_error/style/other
|
||||
lesson_extracted TEXT DEFAULT '', -- הלקח שהופק
|
||||
applied_to TEXT[] DEFAULT '{}', -- לאילו קבצים/כללים הלקח יושם
|
||||
resolved BOOLEAN DEFAULT FALSE, -- האם הלקח יושם
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- Indexes
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
@@ -986,3 +1002,72 @@ async def search_precedents(
|
||||
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
# ── Chair feedback ────────────────────────────────────────────────
|
||||
|
||||
async def record_chair_feedback(
|
||||
case_id: UUID | None,
|
||||
block_id: str,
|
||||
feedback_text: str,
|
||||
category: str = "other",
|
||||
lesson_extracted: str = "",
|
||||
) -> UUID:
|
||||
"""Record feedback from the chair (Dafna) on a draft block."""
|
||||
pool = await get_pool()
|
||||
feedback_id = uuid4()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO chair_feedback
|
||||
(id, case_id, block_id, feedback_text, category, lesson_extracted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||
feedback_id, case_id, block_id, feedback_text, category,
|
||||
lesson_extracted,
|
||||
)
|
||||
return feedback_id
|
||||
|
||||
|
||||
async def list_chair_feedback(
|
||||
case_id: UUID | None = None,
|
||||
category: str | None = None,
|
||||
unresolved_only: bool = False,
|
||||
) -> list[dict]:
|
||||
"""List chair feedback, optionally filtered."""
|
||||
pool = await get_pool()
|
||||
conditions = []
|
||||
params: list = []
|
||||
idx = 1
|
||||
|
||||
if case_id:
|
||||
conditions.append(f"case_id = ${idx}")
|
||||
params.append(case_id)
|
||||
idx += 1
|
||||
if category:
|
||||
conditions.append(f"category = ${idx}")
|
||||
params.append(category)
|
||||
idx += 1
|
||||
if unresolved_only:
|
||||
conditions.append("resolved = FALSE")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC",
|
||||
*params,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def resolve_chair_feedback(
|
||||
feedback_id: UUID,
|
||||
applied_to: list[str],
|
||||
) -> None:
|
||||
"""Mark feedback as resolved and record where it was applied."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""UPDATE chair_feedback
|
||||
SET resolved = TRUE, applied_to = $2
|
||||
WHERE id = $1""",
|
||||
feedback_id, applied_to,
|
||||
)
|
||||
|
||||
@@ -329,3 +329,193 @@ def format_ratios_comment(outcome: str, section: str) -> str:
|
||||
lo, hi = ratios[section]
|
||||
return f"יעד: {lo}-{hi}% מסך ההחלטה"
|
||||
return ""
|
||||
|
||||
|
||||
# ── Content checklists by appeal subtype ──────────────────────────
|
||||
# Based on systematic analysis of 24 decisions from Dafna's corpus.
|
||||
# See: docs/corpus-analysis.md
|
||||
|
||||
CONTENT_CHECKLISTS: dict[str, str] = {
|
||||
"licensing_substantive": """## צ'קליסט תוכן — ערר רישוי מהותי (חובה)
|
||||
הדיון חייב לכלול את הנושאים הרלוונטיים מהרשימה הבאה.
|
||||
**אל תדלג על נושא שרלוונטי לתיק — בדוק כל סעיף.**
|
||||
|
||||
### א. הקשר תכנוני רחב (חובה בכל ערר מהותי)
|
||||
- תכניות חלות — ציין את התכניות הרלוונטיות ברמה מקומית, מחוזית וארצית (לפי הצורך)
|
||||
- ייעוד הקרקע — מה הייעוד בתכנית? מה השימושים המותרים?
|
||||
- אופי הסביבה — מרקם בנוי, צפיפות, אופי שכונה/ישוב
|
||||
- *דוגמה*: בערר פרומר — 12 סעיפים על MI/200, תמ"א 35, תמ"מ 30/1
|
||||
|
||||
### ב. ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה)
|
||||
- ציטוט ישיר מהוראות התכנית הרלוונטיות (200-600 מילים לכל ציטוט)
|
||||
- פרשנות — מה תכלית ההוראה?
|
||||
- יישום — האם הבקשה תואמת או סוטה?
|
||||
- *דוגמה*: בערר לבנון — ניתוח חתכים של נספח בינוי מול הבקשה
|
||||
|
||||
### ג. חניה (כשרלוונטי — מופיע ב-8 מתוך 24 החלטות)
|
||||
- הוראות תכנית + נספח תנועה (ציטוט ישיר)
|
||||
- חישוב מקומות חניה נדרשים vs. מסופקים
|
||||
- חלופות: קרן חניה, חפיפת שימושים, קרבה לתח"צ
|
||||
- *דוגמה*: בערר בית הכרם — 8 סעיפים, 400+ מילים מהוראות תכנית 5166ב
|
||||
|
||||
### ד. קווי בניין ומרווחים (כשרלוונטי)
|
||||
- הוראת תכנית על מרווחים
|
||||
- סטייה ניכרת? — תקנה 2(19) / הלכת בן-יקר-גת
|
||||
- הצדקה + מידתיות — פגיעה בשכנים?
|
||||
|
||||
### ה. גובה וקומות (כשרלוונטי)
|
||||
- הוראת תכנית + נספח בינוי (חתכים)
|
||||
- מטרת ההגבלה — למה יש הגבלת גובה כאן?
|
||||
- סטייה ניכרת — תקנה 2(10) / 2(8)
|
||||
|
||||
### ו. פגיעה בשכנים (כשרלוונטי)
|
||||
- ממצאי סיור באתר
|
||||
- השפעה: צל, פרטיות, רעש, נוף
|
||||
- מידתיות — האם הפגיעה סבירה?
|
||||
|
||||
### ז. שימוש חורג (כשרלוונטי)
|
||||
- מה השימוש המותר בתכנית? מה השימוש המבוקש?
|
||||
- "מבחן ההתאמה" — האם השימוש מתאים למיקום?
|
||||
- תנאים ומגבלות
|
||||
""",
|
||||
|
||||
"licensing_threshold": """## צ'קליסט תוכן — ערר רישוי סף/סמכות
|
||||
הערר עוסק בשאלות סף — אין צורך בדיון תכנוני מקיף.
|
||||
|
||||
### א. שאלת הסמכות
|
||||
- סעיפי חוק רלוונטיים (ס' 12ב, 152, וכו')
|
||||
- פסיקה על גבולות הסמכות
|
||||
|
||||
### ב. זכות ערר
|
||||
- מי רשאי לערור? באיזה מסלול?
|
||||
- הלכת שפר (עע"מ 317/10) — כשרלוונטית
|
||||
|
||||
### ג. שיהוי (אם רלוונטי)
|
||||
""",
|
||||
|
||||
"licensing_property": """## צ'קליסט תוכן — ערר רישוי קנייני
|
||||
הערר עוסק בעיקר בשאלת תימוכין קנייניים — דיון משפטי.
|
||||
|
||||
### א. מסגרת נורמטיבית
|
||||
- הלכת עייזן, בני אליעזר, רוזן — "היתכנות קניינית"
|
||||
- ס' 71ב לחוק המקרקעין
|
||||
|
||||
### ב. בחינת הראיות
|
||||
- הסכמות, רישום, היסטוריית בנייה
|
||||
- חלוקה דה-פקטו ארוכת שנים
|
||||
|
||||
### ג. הפרדה בין קניין לתכנון
|
||||
- גוף תכנוני אינו מכריע בסכסוכי קניין
|
||||
- "היתכנות קניינית" ≠ הוכחת בעלות
|
||||
|
||||
### ד. שאלות תכנוניות (אם רלוונטיות)
|
||||
- אם הערר עולה גם שאלות תכנוניות — דון בהן בנפרד
|
||||
""",
|
||||
|
||||
"tama38": """## צ'קליסט תוכן — ערר תמ"א 38
|
||||
הדיון חייב לאזן בין אינטרס ציבורי לפגיעה בשכנים.
|
||||
|
||||
### א. אינטרס ציבורי — חיזוק/התחדשות
|
||||
- עוצמת האינטרס — בניין גדול vs. בית בודד
|
||||
- "בית בודד" מחליש את אינטרס החיזוק
|
||||
- תרומה לרקמה העירונית
|
||||
|
||||
### ב. תכנית אב / מדיניות אזורית
|
||||
- האם יש תכנית אב? מדיניות 16000?
|
||||
- התאמה לראיה כללית vs. אד-הוק
|
||||
|
||||
### ג. ניתוח השוואתי
|
||||
- זכויות לפי תכנית קיימת vs. מבוקש לפי תמ"א 38
|
||||
- שטחים, קומות, קווי בניין — טבלת השוואה
|
||||
|
||||
### ד. שימור (כשרלוונטי)
|
||||
- חוות דעת אגף שימור
|
||||
- השפעה על מיקום/צורת הבניין
|
||||
|
||||
### ה. חניה (כמעט תמיד רלוונטי)
|
||||
- הוראות תכנית + ס' 17 לתמ"א 38
|
||||
- פטורים — קרבה לתח"צ, קרן חניה, תכנית אב
|
||||
- ניתוח מפורט של חלופות
|
||||
|
||||
### ו. פגיעה בשכנים
|
||||
- ממצאי סיור
|
||||
- צל, פרטיות, קרבה
|
||||
- מידתיות — מה הפגיעה ביחס לתועלת?
|
||||
|
||||
### ז. מטרדי בנייה
|
||||
- "מטרד בנייה אינו עילה לסירוב" — אך תנאים נדרשים
|
||||
- תכנית ארגון אתר
|
||||
""",
|
||||
|
||||
"betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה
|
||||
⚠️ שים לב: אין עדיין החלטות היטל השבחה בקורפוס האימון.
|
||||
הצ'קליסט הזה מבוסס על ידע כללי — לא על ניתוח ספציפי של סגנון דפנה.
|
||||
|
||||
### א. המסגרת הנורמטיבית
|
||||
- התוספת השלישית לחוק התכנון והבנייה
|
||||
- אירוע מס — מה יצר את ההשבחה?
|
||||
|
||||
### ב. שומה
|
||||
- שיטת השומה (שומה מכרעת / שמאי מייעץ)
|
||||
- מועד הקובע
|
||||
- זכויות בנייה — לפני ואחרי
|
||||
|
||||
### ג. שאלות משפטיות
|
||||
- פטורים (ס' 19)
|
||||
- מועדי תשלום
|
||||
- שיערוך
|
||||
|
||||
### ד. ניתוח שמאי
|
||||
- האם השומה תקינה?
|
||||
- פערים בין השומות
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def get_content_checklist(
|
||||
appeal_type: str = "",
|
||||
subject: str = "",
|
||||
subject_categories: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Return the appropriate content checklist based on case characteristics.
|
||||
|
||||
Determines the subtype from case metadata:
|
||||
- TAMA 38 cases → tama38 checklist
|
||||
- Betterment levy (8xxx) → betterment_levy checklist
|
||||
- Property-only cases → licensing_property checklist
|
||||
- Threshold/jurisdiction cases → licensing_threshold checklist
|
||||
- All other licensing → licensing_substantive checklist
|
||||
"""
|
||||
cats = subject_categories or []
|
||||
subject_lower = subject.lower() if subject else ""
|
||||
appeal_lower = appeal_type.lower() if appeal_type else ""
|
||||
|
||||
# TAMA 38
|
||||
if any(
|
||||
kw in subject_lower
|
||||
for kw in ["תמ\"א 38", "תמא 38", "תמ\"א38", "חיזוק", "tama"]
|
||||
) or "תמ\"א 38" in cats:
|
||||
return CONTENT_CHECKLISTS["tama38"]
|
||||
|
||||
# Betterment levy
|
||||
if "היטל השבחה" in appeal_lower or "betterment" in appeal_lower or any(
|
||||
"היטל" in c for c in cats
|
||||
):
|
||||
return CONTENT_CHECKLISTS["betterment_levy"]
|
||||
|
||||
# Property-focused (תימוכין קנייניים)
|
||||
if any(
|
||||
kw in subject_lower
|
||||
for kw in ["תימוכין", "קנייני", "בעלות", "הסכמת דיירים"]
|
||||
):
|
||||
return CONTENT_CHECKLISTS["licensing_property"]
|
||||
|
||||
# Threshold/jurisdiction
|
||||
if any(
|
||||
kw in subject_lower
|
||||
for kw in ["סמכות", "סף", "סילוק על הסף", "זכות ערר"]
|
||||
):
|
||||
return CONTENT_CHECKLISTS["licensing_threshold"]
|
||||
|
||||
# Default: substantive licensing
|
||||
return CONTENT_CHECKLISTS["licensing_substantive"]
|
||||
|
||||
@@ -318,3 +318,97 @@ async def ingest_final_version(
|
||||
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||
except ValueError as e:
|
||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ── Chair feedback tools ──────────────────────────────────────────
|
||||
|
||||
|
||||
async def record_chair_feedback(
|
||||
case_number: str,
|
||||
feedback_text: str,
|
||||
block_id: str = "block-yod",
|
||||
category: str = "missing_content",
|
||||
lesson_extracted: str = "",
|
||||
) -> str:
|
||||
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
feedback_text: ההערה של דפנה (מה חסר, מה לא נכון, מה צריך לשנות)
|
||||
block_id: הבלוק שההערה מתייחסת אליו (ברירת מחדל: block-yod)
|
||||
category: קטגוריה — missing_content/wrong_tone/wrong_structure/factual_error/style/other
|
||||
lesson_extracted: הלקח שהופק מההערה (אם ברור כבר)
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
case_id = UUID(case["id"]) if case else None
|
||||
|
||||
valid_categories = [
|
||||
"missing_content", "wrong_tone", "wrong_structure",
|
||||
"factual_error", "style", "other",
|
||||
]
|
||||
if category not in valid_categories:
|
||||
return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}"
|
||||
|
||||
feedback_id = await db.record_chair_feedback(
|
||||
case_id=case_id,
|
||||
block_id=block_id,
|
||||
feedback_text=feedback_text,
|
||||
category=category,
|
||||
lesson_extracted=lesson_extracted,
|
||||
)
|
||||
|
||||
return json.dumps({
|
||||
"status": "ok",
|
||||
"feedback_id": str(feedback_id),
|
||||
"message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.",
|
||||
"next_steps": [
|
||||
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
|
||||
"כדי לסמן כמטופל: resolve_chair_feedback",
|
||||
],
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def list_chair_feedback(
|
||||
case_number: str = "",
|
||||
category: str = "",
|
||||
unresolved_only: bool = True,
|
||||
) -> str:
|
||||
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
|
||||
|
||||
Args:
|
||||
case_number: סינון לפי תיק (אם ריק — כל ההערות)
|
||||
category: סינון לפי קטגוריה
|
||||
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
|
||||
"""
|
||||
case_id = None
|
||||
if case_number:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
case_id = UUID(case["id"])
|
||||
|
||||
feedbacks = await db.list_chair_feedback(
|
||||
case_id=case_id,
|
||||
category=category or None,
|
||||
unresolved_only=unresolved_only,
|
||||
)
|
||||
|
||||
if not feedbacks:
|
||||
return "אין הערות שמתאימות לסינון."
|
||||
|
||||
items = []
|
||||
for fb in feedbacks:
|
||||
items.append({
|
||||
"id": str(fb["id"]),
|
||||
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
|
||||
"block_id": fb["block_id"],
|
||||
"category": fb["category"],
|
||||
"feedback": fb["feedback_text"],
|
||||
"lesson": fb["lesson_extracted"],
|
||||
"resolved": fb["resolved"],
|
||||
"date": fb["created_at"].isoformat() if fb.get("created_at") else None,
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"total": len(items),
|
||||
"feedbacks": items,
|
||||
}, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
@@ -499,3 +499,39 @@ description: This skill should be used when writing legal decisions (החלטו
|
||||
| תיבות תמונה | מסגרת עם shading אפור בהיר (fill: "F0F0F0"), טקסט "📷 תמונה: [תיאור]" | ShadingType.CLEAR |
|
||||
| חתימות | טבלה ללא גבולות (`visuallyRightToLeft: true`), 2 טורים | כמו בתבנית ב-create-legal-doc.js |
|
||||
| כותרת מוסדית | טבלה ללא גבולות, 2 טורים: ימין=מוסד, שמאל=מספרי תיק | `visuallyRightToLeft: true` |
|
||||
|
||||
|
||||
## 12. צ'קליסט תוכן לפי סוג ערר
|
||||
|
||||
> נוסף אפריל 2026 בעקבות ניתוח שיטתי של 24 החלטות. ראה: `docs/corpus-analysis.md`
|
||||
|
||||
הפרומפט של בלוק י מקבל **צ'קליסט תוכן** אוטומטי לפי סוג הערר (`lessons.py: CONTENT_CHECKLISTS`). זה מבטיח שהדיון יכסה את הנושאים הנדרשים — לא רק סגנון ומתודולוגיה, אלא תוכן ענייני.
|
||||
|
||||
### 12.1 חמישה תת-סוגי רישוי (לא שלושה)
|
||||
ניתוח הקורפוס חשף שלתיקי רישוי יש 5 תת-סוגים שונים מבחינת מבנה הדיון:
|
||||
|
||||
| תת-סוג | מה בדיון | דוגמאות |
|
||||
|---------|---------|---------|
|
||||
| **רישוי מהותי** | דיון תכנוני מקיף + משפטי | רוב ההחלטות |
|
||||
| **סף/סמכות** | משפטי בלבד, ללא תכנון | גבאי, ירושלים שקופה |
|
||||
| **קנייני** | תימוכין קנייניים, מינימום תכנון | טלי-אביב, הראל 1043 |
|
||||
| **תמ"א 38** | איזון אינטרסים + תכנון + שכנות | בית הכרם |
|
||||
| **שימוש חורג** | פרשנות תכניות מרובות | תורן |
|
||||
|
||||
### 12.2 דיון תכנוני — מתי ואיך
|
||||
**מתי חובה:** כשהערר מגיע לדיון מהותי (לא סף/סמכות, לא קנייני טהור).
|
||||
|
||||
**מבנה טיפוסי (מהקורפוס):**
|
||||
1. הקשר תכנוני רחב — תכניות חלות, ייעוד, סביבה (2-8 סעיפים)
|
||||
2. ציטוט ישיר מהוראות תכנית — בלוקים של 200-600 מילים עם "הדגשת הח"מ"
|
||||
3. יישום על המקרה — הוראה → עובדה → מסקנה
|
||||
4. מסקנה תכנונית — תואם/סוטה, מוצדק/לא
|
||||
|
||||
**נושאים שמופיעים בתדירות גבוהה:**
|
||||
- חניה (8/24 החלטות) — הנושא התכנוני הנפוץ ביותר, עומק של 5-15 סעיפים
|
||||
- קווי בניין (7/24) — כולל ניתוח סטייה ניכרת
|
||||
- ניתוח הוראות תכנית (18/24) — כמעט תמיד
|
||||
- פגיעה בשכנים (5/24) — צל, פרטיות, רעש
|
||||
|
||||
### 12.3 הערות יו"ר
|
||||
הערות דפנה על טיוטות מתועדות במערכת `chair_feedback` (DB + API + UI ב-`/feedback`). כל הערה מסווגת לקטגוריה ומפיקה לקח שמשפר את ההחלטות הבאות.
|
||||
|
||||
181
web-ui/README.md
181
web-ui/README.md
@@ -1,36 +1,175 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# עוזר משפטי — Web UI (Next.js rewrite)
|
||||
|
||||
## Getting Started
|
||||
The Next.js 16 rewrite of `legal-ai.nautilus.marcusgroup.org`, currently hosted side-by-side with the legacy vanilla `index.html` at:
|
||||
|
||||
First, run the development server:
|
||||
- **Staging:** https://legal-ai-next.nautilus.marcusgroup.org (auto-deployed from `ui-rewrite` branch via Coolify)
|
||||
- **Production FastAPI:** https://legal-ai.nautilus.marcusgroup.org (same backend, old UI still default)
|
||||
|
||||
The rewrite talks to the existing FastAPI via proxy rewrites in `next.config.ts` — no CORS setup, no duplicated backend.
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js 16.2.3 (App Router, Turbopack, `output: "standalone"`)
|
||||
- React 19.2 · TypeScript · Tailwind v4 · shadcn/ui (radix-nova preset)
|
||||
- TanStack Query v5 + TanStack Table v8
|
||||
- react-hook-form + zod for mutations
|
||||
- react-dropzone for uploads; EventSource for SSE progress
|
||||
- Heebo via `next/font/google`; design tokens in `src/app/globals.css`
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
npm run build # full type check + production build
|
||||
npm run lint
|
||||
npm run api:types # regenerate src/lib/api/types.ts from FastAPI's OpenAPI
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### API connection
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
By default the dev server proxies to production FastAPI (`https://legal-ai.nautilus.marcusgroup.org`). To point at a different backend, set:
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
```bash
|
||||
export NEXT_PUBLIC_API_ORIGIN=http://localhost:8000
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Learn More
|
||||
## Project layout
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
```
|
||||
src/
|
||||
├── app/ # Route segments (App Router)
|
||||
│ ├── layout.tsx # Root: Providers + RTL html + fonts
|
||||
│ ├── page.tsx # Home: KPIs + status donut + cases table
|
||||
│ ├── error.tsx # Route-segment error boundary
|
||||
│ ├── global-error.tsx # Root crash fallback
|
||||
│ ├── not-found.tsx # 404
|
||||
│ ├── cases/
|
||||
│ │ ├── new/ # 3-step create wizard
|
||||
│ │ └── [caseNumber]/
|
||||
│ │ ├── page.tsx # Case detail (tabs + workflow timeline)
|
||||
│ │ └── compose/ # Research analysis + chair-position editor
|
||||
│ ├── training/ # Style portrait + corpus + compare (3 tabs)
|
||||
│ ├── skills/ # Paperclip skills inventory
|
||||
│ └── diagnostics/ # DB health + failed/stuck docs
|
||||
├── components/
|
||||
│ ├── app-shell.tsx # Header + nav with aria-current
|
||||
│ ├── cases/ # Home + detail screens
|
||||
│ ├── compose/ # Research analysis editor
|
||||
│ ├── documents/ # UploadSheet
|
||||
│ ├── training/ # Style report / corpus / compare panels
|
||||
│ ├── wizard/ # Case create wizard + parties-field
|
||||
│ └── ui/ # shadcn primitives
|
||||
├── lib/
|
||||
│ ├── api/ # Typed hooks per domain (cases, documents, research,
|
||||
│ │ # system, skills, training)
|
||||
│ ├── schemas/ # zod schemas (case create / update)
|
||||
│ ├── practice-area.ts # Multi-tenant axis enum + deriveSubtype()
|
||||
│ ├── sse.ts # EventSource wrapper
|
||||
│ ├── providers.tsx # QueryClient + Toaster
|
||||
│ └── utils.ts # cn()
|
||||
```
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Smoke test (run after every deploy)
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
Use any browser at the staging URL. Every step should be doable **without console errors** and each mutation should produce a visible toast.
|
||||
|
||||
## Deploy on Vercel
|
||||
### 1. Home · `/`
|
||||
- [ ] Header nav shows 5 items; the current page is underlined in gold
|
||||
- [ ] 4 KPI cards render real numbers (סה״כ · בהכנה · בכתיבה · מוכנים)
|
||||
- [ ] Cases table lists existing cases; search filters by case number or title
|
||||
- [ ] "פיזור סטטוסים" donut renders with a legend
|
||||
- [ ] "+ תיק חדש" button in the top-left navigates to `/cases/new`
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
### 2. Create case · `/cases/new`
|
||||
- [ ] 3-step wizard: פרטי יסוד → צדדים → השלמות
|
||||
- [ ] Type `1500-25` → appeal_subtype auto-fills to "רישוי ובנייה"
|
||||
- [ ] Type `8500-25` → subtype auto-fills to "היטל השבחה"
|
||||
- [ ] Manually pick a different subtype → auto-fill stops
|
||||
- [ ] Submitting with invalid case number shows a zod field error (no crash)
|
||||
- [ ] Successful create → toast "תיק חדש נוצר" → router pushes to `/cases/{number}`
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
### 3. Case detail · `/cases/[caseNumber]`
|
||||
- [ ] Header shows status badge + gold "ועדת ערר · X" practice-area badge
|
||||
- [ ] Tabs switch cleanly: סקירה / מסמכים / פעולות
|
||||
- [ ] Workflow timeline on the right shows the current phase highlighted in gold
|
||||
- [ ] פעולות tab → "עריכת פרטי תיק" dialog opens; submitting updates the header without full reload (optimistic cache patch)
|
||||
- [ ] "העלאת מסמכים" sheet opens from the tab row; drag-drop fires a POST and a live progress bar appears via SSE
|
||||
|
||||
### 4. Compose · `/cases/[caseNumber]/compose`
|
||||
- [ ] If analysis-and-research.md exists: threshold claims + issues render as collapsible cards
|
||||
- [ ] Chair-position textarea auto-saves on blur with "✓ נשמר {time}" indicator
|
||||
- [ ] If 404 (no analysis yet): empty state card renders, no error toast
|
||||
|
||||
### 5. Training · `/training`
|
||||
- [ ] **Report tab:** headline card, 4 KPIs, subject donut, anatomy bars, top-12 signature phrases
|
||||
- [ ] **Corpus tab:** table of corpus decisions with a trash icon per row (aria-label present)
|
||||
- [ ] Deleting a decision refreshes both the corpus table and the report KPIs
|
||||
- [ ] **Compare tab:** two Selects, pick 2 different decisions, side-by-side panels + shared/only-A/only-B pattern lists
|
||||
|
||||
### 6. Skills · `/skills`
|
||||
- [ ] Card grid of Paperclip skills with sync-status badges (מסונכרן / DB בלבד / לא סונכרן)
|
||||
- [ ] Chars + file counts render; "לא ידוע" doesn't appear for installed skills
|
||||
|
||||
### 7. Diagnostics · `/diagnostics`
|
||||
- [ ] DB status card shows "מחובר" in green
|
||||
- [ ] Table counts populate for cases / documents / chunks / corpus / patterns
|
||||
- [ ] Failed + stuck document lists render (empty states OK)
|
||||
- [ ] Page self-refreshes every 10s — check the network tab for recurring calls
|
||||
|
||||
### 8. Error boundary
|
||||
- [ ] Visit `/cases/NOT-REAL-999-99` → case detail shows an error card with the FastAPI message and "חזרה לרשימת התיקים" button (no white screen)
|
||||
- [ ] Visit `/anything-broken-xyz` → custom 404 page with "חזרה לבית" button
|
||||
|
||||
### 9. Keyboard + RTL
|
||||
- [ ] Tab through the home page — focus rings are gold, visible
|
||||
- [ ] Wizard progresses via Enter on the "הבא" button
|
||||
- [ ] Screen reader announces nav items with "עמוד נוכחי" on the active one
|
||||
|
||||
## Deploy
|
||||
|
||||
```
|
||||
git push # → Coolify auto-build on branch ui-rewrite (~90 s)
|
||||
```
|
||||
|
||||
> **Known issue:** the Gitea → Coolify webhook is not firing at the time of writing. Trigger a manual deploy via the Coolify MCP (`mcp__coolify__deploy` with app UUID `l146g36mtlp0k03vrwkyrgkk`) or the Coolify UI until the webhook is fixed.
|
||||
|
||||
## Phase tracking
|
||||
|
||||
See `~/.claude/plans/joyful-marinating-sutton.md` for the 7-phase rewrite plan and `~/legal-ai-ui-rewrite/.taskmaster/tasks/tasks.json` for the task board.
|
||||
|
||||
| Phase | Scope | Status |
|
||||
|---|---|---|
|
||||
| 1 | Scaffold + Coolify staging | ✅ |
|
||||
| 2 | API client + typed hooks + probe | ✅ |
|
||||
| 3 | Read views (home, case detail, compose) | ✅ |
|
||||
| 4 | Mutations (wizard, edit, upload+SSE) | ✅ |
|
||||
| 4.5 | Practice-area integration | ✅ |
|
||||
| 5 | Secondary screens (training, skills, diagnostics) | ✅ |
|
||||
| 6 | Polish, a11y, error boundaries, smoke test | ✅ |
|
||||
| 7 | DNS cutover to production | pending |
|
||||
|
||||
## Backend contract
|
||||
|
||||
The new UI consumes the existing FastAPI at `legal-ai/web/app.py`. Key endpoints currently relied on:
|
||||
|
||||
| Endpoint | Hook | Used by |
|
||||
|---|---|---|
|
||||
| `GET /api/cases?detail=true` | `useCases` | home table, KPIs |
|
||||
| `GET /api/cases/{n}/details` | `useCase` | case detail |
|
||||
| `POST /api/cases/create` | `useCreateCase` | wizard |
|
||||
| `PUT /api/cases/{n}` | `useUpdateCase` | inline edit |
|
||||
| `DELETE /api/cases?case_number=...` | (MCP only so far) | admin cleanup |
|
||||
| `POST /api/cases/{n}/documents/upload-tagged` | `useUploadDocument` | upload sheet |
|
||||
| `GET /api/progress/{task_id}` (SSE) | `useProgress` | upload progress |
|
||||
| `GET /api/cases/{n}/research/analysis` | `useResearchAnalysis` | compose |
|
||||
| `PATCH .../chair-position` | `useSaveChairPosition` | chair editor |
|
||||
| `GET /api/training/style-report` | `useStyleReport` | training/report tab |
|
||||
| `GET /api/training/corpus` | `useCorpus` | training/corpus tab |
|
||||
| `GET /api/training/compare` | `useCompare` | training/compare tab |
|
||||
| `DELETE /api/training/corpus/{id}` | `useDeleteCorpusEntry` | corpus tab |
|
||||
| `GET /api/system/diagnostics` | `useDiagnostics` | diagnostics page |
|
||||
| `GET /api/admin/skills` | `useSkills` | skills page |
|
||||
|
||||
Any new endpoint should get a typed hook in `src/lib/api/` — do not reach into `fetch` from component code.
|
||||
|
||||
1460
web-ui/package-lock.json
generated
1460
web-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
@@ -7,8 +7,11 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SubsectionCard } from "@/components/compose/subsection-card";
|
||||
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { useCase } from "@/lib/api/cases";
|
||||
import { useResearchAnalysis } from "@/lib/api/research";
|
||||
import { useCasePrecedents } from "@/lib/api/precedents";
|
||||
|
||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
if (!content?.trim()) return null;
|
||||
@@ -17,9 +20,7 @@ function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-ink-soft leading-relaxed whitespace-pre-line prose">
|
||||
{content.trim()}
|
||||
</p>
|
||||
<Markdown content={content.trim()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +33,21 @@ export default function ComposePage({
|
||||
const { caseNumber } = use(params);
|
||||
const caseQuery = useCase(caseNumber);
|
||||
const analysis = useResearchAnalysis(caseNumber);
|
||||
const precedentsQuery = useCasePrecedents(caseNumber);
|
||||
|
||||
/* Partition the flat list into scopes so each child renders its own slice
|
||||
* without re-fetching. Done once at the page level. */
|
||||
const allPrecedents = precedentsQuery.data ?? [];
|
||||
const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null);
|
||||
const precedentsBySection = new Map<string, typeof allPrecedents>();
|
||||
for (const p of allPrecedents) {
|
||||
if (p.section_id) {
|
||||
const existing = precedentsBySection.get(p.section_id) ?? [];
|
||||
existing.push(p);
|
||||
precedentsBySection.set(p.section_id, existing);
|
||||
}
|
||||
}
|
||||
const practiceArea = caseQuery.data?.practice_area ?? null;
|
||||
|
||||
const isNotFound =
|
||||
analysis.error instanceof Error &&
|
||||
@@ -98,9 +114,24 @@ export default function ComposePage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.data ? (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||
{/* Main editable column */}
|
||||
<div className="space-y-6">
|
||||
{/* Case-level general precedents */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-xl mb-1">פסיקה כללית לדיון</h2>
|
||||
<p className="text-[0.78rem] text-ink-muted mb-4">
|
||||
ציטוטים התומכים בעמדה באופן רוחבי — ישולבו בפתיחת בלוק י (דיון).
|
||||
</p>
|
||||
<PrecedentsSection
|
||||
caseNumber={caseNumber}
|
||||
sectionId={null}
|
||||
precedents={caseLevelPrecedents}
|
||||
practiceArea={practiceArea}
|
||||
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Threshold claims */}
|
||||
{analysis.data.threshold_claims &&
|
||||
analysis.data.threshold_claims.length > 0 && (
|
||||
@@ -112,12 +143,13 @@ export default function ComposePage({
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{analysis.data.threshold_claims.map((tc, i) => (
|
||||
{analysis.data.threshold_claims.map((tc) => (
|
||||
<SubsectionCard
|
||||
key={tc.id}
|
||||
caseNumber={caseNumber}
|
||||
item={tc}
|
||||
defaultOpen={i === 0}
|
||||
precedents={precedentsBySection.get(tc.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -139,6 +171,8 @@ export default function ComposePage({
|
||||
key={iss.id}
|
||||
caseNumber={caseNumber}
|
||||
item={iss}
|
||||
precedents={precedentsBySection.get(iss.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -153,13 +187,13 @@ export default function ComposePage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side column: background prose + conclusions */}
|
||||
<aside className="space-y-5">
|
||||
{/* Background prose — moved below the issues so it reads as
|
||||
supporting context after the chair has seen the main
|
||||
decision points, not as a wall of text beside them. */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 space-y-5">
|
||||
<h2 className="text-navy text-base mb-0">רקע לניתוח</h2>
|
||||
<CardContent className="px-6 py-5 space-y-5">
|
||||
<h2 className="text-navy text-xl mb-0">רקע לניתוח</h2>
|
||||
<ProseSection
|
||||
title="צד מיוצג"
|
||||
content={analysis.data.represented_party}
|
||||
@@ -181,15 +215,12 @@ export default function ComposePage({
|
||||
|
||||
{analysis.data.conclusions?.trim() && (
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-5 py-4 space-y-2">
|
||||
<h2 className="text-gold-deep text-base mb-0">מסקנות</h2>
|
||||
<p className="text-sm text-ink leading-relaxed whitespace-pre-line prose">
|
||||
{analysis.data.conclusions.trim()}
|
||||
</p>
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<h2 className="text-gold-deep text-xl mb-0">מסקנות</h2>
|
||||
<Markdown content={analysis.data.conclusions.trim()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -12,8 +12,13 @@ import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||||
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||
import { useCase } from "@/lib/api/cases";
|
||||
|
||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||
);
|
||||
|
||||
/*
|
||||
* Next 16 breaking change: route params are now a Promise.
|
||||
* The `use()` hook unwraps them inside a client component.
|
||||
@@ -25,6 +30,9 @@ export default function CaseDetailPage({
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const { data, isPending, error } = useCase(caseNumber);
|
||||
const expectedOutcomeLabel = data?.expected_outcome
|
||||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
@@ -77,7 +85,7 @@ export default function CaseDetailPage({
|
||||
<div>
|
||||
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
|
||||
<p className="text-ink-soft text-sm leading-relaxed">
|
||||
{data?.expected_outcome ?? "לא נקבעה תוצאה צפויה."}
|
||||
{expectedOutcomeLabel ?? "לא נקבעה תוצאה צפויה."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -100,8 +108,7 @@ export default function CaseDetailPage({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions" className="mt-5">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||
<Link href={`/cases/${caseNumber}/compose`}>
|
||||
פתח בעורך ההחלטה
|
||||
@@ -109,10 +116,6 @@ export default function CaseDetailPage({
|
||||
</Button>
|
||||
{data && <CaseEditDialog data={data} />}
|
||||
</div>
|
||||
<p className="text-xs text-ink-muted">
|
||||
עריכת פרטי התיק נשמרת מיד דרך PUT /api/cases/{caseNumber}.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
||||
219
web-ui/src/app/diagnostics/page.tsx
Normal file
219
web-ui/src/app/diagnostics/page.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, CheckCircle2, Clock, Database } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDiagnostics, type DiagDoc } from "@/lib/api/system";
|
||||
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
cases: "תיקים",
|
||||
documents: "מסמכים",
|
||||
document_chunks: "chunks",
|
||||
style_corpus: "קורפוס סגנון",
|
||||
style_patterns: "דפוסי סגנון",
|
||||
};
|
||||
|
||||
function formatRelativeTime(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
const then = new Date(iso);
|
||||
const diffMs = Date.now() - then.getTime();
|
||||
const min = Math.floor(diffMs / 60000);
|
||||
if (min < 1) return "עכשיו";
|
||||
if (min < 60) return `לפני ${min} דקות`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `לפני ${hr} שעות`;
|
||||
const days = Math.floor(hr / 24);
|
||||
if (days < 30) return `לפני ${days} ימים`;
|
||||
return then.toLocaleDateString("he-IL");
|
||||
}
|
||||
|
||||
function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) {
|
||||
const cls =
|
||||
tone === "danger" ? "bg-danger-bg/60 border-danger/30" : "bg-warn-bg/60 border-warn/30";
|
||||
return (
|
||||
<li
|
||||
className={`rounded border px-3 py-2 flex items-center gap-3 text-sm ${cls}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-ink font-medium truncate" title={doc.title}>
|
||||
{doc.title || "(ללא כותרת)"}
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-muted flex gap-3 mt-0.5">
|
||||
{doc.case_number && (
|
||||
<Link href={`/cases/${doc.case_number}`} className="hover:text-gold-deep">
|
||||
ערר {doc.case_number}
|
||||
</Link>
|
||||
)}
|
||||
<span>{formatRelativeTime(doc.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[0.7rem]">{doc.status}</Badge>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiagnosticsPage() {
|
||||
const { data, isPending, error } = useDiagnostics();
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">אבחון מערכת</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">אבחון מערכת</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
מצב ה-DB, מסמכים שנכשלו או תקועים, ומשימות רקע פעילות. מתעדכן כל 10
|
||||
שניות.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-6 text-center text-danger">
|
||||
{error.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* DB status + table counts */}
|
||||
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 flex flex-col items-start gap-2">
|
||||
<div className="flex items-center gap-2 text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
מצב DB
|
||||
</div>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-6 w-24" />
|
||||
) : data?.db_ok ? (
|
||||
<div className="flex items-center gap-2 text-success font-semibold">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span>מחובר</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-danger font-semibold">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>מנותק</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<div className="text-ink-muted text-[0.72rem] uppercase tracking-wider mb-3">
|
||||
ספירת טבלאות
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 md:grid-cols-5 gap-y-2 gap-x-4">
|
||||
{Object.keys(TABLE_LABELS).map((key) => (
|
||||
<div key={key} className="space-y-0.5">
|
||||
<dt className="text-[0.72rem] text-ink-muted">
|
||||
{TABLE_LABELS[key]}
|
||||
</dt>
|
||||
<dd className="font-display font-bold text-navy text-xl tabular-nums">
|
||||
{isPending ? "—" : (data?.tables[key] ?? "—")}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Active tasks */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
משימות רקע פעילות
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{data?.active_tasks.length ?? 0}
|
||||
</Badge>
|
||||
</h2>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-16 w-full" />
|
||||
) : data?.active_tasks.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין משימות פעילות</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data?.active_tasks.map((t) => (
|
||||
<li
|
||||
key={t.task_id}
|
||||
className="flex items-center gap-3 text-sm rounded bg-rule-soft/60 border border-rule px-3 py-2"
|
||||
>
|
||||
<span className="flex-1 text-ink truncate">
|
||||
{t.filename || t.task_id}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
{t.step || t.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Failed + stuck docs */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-danger text-lg mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
מסמכים שנכשלו
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{data?.failed_documents.length ?? 0}
|
||||
</Badge>
|
||||
</h2>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-20 w-full" />
|
||||
) : data?.failed_documents.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין כשלונות</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data?.failed_documents.map((d) => (
|
||||
<DocRow key={d.id} doc={d} tone="danger" />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-warn text-lg mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
תקועים (> 10 דק׳)
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{data?.stuck_documents.length ?? 0}
|
||||
</Badge>
|
||||
</h2>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-20 w-full" />
|
||||
) : data?.stuck_documents.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין מסמכים תקועים</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data?.stuck_documents.map((d) => (
|
||||
<DocRow key={d.id} doc={d} tone="warn" />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
57
web-ui/src/app/error.tsx
Normal file
57
web-ui/src/app/error.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/*
|
||||
* Route-segment error boundary. Next 16 App Router convention: this file
|
||||
* catches render-time errors thrown below the root layout. `reset` clears
|
||||
* the error and re-renders the segment; we keep the user's nav in place
|
||||
* (no AppShell here — the shell re-renders from the layout above us).
|
||||
*/
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
|
||||
<div className="max-w-xl mx-auto text-center space-y-5 py-16">
|
||||
<AlertTriangle className="w-12 h-12 text-danger mx-auto" aria-hidden="true" />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-navy">משהו השתבש</h1>
|
||||
<p className="text-ink-muted leading-relaxed">
|
||||
נכשלה טעינת המסך. זה עשוי להיות כשל זמני ברשת או באחד מה-endpoints
|
||||
של FastAPI.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-[0.72rem] text-ink-light tabular-nums">
|
||||
error id: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
{error.message && (
|
||||
<code className="text-[0.78rem] text-ink-soft bg-rule-soft/60 rounded px-3 py-1 inline-block mt-2">
|
||||
{error.message}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button onClick={reset} className="bg-navy hover:bg-navy-soft text-parchment">
|
||||
נסה שוב
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/">חזרה לבית</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
329
web-ui/src/app/feedback/page.tsx
Normal file
329
web-ui/src/app/feedback/page.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
useFeedbackList,
|
||||
useCreateFeedback,
|
||||
useResolveFeedback,
|
||||
CATEGORY_LABELS,
|
||||
BLOCK_LABELS,
|
||||
type FeedbackCategory,
|
||||
} from "@/lib/api/feedback";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
|
||||
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
factual_error: "bg-red-100 text-red-800 border-red-200",
|
||||
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
other: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
};
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const [showResolved, setShowResolved] = useState(false);
|
||||
const [filterCategory, setFilterCategory] = useState<string>("");
|
||||
|
||||
const { data: feedbacks, isLoading } = useFeedbackList({
|
||||
category: filterCategory || undefined,
|
||||
unresolved_only: !showResolved,
|
||||
});
|
||||
|
||||
const resolveMutation = useResolveFeedback();
|
||||
|
||||
function handleResolve(id: string) {
|
||||
resolveMutation.mutate(
|
||||
{ feedbackId: id, applied_to: [] },
|
||||
{
|
||||
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
||||
onError: () => toast.error("שגיאה בעדכון"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">
|
||||
בית
|
||||
</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">הערות יו״ר</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">הערות יו״ר על טיוטות</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
תיעוד הערות דפנה על טיוטות החלטות. כל הערה מנותחת ומשפיעה על שיפור
|
||||
כתיבת ההחלטות העתידיות.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<NewFeedbackDialog />
|
||||
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="rounded-md border border-rule bg-surface px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">כל הקטגוריות</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-ink-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showResolved}
|
||||
onChange={(e) => setShowResolved(e.target.checked)}
|
||||
className="rounded border-rule"
|
||||
/>
|
||||
הצג גם מטופלות
|
||||
</label>
|
||||
|
||||
{feedbacks && (
|
||||
<span className="text-sm text-ink-muted me-auto">
|
||||
{feedbacks.length} הערות
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback list */}
|
||||
{isLoading ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||
טוען...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : !feedbacks?.length ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||
אין הערות{!showResolved ? " פתוחות" : ""}
|
||||
{filterCategory ? ` בקטגוריה ${CATEGORY_LABELS[filterCategory as FeedbackCategory]}` : ""}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{feedbacks.map((fb) => (
|
||||
<Card
|
||||
key={fb.id}
|
||||
className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-60" : ""}`}
|
||||
>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
className={`text-[0.7rem] border ${CATEGORY_COLORS[fb.category]}`}
|
||||
>
|
||||
{CATEGORY_LABELS[fb.category]}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
|
||||
</Badge>
|
||||
{fb.case_number && (
|
||||
<Link
|
||||
href={`/cases/${fb.case_number}`}
|
||||
className="text-[0.7rem] text-gold-deep hover:underline"
|
||||
>
|
||||
תיק {fb.case_number}
|
||||
</Link>
|
||||
)}
|
||||
{fb.resolved && (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 text-[0.7rem] border border-emerald-200">
|
||||
טופל
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[0.7rem] text-ink-muted me-auto">
|
||||
{fb.created_at
|
||||
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6 py-4 space-y-3">
|
||||
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
||||
|
||||
{fb.lesson_extracted && (
|
||||
<div className="bg-gold/5 border border-gold/20 rounded-md px-4 py-3">
|
||||
<p className="text-[0.7rem] font-semibold text-gold-deep mb-1">
|
||||
לקח שהופק:
|
||||
</p>
|
||||
<p className="text-sm text-ink-muted leading-relaxed">
|
||||
{fb.lesson_extracted}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fb.resolved && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResolve(fb.id)}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
סמן כמטופל
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── New feedback dialog ─────────────────────────────────── */
|
||||
|
||||
function NewFeedbackDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const createMutation = useCreateFeedback();
|
||||
|
||||
const [caseNumber, setCaseNumber] = useState("");
|
||||
const [blockId, setBlockId] = useState("block-yod");
|
||||
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
|
||||
const [feedbackText, setFeedbackText] = useState("");
|
||||
const [lesson, setLesson] = useState("");
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!feedbackText.trim()) return;
|
||||
|
||||
createMutation.mutate(
|
||||
{
|
||||
case_number: caseNumber || undefined,
|
||||
block_id: blockId,
|
||||
feedback_text: feedbackText,
|
||||
category,
|
||||
lesson_extracted: lesson || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("ההערה נרשמה בהצלחה");
|
||||
setOpen(false);
|
||||
setCaseNumber("");
|
||||
setFeedbackText("");
|
||||
setLesson("");
|
||||
},
|
||||
onError: () => toast.error("שגיאה ברישום ההערה"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>+ הערה חדשה</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>רישום הערת יו״ר</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="fb-case">מספר תיק (אופציונלי)</Label>
|
||||
<Input
|
||||
id="fb-case"
|
||||
value={caseNumber}
|
||||
onChange={(e) => setCaseNumber(e.target.value)}
|
||||
placeholder="1130-25"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fb-block">בלוק</Label>
|
||||
<select
|
||||
id="fb-block"
|
||||
value={blockId}
|
||||
onChange={(e) => setBlockId(e.target.value)}
|
||||
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fb-category">קטגוריה</Label>
|
||||
<select
|
||||
id="fb-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as FeedbackCategory)}
|
||||
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fb-text">ההערה</Label>
|
||||
<Textarea
|
||||
id="fb-text"
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value)}
|
||||
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
|
||||
<Textarea
|
||||
id="fb-lesson"
|
||||
value={lesson}
|
||||
onChange={(e) => setLesson(e.target.value)}
|
||||
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
ביטול
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "שומר..." : "שמור הערה"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
60
web-ui/src/app/global-error.tsx
Normal file
60
web-ui/src/app/global-error.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
/*
|
||||
* Root-level error boundary. Renders only when the root layout itself
|
||||
* crashes, so it must include its own <html> / <body>. No AppShell here —
|
||||
* the Providers that wrap AppShell are also above the crash boundary and
|
||||
* may themselves be the thing that failed.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="he" dir="rtl">
|
||||
<body
|
||||
style={{
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f5f1e8",
|
||||
color: "#1a1a2e",
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 520, textAlign: "center" }}>
|
||||
<h1 style={{ color: "#0f172a", fontSize: "2rem", marginBottom: 8 }}>
|
||||
שגיאה חמורה
|
||||
</h1>
|
||||
<p style={{ color: "#6b7280", lineHeight: 1.6, marginBottom: 16 }}>
|
||||
האפליקציה נכשלה לטעון. נסה לרענן את הדף.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p style={{ fontSize: 12, color: "#9ca3af", marginBottom: 16 }}>
|
||||
error id: {error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
background: "#0f172a",
|
||||
color: "#fbf8f0",
|
||||
border: 0,
|
||||
padding: "0.6rem 1.25rem",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
fontSize: "0.95rem",
|
||||
}}
|
||||
>
|
||||
נסה שוב
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,11 @@ const heebo = Heebo({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "עוזר משפטי — ניהול תיקים",
|
||||
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה",
|
||||
title: {
|
||||
default: "עוזר משפטי — ניהול תיקים",
|
||||
template: "%s · עוזר משפטי",
|
||||
},
|
||||
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה, ירושלים",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
24
web-ui/src/app/not-found.tsx
Normal file
24
web-ui/src/app/not-found.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="max-w-xl mx-auto text-center py-16 space-y-5">
|
||||
<div className="font-display text-gold text-6xl leading-none" aria-hidden="true">
|
||||
404
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-navy">הדף לא נמצא</h1>
|
||||
<p className="text-ink-muted leading-relaxed">
|
||||
הכתובת שביקשת אינה קיימת או שהוזזה לדף אחר.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||
<Link href="/">חזרה לבית</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
128
web-ui/src/app/skills/page.tsx
Normal file
128
web-ui/src/app/skills/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Plug, HardDrive, Database, FileText } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useSkills, type Skill } from "@/lib/api/skills";
|
||||
|
||||
function formatSize(bytes: number | null) {
|
||||
if (bytes == null) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function statusBadge(s: Skill) {
|
||||
if (s.not_in_db) {
|
||||
return <Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">לא סונכרן</Badge>;
|
||||
}
|
||||
if (s.db_markdown_chars > 0 && s.disk_exists) {
|
||||
return <Badge variant="outline" className="bg-success-bg text-success border-success/40">מסונכרן</Badge>;
|
||||
}
|
||||
if (s.db_markdown_chars > 0) {
|
||||
return <Badge variant="outline" className="bg-info-bg text-info border-info/40">DB בלבד</Badge>;
|
||||
}
|
||||
return <Badge variant="outline">לא ידוע</Badge>;
|
||||
}
|
||||
|
||||
function SkillCard({ skill }: { skill: Skill }) {
|
||||
const fileCount = skill.file_inventory?.length ?? 0;
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardContent className="px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Plug className="w-4 h-4 text-gold-deep shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-navy font-semibold text-base mb-0 truncate">
|
||||
{skill.name || skill.slug}
|
||||
</h3>
|
||||
<code className="text-[0.72rem] text-ink-muted tabular-nums">
|
||||
{skill.slug}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{statusBadge(skill)}
|
||||
</div>
|
||||
<dl className="grid grid-cols-3 gap-2 text-[0.72rem] text-ink-muted mt-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span className="tabular-nums">{fileCount}</span>
|
||||
<span>קבצים</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Database className="w-3 h-3" />
|
||||
<span className="tabular-nums">
|
||||
{(skill.db_markdown_chars / 1000).toFixed(1)}K
|
||||
</span>
|
||||
<span>תווים</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span className="tabular-nums">
|
||||
{formatSize(skill.disk_skill_md_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
{skill.updated_at && (
|
||||
<p className="text-[0.7rem] text-ink-light mt-2">
|
||||
עודכן: {new Date(skill.updated_at).toLocaleDateString("he-IL")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { data, isPending, error } = useSkills();
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">מיומנויות</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">מיומנויות Paperclip</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
רשימת ה-skills המותקנים במערכת Paperclip ומצב הסנכרון שלהם בין ה-DB
|
||||
לדיסק.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-6 text-center text-danger">
|
||||
{error.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isPending ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-6 py-12 text-center text-ink-muted">
|
||||
<div className="text-gold text-3xl mb-2" aria-hidden>❦</div>
|
||||
אין skills מותקנים
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data?.map((s) => <SkillCard key={s.slug} skill={s} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
56
web-ui/src/app/training/page.tsx
Normal file
56
web-ui/src/app/training/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { StyleReportPanel } from "@/components/training/style-report-panel";
|
||||
import { CorpusPanel } from "@/components/training/corpus-panel";
|
||||
import { ComparePanel } from "@/components/training/compare-panel";
|
||||
|
||||
export default function TrainingPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">אימון סגנון</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת,
|
||||
ביטויי חתימה, וכלי השוואה בין שתי החלטות.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<Tabs defaultValue="report" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="report">פורטרט סגנון</TabsTrigger>
|
||||
<TabsTrigger value="corpus">קורפוס</TabsTrigger>
|
||||
<TabsTrigger value="compare">השוואה</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="report" className="mt-5">
|
||||
<StyleReportPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="corpus" className="mt-5">
|
||||
<CorpusPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="compare" className="mt-5">
|
||||
<ComparePanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Ezer Mishpati navigation shell.
|
||||
@@ -9,8 +12,9 @@ import Link from "next/link";
|
||||
* - Parchment/cream body background (set on <body> via globals.css)
|
||||
* - Hebrew RTL throughout (set on <html> in layout.tsx)
|
||||
*
|
||||
* Structure mirrors the current vanilla index.html header so that visual
|
||||
* continuity is preserved while we migrate screen-by-screen.
|
||||
* Nav items pick up an `aria-current="page"` and a gold underline when
|
||||
* the current route matches, so screen readers announce the active
|
||||
* section and sighted users can see where they are.
|
||||
*/
|
||||
|
||||
type NavItem = {
|
||||
@@ -20,13 +24,21 @@ type NavItem = {
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "בית" },
|
||||
{ href: "/upload", label: "העלאת מסמכים" },
|
||||
{ href: "/cases/new", label: "תיק חדש" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/feedback", label: "הערות יו״ר" },
|
||||
{ href: "/skills", label: "מיומנויות" },
|
||||
{ href: "/diagnostics", label: "אבחון" },
|
||||
];
|
||||
|
||||
function isActive(pathname: string, href: string): boolean {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
@@ -45,25 +57,43 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
|
||||
</Link>
|
||||
|
||||
<nav className="me-auto flex items-center gap-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<nav
|
||||
className="me-auto flex items-center gap-1"
|
||||
aria-label="ניווט ראשי"
|
||||
>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = isActive(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="
|
||||
px-3 py-1.5 rounded
|
||||
text-sm text-parchment/80
|
||||
transition-colors
|
||||
hover:text-parchment hover:bg-navy-soft/60
|
||||
"
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`
|
||||
relative px-3 py-1.5 rounded text-sm transition-colors
|
||||
${
|
||||
active
|
||||
? "text-parchment font-semibold bg-navy-soft/80"
|
||||
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
{active && (
|
||||
<span
|
||||
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
|
||||
<main
|
||||
id="main"
|
||||
className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/cases/status-badge";
|
||||
import {
|
||||
PRACTICE_AREA_LABELS,
|
||||
APPEAL_SUBTYPE_LABELS,
|
||||
} from "@/lib/practice-area";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
@@ -30,11 +35,22 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
||||
|
||||
<div className="flex items-start justify-between gap-6 flex-wrap">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
|
||||
ערר {data?.case_number ?? "—"}
|
||||
</span>
|
||||
{data?.status && <StatusBadge status={data.status} />}
|
||||
{data?.practice_area && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium bg-gold-wash text-gold-deep border-gold/40"
|
||||
>
|
||||
{PRACTICE_AREA_LABELS[data.practice_area]}
|
||||
{data.appeal_subtype && data.appeal_subtype !== "unknown" && (
|
||||
<> · {APPEAL_SUBTYPE_LABELS[data.appeal_subtype]}</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
||||
{data?.title ?? "טוען…"}
|
||||
@@ -55,10 +71,6 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
||||
עודכן
|
||||
</dt>
|
||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
||||
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
ועדה
|
||||
</dt>
|
||||
<dd className="text-ink-soft">{data?.committee_type ?? "—"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { StatusBadge } from "@/components/cases/status-badge";
|
||||
import { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
|
||||
import type { Case } from "@/lib/api/cases";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
@@ -59,6 +60,15 @@ const columns: ColumnDef<Case>[] = [
|
||||
header: "סטטוס",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "appeal_subtype",
|
||||
header: "תחום",
|
||||
cell: ({ row }) => {
|
||||
const s = row.original.appeal_subtype;
|
||||
if (!s || s === "unknown") return <span className="text-ink-muted">—</span>;
|
||||
return <span className="text-ink-soft text-sm">{APPEAL_SUBTYPE_LABELS[s]}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "document_count",
|
||||
header: "מסמכים",
|
||||
|
||||
@@ -2,15 +2,30 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
|
||||
function formatSize(bytes?: number) {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
/*
|
||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||
* row shape returned by the FastAPI case_get endpoint — see db.list_documents
|
||||
* and the `documents` schema in legal_mcp/services/db.py:
|
||||
* id · case_id · doc_type · title · file_path · extraction_status ·
|
||||
* page_count · created_at · practice_area · appeal_subtype
|
||||
*/
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
appeal: "כתב ערר",
|
||||
response: "כתב תשובה",
|
||||
protocol: "פרוטוקול",
|
||||
decision: "החלטת ועדה מקומית",
|
||||
plan: "תכנית",
|
||||
reference: "חומר רקע",
|
||||
auto: "—",
|
||||
};
|
||||
|
||||
function doctypeLabel(t: string): string {
|
||||
return DOC_TYPE_LABELS[t] ?? t;
|
||||
}
|
||||
|
||||
function categoryTone(category?: string | null) {
|
||||
switch (category) {
|
||||
function doctypeTone(t: string): string {
|
||||
switch (t) {
|
||||
case "appeal": return "bg-info-bg text-info border-info/40";
|
||||
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
|
||||
case "decision": return "bg-success-bg text-success border-success/40";
|
||||
@@ -19,54 +34,83 @@ function categoryTone(category?: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: "בהמתנה",
|
||||
processing: "בעיבוד",
|
||||
completed: "הושלם",
|
||||
proofread: "הוגה",
|
||||
failed: "נכשל",
|
||||
error: "שגיאה",
|
||||
};
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function filenameFromPath(path: string): string {
|
||||
const parts = path.split("/");
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
export function DocumentsPanel({ data }: { data?: CaseDetail }) {
|
||||
const docs = data?.documents ?? [];
|
||||
|
||||
if (docs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-ink-muted">
|
||||
<div className="text-gold text-2xl mb-2" aria-hidden>❦</div>
|
||||
<div className="text-gold text-2xl mb-2" aria-hidden="true">❦</div>
|
||||
<p className="text-sm">אין מסמכים בתיק זה</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="max-h-[520px]">
|
||||
<ul className="divide-y divide-rule">
|
||||
{docs.map((doc) => (
|
||||
<ScrollArea className="max-h-[520px]" dir="rtl">
|
||||
<ul className="divide-y divide-rule" dir="rtl">
|
||||
{docs.map((doc) => {
|
||||
const displayName = doc.title || filenameFromPath(doc.file_path);
|
||||
const statusDone =
|
||||
doc.extraction_status === "completed" ||
|
||||
doc.extraction_status === "proofread";
|
||||
return (
|
||||
<li
|
||||
key={doc.id}
|
||||
className="py-3 flex items-start justify-between gap-4 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded"
|
||||
className="py-3 flex items-start gap-4 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded"
|
||||
>
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="text-ink font-medium truncate" title={doc.filename}>
|
||||
{doc.filename}
|
||||
{/* Title + meta — flex-1 keeps it glued to the start (right in RTL) */}
|
||||
<div className="flex-1 min-w-0 space-y-0.5 text-right">
|
||||
<div className="text-ink font-medium truncate" title={displayName}>
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3">
|
||||
{doc.size_bytes && (
|
||||
<span className="tabular-nums">{formatSize(doc.size_bytes)}</span>
|
||||
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3 flex-wrap">
|
||||
{doc.page_count != null && (
|
||||
<span className="tabular-nums">{doc.page_count} עמ׳</span>
|
||||
)}
|
||||
{doc.uploaded_at && (
|
||||
<span>
|
||||
{new Date(doc.uploaded_at).toLocaleDateString("he-IL")}
|
||||
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||
{!statusDone && doc.extraction_status && (
|
||||
<span className="text-warn">
|
||||
{STATUS_LABELS[doc.extraction_status] ?? doc.extraction_status}
|
||||
</span>
|
||||
)}
|
||||
{doc.status && doc.status !== "ready" && (
|
||||
<span className="text-warn">{doc.status}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{doc.category && (
|
||||
{/* Type badge — ms-auto forces it to the inline-end (= left in RTL) */}
|
||||
{doc.doc_type && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2 py-0.5 text-[0.7rem] ${categoryTone(doc.category)}`}
|
||||
className={`rounded-full px-2 py-0.5 text-[0.7rem] shrink-0 ms-auto ${doctypeTone(doc.doc_type)}`}
|
||||
>
|
||||
{doc.category}
|
||||
{doctypeLabel(doc.doc_type)}
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
229
web-ui/src/components/compose/precedent-attacher.tsx
Normal file
229
web-ui/src/components/compose/precedent-attacher.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Paperclip } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Popover, PopoverContent, PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
useCreatePrecedent,
|
||||
usePrecedentLibrarySearch,
|
||||
uploadPrecedentPdf,
|
||||
} from "@/lib/api/precedents";
|
||||
import type { PracticeArea } from "@/lib/practice-area";
|
||||
|
||||
/*
|
||||
* Inline form for adding a new precedent. Opens in a Popover adjacent
|
||||
* to the trigger button so the user can see the surrounding context
|
||||
* (the threshold_claim body, the chair editor) while they fill it in.
|
||||
*
|
||||
* The citation field has cross-case typeahead: once the user types
|
||||
* 2+ characters, we hit /api/precedents/search and show distinct
|
||||
* matches. Picking one prefills quote + chair_note but keeps them
|
||||
* editable — the new row is a copy, so a customized quote for this
|
||||
* case doesn't affect the library.
|
||||
*/
|
||||
|
||||
export function PrecedentAttacher({
|
||||
caseNumber,
|
||||
sectionId,
|
||||
practiceArea,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
sectionId: string | null;
|
||||
practiceArea: PracticeArea | null | undefined;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [citation, setCitation] = useState("");
|
||||
const [quote, setQuote] = useState("");
|
||||
const [chairNote, setChairNote] = useState("");
|
||||
const [pdfFile, setPdfFile] = useState<File | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [picked, setPicked] = useState(false);
|
||||
|
||||
const create = useCreatePrecedent(caseNumber);
|
||||
const library = usePrecedentLibrarySearch(
|
||||
citation,
|
||||
practiceArea,
|
||||
/* pause typeahead once the user has picked one and we're just editing */
|
||||
!picked,
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setCitation("");
|
||||
setQuote("");
|
||||
setChairNote("");
|
||||
setPdfFile(null);
|
||||
setPicked(false);
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!quote.trim() || !citation.trim()) {
|
||||
toast.error("ציטוט ומראה-מקום חובה");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let pdfDocumentId: string | undefined;
|
||||
if (pdfFile) {
|
||||
const res = await uploadPrecedentPdf(caseNumber, pdfFile);
|
||||
pdfDocumentId = res.document_id;
|
||||
}
|
||||
await create.mutateAsync({
|
||||
quote: quote.trim(),
|
||||
citation: citation.trim(),
|
||||
chair_note: chairNote.trim(),
|
||||
section_id: sectionId ?? undefined,
|
||||
pdf_document_id: pdfDocumentId,
|
||||
});
|
||||
toast.success("נוספה פסיקה");
|
||||
reset();
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "שגיאה בשמירה");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-dashed border-gold/50 text-gold-deep hover:bg-gold-wash"
|
||||
>
|
||||
<Plus className="w-4 h-4 me-1" aria-hidden="true" />
|
||||
הוסף פסיקה תומכת
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[520px] max-w-[90vw] p-5"
|
||||
align="start"
|
||||
dir="rtl"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-3" dir="rtl">
|
||||
<div>
|
||||
<Label htmlFor="prec-citation" className="text-navy">
|
||||
מראה מקום <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="prec-citation"
|
||||
value={citation}
|
||||
onChange={(e) => {
|
||||
setCitation(e.target.value);
|
||||
setPicked(false);
|
||||
}}
|
||||
placeholder="ערר (ירושלים) 1126-08-25 ... נ' ... (נבו 9.3.2026)"
|
||||
autoComplete="off"
|
||||
className="mt-1"
|
||||
/>
|
||||
{!picked && library.data && library.data.length > 0 && citation.length >= 2 && (
|
||||
<ul
|
||||
className="
|
||||
mt-1 rounded border border-rule bg-surface shadow-sm
|
||||
max-h-44 overflow-y-auto divide-y divide-rule
|
||||
"
|
||||
role="listbox"
|
||||
>
|
||||
{library.data.map((m) => (
|
||||
<li key={m.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCitation(m.citation);
|
||||
setQuote(m.quote);
|
||||
setChairNote(m.chair_note || "");
|
||||
setPicked(true);
|
||||
}}
|
||||
className="w-full text-right px-3 py-2 hover:bg-gold-wash/60 transition-colors"
|
||||
>
|
||||
<div className="text-[0.78rem] text-gold-deep font-semibold truncate">
|
||||
{m.citation}
|
||||
</div>
|
||||
<div className="text-[0.72rem] text-ink-muted truncate">
|
||||
{m.quote}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="prec-quote" className="text-navy">
|
||||
ציטוט <span className="text-danger">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="prec-quote"
|
||||
value={quote}
|
||||
onChange={(e) => setQuote(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="הטקסט המדויק שישולב בהחלטה"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="prec-note" className="text-navy">
|
||||
הערה (אופציונלי)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="prec-note"
|
||||
value={chairNote}
|
||||
onChange={(e) => setChairNote(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="למה הציטוט הזה תומך בעמדה"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-navy flex items-center gap-2">
|
||||
<Paperclip className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
צירוף קובץ המקור (אופציונלי, לארכיון)
|
||||
</Label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
onChange={(e) => setPdfFile(e.target.files?.[0] ?? null)}
|
||||
className="mt-1 w-full text-sm file:me-3 file:rounded file:border-0 file:bg-rule-soft file:px-3 file:py-1.5 file:text-navy file:text-sm hover:file:bg-rule"
|
||||
/>
|
||||
{pdfFile && (
|
||||
<p className="text-[0.72rem] text-ink-muted mt-1">
|
||||
{pdfFile.name} · {(pdfFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => { setOpen(false); reset(); }}
|
||||
disabled={submitting}
|
||||
>
|
||||
ביטול
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
>
|
||||
{submitting ? "שומר…" : "שמור פסיקה"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
77
web-ui/src/components/compose/precedent-card.tsx
Normal file
77
web-ui/src/components/compose/precedent-card.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDeletePrecedent, type CasePrecedent } from "@/lib/api/precedents";
|
||||
|
||||
/*
|
||||
* Read-only display of a single attached precedent. Layout is:
|
||||
*
|
||||
* ┌───────────────────────────────────────────┐
|
||||
* │ citation (gold semibold) [🗑] │
|
||||
* │ ┌──┐ │
|
||||
* │ │╎ │ "quote text…" │
|
||||
* │ └──┘ │
|
||||
* │ chair_note (muted) │
|
||||
* │ 📄 קובץ מצורף │
|
||||
* └───────────────────────────────────────────┘
|
||||
*/
|
||||
|
||||
export function PrecedentCard({
|
||||
caseNumber,
|
||||
precedent,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
precedent: CasePrecedent;
|
||||
}) {
|
||||
const del = useDeletePrecedent(caseNumber);
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!window.confirm("להסיר פסיקה זו מהתיק?")) return;
|
||||
try {
|
||||
await del.mutateAsync(precedent.id);
|
||||
toast.success("הפסיקה הוסרה");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "שגיאה בהסרה");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<p className="flex-1 text-[0.82rem] text-gold-deep font-semibold leading-snug">
|
||||
{precedent.citation}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={del.isPending}
|
||||
aria-label="הסר פסיקה זו"
|
||||
className="text-danger hover:text-danger hover:bg-danger-bg shrink-0 -mt-1 -me-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<blockquote className="border-e-2 border-gold pe-3 text-sm text-ink leading-relaxed whitespace-pre-line">
|
||||
{precedent.quote}
|
||||
</blockquote>
|
||||
|
||||
{precedent.chair_note && (
|
||||
<p className="text-[0.78rem] text-ink-muted italic">
|
||||
{precedent.chair_note}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{precedent.pdf_document_id && (
|
||||
<div className="flex items-center gap-1.5 text-[0.72rem] text-ink-muted">
|
||||
<FileText className="w-3 h-3" aria-hidden="true" />
|
||||
<span>קובץ מצורף</span>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
50
web-ui/src/components/compose/precedents-section.tsx
Normal file
50
web-ui/src/components/compose/precedents-section.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { PrecedentCard } from "@/components/compose/precedent-card";
|
||||
import { PrecedentAttacher } from "@/components/compose/precedent-attacher";
|
||||
import type { CasePrecedent } from "@/lib/api/precedents";
|
||||
import type { PracticeArea } from "@/lib/practice-area";
|
||||
|
||||
/*
|
||||
* Wrapper that renders the list of precedents for one scope — either
|
||||
* case-level (sectionId=null) or a specific threshold_claim / issue.
|
||||
* The parent page fetches useCasePrecedents(caseNumber) once and
|
||||
* passes a pre-filtered slice down, so each section doesn't re-query.
|
||||
*/
|
||||
|
||||
export function PrecedentsSection({
|
||||
caseNumber,
|
||||
sectionId,
|
||||
precedents,
|
||||
practiceArea,
|
||||
emptyHelperText,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
sectionId: string | null;
|
||||
precedents: CasePrecedent[];
|
||||
practiceArea: PracticeArea | null | undefined;
|
||||
emptyHelperText?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{precedents.length === 0 ? (
|
||||
emptyHelperText && (
|
||||
<p className="text-[0.78rem] text-ink-muted">{emptyHelperText}</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{precedents.map((p) => (
|
||||
<li key={p.id}>
|
||||
<PrecedentCard caseNumber={caseNumber} precedent={p} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<PrecedentAttacher
|
||||
caseNumber={caseNumber}
|
||||
sectionId={sectionId}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,16 +3,24 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChairEditor } from "@/components/compose/chair-editor";
|
||||
import { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import type { ResearchSubsection } from "@/lib/api/research";
|
||||
import type { CasePrecedent } from "@/lib/api/precedents";
|
||||
import type { PracticeArea } from "@/lib/practice-area";
|
||||
|
||||
export function SubsectionCard({
|
||||
caseNumber,
|
||||
item,
|
||||
defaultOpen = false,
|
||||
precedents = [],
|
||||
practiceArea,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
item: ResearchSubsection;
|
||||
defaultOpen?: boolean;
|
||||
precedents?: CasePrecedent[];
|
||||
practiceArea?: PracticeArea | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isFilled = Boolean(item.chair_position?.trim());
|
||||
@@ -69,8 +77,8 @@ export function SubsectionCard({
|
||||
<dt className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1">
|
||||
{f.label}
|
||||
</dt>
|
||||
<dd className="text-sm text-ink-soft leading-relaxed whitespace-pre-line">
|
||||
{f.content}
|
||||
<dd>
|
||||
<Markdown content={f.content} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
@@ -81,6 +89,18 @@ export function SubsectionCard({
|
||||
sectionId={item.id}
|
||||
initialValue={item.chair_position ?? ""}
|
||||
/>
|
||||
<div className="border-t border-rule pt-4 mt-2">
|
||||
<h4 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold mb-2">
|
||||
פסיקה תומכת
|
||||
</h4>
|
||||
<PrecedentsSection
|
||||
caseNumber={caseNumber}
|
||||
sectionId={item.id}
|
||||
precedents={precedents}
|
||||
practiceArea={practiceArea}
|
||||
emptyHelperText="עדיין לא צורפה פסיקה לסעיף זה"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
189
web-ui/src/components/training/compare-panel.tsx
Normal file
189
web-ui/src/components/training/compare-panel.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useCorpus, useCompare, type CompareSide, type PatternEntry,
|
||||
} from "@/lib/api/training";
|
||||
|
||||
/*
|
||||
* Compare two decisions from the style corpus side-by-side. Uses the
|
||||
* training/compare endpoint which already does the heavy lifting (pattern
|
||||
* extraction, section stats, shared/unique pattern sets). Our job is
|
||||
* layout: two columns of metadata + section bars, plus a third "shared"
|
||||
* section listing patterns that appear in both.
|
||||
*/
|
||||
|
||||
function SideColumn({ side }: { side: CompareSide }) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 space-y-3">
|
||||
<header>
|
||||
<h3 className="text-navy text-lg mb-0 tabular-nums">
|
||||
{side.decision_number || "—"}
|
||||
</h3>
|
||||
<p className="text-[0.78rem] text-ink-muted tabular-nums">
|
||||
{side.decision_date || "—"} · {(side.chars / 1000).toFixed(1)}K תווים
|
||||
</p>
|
||||
</header>
|
||||
{side.subjects.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{side.subjects.map((s) => (
|
||||
<Badge
|
||||
key={s}
|
||||
variant="outline"
|
||||
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
|
||||
>
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{side.sections.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1.5">
|
||||
חלוקה לחלקים
|
||||
</h4>
|
||||
<ul className="space-y-1 text-[0.78rem]">
|
||||
{side.sections.map((sec) => (
|
||||
<li key={sec.type} className="flex items-center justify-between gap-2">
|
||||
<span className="text-ink-soft truncate">{sec.type}</span>
|
||||
<span className="text-ink-muted tabular-nums shrink-0">
|
||||
{(sec.chars / 1000).toFixed(1)}K
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[0.78rem] text-ink-muted">
|
||||
דפוסי סגנון שנמצאו: <span className="text-navy font-semibold tabular-nums">{side.patterns_count}</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternList({
|
||||
title,
|
||||
items,
|
||||
tone,
|
||||
}: {
|
||||
title: string;
|
||||
items: PatternEntry[];
|
||||
tone: "shared" | "a" | "b";
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "shared"
|
||||
? "bg-success-bg border-success/40"
|
||||
: tone === "a"
|
||||
? "bg-info-bg border-info/40"
|
||||
: "bg-gold-wash border-gold/40";
|
||||
const toneHeading =
|
||||
tone === "shared"
|
||||
? "text-success"
|
||||
: tone === "a"
|
||||
? "text-info"
|
||||
: "text-gold-deep";
|
||||
|
||||
return (
|
||||
<Card className={`${toneClass} shadow-sm`}>
|
||||
<CardContent className="px-5 py-4">
|
||||
<h4 className={`${toneHeading} text-sm font-semibold mb-3 flex items-center gap-2`}>
|
||||
{title}
|
||||
<span className="text-[0.7rem] tabular-nums opacity-70">{items.length}</span>
|
||||
</h4>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-ink-muted text-[0.78rem]">—</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 text-[0.78rem] max-h-60 overflow-y-auto">
|
||||
{items.slice(0, 20).map((p) => (
|
||||
<li key={p.id} className="text-ink leading-relaxed">
|
||||
{p.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComparePanel() {
|
||||
const { data: corpus, isPending } = useCorpus();
|
||||
const [a, setA] = useState<string | null>(null);
|
||||
const [b, setB] = useState<string | null>(null);
|
||||
const cmp = useCompare(a, b);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-base mb-3">בחר שתי החלטות להשוואה</h3>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(["a", "b"] as const).map((slot) => (
|
||||
<div key={slot}>
|
||||
<label className="block text-[0.78rem] font-medium text-navy mb-1">
|
||||
{slot === "a" ? "החלטה א" : "החלטה ב"}
|
||||
</label>
|
||||
<Select
|
||||
disabled={isPending}
|
||||
value={(slot === "a" ? a : b) ?? ""}
|
||||
onValueChange={(v) => (slot === "a" ? setA(v) : setB(v))}
|
||||
dir="rtl"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={isPending ? "טוען…" : "בחר החלטה"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{corpus?.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.decision_number || "—"}
|
||||
{c.decision_date ? ` · ${c.decision_date}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{a && b && a === b && (
|
||||
<p className="text-ink-muted text-sm text-center">בחר שתי החלטות שונות</p>
|
||||
)}
|
||||
|
||||
{cmp.error && (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-5 text-danger text-center">
|
||||
{cmp.error.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{cmp.isPending && a && b && a !== b && (
|
||||
<Skeleton className="h-60 w-full" />
|
||||
)}
|
||||
|
||||
{cmp.data && (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SideColumn side={cmp.data.a} />
|
||||
<SideColumn side={cmp.data.b} />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<PatternList title="דפוסים משותפים" items={cmp.data.shared} tone="shared" />
|
||||
<PatternList title="רק בהחלטה א" items={cmp.data.only_a} tone="a" />
|
||||
<PatternList title="רק בהחלטה ב" items={cmp.data.only_b} tone="b" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
web-ui/src/components/training/corpus-panel.tsx
Normal file
140
web-ui/src/components/training/corpus-panel.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useCorpus, useDeleteCorpusEntry, type CorpusDecision } from "@/lib/api/training";
|
||||
|
||||
/*
|
||||
* Corpus tab: table of all decisions currently in the style corpus, with a
|
||||
* single destructive action (remove from corpus). Uses browser confirm() for
|
||||
* the confirmation — a full shadcn AlertDialog would be overkill for an
|
||||
* admin-only destructive action with a server-side safety net.
|
||||
*/
|
||||
|
||||
function formatChars(n: number) {
|
||||
return `${(n / 1000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function Row({ item }: { item: CorpusDecision }) {
|
||||
const del = useDeleteCorpusEntry();
|
||||
const onDelete = async () => {
|
||||
if (!window.confirm(`למחוק את החלטה ${item.decision_number} מהקורפוס?`)) return;
|
||||
try {
|
||||
await del.mutateAsync(item.id);
|
||||
toast.success("נמחק מהקורפוס");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "שגיאה במחיקה");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow className="border-rule hover:bg-gold-wash/30">
|
||||
<TableCell className="font-semibold text-navy tabular-nums">
|
||||
{item.decision_number || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-ink-muted tabular-nums">
|
||||
{formatDate(item.decision_date)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.subject_categories.length === 0 ? (
|
||||
<span className="text-ink-light">—</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.subject_categories.map((s) => (
|
||||
<Badge
|
||||
key={s}
|
||||
variant="outline"
|
||||
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
|
||||
>
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-ink-soft tabular-nums">
|
||||
{formatChars(item.chars)}
|
||||
</TableCell>
|
||||
<TableCell className="text-ink-muted tabular-nums text-[0.78rem]">
|
||||
{formatDate(item.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
disabled={del.isPending}
|
||||
aria-label={`הסר את ${item.decision_number || "החלטה זו"} מהקורפוס`}
|
||||
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function CorpusPanel() {
|
||||
const { data, isPending, error } = useCorpus();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||||
{error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-rule-soft/60">
|
||||
<TableRow className="border-rule">
|
||||
<TableHead className="text-navy text-right">מס׳ החלטה</TableHead>
|
||||
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||||
<TableHead className="text-navy text-right">נושאים</TableHead>
|
||||
<TableHead className="text-navy text-right">תווים</TableHead>
|
||||
<TableHead className="text-navy text-right">נוסף בתאריך</TableHead>
|
||||
<TableHead className="text-navy" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isPending ? (
|
||||
[...Array(4)].map((_, i) => (
|
||||
<TableRow key={i} className="border-rule">
|
||||
{[...Array(6)].map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : data?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-ink-muted py-12">
|
||||
הקורפוס ריק
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data?.map((item) => <Row key={item.id} item={item} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
web-ui/src/components/training/style-report-panel.tsx
Normal file
195
web-ui/src/components/training/style-report-panel.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SubjectDonut } from "@/components/training/subject-donut";
|
||||
import { useStyleReport } from "@/lib/api/training";
|
||||
|
||||
function KPICard({
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
caption?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4 flex flex-col gap-0.5">
|
||||
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-display text-[2rem] font-black leading-none text-navy">
|
||||
{value}
|
||||
</span>
|
||||
{caption && (
|
||||
<span className="text-[0.78rem] text-ink-muted mt-1">{caption}</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function StyleReportPanel() {
|
||||
const { data, isPending, error } = useStyleReport();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-5 text-center text-danger">
|
||||
{error.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending || !data) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const c = data.corpus;
|
||||
const dateRange =
|
||||
c.date_range[0] && c.date_range[1]
|
||||
? `${c.date_range[0]} – ${c.date_range[1]}`
|
||||
: undefined;
|
||||
const total = c.decision_count;
|
||||
const totalSubjects = c.subject_distribution.reduce((a, b) => a + b.count, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Headline */}
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-6 py-4">
|
||||
<p className="font-display text-gold-deep text-lg font-semibold leading-snug">
|
||||
★ {c.headline}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<KPICard label="החלטות בקורפוס" value={String(c.decision_count)} />
|
||||
<KPICard
|
||||
label="סך תווים"
|
||||
value={`${(c.total_chars / 1000).toFixed(0)}K`}
|
||||
/>
|
||||
<KPICard
|
||||
label="ממוצע להחלטה"
|
||||
value={`${(c.avg_chars / 1000).toFixed(1)}K`}
|
||||
/>
|
||||
<KPICard
|
||||
label="דפוסי סגנון"
|
||||
value={String(data.signature_phrases.items.length)}
|
||||
caption={`מתוך ${data.contribution.total_patterns} שחולצו`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subjects + anatomy */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h3 className="text-navy text-lg mb-4">פיזור נושאים</h3>
|
||||
<SubjectDonut
|
||||
segments={c.subject_distribution}
|
||||
total={totalSubjects}
|
||||
/>
|
||||
{dateRange && (
|
||||
<p className="text-[0.72rem] text-ink-muted mt-4">
|
||||
טווח תאריכים: {dateRange}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h3 className="text-navy text-lg mb-1">אנטומיה של החלטה ממוצעת</h3>
|
||||
{data.anatomy.headline && (
|
||||
<p className="text-[0.78rem] text-gold-deep mb-4">
|
||||
{data.anatomy.headline}
|
||||
</p>
|
||||
)}
|
||||
{data.anatomy.sections.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין נתונים על מבנה</p>
|
||||
) : (
|
||||
<ul className="space-y-2.5">
|
||||
{data.anatomy.sections.map((s) => {
|
||||
const pct = Math.round(s.pct * 100);
|
||||
return (
|
||||
<li key={s.type} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-[0.78rem]">
|
||||
<span className="text-ink-soft font-medium">
|
||||
{s.label}
|
||||
</span>
|
||||
<span className="text-ink-muted tabular-nums">
|
||||
{pct}% · {s.avg_chars.toLocaleString()} תווים
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded bg-rule-soft overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-l from-gold to-gold-deep"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Signature phrases */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h3 className="text-navy text-lg mb-1">ביטויי חתימה</h3>
|
||||
{data.signature_phrases.headline && (
|
||||
<p className="text-[0.78rem] text-gold-deep mb-4">
|
||||
{data.signature_phrases.headline}
|
||||
</p>
|
||||
)}
|
||||
{data.signature_phrases.items.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm">אין ביטויים שחולצו עדיין</p>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{data.signature_phrases.items.slice(0, 12).map((p, i) => (
|
||||
<li
|
||||
key={`${p.type}-${i}`}
|
||||
className="flex items-start gap-3 rounded border border-rule bg-parchment/40 px-3 py-2"
|
||||
>
|
||||
<span className="text-[0.7rem] text-ink-muted tabular-nums shrink-0 mt-0.5">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-ink leading-relaxed text-sm">{p.text}</p>
|
||||
{p.context && (
|
||||
<p className="text-[0.7rem] text-ink-muted mt-0.5">
|
||||
{p.context}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="
|
||||
shrink-0 text-[0.72rem] rounded-full
|
||||
bg-gold-wash text-gold-deep border border-gold/40
|
||||
px-2 py-0.5 tabular-nums
|
||||
"
|
||||
>
|
||||
×{p.frequency}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
web-ui/src/components/training/subject-donut.tsx
Normal file
72
web-ui/src/components/training/subject-donut.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/*
|
||||
* Corpus subject-distribution donut.
|
||||
*
|
||||
* Pure CSS conic-gradient — same recipe as the cases StatusDonut, but
|
||||
* uses a palette-of-gold instead of a status-tone palette. Ported from
|
||||
* legal-ai/web/static/index.html `renderHero`.
|
||||
*/
|
||||
|
||||
const DONUT_COLORS = [
|
||||
"var(--color-navy)",
|
||||
"var(--color-gold)",
|
||||
"var(--color-info)",
|
||||
"var(--color-warn)",
|
||||
"var(--color-success)",
|
||||
"var(--color-ink-muted)",
|
||||
"var(--color-gold-deep)",
|
||||
];
|
||||
|
||||
export function SubjectDonut({
|
||||
segments,
|
||||
total,
|
||||
}: {
|
||||
segments: Array<{ label: string; count: number }>;
|
||||
total: number;
|
||||
}) {
|
||||
let pct = 0;
|
||||
const parts = segments.map((s, i) => {
|
||||
const start = total === 0 ? 0 : (pct / total) * 360;
|
||||
pct += s.count;
|
||||
const end = total === 0 ? 360 : (pct / total) * 360;
|
||||
return { ...s, start, end, color: DONUT_COLORS[i % DONUT_COLORS.length] };
|
||||
});
|
||||
|
||||
const background =
|
||||
total === 0
|
||||
? "conic-gradient(var(--color-rule-soft) 0deg 360deg)"
|
||||
: `conic-gradient(${parts
|
||||
.map((p) => `${p.color} ${p.start}deg ${p.end}deg`)
|
||||
.join(", ")})`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<div
|
||||
className="relative w-[140px] h-[140px] rounded-full shadow-sm shrink-0"
|
||||
style={{ background }}
|
||||
aria-label="פיזור נושאים בקורפוס"
|
||||
>
|
||||
<div className="absolute inset-[18px] bg-surface rounded-full flex flex-col items-center justify-center">
|
||||
<span className="font-display text-2xl font-black text-navy leading-none">
|
||||
{total}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-ink-muted mt-1">החלטות</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1.5 text-sm min-w-0">
|
||||
{parts.map((p) => (
|
||||
<li key={p.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ background: p.color }}
|
||||
/>
|
||||
<span className="text-ink-soft truncate">{p.label}</span>
|
||||
<span className="text-ink-muted tabular-nums ms-1">{p.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
web-ui/src/components/ui/markdown.tsx
Normal file
116
web-ui/src/components/ui/markdown.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
/*
|
||||
* Tiny markdown renderer for Hebrew prose blocks — paragraphs, lists,
|
||||
* emphasis, and GFM tables (the main reason this exists). The parsed
|
||||
* research_md fields and the conclusions field both contain tables
|
||||
* like "ציר דיוני" that we want to render as real <table>s, RTL, with
|
||||
* auto-sized columns that line up row-to-row.
|
||||
*
|
||||
* Table styling uses `table-auto` + `whitespace-nowrap` on header cells
|
||||
* so the column widths are dictated by the longest cell in that column,
|
||||
* and every row's borders align exactly underneath each other. The
|
||||
* overflow-x-auto wrapper catches extremely wide tables on narrow
|
||||
* viewports without letting the parent card grow.
|
||||
*/
|
||||
|
||||
export function Markdown({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose-md text-sm text-ink-soft leading-relaxed" dir="rtl">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node: _n, ...props }) => (
|
||||
<p className="mb-2 last:mb-0 text-justify" {...props} />
|
||||
),
|
||||
strong: ({ node: _n, ...props }) => (
|
||||
<strong className="text-navy font-semibold" {...props} />
|
||||
),
|
||||
em: ({ node: _n, ...props }) => (
|
||||
<em className="text-ink" {...props} />
|
||||
),
|
||||
a: ({ node: _n, ...props }) => (
|
||||
<a
|
||||
className="text-gold-deep hover:text-gold underline underline-offset-2"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node: _n, ...props }) => (
|
||||
<ul className="list-disc ps-5 mb-2 space-y-1" {...props} />
|
||||
),
|
||||
ol: ({ node: _n, ...props }) => (
|
||||
<ol className="list-decimal ps-5 mb-2 space-y-1" {...props} />
|
||||
),
|
||||
li: ({ node: _n, ...props }) => (
|
||||
<li className="text-ink" {...props} />
|
||||
),
|
||||
h1: ({ node: _n, ...props }) => (
|
||||
<h3 className="text-navy text-base font-semibold mt-3 mb-1" {...props} />
|
||||
),
|
||||
h2: ({ node: _n, ...props }) => (
|
||||
<h4 className="text-navy text-sm font-semibold mt-3 mb-1" {...props} />
|
||||
),
|
||||
h3: ({ node: _n, ...props }) => (
|
||||
<h5 className="text-navy text-sm font-semibold mt-2 mb-1" {...props} />
|
||||
),
|
||||
blockquote: ({ node: _n, ...props }) => (
|
||||
<blockquote
|
||||
className="border-e-2 border-gold-soft pe-3 text-ink italic my-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: ({ node: _n, ...props }) => (
|
||||
<code
|
||||
className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem] text-ink"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
/* ── Tables ─────────────────────────────────────────────────
|
||||
Wrapped in an overflow-x-auto so very wide tables don't push
|
||||
the parent card out of its track. table-auto lets the browser
|
||||
size columns by their longest cell (that's what keeps borders
|
||||
aligned row-to-row) and whitespace-nowrap on the headers
|
||||
ensures the header row sets column widths instead of
|
||||
breaking mid-word. */
|
||||
table: ({ node: _n, ...props }) => (
|
||||
<div className="my-3 -mx-1 overflow-x-auto">
|
||||
<table
|
||||
className="w-full table-auto border-collapse border border-rule text-sm text-right"
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
thead: ({ node: _n, ...props }) => (
|
||||
<thead className="bg-rule-soft/70" {...props} />
|
||||
),
|
||||
tbody: ({ node: _n, ...props }) => <tbody {...props} />,
|
||||
tr: ({ node: _n, ...props }) => (
|
||||
<tr className="border-b border-rule last:border-b-0" {...props} />
|
||||
),
|
||||
th: ({ node: _n, ...props }) => (
|
||||
<th
|
||||
className="border border-rule px-3 py-2 text-right text-navy font-semibold whitespace-nowrap align-top"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ node: _n, ...props }) => (
|
||||
<td
|
||||
className="border border-rule px-3 py-2 text-right text-ink align-top"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: ({ node: _n, ...props }) => (
|
||||
<hr className="my-3 border-rule" {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
web-ui/src/components/ui/popover.tsx
Normal file
89
web-ui/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-heading font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -16,9 +16,13 @@ import {
|
||||
import { PartiesField } from "@/components/wizard/parties-field";
|
||||
import { useCreateCase } from "@/lib/api/cases";
|
||||
import {
|
||||
caseCreateSchema, committeeTypes, expectedOutcomes,
|
||||
caseCreateSchema, expectedOutcomes,
|
||||
type CaseCreateInput,
|
||||
} from "@/lib/schemas/case";
|
||||
import {
|
||||
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
|
||||
type AppealSubtype,
|
||||
} from "@/lib/practice-area";
|
||||
|
||||
const STEPS = [
|
||||
{ key: "basics", label: "פרטי יסוד" },
|
||||
@@ -31,7 +35,7 @@ type StepKey = (typeof STEPS)[number]["key"];
|
||||
/* Fields validated at each step — lets the user fix just what's on screen
|
||||
* before moving forward, instead of surfacing all errors from page 1. */
|
||||
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
|
||||
basics: ["case_number", "title", "committee_type"],
|
||||
basics: ["case_number", "title", "practice_area", "appeal_subtype"],
|
||||
parties: ["appellants", "respondents"],
|
||||
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
|
||||
};
|
||||
@@ -57,13 +61,31 @@ export function CaseWizard() {
|
||||
subject: "",
|
||||
property_address: "",
|
||||
permit_number: "",
|
||||
committee_type: "ועדה מקומית",
|
||||
hearing_date: "",
|
||||
notes: "",
|
||||
expected_outcome: "",
|
||||
practice_area: "appeals_committee",
|
||||
appeal_subtype: "unknown",
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
* Auto-fill appeal_subtype from the case number as the user types, but
|
||||
* stop the moment they manually pick a value from the dropdown. Mirrors
|
||||
* the wireSubtypeAutofill() behaviour of the vanilla UI
|
||||
* (legal-ai/web/static/index.html around line 2770).
|
||||
*/
|
||||
const userTouchedSubtype = useRef(false);
|
||||
const caseNumber = form.watch("case_number");
|
||||
const practiceArea = form.watch("practice_area");
|
||||
useEffect(() => {
|
||||
if (userTouchedSubtype.current) return;
|
||||
const derived = deriveSubtype(caseNumber, practiceArea);
|
||||
if (derived !== form.getValues("appeal_subtype")) {
|
||||
form.setValue("appeal_subtype", derived, { shouldValidate: false });
|
||||
}
|
||||
}, [caseNumber, practiceArea, form]);
|
||||
|
||||
const stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||
const isLast = stepIndex === STEPS.length - 1;
|
||||
|
||||
@@ -124,7 +146,7 @@ export function CaseWizard() {
|
||||
</Label>
|
||||
<Input
|
||||
id="case_number"
|
||||
placeholder="1234 או 8001/2026"
|
||||
placeholder="1033-25 או 1000-04-26"
|
||||
{...form.register("case_number")}
|
||||
className="mt-1 tabular-nums"
|
||||
/>
|
||||
@@ -137,25 +159,64 @@ export function CaseWizard() {
|
||||
<Input id="title" {...form.register("title")} className="mt-1" />
|
||||
<FieldError message={form.formState.errors.title?.message} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-navy">סוג ועדה</Label>
|
||||
<Label className="text-navy">תחום משפטי</Label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="committee_type"
|
||||
name="practice_area"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{committeeTypes.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
{PRACTICE_AREAS.map((p) => (
|
||||
<SelectItem
|
||||
key={p.value}
|
||||
value={p.value}
|
||||
disabled={!p.enabled}
|
||||
>
|
||||
{p.label}{!p.enabled && " (בקרוב)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-navy">סוג ערר</Label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="appeal_subtype"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
userTouchedSubtype.current = true;
|
||||
field.onChange(v as AppealSubtype);
|
||||
}}
|
||||
dir="rtl"
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{APPEAL_SUBTYPES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-[0.7rem] text-ink-muted mt-1">
|
||||
מזוהה אוטומטית ממספר התיק
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -79,8 +79,14 @@ export function PartiesField({
|
||||
placeholder="שם מלא של הצד"
|
||||
dir="rtl"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={add}>
|
||||
<Plus className="w-4 h-4" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={add}
|
||||
aria-label={`הוסף ${label}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
|
||||
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
|
||||
|
||||
export type CaseStatus =
|
||||
| "new"
|
||||
@@ -35,6 +36,9 @@ export type Case = {
|
||||
expected_outcome?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
/* Multi-tenant axis — populated by backfill + server-side derive */
|
||||
practice_area?: PracticeArea;
|
||||
appeal_subtype?: AppealSubtype;
|
||||
/* Present when loaded with detail=true */
|
||||
document_count?: number;
|
||||
processing_count?: number;
|
||||
@@ -42,15 +46,21 @@ export type Case = {
|
||||
hearing_date?: string | null;
|
||||
};
|
||||
|
||||
export type CaseDocument = {
|
||||
id: string;
|
||||
case_id: string;
|
||||
doc_type: string;
|
||||
title: string;
|
||||
file_path: string;
|
||||
page_count: number | null;
|
||||
extraction_status: string;
|
||||
created_at: string;
|
||||
practice_area?: PracticeArea;
|
||||
appeal_subtype?: AppealSubtype;
|
||||
};
|
||||
|
||||
export type CaseDetail = Case & {
|
||||
documents?: Array<{
|
||||
id: number | string;
|
||||
filename: string;
|
||||
category?: string | null;
|
||||
status?: string;
|
||||
uploaded_at?: string;
|
||||
size_bytes?: number;
|
||||
}>;
|
||||
documents?: CaseDocument[];
|
||||
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
|
||||
};
|
||||
|
||||
|
||||
112
web-ui/src/lib/api/feedback.ts
Normal file
112
web-ui/src/lib/api/feedback.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Chair feedback hooks — recording and managing Dafna's feedback on drafts.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type FeedbackCategory =
|
||||
| "missing_content"
|
||||
| "wrong_tone"
|
||||
| "wrong_structure"
|
||||
| "factual_error"
|
||||
| "style"
|
||||
| "other";
|
||||
|
||||
export type ChairFeedback = {
|
||||
id: string;
|
||||
case_id: string | null;
|
||||
case_number: string;
|
||||
block_id: string;
|
||||
category: FeedbackCategory;
|
||||
feedback_text: string;
|
||||
lesson_extracted: string;
|
||||
resolved: boolean;
|
||||
applied_to: string[];
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type CreateFeedbackInput = {
|
||||
case_number?: string;
|
||||
block_id?: string;
|
||||
feedback_text: string;
|
||||
category?: FeedbackCategory;
|
||||
lesson_extracted?: string;
|
||||
};
|
||||
|
||||
const feedbackKeys = {
|
||||
all: ["feedback"] as const,
|
||||
list: (filters: { category?: string; unresolved_only?: boolean }) =>
|
||||
[...feedbackKeys.all, "list", filters] as const,
|
||||
};
|
||||
|
||||
export function useFeedbackList(filters: {
|
||||
category?: string;
|
||||
unresolved_only?: boolean;
|
||||
} = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.category) params.set("category", filters.category);
|
||||
if (filters.unresolved_only) params.set("unresolved_only", "true");
|
||||
const qs = params.toString();
|
||||
|
||||
return useQuery({
|
||||
queryKey: feedbackKeys.list(filters),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<ChairFeedback[]>(`/api/feedback${qs ? `?${qs}` : ""}`, { signal }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateFeedbackInput) =>
|
||||
apiRequest<{ id: string; status: string }>("/api/feedback/json", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: feedbackKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
feedbackId,
|
||||
applied_to,
|
||||
}: {
|
||||
feedbackId: string;
|
||||
applied_to: string[];
|
||||
}) =>
|
||||
apiRequest<{ status: string }>(
|
||||
`/api/feedback/${feedbackId}/resolve`,
|
||||
{ method: "PATCH", body: { applied_to } },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: feedbackKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Hebrew labels for feedback categories */
|
||||
export const CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
||||
missing_content: "תוכן חסר",
|
||||
wrong_tone: "טון שגוי",
|
||||
wrong_structure: "מבנה שגוי",
|
||||
factual_error: "שגיאה עובדתית",
|
||||
style: "סגנון",
|
||||
other: "אחר",
|
||||
};
|
||||
|
||||
/** Block ID labels */
|
||||
export const BLOCK_LABELS: Record<string, string> = {
|
||||
"block-he": "ה — פתיחה",
|
||||
"block-vav": "ו — רקע עובדתי",
|
||||
"block-zayin": "ז — טענות הצדדים",
|
||||
"block-chet": "ח — הליכים",
|
||||
"block-tet": "ט — תכניות חלות",
|
||||
"block-yod": "י — דיון והכרעה",
|
||||
"block-yod-alef": "יא — סיכום",
|
||||
};
|
||||
140
web-ui/src/lib/api/precedents.ts
Normal file
140
web-ui/src/lib/api/precedents.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Attached-precedent hooks — user-supplied case-law quotes that
|
||||
* justify chair positions in the compose screen.
|
||||
*
|
||||
* Backed by POST/GET/DELETE /api/cases/{n}/precedents and the
|
||||
* cross-case library search at GET /api/precedents/search. The
|
||||
* optional PDF archive chains through POST .../upload-pdf before
|
||||
* precedent creation; that's a plain async function, not a mutation
|
||||
* hook, because it has no cache invalidation of its own.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest, ApiError } from "./client";
|
||||
import type { PracticeArea } from "@/lib/practice-area";
|
||||
|
||||
export type CasePrecedent = {
|
||||
id: string;
|
||||
case_id: string;
|
||||
section_id: string | null;
|
||||
quote: string;
|
||||
citation: string;
|
||||
chair_note: string;
|
||||
pdf_document_id: string | null;
|
||||
practice_area: PracticeArea | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type PrecedentCreateInput = {
|
||||
quote: string;
|
||||
citation: string;
|
||||
section_id?: string;
|
||||
chair_note?: string;
|
||||
pdf_document_id?: string;
|
||||
};
|
||||
|
||||
export type LibraryMatch = {
|
||||
id: string;
|
||||
citation: string;
|
||||
quote: string;
|
||||
chair_note: string;
|
||||
practice_area: PracticeArea | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export const precedentKeys = {
|
||||
all: ["precedents"] as const,
|
||||
forCase: (caseNumber: string) =>
|
||||
[...precedentKeys.all, "case", caseNumber] as const,
|
||||
librarySearch: (q: string, area: string) =>
|
||||
[...precedentKeys.all, "library", area, q] as const,
|
||||
};
|
||||
|
||||
export function useCasePrecedents(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: precedentKeys.forCase(caseNumber ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CasePrecedent[]>(
|
||||
`/api/cases/${caseNumber}/precedents`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePrecedent(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: PrecedentCreateInput) =>
|
||||
apiRequest<CasePrecedent>(`/api/cases/${caseNumber}/precedents`, {
|
||||
method: "POST",
|
||||
body: input,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: precedentKeys.forCase(caseNumber) });
|
||||
qc.invalidateQueries({ queryKey: [...precedentKeys.all, "library"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePrecedent(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (precedentId: string) =>
|
||||
apiRequest<{ deleted: boolean }>(`/api/precedents/${precedentId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: precedentKeys.forCase(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrecedentLibrarySearch(
|
||||
query: string,
|
||||
practiceArea: PracticeArea | null | undefined,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: precedentKeys.librarySearch(query, practiceArea ?? ""),
|
||||
queryFn: ({ signal }) => {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (practiceArea) params.set("practice_area", practiceArea);
|
||||
return apiRequest<LibraryMatch[]>(
|
||||
`/api/precedents/search?${params.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
enabled: enabled && query.trim().length >= 2,
|
||||
staleTime: 10_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot PDF archive upload. Returns the new document_id so the
|
||||
* caller can pass it into useCreatePrecedent. No cache invalidation
|
||||
* — we only care about the id as a handle.
|
||||
*/
|
||||
export async function uploadPrecedentPdf(
|
||||
caseNumber: string,
|
||||
file: File,
|
||||
): Promise<{ document_id: string; filename: string }> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch(
|
||||
`/api/cases/${encodeURIComponent(caseNumber)}/precedents/upload-pdf`,
|
||||
{ method: "POST", body: fd },
|
||||
);
|
||||
const parsed = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
`Upload failed with ${res.status}`,
|
||||
res.status,
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
return parsed as { document_id: string; filename: string };
|
||||
}
|
||||
30
web-ui/src/lib/api/skills.ts
Normal file
30
web-ui/src/lib/api/skills.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Paperclip skills — listing + sync actions.
|
||||
*
|
||||
* Skills live in Paperclip's database (separate from the main legal-ai DB)
|
||||
* and are exposed via /api/admin/skills. The UI just needs read access for
|
||||
* Phase 5; install/sync/delete mutations can follow in Phase 6.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type Skill = {
|
||||
slug: string;
|
||||
name: string;
|
||||
db_markdown_chars: number;
|
||||
file_inventory: Array<{ path: string; size?: number }> | null;
|
||||
updated_at: string | null;
|
||||
disk_exists: boolean;
|
||||
disk_skill_md_bytes: number | null;
|
||||
not_in_db?: boolean;
|
||||
};
|
||||
|
||||
export function useSkills() {
|
||||
return useQuery({
|
||||
queryKey: ["skills", "list"] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<Skill[]>("/api/admin/skills", { signal }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
42
web-ui/src/lib/api/system.ts
Normal file
42
web-ui/src/lib/api/system.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* System-level hooks: diagnostics + active task snapshot.
|
||||
*
|
||||
* The vanilla UI polled /api/system/diagnostics and /api/system/tasks on
|
||||
* an interval. We replace the polling with TanStack Query's refetchInterval
|
||||
* — same effect, but participates in the shared cache and survives route
|
||||
* transitions without setting up its own setInterval bookkeeping.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type DiagDoc = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
case_number: string;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type Diagnostics = {
|
||||
db_ok: boolean;
|
||||
tables: Record<string, number | null>;
|
||||
failed_documents: DiagDoc[];
|
||||
stuck_documents: DiagDoc[];
|
||||
active_tasks: Array<{
|
||||
task_id: string;
|
||||
filename: string;
|
||||
status: string;
|
||||
step: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function useDiagnostics() {
|
||||
return useQuery({
|
||||
queryKey: ["system", "diagnostics"] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<Diagnostics>("/api/system/diagnostics", { signal }),
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 5_000,
|
||||
});
|
||||
}
|
||||
151
web-ui/src/lib/api/training.ts
Normal file
151
web-ui/src/lib/api/training.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Training / style corpus hooks.
|
||||
*
|
||||
* Endpoints touched (all under /api/training/):
|
||||
* - GET /style-report → the dashboard payload (corpus stats + anatomy
|
||||
* + signature phrases + per-decision contribution)
|
||||
* - GET /corpus → flat list of decisions for the corpus tab / compare tool
|
||||
* - GET /compare?a=UUID&b=UUID → side-by-side comparison
|
||||
* - DELETE /corpus/{id} → remove a decision from the corpus
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type StyleReport = {
|
||||
corpus: {
|
||||
decision_count: number;
|
||||
total_chars: number;
|
||||
avg_chars: number;
|
||||
date_range: [string | null, string | null];
|
||||
decisions: Array<{
|
||||
number: string;
|
||||
date: string;
|
||||
chars: number;
|
||||
subjects: string[];
|
||||
}>;
|
||||
subject_distribution: Array<{ label: string; count: number }>;
|
||||
headline: string;
|
||||
};
|
||||
anatomy: {
|
||||
sections: Array<{
|
||||
type: string;
|
||||
label: string;
|
||||
avg_chars: number;
|
||||
pct: number;
|
||||
coverage: number;
|
||||
}>;
|
||||
total_coverage: number;
|
||||
headline: string;
|
||||
};
|
||||
signature_phrases: {
|
||||
items: Array<{
|
||||
type: string;
|
||||
text: string;
|
||||
context: string;
|
||||
frequency: number;
|
||||
examples: string[];
|
||||
}>;
|
||||
total_decisions: number;
|
||||
top_display: string;
|
||||
headline: string;
|
||||
};
|
||||
contribution: {
|
||||
growth_curve: Array<{
|
||||
decision_number: string;
|
||||
date: string;
|
||||
cumulative: number;
|
||||
}>;
|
||||
decision_contributions: unknown[];
|
||||
total_patterns: number;
|
||||
headline: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CorpusDecision = {
|
||||
id: string;
|
||||
decision_number: string;
|
||||
decision_date: string;
|
||||
subject_categories: string[];
|
||||
chars: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CompareResult = {
|
||||
a: CompareSide;
|
||||
b: CompareSide;
|
||||
shared: PatternEntry[];
|
||||
only_a: PatternEntry[];
|
||||
only_b: PatternEntry[];
|
||||
};
|
||||
|
||||
export type CompareSide = {
|
||||
id: string;
|
||||
decision_number: string;
|
||||
decision_date: string;
|
||||
chars: number;
|
||||
subjects: string[];
|
||||
sections: Array<{ type: string; chars: number }>;
|
||||
patterns_count: number;
|
||||
};
|
||||
|
||||
export type PatternEntry = {
|
||||
id: string;
|
||||
type: string;
|
||||
text: string;
|
||||
context: string;
|
||||
};
|
||||
|
||||
export const trainingKeys = {
|
||||
all: ["training"] as const,
|
||||
report: () => [...trainingKeys.all, "style-report"] as const,
|
||||
corpus: () => [...trainingKeys.all, "corpus"] as const,
|
||||
compare: (a: string, b: string) =>
|
||||
[...trainingKeys.all, "compare", a, b] as const,
|
||||
};
|
||||
|
||||
export function useStyleReport() {
|
||||
return useQuery({
|
||||
queryKey: trainingKeys.report(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<StyleReport>("/api/training/style-report", { signal }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCorpus() {
|
||||
return useQuery({
|
||||
queryKey: trainingKeys.corpus(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CorpusDecision[]>("/api/training/corpus", { signal }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompare(a: string | null, b: string | null) {
|
||||
return useQuery({
|
||||
queryKey: trainingKeys.compare(a ?? "", b ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CompareResult>(
|
||||
`/api/training/compare?a=${encodeURIComponent(a!)}&b=${encodeURIComponent(b!)}`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(a && b && a !== b),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCorpusEntry() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ deleted: boolean }>(
|
||||
`/api/training/corpus/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trainingKeys.corpus() });
|
||||
qc.invalidateQueries({ queryKey: trainingKeys.report() });
|
||||
},
|
||||
});
|
||||
}
|
||||
72
web-ui/src/lib/practice-area.ts
Normal file
72
web-ui/src/lib/practice-area.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Client-side mirror of mcp-server/src/legal_mcp/services/practice_area.py.
|
||||
*
|
||||
* Keep the enum values and derivation logic in sync with the backend — the
|
||||
* server is the authority, but the UI needs the labels and derivation for
|
||||
* UX (auto-fill, badges, filters). If the server adds a new practice_area
|
||||
* or subtype, extend the arrays below.
|
||||
*
|
||||
* See also: legal-ai/docs/practice-area-separation.md
|
||||
*/
|
||||
|
||||
export type PracticeArea =
|
||||
| "appeals_committee"
|
||||
| "national_insurance"
|
||||
| "labor_law";
|
||||
|
||||
export type AppealSubtype =
|
||||
| "building_permit"
|
||||
| "betterment_levy"
|
||||
| "compensation_197"
|
||||
| "unknown";
|
||||
|
||||
export const PRACTICE_AREAS: ReadonlyArray<{
|
||||
value: PracticeArea;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}> = [
|
||||
{ value: "appeals_committee", label: "ועדת ערר", enabled: true },
|
||||
{ value: "national_insurance", label: "ביטוח לאומי", enabled: false },
|
||||
{ value: "labor_law", label: "דיני עבודה", enabled: false },
|
||||
];
|
||||
|
||||
export const APPEAL_SUBTYPES: ReadonlyArray<{
|
||||
value: AppealSubtype;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "building_permit", label: "רישוי ובנייה" },
|
||||
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||
{ value: "compensation_197", label: "פיצויים (ס' 197)" },
|
||||
{ value: "unknown", label: "לא ידוע" },
|
||||
];
|
||||
|
||||
export const PRACTICE_AREA_LABELS: Record<PracticeArea, string> =
|
||||
Object.fromEntries(PRACTICE_AREAS.map((p) => [p.value, p.label])) as Record<
|
||||
PracticeArea,
|
||||
string
|
||||
>;
|
||||
|
||||
export const APPEAL_SUBTYPE_LABELS: Record<AppealSubtype, string> =
|
||||
Object.fromEntries(APPEAL_SUBTYPES.map((s) => [s.value, s.label])) as Record<
|
||||
AppealSubtype,
|
||||
string
|
||||
>;
|
||||
|
||||
/*
|
||||
* Derive the appeal_subtype from a case number. Mirrors the Python
|
||||
* `derive_subtype` in practice_area.py. The convention is the case-number
|
||||
* first digit: 1xxx → building_permit, 8xxx → betterment_levy,
|
||||
* 9xxx → compensation_197. Everything else, including non-appeals_committee
|
||||
* domains, returns 'unknown'.
|
||||
*/
|
||||
export function deriveSubtype(
|
||||
caseNumber: string,
|
||||
practiceArea: PracticeArea = "appeals_committee",
|
||||
): AppealSubtype {
|
||||
if (practiceArea !== "appeals_committee") return "unknown";
|
||||
const first = caseNumber.trim().match(/^(\d)/)?.[1];
|
||||
if (first === "1") return "building_permit";
|
||||
if (first === "8") return "betterment_levy";
|
||||
if (first === "9") return "compensation_197";
|
||||
return "unknown";
|
||||
}
|
||||
@@ -10,10 +10,18 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
|
||||
|
||||
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md —
|
||||
* permissive regex that still catches obvious typos. */
|
||||
const caseNumberRe = /^[1-9]\d{3,}(?:[-/][\w\u0590-\u05FF]+)*$/;
|
||||
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md.
|
||||
* Two accepted formats, both hyphen-separated:
|
||||
* NNNN-YY → "1033-25" (case sequence + 2-digit year)
|
||||
* NNNN-MM-YY → "1000-04-26" (case sequence + 2-digit month + year)
|
||||
*
|
||||
* Slashes are deliberately forbidden: FastAPI path routing can't capture
|
||||
* a `/` inside a {case_number} segment even when URL-encoded as %2F, so
|
||||
* any case with a slash becomes unreachable at
|
||||
* GET /api/cases/{case_number}/details. */
|
||||
const caseNumberRe = /^[1-9]\d{3}(?:-\d{2}){1,2}$/;
|
||||
|
||||
const hebrewPartyRe = /[\u0590-\u05FFA-Za-z]/;
|
||||
|
||||
@@ -24,12 +32,6 @@ const dateString = z
|
||||
message: "תאריך חייב להיות בפורמט YYYY-MM-DD",
|
||||
});
|
||||
|
||||
export const committeeTypes = [
|
||||
"ועדה מקומית",
|
||||
"ועדה מחוזית",
|
||||
"ועדת ערר",
|
||||
] as const;
|
||||
|
||||
export const expectedOutcomes = [
|
||||
{ value: "", label: "— לא נקבע —" },
|
||||
{ value: "rejection", label: "דחייה" },
|
||||
@@ -43,7 +45,7 @@ export const caseCreateSchema = z.object({
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "שדה חובה")
|
||||
.regex(caseNumberRe, "מספר תיק לא תקין (למשל 1234 או 8001/2026)"),
|
||||
.regex(caseNumberRe, "פורמט: NNNN-YY או NNNN-MM-YY (למשל 1033-25 או 1000-04-26)"),
|
||||
title: z.string().trim().min(3, "כותרת קצרה מדי").max(200, "כותרת ארוכה מדי"),
|
||||
appellants: z
|
||||
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
|
||||
@@ -54,12 +56,22 @@ export const caseCreateSchema = z.object({
|
||||
subject: z.string().trim().max(500),
|
||||
property_address: z.string().trim().max(200),
|
||||
permit_number: z.string().trim().max(100),
|
||||
committee_type: z.enum(committeeTypes),
|
||||
hearing_date: dateString,
|
||||
notes: z.string().trim().max(2000),
|
||||
expected_outcome: z.enum(
|
||||
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
|
||||
),
|
||||
practice_area: z.enum([
|
||||
"appeals_committee",
|
||||
"national_insurance",
|
||||
"labor_law",
|
||||
] as const satisfies readonly PracticeArea[]),
|
||||
appeal_subtype: z.enum([
|
||||
"building_permit",
|
||||
"betterment_levy",
|
||||
"compensation_197",
|
||||
"unknown",
|
||||
] as const satisfies readonly AppealSubtype[]),
|
||||
});
|
||||
|
||||
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;
|
||||
|
||||
127
web/app.py
127
web/app.py
@@ -2302,6 +2302,133 @@ async def api_reprocess_document(case_number: str, doc_id: str):
|
||||
return {"status": "reprocessing"}
|
||||
|
||||
|
||||
# ── Chair feedback endpoints ──────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/feedback")
|
||||
async def api_list_feedback(
|
||||
case_number: str = "",
|
||||
category: str = "",
|
||||
unresolved_only: bool = False,
|
||||
):
|
||||
"""List chair feedback, optionally filtered by case/category."""
|
||||
case_id = None
|
||||
if case_number:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
case_id = UUID(case["id"])
|
||||
|
||||
feedbacks = await db.list_chair_feedback(
|
||||
case_id=case_id,
|
||||
category=category or None,
|
||||
unresolved_only=unresolved_only,
|
||||
)
|
||||
|
||||
items = []
|
||||
# Build case_number lookup
|
||||
case_numbers: dict[str, str] = {}
|
||||
pool = await db.get_pool()
|
||||
for fb in feedbacks:
|
||||
cid = fb.get("case_id")
|
||||
cn = ""
|
||||
if cid and str(cid) not in case_numbers:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT case_number, title FROM cases WHERE id = $1", cid,
|
||||
)
|
||||
if row:
|
||||
case_numbers[str(cid)] = row["case_number"]
|
||||
if cid:
|
||||
cn = case_numbers.get(str(cid), "")
|
||||
|
||||
items.append({
|
||||
"id": str(fb["id"]),
|
||||
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
|
||||
"case_number": cn,
|
||||
"block_id": fb["block_id"],
|
||||
"category": fb["category"],
|
||||
"feedback_text": fb["feedback_text"],
|
||||
"lesson_extracted": fb["lesson_extracted"],
|
||||
"resolved": fb["resolved"],
|
||||
"applied_to": fb.get("applied_to", []),
|
||||
"created_at": fb["created_at"].isoformat() if fb.get("created_at") else None,
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@app.post("/api/feedback")
|
||||
async def api_create_feedback(
|
||||
case_number: str = Form(""),
|
||||
block_id: str = Form("block-yod"),
|
||||
feedback_text: str = Form(...),
|
||||
category: str = Form("missing_content"),
|
||||
lesson_extracted: str = Form(""),
|
||||
):
|
||||
"""Record a new chair feedback entry."""
|
||||
case_id = None
|
||||
if case_number:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
case_id = UUID(case["id"])
|
||||
|
||||
valid_categories = [
|
||||
"missing_content", "wrong_tone", "wrong_structure",
|
||||
"factual_error", "style", "other",
|
||||
]
|
||||
if category not in valid_categories:
|
||||
raise HTTPException(400, f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
|
||||
|
||||
feedback_id = await db.record_chair_feedback(
|
||||
case_id=case_id,
|
||||
block_id=block_id,
|
||||
feedback_text=feedback_text,
|
||||
category=category,
|
||||
lesson_extracted=lesson_extracted,
|
||||
)
|
||||
|
||||
return {"id": str(feedback_id), "status": "created"}
|
||||
|
||||
|
||||
@app.post("/api/feedback/json")
|
||||
async def api_create_feedback_json(body: dict):
|
||||
"""Record a new chair feedback entry (JSON body)."""
|
||||
case_number = body.get("case_number", "")
|
||||
case_id = None
|
||||
if case_number:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
case_id = UUID(case["id"])
|
||||
|
||||
valid_categories = [
|
||||
"missing_content", "wrong_tone", "wrong_structure",
|
||||
"factual_error", "style", "other",
|
||||
]
|
||||
category = body.get("category", "missing_content")
|
||||
if category not in valid_categories:
|
||||
raise HTTPException(400, f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
|
||||
|
||||
feedback_id = await db.record_chair_feedback(
|
||||
case_id=case_id,
|
||||
block_id=body.get("block_id", "block-yod"),
|
||||
feedback_text=body.get("feedback_text", ""),
|
||||
category=category,
|
||||
lesson_extracted=body.get("lesson_extracted", ""),
|
||||
)
|
||||
|
||||
return {"id": str(feedback_id), "status": "created"}
|
||||
|
||||
|
||||
@app.patch("/api/feedback/{feedback_id}/resolve")
|
||||
async def api_resolve_feedback(feedback_id: str, body: dict):
|
||||
"""Mark feedback as resolved."""
|
||||
await db.resolve_chair_feedback(
|
||||
feedback_id=UUID(feedback_id),
|
||||
applied_to=body.get("applied_to", []),
|
||||
)
|
||||
return {"status": "resolved"}
|
||||
|
||||
|
||||
# ── Background Processing ─────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user