15 Commits

Author SHA1 Message Date
ed8502d46b Update knowledge files with corpus analysis and feedback system docs
- CLAUDE.md: added corpus-analysis.md to reference table, documented chair feedback system
- block-schema.md: added content_checklist constraint to block-yod
- legal-decision-lessons.md: added lessons 12-16 from corpus analysis (planning discussion, 5 subtypes, feedback system)
- SKILL.md: added section 12 (content checklists, planning discussion patterns, chair feedback)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:25:54 +00:00
0fef20e272 Add content checklists for block-yod and chair feedback system
Addresses Dafna's observation that licensing decisions lack comprehensive
planning discussion. Systematic corpus analysis of all 24 training decisions
revealed the system learned writing style but not substantive content.

Changes:
- Corpus analysis of all 24 decisions (docs/corpus-analysis.md)
- 5 content checklists by appeal subtype injected into block-yod prompt
- chair_feedback DB table + API endpoints + MCP tools
- Feedback management page in Next.js UI (/feedback)
- Navigation updated with "הערות יו״ר" link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:58:28 +00:00
ca6ec48580 Render research prose as Markdown with RTL tables
The analysis-and-research.md content has markdown tables (ציר דיוני)
and inline formatting like **label:** strong runs, which were
rendering as raw pipes and dashes because the compose page used
whitespace-pre-line on plain text.

Add a reusable <Markdown> component backed by react-markdown +
remark-gfm with a custom `components` map that styles paragraphs,
lists, blockquotes, strong runs, and especially GFM tables for RTL
+ aligned columns:

- table: table-auto + border-collapse, wrapped in overflow-x-auto
  so very wide tables don't push the parent card out
- th: whitespace-nowrap so the header row sets column widths and
  every row border lines up row-to-row
- everything text-right + the whole block gets dir="rtl" at the root

Use it in three places on the compose screen:
- ProseSection (represented_party, procedural_background,
  agreed_facts, disputed_facts)
- Conclusions card
- SubsectionCard field content — threshold_claims and issues have
  the same markdown shape in their fields[]

react-markdown + remark-gfm added (~30KB gzipped).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:43:37 +00:00
4e418787cf Full-width cards on compose
Drop the max-w-4xl wrapper so the compose cards fill the available
width of the AppShell main (which is already capped at 1400px
upstream). Threshold claim / issue cards with long Hebrew prose
now get the room they need without horizontal dead space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:33:08 +00:00
fdd12c6726 Move background + conclusions below the issues on compose
The right sidebar with "רקע לניתוח" and "מסקנות" was stealing
horizontal space and forced the editable cards (threshold claims,
issues, chair positions, now precedents) into a narrow column. Move
both cards into the main stack, after the issues list, so the chair
sees the decision points first and the surrounding context after.

Collapse the lg:grid-cols-[1fr_320px] layout into a single max-w-4xl
stack since there's no longer a sidebar. Bump the moved cards from
px-5 py-4 to px-6 py-5 to match the rest of the compose page's
card padding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:30:02 +00:00
e34d217345 Close first threshold claim by default on compose screen
Drop the defaultOpen={i===0} on the first threshold_claim — when a
case has a lot of material already on screen (research background
+ chair positions + now precedents), auto-opening the first card
creates a wall of text on page load. All cards now start collapsed,
same as the issues list already did.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:27:28 +00:00
6b8f002596 Precedent attachment UI in the compose screen
Surface the new POST/GET/DELETE /api/cases/{n}/precedents endpoints
in the compose screen as two insertion points:

1. A new case-level card "פסיקה כללית לדיון" at the top of the
   main column, for precedents that support the discussion intro
   rather than a specific threshold_claim / issue.
2. An inline "פסיקה תומכת" section inside each SubsectionCard,
   below the ChairEditor.

Both insertion points render a `<PrecedentsSection>` which shows a
list of `<PrecedentCard>` (citation + blockquote + optional chair
note + 📄 chip if a PDF was archived) followed by a `<PrecedentAttacher>`
popover trigger.

The Attacher is a Popover with cross-case typeahead: typing 2+
characters into the citation field hits /api/precedents/search and
shows distinct library matches; picking one prefills quote + chair
note but leaves them editable so customizing the quote for this
case doesn't mutate the library. An optional PDF/DOCX/DOC file can
be attached — it uploads first via POST .../upload-pdf and the
returned document_id is passed into the precedent create call.

The parent compose page issues a single useCasePrecedents query
and partitions the result by section_id into a Map so each
SubsectionCard renders its own slice without re-fetching.

shadcn Popover installed as a new primitive. sonner toasts wired
for success/error in both attach and delete flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:20:45 +00:00
aa0e608a4a Fix RTL alignment in DocumentsPanel
Force the title + meta column to the inline-start (visual right in
RTL) and the doc_type badge to the inline-end (visual left). The
previous layout used justify-between with an ambient dir="rtl"
inherited from <html>, but the Radix ScrollArea Viewport was eating
the direction context and flipping the row to LTR.

Fix: set dir="rtl" explicitly on both ScrollArea and the inner ul,
drop justify-between, and use ms-auto on the badge so it grows its
inline-start margin regardless of ambient direction. Title column
gets an explicit text-right for good measure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:34:09 +00:00
916360e9b2 Fix case detail: document fields, expected-outcome label, drop debug note
Three user-reported bugs on /cases/[caseNumber]:

1. Documents tab showed "9 מסמכים" in the count but rendered nothing —
   DocumentsPanel was reading filename/category/status/size_bytes/uploaded_at,
   but the real FastAPI payload (case_get → db.list_documents) returns
   title/doc_type/extraction_status/page_count/created_at. Rewrote the
   panel against the actual document row shape, added a CaseDocument
   type alias in lib/api/cases.ts, mapped doc_type to Hebrew labels
   (כתב ערר / כתב תשובה / ...) and extraction_status likewise.

2. The "פעולות" tab showed a debug-flavoured paragraph "עריכת פרטי התיק
   נשמרת מיד דרך PUT /api/cases/1033-25" — that was internal wording,
   not user copy. Removed.

3. Overview tab showed the raw enum value "full_acceptance" in the
   expected-outcome line. Mapped through the existing expectedOutcomes
   label array so it now reads "קבלה מלאה".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:00:24 +00:00
cbe9d60901 Phase 6: polish — error boundaries, a11y, smoke test doc
Close out the read-only surface before cutover with three families of
small fixes that the previous phases left unfinished:

- Error boundaries: add src/app/error.tsx (route-segment), global-error.tsx
  (root crash fallback with its own minimal html/body — no Providers
  dependency since those may be the thing that crashed), and not-found.tsx
  for a Hebrew 404 instead of the default Next page.

- Accessibility: wire usePathname() into AppShell so the current nav item
  gets aria-current="page" and a gold underline. Add aria-label + aria-hidden
  on the icon-only buttons that Phase 5 left text-less (corpus trash,
  parties-field Plus). Nav gets an aria-label of its own.

- Metadata template: title on each route now reads "X · עוזר משפטי" via
  the layout.tsx title.template. Description localized to Jerusalem.

- README: full E2E smoke test checklist covering all 9 screens, plus a
  backend contract table so future phases know which hook wraps which
  endpoint. Documents the known Gitea→Coolify webhook issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:43:59 +00:00
fb1f73fa25 Phase 5: secondary screens (diagnostics, skills, training)
Port the remaining read views from the vanilla UI to Next.js:

- /diagnostics — system health snapshot (DB connected, table counts,
  active tasks, failed and stuck documents). Uses the existing
  /api/system/diagnostics payload with a 10s refetchInterval so the
  page self-updates while the user watches.
- /skills — Paperclip skill inventory with sync status (DB-only,
  disk-only, synced, not-synced) as a card grid driven by
  /api/admin/skills.
- /training — Dafna's style portrait as three tabs on one page:
  * Report: corpus KPIs + CSS conic-gradient subject donut
    (SubjectDonut ported from index.html renderHero) + horizontal
    anatomy bars + top-12 signature phrases.
  * Corpus: TanStack Table of style_corpus rows with an inline
    delete mutation (useDeleteCorpusEntry invalidates both the
    corpus list and the style report so KPIs update).
  * Compare: two-decision selector backed by /api/training/compare,
    side-by-side panels plus shared / only-A / only-B pattern
    lists.

New API modules: lib/api/system.ts, lib/api/skills.ts,
lib/api/training.ts. All three use TanStack Query with staleTime
profiles tuned per endpoint (10s for diagnostics, 30s for skills,
60s for training reports).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:33:33 +00:00
ac0a5ee30b Phase 4.5: practice area integration in the Next.js UI
Backend commit 26d09d6 introduced a multi-tenant axis (practice_area +
appeal_subtype) that the vanilla UI picked up but the new Next.js
rewrite did not. Close the gap in the screens we already shipped so
future search/filter work in Phase 5 has the right data on screen.

- lib/practice-area.ts — new: enum + label maps + deriveSubtype(),
  mirrors mcp-server/src/legal_mcp/services/practice_area.py.
