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.",
|
"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.",
|
"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.",
|
"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": [
|
"dependencies": [
|
||||||
"85"
|
"85"
|
||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-04-11T16:18:28.714Z"
|
"updatedAt": "2026-04-11T16:25:55.569Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "87",
|
"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.",
|
"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.",
|
"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.",
|
"testStrategy": "Feature parity with old legal-ai/web/static/index.html across all 10 views.",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"86"
|
"86"
|
||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T17:33:42.976Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "88",
|
"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.",
|
"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.",
|
"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.",
|
"testStrategy": "Lighthouse a11y score > 90, all loading states visible, errors show toasts, README has documented smoke test steps.",
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"87"
|
"87"
|
||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T17:44:08.337Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "89",
|
"id": "89",
|
||||||
@@ -880,13 +882,39 @@
|
|||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"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": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-04-11T16:18:28.714Z",
|
"lastModified": "2026-04-11T19:20:56.040Z",
|
||||||
"taskCount": 58,
|
"taskCount": 60,
|
||||||
"completedCount": 52,
|
"completedCount": 57,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"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/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** |
|
||||||
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
||||||
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
|
| [`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 | להתמצאות כללית |
|
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
|
||||||
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||||
|
|
||||||
@@ -107,6 +108,16 @@
|
|||||||
3. **"ללא כפילות"** — בלוק י (דיון) מפנה לבלוקים קודמים, לא חוזר עליהם
|
3. **"ללא כפילות"** — בלוק י (דיון) מפנה לבלוקים קודמים, לא חוזר עליהם
|
||||||
4. **"טענות מקוריות בלבד"** — בלוק ז = מכתבי טענות מקוריים בלבד. השלמות → בלוק ח
|
4. **"טענות מקוריות בלבד"** — בלוק ז = מכתבי טענות מקוריים בלבד. השלמות → בלוק ח
|
||||||
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
|
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`
|
- מדריך סגנון מלא: `skills/decision/SKILL.md`
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
|||||||
- MUST: מסקנה בפתיחת הדיון (לא בסוף)
|
- MUST: מסקנה בפתיחת הדיון (לא בסוף)
|
||||||
- MUST: מענה לכל טענה שהוצגה בבלוק ז
|
- MUST: מענה לכל טענה שהוצגה בבלוק ז
|
||||||
- MUST: ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
|
- MUST: ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
|
||||||
|
- MUST: **צ'קליסט תוכן** — הפרומפט מזריק `{content_checklist}` אוטומטית לפי סוג הערר (מתוך `lessons.py: CONTENT_CHECKLISTS`). ראה `docs/corpus-analysis.md` לדפוסי תוכן לפי סוג.
|
||||||
- ⚠️ **MUST NOT ("ללא כפילות"):** חזרה על עובדות/טענות מבלוקים קודמים. השתמש בהפניות: "כאמור בסעיף X לעיל", "כפי שפורט", "כפי שציינו"
|
- ⚠️ **MUST NOT ("ללא כפילות"):** חזרה על עובדות/טענות מבלוקים קודמים. השתמש בהפניות: "כאמור בסעיף X לעיל", "כפי שפורט", "כפי שציינו"
|
||||||
- MUST NOT: כותרות משנה (חריג: נושאים נפרדים לחלוטין)
|
- MUST NOT: כותרות משנה (חריג: נושאים נפרדים לחלוטין)
|
||||||
- Dependencies: **ALL** previous blocks (ה-ט)
|
- 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
|
- Created `create-decision-structure.cjs` script for generating structure DOCX
|
||||||
- Key innovation from Arieli: "ההליכים בפני ועדת הערר" as separate section (Block ח)
|
- Key innovation from Arieli: "ההליכים בפני ועדת הערר" as separate section (Block ח)
|
||||||
- "Judge Test": every block written as if administrative court judge reads cold
|
- "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)
|
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():
|
def main():
|
||||||
mcp.run(transport="stdio")
|
mcp.run(transport="stdio")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import db, embeddings, claude_session
|
from legal_mcp.services import db, embeddings, claude_session
|
||||||
|
from legal_mcp.services.lessons import get_content_checklist
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -215,6 +216,8 @@ BLOCK_PROMPTS = {
|
|||||||
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
|
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
|
||||||
- מספור רציף
|
- מספור רציף
|
||||||
|
|
||||||
|
{content_checklist}
|
||||||
|
|
||||||
## כיוון מאושר (חובה):
|
## כיוון מאושר (חובה):
|
||||||
{direction_context}
|
{direction_context}
|
||||||
|
|
||||||
@@ -310,6 +313,15 @@ async def write_block(
|
|||||||
outcome = (decision or {}).get("outcome", "rejected")
|
outcome = (decision or {}).get("outcome", "rejected")
|
||||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
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:
|
# Format prompt — per Anthropic long-context best practices:
|
||||||
# Place source documents FIRST (top of prompt), instructions LAST.
|
# Place source documents FIRST (top of prompt), instructions LAST.
|
||||||
# "Queries at the end can improve response quality by up to 30%"
|
# "Queries at the end can improve response quality by up to 30%"
|
||||||
@@ -323,6 +335,7 @@ async def write_block(
|
|||||||
style_context=style_context,
|
style_context=style_context,
|
||||||
discussion_context=discussion_context,
|
discussion_context=discussion_context,
|
||||||
structure_guidance=structure_guidance,
|
structure_guidance=structure_guidance,
|
||||||
|
content_checklist=content_checklist,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restructure: sources first, then instructions
|
# Restructure: sources first, then instructions
|
||||||
|
|||||||
@@ -358,6 +358,22 @@ CREATE TABLE IF NOT EXISTS case_law_embeddings (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now()
|
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
|
-- Indexes
|
||||||
-- ═══════════════════════════════════════════════════════════════════
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -986,3 +1002,72 @@ async def search_precedents(
|
|||||||
|
|
||||||
results.sort(key=lambda x: x["score"], reverse=True)
|
results.sort(key=lambda x: x["score"], reverse=True)
|
||||||
return results[:limit]
|
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]
|
lo, hi = ratios[section]
|
||||||
return f"יעד: {lo}-{hi}% מסך ההחלטה"
|
return f"יעד: {lo}-{hi}% מסך ההחלטה"
|
||||||
return ""
|
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)
|
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
|
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 |
|
| תיבות תמונה | מסגרת עם shading אפור בהיר (fill: "F0F0F0"), טקסט "📷 תמונה: [תיאור]" | ShadingType.CLEAR |
|
||||||
| חתימות | טבלה ללא גבולות (`visuallyRightToLeft: true`), 2 טורים | כמו בתבנית ב-create-legal-doc.js |
|
| חתימות | טבלה ללא גבולות (`visuallyRightToLeft: true`), 2 טורים | כמו בתבנית ב-create-legal-doc.js |
|
||||||
| כותרת מוסדית | טבלה ללא גבולות, 2 טורים: ימין=מוסד, שמאל=מספרי תיק | `visuallyRightToLeft: true` |
|
| כותרת מוסדית | טבלה ללא גבולות, 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
|
```bash
|
||||||
npm run dev
|
npm install
|
||||||
# or
|
npm run dev # http://localhost:3000
|
||||||
yarn dev
|
npm run build # full type check + production build
|
||||||
# or
|
npm run lint
|
||||||
pnpm dev
|
npm run api:types # regenerate src/lib/api/types.ts from FastAPI's OpenAPI
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
## Smoke test (run after every deploy)
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
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-dom": "19.2.4",
|
||||||
"react-dropzone": "^15.0.0",
|
"react-dropzone": "^15.0.0",
|
||||||
"react-hook-form": "^7.72.1",
|
"react-hook-form": "^7.72.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^4.2.0",
|
"shadcn": "^4.2.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { SubsectionCard } from "@/components/compose/subsection-card";
|
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 { useCase } from "@/lib/api/cases";
|
||||||
import { useResearchAnalysis } from "@/lib/api/research";
|
import { useResearchAnalysis } from "@/lib/api/research";
|
||||||
|
import { useCasePrecedents } from "@/lib/api/precedents";
|
||||||
|
|
||||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||||
if (!content?.trim()) return null;
|
if (!content?.trim()) return null;
|
||||||
@@ -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">
|
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-ink-soft leading-relaxed whitespace-pre-line prose">
|
<Markdown content={content.trim()} />
|
||||||
{content.trim()}
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,21 @@ export default function ComposePage({
|
|||||||
const { caseNumber } = use(params);
|
const { caseNumber } = use(params);
|
||||||
const caseQuery = useCase(caseNumber);
|
const caseQuery = useCase(caseNumber);
|
||||||
const analysis = useResearchAnalysis(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 =
|
const isNotFound =
|
||||||
analysis.error instanceof Error &&
|
analysis.error instanceof Error &&
|
||||||
@@ -98,9 +114,24 @@ export default function ComposePage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : analysis.data ? (
|
) : analysis.data ? (
|
||||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
|
||||||
{/* Main editable column */}
|
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Threshold claims */}
|
||||||
{analysis.data.threshold_claims &&
|
{analysis.data.threshold_claims &&
|
||||||
analysis.data.threshold_claims.length > 0 && (
|
analysis.data.threshold_claims.length > 0 && (
|
||||||
@@ -112,12 +143,13 @@ export default function ComposePage({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{analysis.data.threshold_claims.map((tc, i) => (
|
{analysis.data.threshold_claims.map((tc) => (
|
||||||
<SubsectionCard
|
<SubsectionCard
|
||||||
key={tc.id}
|
key={tc.id}
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
item={tc}
|
item={tc}
|
||||||
defaultOpen={i === 0}
|
precedents={precedentsBySection.get(tc.id) ?? []}
|
||||||
|
practiceArea={practiceArea}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -139,6 +171,8 @@ export default function ComposePage({
|
|||||||
key={iss.id}
|
key={iss.id}
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
item={iss}
|
item={iss}
|
||||||
|
precedents={precedentsBySection.get(iss.id) ?? []}
|
||||||
|
practiceArea={practiceArea}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,13 +187,13 @@ export default function ComposePage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Side column: background prose + conclusions */}
|
{/* Background prose — moved below the issues so it reads as
|
||||||
<aside className="space-y-5">
|
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">
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
<CardContent className="px-5 py-4 space-y-5">
|
<CardContent className="px-6 py-5 space-y-5">
|
||||||
<h2 className="text-navy text-base mb-0">רקע לניתוח</h2>
|
<h2 className="text-navy text-xl mb-0">רקע לניתוח</h2>
|
||||||
<ProseSection
|
<ProseSection
|
||||||
title="צד מיוצג"
|
title="צד מיוצג"
|
||||||
content={analysis.data.represented_party}
|
content={analysis.data.represented_party}
|
||||||
@@ -181,15 +215,12 @@ export default function ComposePage({
|
|||||||
|
|
||||||
{analysis.data.conclusions?.trim() && (
|
{analysis.data.conclusions?.trim() && (
|
||||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||||
<CardContent className="px-5 py-4 space-y-2">
|
<CardContent className="px-6 py-5 space-y-3">
|
||||||
<h2 className="text-gold-deep text-base mb-0">מסקנות</h2>
|
<h2 className="text-gold-deep text-xl mb-0">מסקנות</h2>
|
||||||
<p className="text-sm text-ink leading-relaxed whitespace-pre-line prose">
|
<Markdown content={analysis.data.conclusions.trim()} />
|
||||||
{analysis.data.conclusions.trim()}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
|||||||
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
|
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||||
import { useCase } from "@/lib/api/cases";
|
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.
|
* Next 16 breaking change: route params are now a Promise.
|
||||||
* The `use()` hook unwraps them inside a client component.
|
* The `use()` hook unwraps them inside a client component.
|
||||||
@@ -25,6 +30,9 @@ export default function CaseDetailPage({
|
|||||||
}) {
|
}) {
|
||||||
const { caseNumber } = use(params);
|
const { caseNumber } = use(params);
|
||||||
const { data, isPending, error } = useCase(caseNumber);
|
const { data, isPending, error } = useCase(caseNumber);
|
||||||
|
const expectedOutcomeLabel = data?.expected_outcome
|
||||||
|
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
@@ -77,7 +85,7 @@ export default function CaseDetailPage({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
|
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
|
||||||
<p className="text-ink-soft text-sm leading-relaxed">
|
<p className="text-ink-soft text-sm leading-relaxed">
|
||||||
{data?.expected_outcome ?? "לא נקבעה תוצאה צפויה."}
|
{expectedOutcomeLabel ?? "לא נקבעה תוצאה צפויה."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -100,8 +108,7 @@ export default function CaseDetailPage({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="actions" className="mt-5">
|
<TabsContent value="actions" className="mt-5">
|
||||||
<div className="space-y-4">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
<Link href={`/cases/${caseNumber}/compose`}>
|
<Link href={`/cases/${caseNumber}/compose`}>
|
||||||
פתח בעורך ההחלטה
|
פתח בעורך ההחלטה
|
||||||
@@ -109,10 +116,6 @@ export default function CaseDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
{data && <CaseEditDialog data={data} />}
|
{data && <CaseEditDialog data={data} />}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-ink-muted">
|
|
||||||
עריכת פרטי התיק נשמרת מיד דרך PUT /api/cases/{caseNumber}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</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 = {
|
export const metadata: Metadata = {
|
||||||
title: "עוזר משפטי — ניהול תיקים",
|
title: {
|
||||||
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה",
|
default: "עוזר משפטי — ניהול תיקים",
|
||||||
|
template: "%s · עוזר משפטי",
|
||||||
|
},
|
||||||
|
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה, ירושלים",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
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 type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ezer Mishpati navigation shell.
|
* Ezer Mishpati navigation shell.
|
||||||
@@ -9,8 +12,9 @@ import Link from "next/link";
|
|||||||
* - Parchment/cream body background (set on <body> via globals.css)
|
* - Parchment/cream body background (set on <body> via globals.css)
|
||||||
* - Hebrew RTL throughout (set on <html> in layout.tsx)
|
* - Hebrew RTL throughout (set on <html> in layout.tsx)
|
||||||
*
|
*
|
||||||
* Structure mirrors the current vanilla index.html header so that visual
|
* Nav items pick up an `aria-current="page"` and a gold underline when
|
||||||
* continuity is preserved while we migrate screen-by-screen.
|
* the current route matches, so screen readers announce the active
|
||||||
|
* section and sighted users can see where they are.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
@@ -20,13 +24,21 @@ type NavItem = {
|
|||||||
|
|
||||||
const NAV_ITEMS: NavItem[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ href: "/", label: "בית" },
|
{ href: "/", label: "בית" },
|
||||||
{ href: "/upload", label: "העלאת מסמכים" },
|
{ href: "/cases/new", label: "תיק חדש" },
|
||||||
{ href: "/training", label: "אימון סגנון" },
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
|
{ href: "/feedback", label: "הערות יו״ר" },
|
||||||
{ href: "/skills", label: "מיומנויות" },
|
{ href: "/skills", label: "מיומנויות" },
|
||||||
{ href: "/diagnostics", 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 }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header
|
||||||
@@ -45,25 +57,43 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
|
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="me-auto flex items-center gap-1">
|
<nav
|
||||||
{NAV_ITEMS.map((item) => (
|
className="me-auto flex items-center gap-1"
|
||||||
|
aria-label="ניווט ראשי"
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = isActive(pathname, item.href);
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="
|
aria-current={active ? "page" : undefined}
|
||||||
px-3 py-1.5 rounded
|
className={`
|
||||||
text-sm text-parchment/80
|
relative px-3 py-1.5 rounded text-sm transition-colors
|
||||||
transition-colors
|
${
|
||||||
hover:text-parchment hover:bg-navy-soft/60
|
active
|
||||||
"
|
? "text-parchment font-semibold bg-navy-soft/80"
|
||||||
|
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</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}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { StatusBadge } from "@/components/cases/status-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";
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
function formatDate(iso?: string | null) {
|
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="flex items-start justify-between gap-6 flex-wrap">
|
||||||
<div className="space-y-2">
|
<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">
|
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
|
||||||
ערר {data?.case_number ?? "—"}
|
ערר {data?.case_number ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
{data?.status && <StatusBadge status={data.status} />}
|
{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>
|
</div>
|
||||||
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
||||||
{data?.title ?? "טוען…"}
|
{data?.title ?? "טוען…"}
|
||||||
@@ -55,10 +71,6 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
|||||||
עודכן
|
עודכן
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
<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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
|
||||||
import type { Case } from "@/lib/api/cases";
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
function formatDate(iso?: string) {
|
function formatDate(iso?: string) {
|
||||||
@@ -59,6 +60,15 @@ const columns: ColumnDef<Case>[] = [
|
|||||||
header: "סטטוס",
|
header: "סטטוס",
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
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",
|
accessorKey: "document_count",
|
||||||
header: "מסמכים",
|
header: "מסמכים",
|
||||||
|
|||||||
@@ -2,15 +2,30 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import type { CaseDetail } from "@/lib/api/cases";
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
function formatSize(bytes?: number) {
|
/*
|
||||||
if (!bytes) return "";
|
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
* row shape returned by the FastAPI case_get endpoint — see db.list_documents
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
* and the `documents` schema in legal_mcp/services/db.py:
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
* 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) {
|
function doctypeTone(t: string): string {
|
||||||
switch (category) {
|
switch (t) {
|
||||||
case "appeal": return "bg-info-bg text-info border-info/40";
|
case "appeal": return "bg-info-bg text-info border-info/40";
|
||||||
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
|
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
|
||||||
case "decision": return "bg-success-bg text-success border-success/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 }) {
|
export function DocumentsPanel({ data }: { data?: CaseDetail }) {
|
||||||
const docs = data?.documents ?? [];
|
const docs = data?.documents ?? [];
|
||||||
|
|
||||||
if (docs.length === 0) {
|
if (docs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12 text-ink-muted">
|
<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>
|
<p className="text-sm">אין מסמכים בתיק זה</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="max-h-[520px]">
|
<ScrollArea className="max-h-[520px]" dir="rtl">
|
||||||
<ul className="divide-y divide-rule">
|
<ul className="divide-y divide-rule" dir="rtl">
|
||||||
{docs.map((doc) => (
|
{docs.map((doc) => {
|
||||||
|
const displayName = doc.title || filenameFromPath(doc.file_path);
|
||||||
|
const statusDone =
|
||||||
|
doc.extraction_status === "completed" ||
|
||||||
|
doc.extraction_status === "proofread";
|
||||||
|
return (
|
||||||
<li
|
<li
|
||||||
key={doc.id}
|
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">
|
{/* Title + meta — flex-1 keeps it glued to the start (right in RTL) */}
|
||||||
<div className="text-ink font-medium truncate" title={doc.filename}>
|
<div className="flex-1 min-w-0 space-y-0.5 text-right">
|
||||||
{doc.filename}
|
<div className="text-ink font-medium truncate" title={displayName}>
|
||||||
|
{displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3">
|
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3 flex-wrap">
|
||||||
{doc.size_bytes && (
|
{doc.page_count != null && (
|
||||||
<span className="tabular-nums">{formatSize(doc.size_bytes)}</span>
|
<span className="tabular-nums">{doc.page_count} עמ׳</span>
|
||||||
)}
|
)}
|
||||||
{doc.uploaded_at && (
|
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||||
<span>
|
{!statusDone && doc.extraction_status && (
|
||||||
{new Date(doc.uploaded_at).toLocaleDateString("he-IL")}
|
<span className="text-warn">
|
||||||
|
{STATUS_LABELS[doc.extraction_status] ?? doc.extraction_status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{doc.status && doc.status !== "ready" && (
|
|
||||||
<span className="text-warn">{doc.status}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{doc.category && (
|
{/* Type badge — ms-auto forces it to the inline-end (= left in RTL) */}
|
||||||
|
{doc.doc_type && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</ScrollArea>
|
</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 { useState } from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { ChairEditor } from "@/components/compose/chair-editor";
|
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 { ResearchSubsection } from "@/lib/api/research";
|
||||||
|
import type { CasePrecedent } from "@/lib/api/precedents";
|
||||||
|
import type { PracticeArea } from "@/lib/practice-area";
|
||||||
|
|
||||||
export function SubsectionCard({
|
export function SubsectionCard({
|
||||||
caseNumber,
|
caseNumber,
|
||||||
item,
|
item,
|
||||||
defaultOpen = false,
|
defaultOpen = false,
|
||||||
|
precedents = [],
|
||||||
|
practiceArea,
|
||||||
}: {
|
}: {
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
item: ResearchSubsection;
|
item: ResearchSubsection;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
|
precedents?: CasePrecedent[];
|
||||||
|
practiceArea?: PracticeArea | null;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const isFilled = Boolean(item.chair_position?.trim());
|
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">
|
<dt className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1">
|
||||||
{f.label}
|
{f.label}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-ink-soft leading-relaxed whitespace-pre-line">
|
<dd>
|
||||||
{f.content}
|
<Markdown content={f.content} />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -81,6 +89,18 @@ export function SubsectionCard({
|
|||||||
sectionId={item.id}
|
sectionId={item.id}
|
||||||
initialValue={item.chair_position ?? ""}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -16,9 +16,13 @@ import {
|
|||||||
import { PartiesField } from "@/components/wizard/parties-field";
|
import { PartiesField } from "@/components/wizard/parties-field";
|
||||||
import { useCreateCase } from "@/lib/api/cases";
|
import { useCreateCase } from "@/lib/api/cases";
|
||||||
import {
|
import {
|
||||||
caseCreateSchema, committeeTypes, expectedOutcomes,
|
caseCreateSchema, expectedOutcomes,
|
||||||
type CaseCreateInput,
|
type CaseCreateInput,
|
||||||
} from "@/lib/schemas/case";
|
} from "@/lib/schemas/case";
|
||||||
|
import {
|
||||||
|
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
|
||||||
|
type AppealSubtype,
|
||||||
|
} from "@/lib/practice-area";
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ key: "basics", label: "פרטי יסוד" },
|
{ 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
|
/* 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. */
|
* before moving forward, instead of surfacing all errors from page 1. */
|
||||||
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
|
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
|
||||||
basics: ["case_number", "title", "committee_type"],
|
basics: ["case_number", "title", "practice_area", "appeal_subtype"],
|
||||||
parties: ["appellants", "respondents"],
|
parties: ["appellants", "respondents"],
|
||||||
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
|
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
|
||||||
};
|
};
|
||||||
@@ -57,13 +61,31 @@ export function CaseWizard() {
|
|||||||
subject: "",
|
subject: "",
|
||||||
property_address: "",
|
property_address: "",
|
||||||
permit_number: "",
|
permit_number: "",
|
||||||
committee_type: "ועדה מקומית",
|
|
||||||
hearing_date: "",
|
hearing_date: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
expected_outcome: "",
|
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 stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||||
const isLast = stepIndex === STEPS.length - 1;
|
const isLast = stepIndex === STEPS.length - 1;
|
||||||
|
|
||||||
@@ -124,7 +146,7 @@ export function CaseWizard() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="case_number"
|
id="case_number"
|
||||||
placeholder="1234 או 8001/2026"
|
placeholder="1033-25 או 1000-04-26"
|
||||||
{...form.register("case_number")}
|
{...form.register("case_number")}
|
||||||
className="mt-1 tabular-nums"
|
className="mt-1 tabular-nums"
|
||||||
/>
|
/>
|
||||||
@@ -137,25 +159,64 @@ export function CaseWizard() {
|
|||||||
<Input id="title" {...form.register("title")} className="mt-1" />
|
<Input id="title" {...form.register("title")} className="mt-1" />
|
||||||
<FieldError message={form.formState.errors.title?.message} />
|
<FieldError message={form.formState.errors.title?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-navy">סוג ועדה</Label>
|
<Label className="text-navy">תחום משפטי</Label>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="committee_type"
|
name="practice_area"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
|
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
|
||||||
<SelectTrigger className="mt-1">
|
<SelectTrigger className="mt-1">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{committeeTypes.map((t) => (
|
{PRACTICE_AREAS.map((p) => (
|
||||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
<SelectItem
|
||||||
|
key={p.value}
|
||||||
|
value={p.value}
|
||||||
|
disabled={!p.enabled}
|
||||||
|
>
|
||||||
|
{p.label}{!p.enabled && " (בקרוב)"}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,14 @@ export function PartiesField({
|
|||||||
placeholder="שם מלא של הצד"
|
placeholder="שם מלא של הצד"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
/>
|
/>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={add}>
|
<Button
|
||||||
<Plus className="w-4 h-4" />
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={add}
|
||||||
|
aria-label={`הוסף ${label}`}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}
|
{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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "./client";
|
import { apiRequest } from "./client";
|
||||||
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
|
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
|
||||||
|
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
|
||||||
|
|
||||||
export type CaseStatus =
|
export type CaseStatus =
|
||||||
| "new"
|
| "new"
|
||||||
@@ -35,6 +36,9 @@ export type Case = {
|
|||||||
expected_outcome?: string | null;
|
expected_outcome?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_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 */
|
/* Present when loaded with detail=true */
|
||||||
document_count?: number;
|
document_count?: number;
|
||||||
processing_count?: number;
|
processing_count?: number;
|
||||||
@@ -42,15 +46,21 @@ export type Case = {
|
|||||||
hearing_date?: string | null;
|
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 & {
|
export type CaseDetail = Case & {
|
||||||
documents?: Array<{
|
documents?: CaseDocument[];
|
||||||
id: number | string;
|
|
||||||
filename: string;
|
|
||||||
category?: string | null;
|
|
||||||
status?: string;
|
|
||||||
uploaded_at?: string;
|
|
||||||
size_bytes?: number;
|
|
||||||
}>;
|
|
||||||
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
|
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 { z } from "zod";
|
||||||
|
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
|
||||||
|
|
||||||
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md —
|
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md.
|
||||||
* permissive regex that still catches obvious typos. */
|
* Two accepted formats, both hyphen-separated:
|
||||||
const caseNumberRe = /^[1-9]\d{3,}(?:[-/][\w\u0590-\u05FF]+)*$/;
|
* 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]/;
|
const hebrewPartyRe = /[\u0590-\u05FFA-Za-z]/;
|
||||||
|
|
||||||
@@ -24,12 +32,6 @@ const dateString = z
|
|||||||
message: "תאריך חייב להיות בפורמט YYYY-MM-DD",
|
message: "תאריך חייב להיות בפורמט YYYY-MM-DD",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const committeeTypes = [
|
|
||||||
"ועדה מקומית",
|
|
||||||
"ועדה מחוזית",
|
|
||||||
"ועדת ערר",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const expectedOutcomes = [
|
export const expectedOutcomes = [
|
||||||
{ value: "", label: "— לא נקבע —" },
|
{ value: "", label: "— לא נקבע —" },
|
||||||
{ value: "rejection", label: "דחייה" },
|
{ value: "rejection", label: "דחייה" },
|
||||||
@@ -43,7 +45,7 @@ export const caseCreateSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.min(1, "שדה חובה")
|
.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, "כותרת ארוכה מדי"),
|
title: z.string().trim().min(3, "כותרת קצרה מדי").max(200, "כותרת ארוכה מדי"),
|
||||||
appellants: z
|
appellants: z
|
||||||
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
|
.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),
|
subject: z.string().trim().max(500),
|
||||||
property_address: z.string().trim().max(200),
|
property_address: z.string().trim().max(200),
|
||||||
permit_number: z.string().trim().max(100),
|
permit_number: z.string().trim().max(100),
|
||||||
committee_type: z.enum(committeeTypes),
|
|
||||||
hearing_date: dateString,
|
hearing_date: dateString,
|
||||||
notes: z.string().trim().max(2000),
|
notes: z.string().trim().max(2000),
|
||||||
expected_outcome: z.enum(
|
expected_outcome: z.enum(
|
||||||
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
|
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>;
|
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"}
|
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 ─────────────────────────────────────────
|
# ── Background Processing ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user