- lib/schemas/case.ts — two new z.enum fields on caseCreateSchema.
- lib/api/cases.ts — Case / CaseDetail gain practice_area and
  appeal_subtype as optional (cached pre-migration responses still
  parse cleanly).
- wizard/case-wizard.tsx — basics step gains a practice_area dropdown
  (future domains disabled with "(בקרוב)") and an appeal_subtype
  dropdown with auto-fill effect tracking a userTouchedSubtype ref,
  same behaviour as wireSubtypeAutofill() in the vanilla UI.
- cases/case-header.tsx — gold badge next to the status shows
  "ועדת ערר · רישוי ובנייה" when both fields are populated.
- cases/cases-table.tsx — new "תחום" column showing subtype label
  (dash for unknown). No filter yet — that's phase 5 when a second
  domain actually exists.

Plan: ~/.claude/plans/woolly-cooking-graham.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:15:48 +00:00
e483eba1a9 Allow two case-number formats: NNNN-YY and NNNN-MM-YY
Narrow the regex to exactly the two hyphen-separated shapes Dafna
uses in practice (1033-25, 1000-04-26). Wider shapes like "1033-foo"
are rejected too — they didn't exist in any real case and would
quietly widen the surface area for routing bugs later.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:44:25 +00:00
d8a537e7aa Forbid slashes in case numbers
FastAPI path routing can't capture a slash inside a {case_number}
segment — %2F gets decoded before route matching, so any case created
with "1000/0426" becomes permanently unreachable at
GET /api/cases/{case_number}/details. Enforce the hyphen convention
used by existing prod cases (1033-25, 1130-25) at the zod layer so
the wizard rejects the bad shape before submit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:41:40 +00:00
75ea6825b2 Remove committee_type from case wizard and header
The committee_type field in FastAPI's CaseCreateRequest is a leftover
with no meaningful UI. Appeals against a ועדה מקומית / ועדה מחוזית are
legally distinguishable by case-number range, not by a picked committee
label; appeals against a ועדת ערר decision go to בית משפט לעניינים
מנהליים and never enter this system at all. The backend retains its
"ועדה מקומית" default, which is what we'd send anyway.

Drop the Select from the wizard's basics step and the "ועדה" row from
the case detail header. The Case TS type keeps the optional field so
existing API responses still parse cleanly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:36:50 +00:00
49 changed files with 5491 additions and 188 deletions

View File

@@ -834,13 +834,13 @@
"description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
"testStrategy": "Users can create a new case via the multi-step wizard (case appears in Gitea + Paperclip), upload documents with live SSE progress, and edit case fields inline.",
"status": "in-progress",
"status": "done",
"dependencies": [
"85"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-04-11T16:18:28.714Z"
"updatedAt": "2026-04-11T16:25:55.569Z"
},
{
"id": "87",
@@ -848,12 +848,13 @@
"description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
"testStrategy": "Feature parity with old legal-ai/web/static/index.html across all 10 views.",
"status": "pending",
"status": "done",
"dependencies": [
"86"
],
"priority": "medium",
"subtasks": []
"subtasks": [],
"updatedAt": "2026-04-11T17:33:42.976Z"
},
{
"id": "88",
@@ -861,12 +862,13 @@
"description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
"testStrategy": "Lighthouse a11y score > 90, all loading states visible, errors show toasts, README has documented smoke test steps.",
"status": "pending",
"status": "done",
"dependencies": [
"87"
],
"priority": "medium",
"subtasks": []
"subtasks": [],
"updatedAt": "2026-04-11T17:44:08.337Z"
},
{
"id": "89",
@@ -880,13 +882,39 @@
],
"priority": "medium",
"subtasks": []
},
{
"id": "90",
"title": "Phase 4.5 — Practice area integration",
"description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [
"86"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-04-11T17:15:57.831Z"
},
{
"id": "91",
"title": "Precedent attachment in compose screen",
"description": "Add case_precedents table + FastAPI endpoints + MCP tools + Next.js compose UI for attaching legal precedents (quote + citation + optional archived PDF) to threshold_claims/issues and to the case as a whole. Plan: ~/.claude/plans/woolly-cooking-graham.md",
"details": "",
"testStrategy": "",
"status": "done",
"dependencies": [],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-04-11T19:20:56.040Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-04-11T16:18:28.714Z",
"taskCount": 58,
"completedCount": 52,
"lastModified": "2026-04-11T19:20:56.040Z",
"taskCount": 60,
"completedCount": 57,
"tags": [
"master"
]

View File

@@ -42,6 +42,7 @@
| [`docs/block-schema.md`](docs/block-schema.md) | הגדרת 12 בלוקים — content model, constraints, processing params | **לפני כל כתיבת החלטה** |
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
@@ -107,6 +108,16 @@
3. **"ללא כפילות"** — בלוק י (דיון) מפנה לבלוקים קודמים, לא חוזר עליהם
4. **"טענות מקוריות בלבד"** — בלוק ז = מכתבי טענות מקוריים בלבד. השלמות → בלוק ח
5. **ארכיטקטורת 12 בלוקים** — ראה `docs/block-schema.md`
6. **צ'קליסט תוכן** — בלוק י מקבל צ'קליסט תוכן אוטומטי לפי סוג הערר (ראה `lessons.py: CONTENT_CHECKLISTS`)
## הערות יו"ר (Chair Feedback)
מנגנון לתיעוד הערות דפנה על טיוטות:
- **DB**: טבלת `chair_feedback` (case_id, block_id, feedback_text, category, lesson_extracted)
- **API**: `GET/POST /api/feedback`, `PATCH /api/feedback/{id}/resolve`
- **MCP tools**: `record_chair_feedback`, `list_chair_feedback`
- **UI**: דף ניהול ב-`/feedback` (ב-Next.js)
- **קטגוריות**: missing_content, wrong_tone, wrong_structure, factual_error, style, other
## יו"ר: עו"ד דפנה תמיר
- מדריך סגנון מלא: `skills/decision/SKILL.md`

View File

@@ -371,6 +371,7 @@ Conclusion → Rule → Explanation → Application → Conclusion.
- MUST: מסקנה בפתיחת הדיון (לא בסוף)
- MUST: מענה לכל טענה שהוצגה בבלוק ז
- MUST: ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
- MUST: **צ'קליסט תוכן** — הפרומפט מזריק `{content_checklist}` אוטומטית לפי סוג הערר (מתוך `lessons.py: CONTENT_CHECKLISTS`). ראה `docs/corpus-analysis.md` לדפוסי תוכן לפי סוג.
- ⚠️ **MUST NOT ("ללא כפילות"):** חזרה על עובדות/טענות מבלוקים קודמים. השתמש בהפניות: "כאמור בסעיף X לעיל", "כפי שפורט", "כפי שציינו"
- MUST NOT: כותרות משנה (חריג: נושאים נפרדים לחלוטין)
- Dependencies: **ALL** previous blocks (ה-ט)

240
docs/corpus-analysis.md Normal file
View 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 החלטות של היטל השבחה לפני שהמערכת יכולה לכתוב החלטות בתחום הזה.

View File

@@ -161,3 +161,44 @@ Our skill was "over-indexed" on one case type (הכט = rejected appeal). The co
- Created `create-decision-structure.cjs` script for generating structure DOCX
- Key innovation from Arieli: "ההליכים בפני ועדת הערר" as separate section (Block ח)
- "Judge Test": every block written as if administrative court judge reads cold
---
## Lessons from Systematic Corpus Analysis (24 decisions, April 2026)
### Source
- All 24 proofread decisions in `/data/training/proofread/`
- Full analysis: [`docs/corpus-analysis.md`](corpus-analysis.md)
- Date: April 2026
### 12. System Learned Style but Not Substantive Content
- **Problem:** Dafna reviewed Kiryat Yearim draft and noted missing planning discussion in block-yod
- **Root cause:** The block-yod prompt taught CREAC methodology and "answer all claims" but never said "in licensing cases, include comprehensive planning discussion"
- **Fix:** Content checklists added to `lessons.py` (`CONTENT_CHECKLISTS`), injected into block-yod prompt via `{content_checklist}`
- **Applied to:** `lessons.py`, `block_writer.py`
### 13. Corpus Composition — All Licensing, No Betterment Levy
- All 24 training decisions are licensing/construction (1xxx)
- Zero betterment levy (8xxx) decisions in corpus
- Not a current priority gap — focusing on licensing first
### 14. Planning Discussion Patterns in Licensing Decisions
- **Always present** when the appeal reaches substantive planning questions
- **Never present** when the appeal is purely jurisdictional or property-based
- **Structure**: broad planning context → direct plan provision citations (200-600 words) → application to specific case → planning conclusion
- **Deepest planning**: פרומר (pure plan interpretation), לבנון (height/building appendix), בית הכרם (multi-plan TAMA 38)
- **No planning**: טלי-אביב (property only), גבאי (jurisdiction only)
### 15. Five Appeal Subtypes Identified (Not Just Three)
Licensing appeals are not homogeneous — the discussion structure varies significantly:
1. **Substantive licensing** — full planning discussion + legal analysis (majority of cases)
2. **Threshold/jurisdiction** — legal analysis only, no planning
3. **Property-focused** — תימוכין קנייניים, minimal planning
4. **TAMA 38** — balancing public interest + planning + neighbor impact
5. **Deviant use (שימוש חורג)** — deep plan interpretation across multiple plans
### 16. Chair Feedback System Established
- DB table `chair_feedback` records Dafna's comments on drafts
- Categories: missing_content, wrong_tone, wrong_structure, factual_error, style, other
- MCP tools + UI page for recording and reviewing feedback
- First entry: Kiryat Yearim — missing planning discussion (2026-04-12)

View File

@@ -346,6 +346,29 @@ async def ingest_final_version(
return await workflow.ingest_final_version(case_number, file_path, final_text)
@mcp.tool()
async def record_chair_feedback(
case_number: str,
feedback_text: str,
block_id: str = "block-yod",
category: str = "missing_content",
lesson_extracted: str = "",
) -> str:
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה — חסר, שגיאה, סגנון."""
return await workflow.record_chair_feedback(
case_number, feedback_text, block_id, category, lesson_extracted,
)
@mcp.tool()
async def list_chair_feedback(
case_number: str = "",
category: str = "",
unresolved_only: bool = True,
) -> str:
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
return await workflow.list_chair_feedback(case_number, category, unresolved_only)
def main():
mcp.run(transport="stdio")

View File

@@ -20,6 +20,7 @@ from uuid import UUID
from legal_mcp import config
from legal_mcp.services import db, embeddings, claude_session
from legal_mcp.services.lessons import get_content_checklist
logger = logging.getLogger(__name__)
@@ -215,6 +216,8 @@ BLOCK_PROMPTS = {
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
- מספור רציף
{content_checklist}
## כיוון מאושר (חובה):
{direction_context}
@@ -310,6 +313,15 @@ async def write_block(
outcome = (decision or {}).get("outcome", "rejected")
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
# Content checklist — tells block-yod WHAT topics to cover
content_checklist = ""
if block_id == "block-yod":
content_checklist = get_content_checklist(
appeal_type=case.get("appeal_type", ""),
subject=case.get("subject", ""),
subject_categories=case.get("subject_categories", []),
)
# Format prompt — per Anthropic long-context best practices:
# Place source documents FIRST (top of prompt), instructions LAST.
# "Queries at the end can improve response quality by up to 30%"
@@ -323,6 +335,7 @@ async def write_block(
style_context=style_context,
discussion_context=discussion_context,
structure_guidance=structure_guidance,
content_checklist=content_checklist,
)
# Restructure: sources first, then instructions

View File

@@ -358,6 +358,22 @@ CREATE TABLE IF NOT EXISTS case_law_embeddings (
created_at TIMESTAMPTZ DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════════════
-- Chair Feedback (הערות דפנה על טיוטות)
-- ═══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS chair_feedback (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
block_id TEXT DEFAULT '', -- block-yod, block-vav, etc.
feedback_text TEXT NOT NULL, -- ההערה של דפנה
category TEXT DEFAULT 'other', -- missing_content/wrong_tone/wrong_structure/factual_error/style/other
lesson_extracted TEXT DEFAULT '', -- הלקח שהופק
applied_to TEXT[] DEFAULT '{}', -- לאילו קבצים/כללים הלקח יושם
resolved BOOLEAN DEFAULT FALSE, -- האם הלקח יושם
created_at TIMESTAMPTZ DEFAULT now()
);
-- ═══════════════════════════════════════════════════════════════════
-- Indexes
-- ═══════════════════════════════════════════════════════════════════
@@ -986,3 +1002,72 @@ async def search_precedents(
results.sort(key=lambda x: x["score"], reverse=True)
return results[:limit]
# ── Chair feedback ────────────────────────────────────────────────
async def record_chair_feedback(
case_id: UUID | None,
block_id: str,
feedback_text: str,
category: str = "other",
lesson_extracted: str = "",
) -> UUID:
"""Record feedback from the chair (Dafna) on a draft block."""
pool = await get_pool()
feedback_id = uuid4()
async with pool.acquire() as conn:
await conn.execute(
"""INSERT INTO chair_feedback
(id, case_id, block_id, feedback_text, category, lesson_extracted)
VALUES ($1, $2, $3, $4, $5, $6)""",
feedback_id, case_id, block_id, feedback_text, category,
lesson_extracted,
)
return feedback_id
async def list_chair_feedback(
case_id: UUID | None = None,
category: str | None = None,
unresolved_only: bool = False,
) -> list[dict]:
"""List chair feedback, optionally filtered."""
pool = await get_pool()
conditions = []
params: list = []
idx = 1
if case_id:
conditions.append(f"case_id = ${idx}")
params.append(case_id)
idx += 1
if category:
conditions.append(f"category = ${idx}")
params.append(category)
idx += 1
if unresolved_only:
conditions.append("resolved = FALSE")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
async with pool.acquire() as conn:
rows = await conn.fetch(
f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC",
*params,
)
return [dict(r) for r in rows]
async def resolve_chair_feedback(
feedback_id: UUID,
applied_to: list[str],
) -> None:
"""Mark feedback as resolved and record where it was applied."""
pool = await get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""UPDATE chair_feedback
SET resolved = TRUE, applied_to = $2
WHERE id = $1""",
feedback_id, applied_to,
)

View File

@@ -329,3 +329,193 @@ def format_ratios_comment(outcome: str, section: str) -> str:
lo, hi = ratios[section]
return f"יעד: {lo}-{hi}% מסך ההחלטה"
return ""
# ── Content checklists by appeal subtype ──────────────────────────
# Based on systematic analysis of 24 decisions from Dafna's corpus.
# See: docs/corpus-analysis.md
CONTENT_CHECKLISTS: dict[str, str] = {
"licensing_substantive": """## צ'קליסט תוכן — ערר רישוי מהותי (חובה)
הדיון חייב לכלול את הנושאים הרלוונטיים מהרשימה הבאה.
**אל תדלג על נושא שרלוונטי לתיק — בדוק כל סעיף.**
### א. הקשר תכנוני רחב (חובה בכל ערר מהותי)
- תכניות חלות — ציין את התכניות הרלוונטיות ברמה מקומית, מחוזית וארצית (לפי הצורך)
- ייעוד הקרקע — מה הייעוד בתכנית? מה השימושים המותרים?
- אופי הסביבה — מרקם בנוי, צפיפות, אופי שכונה/ישוב
- *דוגמה*: בערר פרומר — 12 סעיפים על MI/200, תמ"א 35, תמ"מ 30/1
### ב. ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה)
- ציטוט ישיר מהוראות התכנית הרלוונטיות (200-600 מילים לכל ציטוט)
- פרשנות — מה תכלית ההוראה?
- יישום — האם הבקשה תואמת או סוטה?
- *דוגמה*: בערר לבנון — ניתוח חתכים של נספח בינוי מול הבקשה
### ג. חניה (כשרלוונטי — מופיע ב-8 מתוך 24 החלטות)
- הוראות תכנית + נספח תנועה (ציטוט ישיר)
- חישוב מקומות חניה נדרשים vs. מסופקים
- חלופות: קרן חניה, חפיפת שימושים, קרבה לתח"צ
- *דוגמה*: בערר בית הכרם — 8 סעיפים, 400+ מילים מהוראות תכנית 5166ב
### ד. קווי בניין ומרווחים (כשרלוונטי)
- הוראת תכנית על מרווחים
- סטייה ניכרת? — תקנה 2(19) / הלכת בן-יקר-גת
- הצדקה + מידתיות — פגיעה בשכנים?
### ה. גובה וקומות (כשרלוונטי)
- הוראת תכנית + נספח בינוי (חתכים)
- מטרת ההגבלה — למה יש הגבלת גובה כאן?
- סטייה ניכרת — תקנה 2(10) / 2(8)
### ו. פגיעה בשכנים (כשרלוונטי)
- ממצאי סיור באתר
- השפעה: צל, פרטיות, רעש, נוף
- מידתיות — האם הפגיעה סבירה?
### ז. שימוש חורג (כשרלוונטי)
- מה השימוש המותר בתכנית? מה השימוש המבוקש?
- "מבחן ההתאמה" — האם השימוש מתאים למיקום?
- תנאים ומגבלות
""",
"licensing_threshold": """## צ'קליסט תוכן — ערר רישוי סף/סמכות
הערר עוסק בשאלות סף — אין צורך בדיון תכנוני מקיף.
### א. שאלת הסמכות
- סעיפי חוק רלוונטיים (ס' 12ב, 152, וכו')
- פסיקה על גבולות הסמכות
### ב. זכות ערר
- מי רשאי לערור? באיזה מסלול?
- הלכת שפר (עע"מ 317/10) — כשרלוונטית
### ג. שיהוי (אם רלוונטי)
""",
"licensing_property": """## צ'קליסט תוכן — ערר רישוי קנייני
הערר עוסק בעיקר בשאלת תימוכין קנייניים — דיון משפטי.
### א. מסגרת נורמטיבית
- הלכת עייזן, בני אליעזר, רוזן — "היתכנות קניינית"
- ס' 71ב לחוק המקרקעין
### ב. בחינת הראיות
- הסכמות, רישום, היסטוריית בנייה
- חלוקה דה-פקטו ארוכת שנים
### ג. הפרדה בין קניין לתכנון
- גוף תכנוני אינו מכריע בסכסוכי קניין
- "היתכנות קניינית" ≠ הוכחת בעלות
### ד. שאלות תכנוניות (אם רלוונטיות)
- אם הערר עולה גם שאלות תכנוניות — דון בהן בנפרד
""",
"tama38": """## צ'קליסט תוכן — ערר תמ"א 38
הדיון חייב לאזן בין אינטרס ציבורי לפגיעה בשכנים.
### א. אינטרס ציבורי — חיזוק/התחדשות
- עוצמת האינטרס — בניין גדול vs. בית בודד
- "בית בודד" מחליש את אינטרס החיזוק
- תרומה לרקמה העירונית
### ב. תכנית אב / מדיניות אזורית
- האם יש תכנית אב? מדיניות 16000?
- התאמה לראיה כללית vs. אד-הוק
### ג. ניתוח השוואתי
- זכויות לפי תכנית קיימת vs. מבוקש לפי תמ"א 38
- שטחים, קומות, קווי בניין — טבלת השוואה
### ד. שימור (כשרלוונטי)
- חוות דעת אגף שימור
- השפעה על מיקום/צורת הבניין
### ה. חניה (כמעט תמיד רלוונטי)
- הוראות תכנית + ס' 17 לתמ"א 38
- פטורים — קרבה לתח"צ, קרן חניה, תכנית אב
- ניתוח מפורט של חלופות
### ו. פגיעה בשכנים
- ממצאי סיור
- צל, פרטיות, קרבה
- מידתיות — מה הפגיעה ביחס לתועלת?
### ז. מטרדי בנייה
- "מטרד בנייה אינו עילה לסירוב" — אך תנאים נדרשים
- תכנית ארגון אתר
""",
"betterment_levy": """## צ'קליסט תוכן — ערר היטל השבחה
⚠️ שים לב: אין עדיין החלטות היטל השבחה בקורפוס האימון.
הצ'קליסט הזה מבוסס על ידע כללי — לא על ניתוח ספציפי של סגנון דפנה.
### א. המסגרת הנורמטיבית
- התוספת השלישית לחוק התכנון והבנייה
- אירוע מס — מה יצר את ההשבחה?
### ב. שומה
- שיטת השומה (שומה מכרעת / שמאי מייעץ)
- מועד הקובע
- זכויות בנייה — לפני ואחרי
### ג. שאלות משפטיות
- פטורים (ס' 19)
- מועדי תשלום
- שיערוך
### ד. ניתוח שמאי
- האם השומה תקינה?
- פערים בין השומות
""",
}
def get_content_checklist(
appeal_type: str = "",
subject: str = "",
subject_categories: list[str] | None = None,
) -> str:
"""Return the appropriate content checklist based on case characteristics.
Determines the subtype from case metadata:
- TAMA 38 cases → tama38 checklist
- Betterment levy (8xxx) → betterment_levy checklist
- Property-only cases → licensing_property checklist
- Threshold/jurisdiction cases → licensing_threshold checklist
- All other licensing → licensing_substantive checklist
"""
cats = subject_categories or []
subject_lower = subject.lower() if subject else ""
appeal_lower = appeal_type.lower() if appeal_type else ""
# TAMA 38
if any(
kw in subject_lower
for kw in ["תמ\"א 38", "תמא 38", "תמ\"א38", "חיזוק", "tama"]
) or "תמ\"א 38" in cats:
return CONTENT_CHECKLISTS["tama38"]
# Betterment levy
if "היטל השבחה" in appeal_lower or "betterment" in appeal_lower or any(
"היטל" in c for c in cats
):
return CONTENT_CHECKLISTS["betterment_levy"]
# Property-focused (תימוכין קנייניים)
if any(
kw in subject_lower
for kw in ["תימוכין", "קנייני", "בעלות", "הסכמת דיירים"]
):
return CONTENT_CHECKLISTS["licensing_property"]
# Threshold/jurisdiction
if any(
kw in subject_lower
for kw in ["סמכות", "סף", "סילוק על הסף", "זכות ערר"]
):
return CONTENT_CHECKLISTS["licensing_threshold"]
# Default: substantive licensing
return CONTENT_CHECKLISTS["licensing_substantive"]

View File

@@ -318,3 +318,97 @@ async def ingest_final_version(
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
# ── Chair feedback tools ──────────────────────────────────────────
async def record_chair_feedback(
case_number: str,
feedback_text: str,
block_id: str = "block-yod",
category: str = "missing_content",
lesson_extracted: str = "",
) -> str:
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה.
Args:
case_number: מספר תיק הערר
feedback_text: ההערה של דפנה (מה חסר, מה לא נכון, מה צריך לשנות)
block_id: הבלוק שההערה מתייחסת אליו (ברירת מחדל: block-yod)
category: קטגוריה — missing_content/wrong_tone/wrong_structure/factual_error/style/other
lesson_extracted: הלקח שהופק מההערה (אם ברור כבר)
"""
case = await db.get_case_by_number(case_number)
case_id = UUID(case["id"]) if case else None
valid_categories = [
"missing_content", "wrong_tone", "wrong_structure",
"factual_error", "style", "other",
]
if category not in valid_categories:
return f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}"
feedback_id = await db.record_chair_feedback(
case_id=case_id,
block_id=block_id,
feedback_text=feedback_text,
category=category,
lesson_extracted=lesson_extracted,
)
return json.dumps({
"status": "ok",
"feedback_id": str(feedback_id),
"message": f"הערה נרשמה בהצלחה. קטגוריה: {category}.",
"next_steps": [
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
"כדי לסמן כמטופל: resolve_chair_feedback",
],
}, ensure_ascii=False, indent=2)
async def list_chair_feedback(
case_number: str = "",
category: str = "",
unresolved_only: bool = True,
) -> str:
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
Args:
case_number: סינון לפי תיק (אם ריק — כל ההערות)
category: סינון לפי קטגוריה
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
"""
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
feedbacks = await db.list_chair_feedback(
case_id=case_id,
category=category or None,
unresolved_only=unresolved_only,
)
if not feedbacks:
return "אין הערות שמתאימות לסינון."
items = []
for fb in feedbacks:
items.append({
"id": str(fb["id"]),
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
"block_id": fb["block_id"],
"category": fb["category"],
"feedback": fb["feedback_text"],
"lesson": fb["lesson_extracted"],
"resolved": fb["resolved"],
"date": fb["created_at"].isoformat() if fb.get("created_at") else None,
})
return json.dumps({
"total": len(items),
"feedbacks": items,
}, ensure_ascii=False, indent=2, default=str)

View File

@@ -499,3 +499,39 @@ description: This skill should be used when writing legal decisions (החלטו
| תיבות תמונה | מסגרת עם shading אפור בהיר (fill: "F0F0F0"), טקסט "📷 תמונה: [תיאור]" | ShadingType.CLEAR |
| חתימות | טבלה ללא גבולות (`visuallyRightToLeft: true`), 2 טורים | כמו בתבנית ב-create-legal-doc.js |
| כותרת מוסדית | טבלה ללא גבולות, 2 טורים: ימין=מוסד, שמאל=מספרי תיק | `visuallyRightToLeft: true` |
## 12. צ'קליסט תוכן לפי סוג ערר
> נוסף אפריל 2026 בעקבות ניתוח שיטתי של 24 החלטות. ראה: `docs/corpus-analysis.md`
הפרומפט של בלוק י מקבל **צ'קליסט תוכן** אוטומטי לפי סוג הערר (`lessons.py: CONTENT_CHECKLISTS`). זה מבטיח שהדיון יכסה את הנושאים הנדרשים — לא רק סגנון ומתודולוגיה, אלא תוכן ענייני.
### 12.1 חמישה תת-סוגי רישוי (לא שלושה)
ניתוח הקורפוס חשף שלתיקי רישוי יש 5 תת-סוגים שונים מבחינת מבנה הדיון:
| תת-סוג | מה בדיון | דוגמאות |
|---------|---------|---------|
| **רישוי מהותי** | דיון תכנוני מקיף + משפטי | רוב ההחלטות |
| **סף/סמכות** | משפטי בלבד, ללא תכנון | גבאי, ירושלים שקופה |
| **קנייני** | תימוכין קנייניים, מינימום תכנון | טלי-אביב, הראל 1043 |
| **תמ"א 38** | איזון אינטרסים + תכנון + שכנות | בית הכרם |
| **שימוש חורג** | פרשנות תכניות מרובות | תורן |
### 12.2 דיון תכנוני — מתי ואיך
**מתי חובה:** כשהערר מגיע לדיון מהותי (לא סף/סמכות, לא קנייני טהור).
**מבנה טיפוסי (מהקורפוס):**
1. הקשר תכנוני רחב — תכניות חלות, ייעוד, סביבה (2-8 סעיפים)
2. ציטוט ישיר מהוראות תכנית — בלוקים של 200-600 מילים עם "הדגשת הח"מ"
3. יישום על המקרה — הוראה → עובדה → מסקנה
4. מסקנה תכנונית — תואם/סוטה, מוצדק/לא
**נושאים שמופיעים בתדירות גבוהה:**
- חניה (8/24 החלטות) — הנושא התכנוני הנפוץ ביותר, עומק של 5-15 סעיפים
- קווי בניין (7/24) — כולל ניתוח סטייה ניכרת
- ניתוח הוראות תכנית (18/24) — כמעט תמיד
- פגיעה בשכנים (5/24) — צל, פרטיות, רעש
### 12.3 הערות יו
הערות דפנה על טיוטות מתועדות במערכת `chair_feedback` (DB + API + UI ב-`/feedback`). כל הערה מסווגת לקטגוריה ומפיקה לקח שמשפר את ההחלטות הבאות.

View File

@@ -1,36 +1,175 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# עוזר משפטי — Web UI (Next.js rewrite)
## Getting Started
The Next.js 16 rewrite of `legal-ai.nautilus.marcusgroup.org`, currently hosted side-by-side with the legacy vanilla `index.html` at:
First, run the development server:
- **Staging:** https://legal-ai-next.nautilus.marcusgroup.org (auto-deployed from `ui-rewrite` branch via Coolify)
- **Production FastAPI:** https://legal-ai.nautilus.marcusgroup.org (same backend, old UI still default)
The rewrite talks to the existing FastAPI via proxy rewrites in `next.config.ts` — no CORS setup, no duplicated backend.
## Stack
- Next.js 16.2.3 (App Router, Turbopack, `output: "standalone"`)
- React 19.2 · TypeScript · Tailwind v4 · shadcn/ui (radix-nova preset)
- TanStack Query v5 + TanStack Table v8
- react-hook-form + zod for mutations
- react-dropzone for uploads; EventSource for SSE progress
- Heebo via `next/font/google`; design tokens in `src/app/globals.css`
## Local development
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
npm install
npm run dev # http://localhost:3000
npm run build # full type check + production build
npm run lint
npm run api:types # regenerate src/lib/api/types.ts from FastAPI's OpenAPI
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
### API connection
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
By default the dev server proxies to production FastAPI (`https://legal-ai.nautilus.marcusgroup.org`). To point at a different backend, set:
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
```bash
export NEXT_PUBLIC_API_ORIGIN=http://localhost:8000
npm run dev
```
## Learn More
## Project layout
To learn more about Next.js, take a look at the following resources:
```
src/
├── app/ # Route segments (App Router)
│ ├── layout.tsx # Root: Providers + RTL html + fonts
│ ├── page.tsx # Home: KPIs + status donut + cases table
│ ├── error.tsx # Route-segment error boundary
│ ├── global-error.tsx # Root crash fallback
│ ├── not-found.tsx # 404
│ ├── cases/
│ │ ├── new/ # 3-step create wizard
│ │ └── [caseNumber]/
│ │ ├── page.tsx # Case detail (tabs + workflow timeline)
│ │ └── compose/ # Research analysis + chair-position editor
│ ├── training/ # Style portrait + corpus + compare (3 tabs)
│ ├── skills/ # Paperclip skills inventory
│ └── diagnostics/ # DB health + failed/stuck docs
├── components/
│ ├── app-shell.tsx # Header + nav with aria-current
│ ├── cases/ # Home + detail screens
│ ├── compose/ # Research analysis editor
│ ├── documents/ # UploadSheet
│ ├── training/ # Style report / corpus / compare panels
│ ├── wizard/ # Case create wizard + parties-field
│ └── ui/ # shadcn primitives
├── lib/
│ ├── api/ # Typed hooks per domain (cases, documents, research,
│ │ # system, skills, training)
│ ├── schemas/ # zod schemas (case create / update)
│ ├── practice-area.ts # Multi-tenant axis enum + deriveSubtype()
│ ├── sse.ts # EventSource wrapper
│ ├── providers.tsx # QueryClient + Toaster
│ └── utils.ts # cn()
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Smoke test (run after every deploy)
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
Use any browser at the staging URL. Every step should be doable **without console errors** and each mutation should produce a visible toast.
## Deploy on Vercel
### 1. Home · `/`
- [ ] Header nav shows 5 items; the current page is underlined in gold
- [ ] 4 KPI cards render real numbers (סה״כ · בהכנה · בכתיבה · מוכנים)
- [ ] Cases table lists existing cases; search filters by case number or title
- [ ] "פיזור סטטוסים" donut renders with a legend
- [ ] "+ תיק חדש" button in the top-left navigates to `/cases/new`
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
### 2. Create case · `/cases/new`
- [ ] 3-step wizard: פרטי יסוד → צדדים → השלמות
- [ ] Type `1500-25` → appeal_subtype auto-fills to "רישוי ובנייה"
- [ ] Type `8500-25` → subtype auto-fills to "היטל השבחה"
- [ ] Manually pick a different subtype → auto-fill stops
- [ ] Submitting with invalid case number shows a zod field error (no crash)
- [ ] Successful create → toast "תיק חדש נוצר" → router pushes to `/cases/{number}`
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### 3. Case detail · `/cases/[caseNumber]`
- [ ] Header shows status badge + gold "ועדת ערר · X" practice-area badge
- [ ] Tabs switch cleanly: סקירה / מסמכים / פעולות
- [ ] Workflow timeline on the right shows the current phase highlighted in gold
- [ ] פעולות tab → "עריכת פרטי תיק" dialog opens; submitting updates the header without full reload (optimistic cache patch)
- [ ] "העלאת מסמכים" sheet opens from the tab row; drag-drop fires a POST and a live progress bar appears via SSE
### 4. Compose · `/cases/[caseNumber]/compose`
- [ ] If analysis-and-research.md exists: threshold claims + issues render as collapsible cards
- [ ] Chair-position textarea auto-saves on blur with "✓ נשמר {time}" indicator
- [ ] If 404 (no analysis yet): empty state card renders, no error toast
### 5. Training · `/training`
- [ ] **Report tab:** headline card, 4 KPIs, subject donut, anatomy bars, top-12 signature phrases
- [ ] **Corpus tab:** table of corpus decisions with a trash icon per row (aria-label present)
- [ ] Deleting a decision refreshes both the corpus table and the report KPIs
- [ ] **Compare tab:** two Selects, pick 2 different decisions, side-by-side panels + shared/only-A/only-B pattern lists
### 6. Skills · `/skills`
- [ ] Card grid of Paperclip skills with sync-status badges (מסונכרן / DB בלבד / לא סונכרן)
- [ ] Chars + file counts render; "לא ידוע" doesn't appear for installed skills
### 7. Diagnostics · `/diagnostics`
- [ ] DB status card shows "מחובר" in green
- [ ] Table counts populate for cases / documents / chunks / corpus / patterns
- [ ] Failed + stuck document lists render (empty states OK)
- [ ] Page self-refreshes every 10s — check the network tab for recurring calls
### 8. Error boundary
- [ ] Visit `/cases/NOT-REAL-999-99` → case detail shows an error card with the FastAPI message and "חזרה לרשימת התיקים" button (no white screen)
- [ ] Visit `/anything-broken-xyz` → custom 404 page with "חזרה לבית" button
### 9. Keyboard + RTL
- [ ] Tab through the home page — focus rings are gold, visible
- [ ] Wizard progresses via Enter on the "הבא" button
- [ ] Screen reader announces nav items with "עמוד נוכחי" on the active one
## Deploy
```
git push # → Coolify auto-build on branch ui-rewrite (~90 s)
```
> **Known issue:** the Gitea → Coolify webhook is not firing at the time of writing. Trigger a manual deploy via the Coolify MCP (`mcp__coolify__deploy` with app UUID `l146g36mtlp0k03vrwkyrgkk`) or the Coolify UI until the webhook is fixed.
## Phase tracking
See `~/.claude/plans/joyful-marinating-sutton.md` for the 7-phase rewrite plan and `~/legal-ai-ui-rewrite/.taskmaster/tasks/tasks.json` for the task board.
| Phase | Scope | Status |
|---|---|---|
| 1 | Scaffold + Coolify staging | ✅ |
| 2 | API client + typed hooks + probe | ✅ |
| 3 | Read views (home, case detail, compose) | ✅ |
| 4 | Mutations (wizard, edit, upload+SSE) | ✅ |
| 4.5 | Practice-area integration | ✅ |
| 5 | Secondary screens (training, skills, diagnostics) | ✅ |
| 6 | Polish, a11y, error boundaries, smoke test | ✅ |
| 7 | DNS cutover to production | pending |
## Backend contract
The new UI consumes the existing FastAPI at `legal-ai/web/app.py`. Key endpoints currently relied on:
| Endpoint | Hook | Used by |
|---|---|---|
| `GET /api/cases?detail=true` | `useCases` | home table, KPIs |
| `GET /api/cases/{n}/details` | `useCase` | case detail |
| `POST /api/cases/create` | `useCreateCase` | wizard |
| `PUT /api/cases/{n}` | `useUpdateCase` | inline edit |
| `DELETE /api/cases?case_number=...` | (MCP only so far) | admin cleanup |
| `POST /api/cases/{n}/documents/upload-tagged` | `useUploadDocument` | upload sheet |
| `GET /api/progress/{task_id}` (SSE) | `useProgress` | upload progress |
| `GET /api/cases/{n}/research/analysis` | `useResearchAnalysis` | compose |
| `PATCH .../chair-position` | `useSaveChairPosition` | chair editor |
| `GET /api/training/style-report` | `useStyleReport` | training/report tab |
| `GET /api/training/corpus` | `useCorpus` | training/corpus tab |
| `GET /api/training/compare` | `useCompare` | training/compare tab |
| `DELETE /api/training/corpus/{id}` | `useDeleteCorpusEntry` | corpus tab |
| `GET /api/system/diagnostics` | `useDiagnostics` | diagnostics page |
| `GET /api/admin/skills` | `useSkills` | skills page |
Any new endpoint should get a typed hook in `src/lib/api/` — do not reach into `fetch` from component code.

1460
web-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@
"react-dom": "19.2.4",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.72.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",

View File

@@ -7,8 +7,11 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SubsectionCard } from "@/components/compose/subsection-card";
import { PrecedentsSection } from "@/components/compose/precedents-section";
import { Markdown } from "@/components/ui/markdown";
import { useCase } from "@/lib/api/cases";
import { useResearchAnalysis } from "@/lib/api/research";
import { useCasePrecedents } from "@/lib/api/precedents";
function ProseSection({ title, content }: { title: string; content?: string }) {
if (!content?.trim()) return null;
@@ -17,9 +20,7 @@ function ProseSection({ title, content }: { title: string; content?: string }) {
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
{title}
</h3>
<p className="text-sm text-ink-soft leading-relaxed whitespace-pre-line prose">
{content.trim()}
</p>
<Markdown content={content.trim()} />
</section>
);
}
@@ -32,6 +33,21 @@ export default function ComposePage({
const { caseNumber } = use(params);
const caseQuery = useCase(caseNumber);
const analysis = useResearchAnalysis(caseNumber);
const precedentsQuery = useCasePrecedents(caseNumber);
/* Partition the flat list into scopes so each child renders its own slice
* without re-fetching. Done once at the page level. */
const allPrecedents = precedentsQuery.data ?? [];
const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null);
const precedentsBySection = new Map<string, typeof allPrecedents>();
for (const p of allPrecedents) {
if (p.section_id) {
const existing = precedentsBySection.get(p.section_id) ?? [];
existing.push(p);
precedentsBySection.set(p.section_id, existing);
}
}
const practiceArea = caseQuery.data?.practice_area ?? null;
const isNotFound =
analysis.error instanceof Error &&
@@ -98,9 +114,24 @@ export default function ComposePage({
</CardContent>
</Card>
) : analysis.data ? (
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* Main editable column */}
<div className="space-y-6">
{/* Case-level general precedents */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-xl mb-1">פסיקה כללית לדיון</h2>
<p className="text-[0.78rem] text-ink-muted mb-4">
ציטוטים התומכים בעמדה באופן רוחבי ישולבו בפתיחת בלוק י (דיון).
</p>
<PrecedentsSection
caseNumber={caseNumber}
sectionId={null}
precedents={caseLevelPrecedents}
practiceArea={practiceArea}
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
/>
</CardContent>
</Card>
{/* Threshold claims */}
{analysis.data.threshold_claims &&
analysis.data.threshold_claims.length > 0 && (
@@ -112,12 +143,13 @@ export default function ComposePage({
</span>
</div>
<div className="space-y-3">
{analysis.data.threshold_claims.map((tc, i) => (
{analysis.data.threshold_claims.map((tc) => (
<SubsectionCard
key={tc.id}
caseNumber={caseNumber}
item={tc}
defaultOpen={i === 0}
precedents={precedentsBySection.get(tc.id) ?? []}
practiceArea={practiceArea}
/>
))}
</div>
@@ -139,6 +171,8 @@ export default function ComposePage({
key={iss.id}
caseNumber={caseNumber}
item={iss}
precedents={precedentsBySection.get(iss.id) ?? []}
practiceArea={practiceArea}
/>
))}
</div>
@@ -153,13 +187,13 @@ export default function ComposePage({
</CardContent>
</Card>
)}
</div>
{/* Side column: background prose + conclusions */}
<aside className="space-y-5">
{/* Background prose — moved below the issues so it reads as
supporting context after the chair has seen the main
decision points, not as a wall of text beside them. */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 space-y-5">
<h2 className="text-navy text-base mb-0">רקע לניתוח</h2>
<CardContent className="px-6 py-5 space-y-5">
<h2 className="text-navy text-xl mb-0">רקע לניתוח</h2>
<ProseSection
title="צד מיוצג"
content={analysis.data.represented_party}
@@ -181,15 +215,12 @@ export default function ComposePage({
{analysis.data.conclusions?.trim() && (
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-5 py-4 space-y-2">
<h2 className="text-gold-deep text-base mb-0">מסקנות</h2>
<p className="text-sm text-ink leading-relaxed whitespace-pre-line prose">
{analysis.data.conclusions.trim()}
</p>
<CardContent className="px-6 py-5 space-y-3">
<h2 className="text-gold-deep text-xl mb-0">מסקנות</h2>
<Markdown content={analysis.data.conclusions.trim()} />
</CardContent>
</Card>
)}
</aside>
</div>
) : null}
</section>

View File

@@ -12,8 +12,13 @@ import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
import { DocumentsPanel } from "@/components/cases/documents-panel";
import { UploadSheet } from "@/components/documents/upload-sheet";
import { expectedOutcomes } from "@/lib/schemas/case";
import { useCase } from "@/lib/api/cases";
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
expectedOutcomes.map((o) => [o.value, o.label]),
);
/*
* Next 16 breaking change: route params are now a Promise.
* The `use()` hook unwraps them inside a client component.
@@ -25,6 +30,9 @@ export default function CaseDetailPage({
}) {
const { caseNumber } = use(params);
const { data, isPending, error } = useCase(caseNumber);
const expectedOutcomeLabel = data?.expected_outcome
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
: null;
return (
<AppShell>
@@ -77,7 +85,7 @@ export default function CaseDetailPage({
<div>
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
<p className="text-ink-soft text-sm leading-relaxed">
{data?.expected_outcome ?? "לא נקבעה תוצאה צפויה."}
{expectedOutcomeLabel ?? "לא נקבעה תוצאה צפויה."}
</p>
</div>
<div>
@@ -100,8 +108,7 @@ export default function CaseDetailPage({
</TabsContent>
<TabsContent value="actions" className="mt-5">
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<Link href={`/cases/${caseNumber}/compose`}>
פתח בעורך ההחלטה
@@ -109,10 +116,6 @@ export default function CaseDetailPage({
</Button>
{data && <CaseEditDialog data={data} />}
</div>
<p className="text-xs text-ink-muted">
עריכת פרטי התיק נשמרת מיד דרך PUT /api/cases/{caseNumber}.
</p>
</div>
</TabsContent>
</Tabs>
</CardContent>

View 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" />
תקועים (&gt; 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
View 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>
);
}

View 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> &middot; </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>
);
}

View 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>
);
}

View File

@@ -14,8 +14,11 @@ const heebo = Heebo({
});
export const metadata: Metadata = {
title: "עוזר משפטי — ניהול תיקים",
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה",
title: {
default: "עוזר משפטי — ניהול תיקים",
template: "%s · עוזר משפטי",
},
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה, ירושלים",
};
export default function RootLayout({

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,5 +1,8 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
/**
* Ezer Mishpati navigation shell.
@@ -9,8 +12,9 @@ import Link from "next/link";
* - Parchment/cream body background (set on <body> via globals.css)
* - Hebrew RTL throughout (set on <html> in layout.tsx)
*
* Structure mirrors the current vanilla index.html header so that visual
* continuity is preserved while we migrate screen-by-screen.
* Nav items pick up an `aria-current="page"` and a gold underline when
* the current route matches, so screen readers announce the active
* section and sighted users can see where they are.
*/
type NavItem = {
@@ -20,13 +24,21 @@ type NavItem = {
const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" },
{ href: "/upload", label: "העלאת מסמכים" },
{ href: "/cases/new", label: "תיק חדש" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/feedback", label: "הערות יו״ר" },
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" },
];
function isActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
return (
<>
<header
@@ -45,25 +57,43 @@ export function AppShell({ children }: { children: ReactNode }) {
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
</Link>
<nav className="me-auto flex items-center gap-1">
{NAV_ITEMS.map((item) => (
<nav
className="me-auto flex items-center gap-1"
aria-label="ניווט ראשי"
>
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
className="
px-3 py-1.5 rounded
text-sm text-parchment/80
transition-colors
hover:text-parchment hover:bg-navy-soft/60
"
aria-current={active ? "page" : undefined}
className={`
relative px-3 py-1.5 rounded text-sm transition-colors
${
active
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"
}
`}
>
{item.label}
{active && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</Link>
))}
);
})}
</nav>
</header>
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
<main
id="main"
className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10"
>
{children}
</main>
</>

View File

@@ -1,6 +1,11 @@
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge";
import {
PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS,
} from "@/lib/practice-area";
import type { CaseDetail } from "@/lib/api/cases";
function formatDate(iso?: string | null) {
@@ -30,11 +35,22 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
<div className="flex items-start justify-between gap-6 flex-wrap">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
ערר {data?.case_number ?? "—"}
</span>
{data?.status && <StatusBadge status={data.status} />}
{data?.practice_area && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium bg-gold-wash text-gold-deep border-gold/40"
>
{PRACTICE_AREA_LABELS[data.practice_area]}
{data.appeal_subtype && data.appeal_subtype !== "unknown" && (
<> · {APPEAL_SUBTYPE_LABELS[data.appeal_subtype]}</>
)}
</Badge>
)}
</div>
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
{data?.title ?? "טוען…"}
@@ -55,10 +71,6 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
עודכן
</dt>
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
ועדה
</dt>
<dd className="text-ink-soft">{data?.committee_type ?? "—"}</dd>
</dl>
</div>
</CardContent>

View File

@@ -17,6 +17,7 @@ import {
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { StatusBadge } from "@/components/cases/status-badge";
import { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
import type { Case } from "@/lib/api/cases";
function formatDate(iso?: string) {
@@ -59,6 +60,15 @@ const columns: ColumnDef<Case>[] = [
header: "סטטוס",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: "appeal_subtype",
header: "תחום",
cell: ({ row }) => {
const s = row.original.appeal_subtype;
if (!s || s === "unknown") return <span className="text-ink-muted"></span>;
return <span className="text-ink-soft text-sm">{APPEAL_SUBTYPE_LABELS[s]}</span>;
},
},
{
accessorKey: "document_count",
header: "מסמכים",

View File

@@ -2,15 +2,30 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { CaseDetail } from "@/lib/api/cases";
function formatSize(bytes?: number) {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
/*
* Document list for the case detail "מסמכים" tab. Uses the real document
* row shape returned by the FastAPI case_get endpoint — see db.list_documents
* and the `documents` schema in legal_mcp/services/db.py:
* id · case_id · doc_type · title · file_path · extraction_status ·
* page_count · created_at · practice_area · appeal_subtype
*/
const DOC_TYPE_LABELS: Record<string, string> = {
appeal: "כתב ערר",
response: "כתב תשובה",
protocol: "פרוטוקול",
decision: "החלטת ועדה מקומית",
plan: "תכנית",
reference: "חומר רקע",
auto: "—",
};
function doctypeLabel(t: string): string {
return DOC_TYPE_LABELS[t] ?? t;
}
function categoryTone(category?: string | null) {
switch (category) {
function doctypeTone(t: string): string {
switch (t) {
case "appeal": return "bg-info-bg text-info border-info/40";
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
case "decision": return "bg-success-bg text-success border-success/40";
@@ -19,54 +34,83 @@ function categoryTone(category?: string | null) {
}
}
const STATUS_LABELS: Record<string, string> = {
pending: "בהמתנה",
processing: "בעיבוד",
completed: "הושלם",
proofread: "הוגה",
failed: "נכשל",
error: "שגיאה",
};
function formatDate(iso: string) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("he-IL");
} catch {
return iso;
}
}
function filenameFromPath(path: string): string {
const parts = path.split("/");
return parts[parts.length - 1] || path;
}
export function DocumentsPanel({ data }: { data?: CaseDetail }) {
const docs = data?.documents ?? [];
if (docs.length === 0) {
return (
<div className="text-center py-12 text-ink-muted">
<div className="text-gold text-2xl mb-2" aria-hidden></div>
<div className="text-gold text-2xl mb-2" aria-hidden="true"></div>
<p className="text-sm">אין מסמכים בתיק זה</p>
</div>
);
}
return (
<ScrollArea className="max-h-[520px]">
<ul className="divide-y divide-rule">
{docs.map((doc) => (
<ScrollArea className="max-h-[520px]" dir="rtl">
<ul className="divide-y divide-rule" dir="rtl">
{docs.map((doc) => {
const displayName = doc.title || filenameFromPath(doc.file_path);
const statusDone =
doc.extraction_status === "completed" ||
doc.extraction_status === "proofread";
return (
<li
key={doc.id}
className="py-3 flex items-start justify-between gap-4 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded"
className="py-3 flex items-start gap-4 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded"
>
<div className="flex-1 min-w-0 space-y-0.5">
<div className="text-ink font-medium truncate" title={doc.filename}>
{doc.filename}
{/* Title + meta — flex-1 keeps it glued to the start (right in RTL) */}
<div className="flex-1 min-w-0 space-y-0.5 text-right">
<div className="text-ink font-medium truncate" title={displayName}>
{displayName}
</div>
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3">
{doc.size_bytes && (
<span className="tabular-nums">{formatSize(doc.size_bytes)}</span>
<div className="text-[0.72rem] text-ink-muted flex items-center gap-3 flex-wrap">
{doc.page_count != null && (
<span className="tabular-nums">{doc.page_count} עמ׳</span>
)}
{doc.uploaded_at && (
<span>
{new Date(doc.uploaded_at).toLocaleDateString("he-IL")}
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
{!statusDone && doc.extraction_status && (
<span className="text-warn">
{STATUS_LABELS[doc.extraction_status] ?? doc.extraction_status}
</span>
)}
{doc.status && doc.status !== "ready" && (
<span className="text-warn">{doc.status}</span>
)}
</div>
</div>
{doc.category && (
{/* Type badge — ms-auto forces it to the inline-end (= left in RTL) */}
{doc.doc_type && (
<Badge
variant="outline"
className={`rounded-full px-2 py-0.5 text-[0.7rem] ${categoryTone(doc.category)}`}
className={`rounded-full px-2 py-0.5 text-[0.7rem] shrink-0 ms-auto ${doctypeTone(doc.doc_type)}`}
>
{doc.category}
{doctypeLabel(doc.doc_type)}
</Badge>
)}
</li>
))}
);
})}
</ul>
</ScrollArea>
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -3,16 +3,24 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { ChairEditor } from "@/components/compose/chair-editor";
import { PrecedentsSection } from "@/components/compose/precedents-section";
import { Markdown } from "@/components/ui/markdown";
import type { ResearchSubsection } from "@/lib/api/research";
import type { CasePrecedent } from "@/lib/api/precedents";
import type { PracticeArea } from "@/lib/practice-area";
export function SubsectionCard({
caseNumber,
item,
defaultOpen = false,
precedents = [],
practiceArea,
}: {
caseNumber: string;
item: ResearchSubsection;
defaultOpen?: boolean;
precedents?: CasePrecedent[];
practiceArea?: PracticeArea | null;
}) {
const [open, setOpen] = useState(defaultOpen);
const isFilled = Boolean(item.chair_position?.trim());
@@ -69,8 +77,8 @@ export function SubsectionCard({
<dt className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1">
{f.label}
</dt>
<dd className="text-sm text-ink-soft leading-relaxed whitespace-pre-line">
{f.content}
<dd>
<Markdown content={f.content} />
</dd>
</div>
))}
@@ -81,6 +89,18 @@ export function SubsectionCard({
sectionId={item.id}
initialValue={item.chair_position ?? ""}
/>
<div className="border-t border-rule pt-4 mt-2">
<h4 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold mb-2">
פסיקה תומכת
</h4>
<PrecedentsSection
caseNumber={caseNumber}
sectionId={item.id}
precedents={precedents}
practiceArea={practiceArea}
emptyHelperText="עדיין לא צורפה פסיקה לסעיף זה"
/>
</div>
</div>
)}
</article>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -16,9 +16,13 @@ import {
import { PartiesField } from "@/components/wizard/parties-field";
import { useCreateCase } from "@/lib/api/cases";
import {
caseCreateSchema, committeeTypes, expectedOutcomes,
caseCreateSchema, expectedOutcomes,
type CaseCreateInput,
} from "@/lib/schemas/case";
import {
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
type AppealSubtype,
} from "@/lib/practice-area";
const STEPS = [
{ key: "basics", label: "פרטי יסוד" },
@@ -31,7 +35,7 @@ type StepKey = (typeof STEPS)[number]["key"];
/* Fields validated at each step — lets the user fix just what's on screen
* before moving forward, instead of surfacing all errors from page 1. */
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
basics: ["case_number", "title", "committee_type"],
basics: ["case_number", "title", "practice_area", "appeal_subtype"],
parties: ["appellants", "respondents"],
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
};
@@ -57,13 +61,31 @@ export function CaseWizard() {
subject: "",
property_address: "",
permit_number: "",
committee_type: "ועדה מקומית",
hearing_date: "",
notes: "",
expected_outcome: "",
practice_area: "appeals_committee",
appeal_subtype: "unknown",
},
});
/*
* Auto-fill appeal_subtype from the case number as the user types, but
* stop the moment they manually pick a value from the dropdown. Mirrors
* the wireSubtypeAutofill() behaviour of the vanilla UI
* (legal-ai/web/static/index.html around line 2770).
*/
const userTouchedSubtype = useRef(false);
const caseNumber = form.watch("case_number");
const practiceArea = form.watch("practice_area");
useEffect(() => {
if (userTouchedSubtype.current) return;
const derived = deriveSubtype(caseNumber, practiceArea);
if (derived !== form.getValues("appeal_subtype")) {
form.setValue("appeal_subtype", derived, { shouldValidate: false });
}
}, [caseNumber, practiceArea, form]);
const stepIndex = STEPS.findIndex((s) => s.key === step);
const isLast = stepIndex === STEPS.length - 1;
@@ -124,7 +146,7 @@ export function CaseWizard() {
</Label>
<Input
id="case_number"
placeholder="1234 או 8001/2026"
placeholder="1033-25 או 1000-04-26"
{...form.register("case_number")}
className="mt-1 tabular-nums"
/>
@@ -137,25 +159,64 @@ export function CaseWizard() {
<Input id="title" {...form.register("title")} className="mt-1" />
<FieldError message={form.formState.errors.title?.message} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-navy">סוג ועדה</Label>
<Label className="text-navy">תחום משפטי</Label>
<Controller
control={form.control}
name="committee_type"
name="practice_area"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{committeeTypes.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
{PRACTICE_AREAS.map((p) => (
<SelectItem
key={p.value}
value={p.value}
disabled={!p.enabled}
>
{p.label}{!p.enabled && " (בקרוב)"}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div>
<Label className="text-navy">סוג ערר</Label>
<Controller
control={form.control}
name="appeal_subtype"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => {
userTouchedSubtype.current = true;
field.onChange(v as AppealSubtype);
}}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{APPEAL_SUBTYPES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-[0.7rem] text-ink-muted mt-1">
מזוהה אוטומטית ממספר התיק
</p>
</div>
</div>
</div>
)}

View File

@@ -79,8 +79,14 @@ export function PartiesField({
placeholder="שם מלא של הצד"
dir="rtl"
/>
<Button type="button" variant="outline" size="sm" onClick={add}>
<Plus className="w-4 h-4" />
<Button
type="button"
variant="outline"
size="sm"
onClick={add}
aria-label={`הוסף ${label}`}
>
<Plus className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}

View File

@@ -11,6 +11,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
export type CaseStatus =
| "new"
@@ -35,6 +36,9 @@ export type Case = {
expected_outcome?: string | null;
created_at?: string;
updated_at?: string;
/* Multi-tenant axis — populated by backfill + server-side derive */
practice_area?: PracticeArea;
appeal_subtype?: AppealSubtype;
/* Present when loaded with detail=true */
document_count?: number;
processing_count?: number;
@@ -42,15 +46,21 @@ export type Case = {
hearing_date?: string | null;
};
export type CaseDocument = {
id: string;
case_id: string;
doc_type: string;
title: string;
file_path: string;
page_count: number | null;
extraction_status: string;
created_at: string;
practice_area?: PracticeArea;
appeal_subtype?: AppealSubtype;
};
export type CaseDetail = Case & {
documents?: Array<{
id: number | string;
filename: string;
category?: string | null;
status?: string;
uploaded_at?: string;
size_bytes?: number;
}>;
documents?: CaseDocument[];
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
};

View 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": "יא — סיכום",
};

View 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 };
}

View 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,
});
}

View 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,
});
}

View 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() });
},
});
}

View 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";
}

View File

@@ -10,10 +10,18 @@
*/
import { z } from "zod";
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md
* permissive regex that still catches obvious typos. */
const caseNumberRe = /^[1-9]\d{3,}(?:[-/][\w\u0590-\u05FF]+)*$/;
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md.
* Two accepted formats, both hyphen-separated:
* NNNN-YY → "1033-25" (case sequence + 2-digit year)
* NNNN-MM-YY → "1000-04-26" (case sequence + 2-digit month + year)
*
* Slashes are deliberately forbidden: FastAPI path routing can't capture
* a `/` inside a {case_number} segment even when URL-encoded as %2F, so
* any case with a slash becomes unreachable at
* GET /api/cases/{case_number}/details. */
const caseNumberRe = /^[1-9]\d{3}(?:-\d{2}){1,2}$/;
const hebrewPartyRe = /[\u0590-\u05FFA-Za-z]/;
@@ -24,12 +32,6 @@ const dateString = z
message: "תאריך חייב להיות בפורמט YYYY-MM-DD",
});
export const committeeTypes = [
"ועדה מקומית",
"ועדה מחוזית",
"ועדת ערר",
] as const;
export const expectedOutcomes = [
{ value: "", label: "— לא נקבע —" },
{ value: "rejection", label: "דחייה" },
@@ -43,7 +45,7 @@ export const caseCreateSchema = z.object({
.string()
.trim()
.min(1, "שדה חובה")
.regex(caseNumberRe, "מספר תיק לא תקין (למשל 1234 או 8001/2026)"),
.regex(caseNumberRe, "פורמט: NNNN-YY או NNNN-MM-YY (למשל 1033-25 או 1000-04-26)"),
title: z.string().trim().min(3, "כותרת קצרה מדי").max(200, "כותרת ארוכה מדי"),
appellants: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
@@ -54,12 +56,22 @@ export const caseCreateSchema = z.object({
subject: z.string().trim().max(500),
property_address: z.string().trim().max(200),
permit_number: z.string().trim().max(100),
committee_type: z.enum(committeeTypes),
hearing_date: dateString,
notes: z.string().trim().max(2000),
expected_outcome: z.enum(
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
),
practice_area: z.enum([
"appeals_committee",
"national_insurance",
"labor_law",
] as const satisfies readonly PracticeArea[]),
appeal_subtype: z.enum([
"building_permit",
"betterment_levy",
"compensation_197",
"unknown",
] as const satisfies readonly AppealSubtype[]),
});
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;

View File

@@ -2302,6 +2302,133 @@ async def api_reprocess_document(case_number: str, doc_id: str):
return {"status": "reprocessing"}
# ── Chair feedback endpoints ──────────────────────────────────────
@app.get("/api/feedback")
async def api_list_feedback(
case_number: str = "",
category: str = "",
unresolved_only: bool = False,
):
"""List chair feedback, optionally filtered by case/category."""
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
feedbacks = await db.list_chair_feedback(
case_id=case_id,
category=category or None,
unresolved_only=unresolved_only,
)
items = []
# Build case_number lookup
case_numbers: dict[str, str] = {}
pool = await db.get_pool()
for fb in feedbacks:
cid = fb.get("case_id")
cn = ""
if cid and str(cid) not in case_numbers:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT case_number, title FROM cases WHERE id = $1", cid,
)
if row:
case_numbers[str(cid)] = row["case_number"]
if cid:
cn = case_numbers.get(str(cid), "")
items.append({
"id": str(fb["id"]),
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
"case_number": cn,
"block_id": fb["block_id"],
"category": fb["category"],
"feedback_text": fb["feedback_text"],
"lesson_extracted": fb["lesson_extracted"],
"resolved": fb["resolved"],
"applied_to": fb.get("applied_to", []),
"created_at": fb["created_at"].isoformat() if fb.get("created_at") else None,
})
return items
@app.post("/api/feedback")
async def api_create_feedback(
case_number: str = Form(""),
block_id: str = Form("block-yod"),
feedback_text: str = Form(...),
category: str = Form("missing_content"),
lesson_extracted: str = Form(""),
):
"""Record a new chair feedback entry."""
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
valid_categories = [
"missing_content", "wrong_tone", "wrong_structure",
"factual_error", "style", "other",
]
if category not in valid_categories:
raise HTTPException(400, f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
feedback_id = await db.record_chair_feedback(
case_id=case_id,
block_id=block_id,
feedback_text=feedback_text,
category=category,
lesson_extracted=lesson_extracted,
)
return {"id": str(feedback_id), "status": "created"}
@app.post("/api/feedback/json")
async def api_create_feedback_json(body: dict):
"""Record a new chair feedback entry (JSON body)."""
case_number = body.get("case_number", "")
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
valid_categories = [
"missing_content", "wrong_tone", "wrong_structure",
"factual_error", "style", "other",
]
category = body.get("category", "missing_content")
if category not in valid_categories:
raise HTTPException(400, f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
feedback_id = await db.record_chair_feedback(
case_id=case_id,
block_id=body.get("block_id", "block-yod"),
feedback_text=body.get("feedback_text", ""),
category=category,
lesson_extracted=body.get("lesson_extracted", ""),
)
return {"id": str(feedback_id), "status": "created"}
@app.patch("/api/feedback/{feedback_id}/resolve")
async def api_resolve_feedback(feedback_id: str, body: dict):
"""Mark feedback as resolved."""
await db.resolve_chair_feedback(
feedback_id=UUID(feedback_id),
applied_to=body.get("applied_to", []),
)
return {"status": "resolved"}
# ── Background Processing ─────────────────────────────────────────