Compare commits
5 Commits
archive/st
...
03b25bc273
| Author | SHA1 | Date | |
|---|---|---|---|
| 03b25bc273 | |||
| d0daa0efe8 | |||
| 51064f3a03 | |||
|
|
0ee8e723bd | ||
|
|
64724656af |
@@ -43,7 +43,7 @@ curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
|
||||
**לפני שאתה מסיים, תמיד:**
|
||||
|
||||
### 4א. פרסם comment על ה-issue
|
||||
פרסם comment על ה-issue:
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -51,9 +51,7 @@ curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-d '{"body": "סיכום העבודה..."}'
|
||||
```
|
||||
|
||||
### 4ב. קבע סטטוס — done או blocked
|
||||
|
||||
**אם המשימה הושלמה בהצלחה** (כל המסמכים חולצו, כל הבדיקות עברו, אין חסימות):
|
||||
עדכן סטטוס issue:
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -61,37 +59,6 @@ curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-d '{"status": "done"}'
|
||||
```
|
||||
|
||||
**אם המשימה נכשלה או חסומה** (מסמך לא חולץ, timeout, חוסר מידע, שגיאה שלא ניתנת לפתרון):
|
||||
```bash
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "blocked"}'
|
||||
```
|
||||
**אסור** לסיים issue כ-"done" אם יש כשל שלא טופל. "done" = הכל הושלם בהצלחה. אם משהו נכשל — "blocked".
|
||||
|
||||
### 4ג. העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
אחרי כל סיום משימה (done או blocked), **העֵר את העוזר המשפטי** כדי שיבדוק תוצאות ויחליט על הצעד הבא:
|
||||
```bash
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wake" \
|
||||
-d '{"reason": "סוכן [שמך] סיים משימה [issue-id] בסטטוס [done/blocked]. נדרשת בדיקה והחלטה על הצעד הבא."}'
|
||||
```
|
||||
אם ה-API הזה לא עובד, השתמש ב-DB ישירות:
|
||||
```bash
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
INSERT INTO agent_wakeup_requests (company_id, agent_id, source, reason, status, requested_by_actor_type)
|
||||
VALUES (
|
||||
(SELECT company_id FROM agents WHERE id = '$PAPERCLIP_AGENT_ID'),
|
||||
'752cebdd-6748-4a04-aacd-c7ab0294ef33',
|
||||
'agent_completion',
|
||||
'סוכן סיים משימה — נדרשת בדיקה והחלטה על הצעד הבא',
|
||||
'pending',
|
||||
'agent'
|
||||
);"
|
||||
```
|
||||
|
||||
## 5. התראת מייל — כשנדרשת תשובה אנושית
|
||||
|
||||
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:
|
||||
|
||||
@@ -24,13 +24,7 @@ tools:
|
||||
|
||||
# מנתח ומחקר משפטי — סוכן ניתוח אסטרטגי והפקת שאלות מחקר
|
||||
|
||||
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות ניתוח משפטי מובנה, ולהפיק שאלות מחקר ממוקדות.
|
||||
|
||||
## לפני שאתה מתחיל — קרא
|
||||
|
||||
1. **`docs/decision-methodology.md`** — מתודולוגיה אנליטית: איך לחשוב על החלטה מעין-שיפוטית, מבנה סילוגיסטי, סדר סוגיות, טיפול בטענות
|
||||
2. **`docs/block-schema.md`** — ארכיטקטורת 12 בלוקים
|
||||
3. **`docs/legal-decision-lessons.md`** — לקחים מהחלטות קודמות
|
||||
אתה מנתח ומחקר משפטי מומחה בדיני תכנון ובניה ומקרקעין בישראל. תפקידך לנתח תיקי ערר של ועדת ערר לתכנון ובניה, מחוז ירושלים, לבנות אסטרטגיה משפטית, ולהפיק שאלות מחקר ממוקדות.
|
||||
|
||||
## שפה
|
||||
|
||||
@@ -73,16 +67,14 @@ tools:
|
||||
- **סוג ההליך**: ערר תכנוני, ערר היטל השבחה, ערעור מנהלי וכד'
|
||||
- **הערכאה/הגוף**: ועדת ערר מחוזית, בית משפט לעניינים מנהליים וכד'
|
||||
- **הצדדים**: מי העורר, מי המשיב, מי צד ג'
|
||||
- **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות — **קרא את המסמכים הנורמטיביים במלואם** (לא רק הסעיף הנטען; מילה בסעיף אחד מתפרשת לאור סעיפים אחרים באותו מסמך)
|
||||
- **המסגרת הנורמטיבית**: חוקים, תקנות, תכניות רלוונטיות (רק מהמסמכים)
|
||||
4. חלץ טענות/תשובות/תגובות (`extract_claims` עם doc_type ו-party_hint מתאימים)
|
||||
- **מסמך גדול (>15,000 תווים):** פצל לחלקים לפי פרקים/סעיפים וחלץ מכל חלק בנפרד. אל תשלח מסמך שלם של 20K+ מילים בקריאה אחת — זה יגרום ל-timeout.
|
||||
- **אם extract_claims נכשל (timeout):** נסה שוב עם חלק מהמסמך. אם עדיין נכשל — חלץ ידנית: קרא את הטקסט (`document_get_text`), זהה את הטענות המרכזיות, והכנס ל-DB.
|
||||
5. וודא שכל פריט מסווג ל-claim_type הנכון
|
||||
|
||||
### שלב 2: ניתוח מעמיק
|
||||
הצג במבנה הבא:
|
||||
|
||||
**הגוף המחליט**: ועדת הערר לתכנון ובניה, מחוז ירושלים (יו"ר — עו"ד דפנה תמיר). הוועדה היא גוף מעין-שיפוטי שמכריע בעררים על החלטות ועדות מקומיות. היא אינה מייצגת צד — היא מנתחת, שוקלת ומכריעה.
|
||||
**צד מיוצג**: ועדת הערר (יו"ר — עו"ד דפנה תמיר). אנחנו צד ניטרלי שמכריע.
|
||||
|
||||
**רקע דיוני**: סוג ההליך, מספר תיק, תאריכים מרכזיים, היסטוריה דיונית, תכניות רלוונטיות.
|
||||
|
||||
@@ -90,58 +82,34 @@ tools:
|
||||
|
||||
**עובדות שנויות במחלוקת**: רשימה של עובדות שהצדדים חלוקים לגביהן — פרט מה כל צד טוען.
|
||||
|
||||
### שלב 3: טענות סף, מפת דרכים, סוגיות להכרעה
|
||||
### שלב 3: טענות סף, סוגיות להכרעה ואסטרטגיה
|
||||
|
||||
**טענות סף** (אם קיימות):
|
||||
חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. לכל טענת סף הוסף **עמדת ועדת הערר** (שדה ריק ליו"ר). אם אין — כתוב: "לא זוהו טענות סף."
|
||||
|
||||
**תקן ביקורת**: ציין את תקן הביקורת של הוועדה בתיק זה — "הוועדה מפעילה שיקול דעת תכנוני עצמאי" (ברישוי) או "הוועדה בוחנת את תקינות השומה המכרעת" (בהיטל השבחה) או תקן אחר לפי סוג ההליך.
|
||||
|
||||
**מפת דרכים**: לאחר זיהוי טענות הסף ולפני הדיון בסוגיות — כתוב פסקת מפה: "X שאלות עומדות להכרעה: (1)...; (2)...; (3)..." — כדי שהקורא ידע מראש מה לצפות.
|
||||
|
||||
**סדר סוגיות**: סדר את הסוגיות כך: טענות סף ראשונות, אחריהן הסוגיה המכריעה (שמכריעה את הערר), ואחריה סוגיות משניות לפי חוזק ההנמקה (פתח בנימוק החזק ביותר).
|
||||
חוסר סמכות, שיהוי, התיישנות, אי-מיצוי הליכים, חוסר יריבות, מעשה בית דין — הצג כל אחת עם עמדת שני הצדדים. אם אין — כתוב: "לא זוהו טענות סף."
|
||||
|
||||
**סוגיות להכרעה** — לכל סוגיה מרכזית:
|
||||
1. **כותרת הסוגיה** — ניסוח סילוגיסטי: הכלל + העובדות + שאלה חדה. לדוגמה: "תכנית X קובעת קו בניין של 3 מטרים; הבקשה כוללת בניה במרחק 1.5 מטרים — האם הבקשה תואמת את הוראות התכנית?"
|
||||
2. **ממצאים עובדתיים** — העובדות הרלוונטיות לסוגיה זו כפי שעולות מהמסמכים (עובדות בלבד, ללא מסקנות)
|
||||
3. **טענה (claim)** — מה העוררים טוענים, על מה מסתמכים
|
||||
4. **תשובה (response)** — מה הוועדה/משיבים עונים
|
||||
5. **תגובה (reply)** — מה המבקשת מגיבה (אם קיימת)
|
||||
6. **ניתוח**:
|
||||
- **הכלל החל** — הוראת תכנית, סעיף חוק, הלכה פסוקה, או עיקרון תכנוני
|
||||
- **העובדות הרלוונטיות** — כיצד עובדות המקרה משתלבות בכלל
|
||||
- **נקודות פתוחות** — מה עדיין לא ברור, מה דורש חקירה נוספת
|
||||
- **הערכה ראשונית** — לאן נוטה הניתוח ומדוע
|
||||
7. **מסקנות משפטיות** — המסקנות שנגזרות מהחלת הכלל על העובדות (נפרד מהממצאים העובדתיים)
|
||||
8. **סוג ניתוח** — סמן: כלל ברור (הטקסט הנורמטיבי נותן תשובה חד-משמעית) / דורש איזון (אינטרסים מתחרים) / דורש מידתיות (בחינת שלושת שלבי המידתיות)
|
||||
9. **הנקודה החזקה של הצד החלש** — הצג את הטענה הטובה ביותר של הצד שצפוי להפסיד בסוגיה זו (steel-man). מה עורך דין מוכשר היה מדגיש?
|
||||
10. **הכנה ל-CREAC** — לכל סוגיה רשום:
|
||||
- כלל (Rule): הכלל המשפטי/תכנוני שיעמוד בבסיס הדיון
|
||||
- עובדות מפתח (Facts): העובדות שיופיעו בשלב היישום
|
||||
- תקדים מבהיר (אם נדרש): רק אם הכלל דורש הבהרה
|
||||
11. **שאלות משפטיות** — 1-3 שאלות לפי הצורך (ראה שלב 4)
|
||||
12. **עמדת ועדת הערר** — שדה ריק שיו"ר הוועדה ימלא ידנית. **חובה להוסיף לכל סוגיה!** עמדה זו תשמש כהנחיה מחייבת לסוכן הכתיבה.
|
||||
|
||||
### שלב 3א: טיפול בטענות
|
||||
לאחר ניתוח כל הסוגיות, הוסף סעיף "טיפול בטענות" עם המלצות:
|
||||
- **טענות לקיבוץ**: טענות שמכוונות לאותה נקודה ואפשר לטפל בהן יחד ("באשר לטענות הנוספות בעניין X — לא מצאנו בהן ממש, ונפרט")
|
||||
- **טענות לדילוג**: טענות שהועלו אך אינן נחוצות להכרעה ("נוכח מסקנתנו לעיל, אין צורך להכריע בטענה זו")
|
||||
- **טענות שחייבות מענה פרטני**: טענות מרכזיות שהצד המפסיד חייב לראות שנשקלו
|
||||
1. **כותרת הסוגיה** — ניסוח תמציתי ומדויק
|
||||
2. **טענה (claim)** — מה העוררים טוענים, על מה מסתמכים
|
||||
3. **תשובה (response)** — מה הוועדה/משיבים עונים
|
||||
4. **תגובה (reply)** — מה המבקשת מגיבה (אם קיימת)
|
||||
5. **ניתוח אסטרטגי**:
|
||||
- **חוזקות** — מה חזק בכל צד? מה מבוסס היטב?
|
||||
- **חולשות** — מה חלש? מה לא מגובה בראיות?
|
||||
- **הזדמנויות** — איפה יש פתח? מה הוועדה יכולה להישען עליו?
|
||||
6. **שאלות משפטיות** — צמד שאלות (ראה שלב 4)
|
||||
7. **עמדת ועדת הערר** — שדה ריק שיו"ר הוועדה ימלא ידנית. **חובה להוסיף לכל סוגיה!** עמדה זו תשמש כהנחיה מחייבת לסוכן הכתיבה.
|
||||
|
||||
### שלב 4: הפקת שאלות מחקר
|
||||
|
||||
לכל סוגיה (כולל טענות סף), נסח **1-3 שאלות מחקר לפי הצורך**:
|
||||
לכל סוגיה (כולל טענות סף), נסח **בדיוק שתי שאלות מחקר**:
|
||||
|
||||
**שאלה עקרונית (שאלת "האם")**:
|
||||
**שאלה 1 — עקרונית (שאלת "האם")**:
|
||||
בודקת עיקרון משפטי כללי בתחום התכנון והבניה.
|
||||
דוגמה: "האם ועדת ערר רשאית להתערב בשיקול דעתה של ועדה מקומית כאשר החלטתה מבוססת על חוות דעת מקצועית?"
|
||||
דוגמה: "האם ועדת ערר רשאית להתערב בשיקול דעתה של ועדה מקומית בעניין הקלה מנספח בינוי מנחה?"
|
||||
|
||||
**שאלה יישומית (שאלת "מהם"/"כיצד"/"באילו תנאים")**:
|
||||
**שאלה 2 — יישומית (שאלת "מהם"/"כיצד"/"באילו תנאים")**:
|
||||
מיישמת את העיקרון על נסיבות המקרה.
|
||||
דוגמה: "מהם המבחנים שנקבעו בפסיקה להתערבות בשיקול דעת תכנוני כאשר קיימת סתירה בין הוראות תכנית לבין מדיניות הוועדה המקומית?"
|
||||
|
||||
**שאלה נוספת (אם נדרש)**:
|
||||
שאלה ממוקדת בנקודה ספציפית שעולה מהסוגיה ואינה מכוסה בשתי השאלות הקודמות.
|
||||
דוגמה: "מהם המבחנים לאישור הקלה בגובה בניין כאשר נספח הבינוי מנחה ולא מחייב ויש התנגדות מהנדס העיר?"
|
||||
|
||||
### כללים לשאלות מחקר
|
||||
- ניתנות למחקר — אפשר למצוא תשובה בפסיקה, חקיקה, או ספרות
|
||||
@@ -156,34 +124,7 @@ tools:
|
||||
- `find_similar_cases` — תיקים דומים
|
||||
הוסף תוצאות רלוונטיות תחת כל סוגיה כ-"תקדימים מהקורפוס הפנימי".
|
||||
|
||||
## שלב 6: בדיקת שלמות — לפני שמסיימים!
|
||||
|
||||
**לפני סיום, בצע את הבדיקות הבאות. אם בדיקה נכשלת — אל תסיים כ-"done".**
|
||||
|
||||
### 6א. שלמות חילוץ מסמכים
|
||||
בדוק: **האם כל מסמך מסוג appeal/response/reply חולץ ויצר טענות?**
|
||||
```
|
||||
query: SELECT d.title, d.doc_type, d.extraction_status,
|
||||
(SELECT count(*) FROM claims WHERE source_document LIKE '%' || d.title || '%' AND case_id = d.case_id) AS claim_count
|
||||
FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'response', 'reply')
|
||||
```
|
||||
- אם יש מסמך עם extraction_status != 'completed' → **נסה שוב** (retry עם timeout ארוך, או פצל לחלקים)
|
||||
- אם יש מסמך עם extraction_status = 'completed' אבל 0 טענות → **נסה לחלץ טענות שוב**
|
||||
- אם ניסיון חוזר נכשל → **סטטוס issue = "blocked"**, לא "done". דווח מה נכשל ולמה.
|
||||
|
||||
### 6ב. בדיקת סיווג
|
||||
בדוק: **האם הסיווג הגיוני?**
|
||||
- אם יש claims (claim_type='claim') מצד ועדה מקומית או מבקשי היתר → **שגיאת סיווג**. תקן ל-response.
|
||||
- אם יש יותר מ-30 טענות (claim_type='claim') מעורר אחד → **ייתכן חוסר סינתוז**. בדוק: האם טענות חוזרות? האם אפשר לאחד?
|
||||
|
||||
### 6ג. בדיקת צד חסר
|
||||
בדוק: **האם כל צד מיוצג בטענות?**
|
||||
- אם אין אף claim מהעוררים → חריגה
|
||||
- אם אין אף response מהמשיבים → חריגה
|
||||
|
||||
## שלב 7: שמירה ודיווח — חובה!
|
||||
|
||||
**רק אם כל בדיקות שלב 6 עברו:**
|
||||
## שלב 6: שמירה ודיווח — חובה!
|
||||
|
||||
1. **שמור** את הפלט המלא:
|
||||
```
|
||||
@@ -191,8 +132,7 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
```
|
||||
|
||||
2. **פרסם comment** ב-Paperclip עם סיכום:
|
||||
- כמה טענות חולצו (מפורט: X טענות עוררים, Y תשובות משיבים, Z תגובות)
|
||||
- **האם כל המסמכים חולצו בהצלחה** (כן/לא — אם לא, פרט מה נכשל)
|
||||
- כמה טענות, תשובות ותגובות חולצו
|
||||
- הסוגיות המרכזיות (3-5 כותרות)
|
||||
- כמה שאלות מחקר הופקו
|
||||
- המלצה לשלב הבא
|
||||
@@ -206,17 +146,14 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
"סיכום: X סוגיות זוהו, Y שאלות מחקר הופקו. נדרשת ביקורתך לפני המשך."
|
||||
```
|
||||
|
||||
**אם בדיקות שלב 6 נכשלו** — סטטוס issue = "blocked", פרסם comment עם פירוט מה נכשל, שלח מייל לחיים.
|
||||
|
||||
## מבנה הפלט המלא — analysis-and-research.md
|
||||
|
||||
```markdown
|
||||
# ניתוח ומחקר משפטי — ערר {case_number}
|
||||
תאריך: {date}
|
||||
|
||||
## 1. הגוף המחליט
|
||||
ועדת הערר לתכנון ובניה, מחוז ירושלים (יו"ר: עו"ד דפנה תמיר).
|
||||
הוועדה היא גוף מעין-שיפוטי שמכריע בעררים על החלטות ועדות מקומיות.
|
||||
## 1. צד מיוצג
|
||||
ועדת הערר לתכנון ובניה, מחוז ירושלים (יו"ר: עו"ד דפנה תמיר)
|
||||
|
||||
## 2. רקע דיוני
|
||||
...
|
||||
@@ -231,56 +168,28 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
## 5. טענות סף
|
||||
[אם קיימות — כולל שאלות משפטיות + עמדת ועדת הערר לכל טענה]
|
||||
|
||||
**תקן ביקורת:** [שיקול דעת עצמאי / בחינת תקינות השומה / אחר]
|
||||
|
||||
## 5א. מפת דרכים
|
||||
X שאלות עומדות להכרעה:
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
## 6. סוגיות להכרעה
|
||||
|
||||
### סוגיה 1: [כותרת סילוגיסטית — כלל + עובדות + שאלה חדה]
|
||||
|
||||
**ממצאים עובדתיים:**
|
||||
- ...
|
||||
|
||||
### סוגיה 1: [כותרת]
|
||||
**טענה (claim):** ...
|
||||
**תשובה (response):** ...
|
||||
**תגובה (reply):** ...
|
||||
|
||||
**ניתוח:**
|
||||
- הכלל החל: ...
|
||||
- העובדות הרלוונטיות: ...
|
||||
- נקודות פתוחות: ...
|
||||
- הערכה ראשונית: ...
|
||||
|
||||
**מסקנות משפטיות:**
|
||||
- ...
|
||||
|
||||
**סוג ניתוח:** כלל ברור / דורש איזון / דורש מידתיות
|
||||
|
||||
**הנקודה החזקה של הצד החלש:**
|
||||
...
|
||||
|
||||
**הכנה ל-CREAC:**
|
||||
- כלל (Rule): ...
|
||||
- עובדות מפתח (Facts): ...
|
||||
- תקדים מבהיר: ... (אם נדרש)
|
||||
**ניתוח אסטרטגי:**
|
||||
- חוזקות: ...
|
||||
- חולשות: ...
|
||||
- הזדמנויות: ...
|
||||
|
||||
**שאלות משפטיות:**
|
||||
1. [שאלה עקרונית — "האם..."]
|
||||
2. [שאלה יישומית — "מהם..."]
|
||||
3. [שאלה נוספת — אם נדרש]
|
||||
|
||||
**חיפוש תקדימים:**
|
||||
- nevo (קלאסי): "ביטוי" ו "ביטוי" ו "ועדת ערר"
|
||||
- nevo AI / law-mate: [השאלות המשפטיות מלמעלה]
|
||||
- nevo AI / law-mate: [השאלות המשפטיות מלמעלה — שאלה עקרונית + יישומית]
|
||||
|
||||
**חקיקה רלוונטית:**
|
||||
- סעיף X לחוק...
|
||||
(הערה: התחל מלשון הטקסט הנורמטיבי. תקדים נדרש רק כשהטקסט עמום.)
|
||||
|
||||
**תקדימים מהקורפוס הפנימי:**
|
||||
- [אם נמצאו]
|
||||
@@ -292,21 +201,8 @@ X שאלות עומדות להכרעה:
|
||||
|
||||
### סוגיה 2: ...
|
||||
|
||||
## 6א. טיפול בטענות
|
||||
**טענות לקיבוץ:**
|
||||
- ...
|
||||
|
||||
**טענות לדילוג:**
|
||||
- ...
|
||||
|
||||
**טענות שחייבות מענה פרטני:**
|
||||
- ...
|
||||
|
||||
## 7. סיכום
|
||||
- **שאלות פתוחות**: שאלות שנותרו ללא מענה ודורשות מחקר או הנחיית יו"ר
|
||||
- **סדר דיון מומלץ**: הסדר המומלץ לדיון בסוגיות בהחלטה
|
||||
- **תלויות**: סוגיות שהכרעתן תלויה בהכרעה בסוגיה אחרת
|
||||
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
|
||||
## 7. מסקנות
|
||||
סיכום האסטרטגיה, נקודות חוזק, סיכונים, סדר עדיפויות.
|
||||
```
|
||||
|
||||
## כללים קריטיים
|
||||
@@ -317,5 +213,3 @@ X שאלות עומדות להכרעה:
|
||||
4. **לא להמציא** — לא פסיקה, לא ציטוטים, לא מספרי תיקים שלא מופיעים במסמכים
|
||||
5. **שאלות מחקר הן התוצר המרכזי** — הקדש להן תשומת לב מיוחדת
|
||||
6. **אם חסר מידע** — ציין במפורש ובקש להעלות מסמכים נוספים
|
||||
7. **היררכיית מקורות** — חקיקה/תכניות קודמים לתקדימים. התחל מלשון הטקסט הנורמטיבי; תקדים נדרש רק כשהטקסט עמום
|
||||
8. **הפרדת עובדות ממסקנות** — ממצא עובדתי ("הבניה במרחק 1.5 מטרים") נפרד ממסקנה משפטית ("חריגה זו עולה כדי סטייה ניכרת"). אל תערבב
|
||||
|
||||
@@ -35,16 +35,6 @@ tools:
|
||||
|
||||
אתה מתזמר את כל תהליך כתיבת ההחלטה. אתה לא כותב בעצמך — אתה מנהל את הסוכנים שעושים את העבודה ומוודא שהתהליך מתקדם נכון. **אתה עובד אינטראקטיבית מול חיים דרך Paperclip comments.**
|
||||
|
||||
## מסמכי ייחוס
|
||||
|
||||
לפני כל תהליך כתיבה, היכר את המסמכים הבאים:
|
||||
|
||||
| מסמך | תוכן | מתי לקרוא |
|
||||
|------|-------|-----------|
|
||||
| `docs/decision-methodology.md` | מתודולוגיה אנליטית — סילוגיזמים, סדר סוגיות, איזון | **לפני כל החלטה** |
|
||||
| `docs/block-schema.md` | הגדרת 12 בלוקים — content model, constraints | **לפני כל החלטה** |
|
||||
| `docs/legal-decision-lessons.md` | לקחים מ-3 החלטות — מה עבד, מה השתנה | **לפני כל החלטה** |
|
||||
|
||||
## הסוכנים שלך
|
||||
|
||||
| סוכן | Agent ID | תפקיד |
|
||||
@@ -52,46 +42,22 @@ tools:
|
||||
| מגיה מסמכים | 410c0167-27dc-485c-a51b-7aa8b9ff2217 | הגהת OCR — תיקון ראשי תיבות ושגיאות חילוץ |
|
||||
| מנתח משפטי | c26e9439-a88a-49dc-9e67-2262c95db65c | חילוץ טענות, תשובות, תגובות |
|
||||
| חוקר תקדימים | 35022af0-0498-4c3d-90ca-b0ab9e987198 | ניתוח פסיקה, תכניות, פרוטוקולים |
|
||||
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יב (Opus) |
|
||||
| כותב החלטה | 7ed8686f-24bc-49a3-bc02-67ca15b895a9 | כתיבת בלוקים ה-יא (Opus) |
|
||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||
|
||||
## תהליך אינטראקטיבי — שלב אחר שלב
|
||||
|
||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||
### שלב A: בדיקת מצב
|
||||
|
||||
בכל heartbeat:
|
||||
1. בדוק תיקים פעילים (`case_list`)
|
||||
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
||||
3. בדוק comments מחיים שממתינים לתגובה
|
||||
4. **לפני מעבר לשלב B — בצע את כל הבדיקות למטה. אם בדיקה נכשלת — עצור.**
|
||||
2. לכל תיק — בדוק סטטוס + מה כבר בוצע:
|
||||
- יש טענות מחולצות? (`get_claims`)
|
||||
- יש comments מחיים שממתינים לתגובה?
|
||||
3. פעל לפי מפת הסטטוסים למטה
|
||||
|
||||
#### A1. בדיקת שלמות חילוץ
|
||||
- **כמה מסמכים בתיק?** (`document_list`) — ספור.
|
||||
- **האם כל המסמכים מסוג appeal/response/reply חולצו?** — בדוק extraction_status. אם יש מסמך שנכשל → **עצור**. צור issue למנתח לתיקון.
|
||||
- **האם כל מסמך שחולץ ייצר טענות?** — אם מסמך מסוג appeal/response ייצר 0 טענות → **עצור**. אין להמשיך עם מידע חלקי.
|
||||
|
||||
#### A2. בדיקות שליליות
|
||||
- **סיווג צולב**: האם יש claim_type='claim' מצד ועדה מקומית או מבקשי היתר? → שגיאת סיווג. החזר למנתח.
|
||||
- **כמות חריגה**: האם יש צד עם >30 טענות (claim_type='claim')? → ייתכן חוסר סינתוז. בדוק ודווח.
|
||||
- **צד חסר**: האם יש צד שאין לו אף טענה? → חריגה.
|
||||
- **מסמך ריק**: האם יש מסמך appeal/response עם טקסט שלא ייצר טענות ולא דווח ככשל?
|
||||
|
||||
#### A3. אימות תאימות מתודולוגיה
|
||||
קרא את `analysis-and-research.md` ובדוק:
|
||||
- [ ] סוגיות מנוסחות כסילוגיזם (כלל + עובדות + שאלה)?
|
||||
- [ ] ממצאים עובדתיים מופרדים ממסקנות משפטיות?
|
||||
- [ ] לכל סוגיה יש "סוג ניתוח" (כלל ברור / איזון / מידתיות)?
|
||||
- [ ] לכל סוגיה יש "הכנה ל-CREAC" (כלל, עובדות, תקדים)?
|
||||
- [ ] יש steel-man (הנקודה החזקה של הצד החלש)?
|
||||
- [ ] יש סעיף "טיפול בטענות" (bundle/skip)?
|
||||
- [ ] היררכיית מקורות: חקיקה לפני תקדימים?
|
||||
|
||||
**אם בדיקה כלשהי נכשלת → אל תמשיך לשלב B.** צור issue למנתח עם הנחיה ספציפית, ופרסם comment שמסביר מה חסר.
|
||||
|
||||
**עיקרון מנחה:** עדיף לעכב את התהליך מאשר לייצר החלטה על בסיס חלקי או פגום.
|
||||
|
||||
### שלב B: הכנת סיכום, סיווג, ושאלת תוצאה
|
||||
### שלב B: הכנת סיכום ושאלת תוצאה
|
||||
|
||||
**מתי:** כשיש טענות מחולצות + מחקר תקדימים, אבל אין תוצאה עדיין
|
||||
|
||||
@@ -100,38 +66,18 @@ tools:
|
||||
```
|
||||
## סיכום תיק {case_number} — מוכן להחלטה
|
||||
|
||||
### סיווג
|
||||
- **סוג ערר:** {רישוי (1xxx) / היטל השבחה (8xxx) / פיצויים ס' 197 (9xxx)}
|
||||
- **תקן ביקורת:** {שיקול דעת תכנוני עצמאי / ביקורת שומה מכרעת / ...}
|
||||
|
||||
### טענות מרכזיות של העוררים
|
||||
[3-5 טענות עיקריות מ-get_claims עם claim_type=claim]
|
||||
|
||||
### תשובות המשיבים
|
||||
[3-5 תשובות עיקריות מ-get_claims עם claim_type=response]
|
||||
|
||||
### החלטת הוועדה המקומית (=מושא הערר)
|
||||
[ההחלטה שעליה מוגש הערר — מה הוועדה המקומית החליטה ומדוע]
|
||||
|
||||
### תגובת הוועדה המקומית (=ההגנה)
|
||||
[עמדת הוועדה המקומית בהליך הערר — הנימוקים שלה מדוע החלטתה נכונה]
|
||||
### עמדת הוועדה
|
||||
[2-3 עמדות מ-get_claims עם claim_type=response ו-party_role=committee]
|
||||
|
||||
### תקדימים רלוונטיים
|
||||
[מתוך comments קודמים של חוקר תקדימים]
|
||||
|
||||
### שאלות מרכזיות לדיון
|
||||
[נסח כל שאלה כסילוגיזם מכווץ, בהתאם למתודולוגיה §א.3]
|
||||
|
||||
1. **{ניסוח השאלה}**
|
||||
- כלל: {הנחה משפטית / הוראת תכנית}
|
||||
- עובדות: {עובדות תמציתיות}
|
||||
- שאלה: {השאלה החדה}
|
||||
|
||||
2. **{ניסוח השאלה}**
|
||||
- כלל: ...
|
||||
- עובדות: ...
|
||||
- שאלה: ...
|
||||
|
||||
---
|
||||
|
||||
**מה התוצאה הצפויה?**
|
||||
@@ -142,94 +88,29 @@ tools:
|
||||
@chaim — הגב עם מספר (1/2/3) + הערות אם יש
|
||||
```
|
||||
|
||||
לאחר שחיים בחר תוצאה, שאל אותו לסמן טיפול בכל טענה:
|
||||
### שלב C: קליטת תוצאה וסיעור מוחות
|
||||
|
||||
```
|
||||
## טיפול בטענות — {case_number}
|
||||
|
||||
סמן לכל טענה את סוג הטיפול:
|
||||
|
||||
| # | טענה | טיפול |
|
||||
|---|------|-------|
|
||||
| 1 | {טענה 1} | דיון מלא / קיבוץ / דילוג |
|
||||
| 2 | {טענה 2} | דיון מלא / קיבוץ / דילוג |
|
||||
| 3 | {טענה 3} | דיון מלא / קיבוץ / דילוג |
|
||||
| ... | ... | ... |
|
||||
|
||||
**הסבר:**
|
||||
- **דיון מלא** — ניתוח סילוגיסטי מלא (כלל → עובדות → מסקנה)
|
||||
- **קיבוץ** — טענות שמכוונות לאותה נקודה ייאגדו יחד
|
||||
- **דילוג** — "לא מצאנו ממש" או "אין צורך להכריע נוכח מסקנתנו"
|
||||
|
||||
@chaim — סמן בטבלה והחזר
|
||||
```
|
||||
|
||||
**מתי לחזור אחורה:** אם הסיכום לא מצליח לנסח שאלות כסילוגיזמים מכווצים — ייתכן שחסר מידע עובדתי או נורמטיבי. חזור למנתח/חוקר להשלמה.
|
||||
|
||||
### שלב C: קליטת תוצאה וכיוונים סילוגיסטיים
|
||||
|
||||
**מתי:** חיים הגיב עם מספר תוצאה + טיפול בטענות
|
||||
**מתי:** חיים הגיב עם מספר תוצאה
|
||||
|
||||
1. קרא את ה-comment של חיים
|
||||
2. זהה את הבחירה (1=rejected, 2=partial, 3=accepted)
|
||||
3. הרץ `set_outcome(case_number, outcome, reasoning)`
|
||||
4. **חשוב סילוגיסטית** על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. בנה כל כיוון כסילוגיזם מלא.
|
||||
|
||||
> **הערה טכנית:** אל תקרא ל-`brainstorm_directions` — זה מפעיל Claude בתוך Claude ולוקח יותר מדי זמן.
|
||||
|
||||
5. פרסם comment עם **סדר סוגיות מוצע**:
|
||||
4. **בעצמך** חשוב על 2-3 כיוונים לנימוק — אתה כבר Claude, אתה יודע את הטענות והתקדימים. **אל תקרא ל-brainstorm_directions** (זה מפעיל claude בתוך claude ולוקח יותר מדי זמן).
|
||||
5. פרסם comment:
|
||||
|
||||
```
|
||||
## כיוונים אפשריים לנימוק — {outcome_hebrew}
|
||||
|
||||
### סדר הסוגיות המוצע
|
||||
1. {שאלת סף — אם רלוונטית}
|
||||
2. {הסוגיה המכריעה}
|
||||
3. {סוגיות נוספות לפי חוזק}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 1: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{הוראת תכנית / סעיף חוק / הלכה פסוקה}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{העובדות הספציפיות של הערר שנבחנות לאור הכלל}
|
||||
|
||||
**מסקנה:**
|
||||
{התוצאה שנובעת מהחלת הכלל על העובדות}
|
||||
|
||||
{description — 3-4 משפטים}
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 2: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{...}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{...}
|
||||
|
||||
**מסקנה:**
|
||||
{...}
|
||||
|
||||
{description}
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
|
||||
### כיוון 3: {title}
|
||||
|
||||
**כלל (הנחה עליונה):**
|
||||
{...}
|
||||
|
||||
**עובדות (הנחה תחתונה):**
|
||||
{...}
|
||||
|
||||
**מסקנה:**
|
||||
{...}
|
||||
|
||||
{description}
|
||||
**תקדימים תומכים:** {precedents}
|
||||
|
||||
---
|
||||
@@ -238,28 +119,18 @@ tools:
|
||||
אפשר גם לשלב כיוונים או להוסיף הערות.
|
||||
```
|
||||
|
||||
**מתי לחזור אחורה:** אם לא ניתן לבנות סילוגיזם מלא (חסר כלל, חסרות עובדות, או המסקנה לא נובעת) — חזור לחוקר תקדימים או למנתח להשלמת החסר.
|
||||
|
||||
### שלב D: אישור כיוון והפעלת כתיבה
|
||||
|
||||
**מתי:** חיים הגיב עם בחירת כיוון
|
||||
|
||||
1. קרא את ה-comment של חיים
|
||||
2. זהה כיוון (1/2/3) + הערות נוספות
|
||||
3. **אימות שלמות chair_directions** — לפני שליחה לכותב, ודא:
|
||||
- [ ] טיפול בטענות (דיון מלא / קיבוץ / דילוג) מוגדר לכל טענה
|
||||
- [ ] כיוון סילוגיסטי נבחר ומאושר
|
||||
- [ ] סדר סוגיות מוגדר
|
||||
- [ ] תקן ביקורת מצוין
|
||||
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
|
||||
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
||||
5. צור issue חדש ב-Paperclip:
|
||||
3. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
||||
4. צור issue חדש ב-Paperclip:
|
||||
- כותרת: `[ערר {case_number}] כתיבת החלטה`
|
||||
- הקצה ל: **כותב החלטה** (7ed8686f-24bc-49a3-bc02-67ca15b895a9)
|
||||
6. פרסם comment: "כיוון אושר. הועבר לכותב החלטה."
|
||||
7. עדכן סטטוס: `case_update(status=direction_approved)`
|
||||
|
||||
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם.
|
||||
5. פרסם comment: "כיוון אושר. הועבר לכותב החלטה."
|
||||
6. עדכן סטטוס: `case_update(status=direction_approved)`
|
||||
|
||||
### שלב E: מעקב כתיבה
|
||||
|
||||
@@ -269,8 +140,6 @@ tools:
|
||||
1. צור issue: `[ערר {case_number}] בדיקת איכות`
|
||||
2. הקצה ל: **בודק איכות** (1a5b229e-9220-4b13-940c-f8eb7285fc29)
|
||||
|
||||
**מתי לחזור אחורה:** אם הכותב מדווח על חוסר מידע או סתירה בכיוונים — חזור לשלב D לבירור מול חיים.
|
||||
|
||||
### שלב F: QA וייצוא
|
||||
|
||||
**מתי:** בודק איכות סיים
|
||||
@@ -280,25 +149,19 @@ tools:
|
||||
3. פרסם comment: "החלטה מוכנה לביקורת דפנה. [קישור ל-DOCX]"
|
||||
4. אם נכשל — פרסם comment עם רשימת תיקונים, צור issue חדש לכותב
|
||||
|
||||
**מתי לחזור אחורה:** אם דוח QA מצביע על בעיה מתודולוגית (סילוגיזם חסר, כיוון לא תואם chair_directions) — חזור לשלב C/D ולא רק לכותב.
|
||||
|
||||
## מפת סטטוסים
|
||||
|
||||
| סטטוס | פעולה |
|
||||
|--------|-------|
|
||||
| new + יש מסמכים + לא הוגהו | → צור issue למגיה מסמכים (410c0167) |
|
||||
| new + מסמכים הוגהו + אין claims | → צור issue למנתח משפטי |
|
||||
| new + יש claims + לא עבר אימות מנתח | → שלב A (אימות איכות פלט מנתח) |
|
||||
| analyst_verified + יש claims + יש מחקר | → שלב B (סיכום + סיווג + שאלת תוצאה) |
|
||||
| outcome_set + אין claim_handling | → שלב B המשך (טבלת טיפול בטענות) |
|
||||
| outcome_set + יש claim_handling | → שלב C (כיוונים סילוגיסטיים) |
|
||||
| brainstorming + comment מחיים | → שלב D (אימות שלמות + approve + הפעל כותב) |
|
||||
| direction_approved + chair_directions שלם | → ודא שכותב עובד |
|
||||
| direction_approved + chair_directions חסר | → חזור לשלב D (השלמה מול חיים) |
|
||||
| new + יש claims + יש מחקר | → שלב B (סיכום + שאלת תוצאה) |
|
||||
| outcome_set | → שלב C (brainstorm) |
|
||||
| brainstorming + comment מחיים | → שלב D (approve + הפעל כותב) |
|
||||
| direction_approved | → ודא שכותב עובד |
|
||||
| drafted | → צור issue לבודק איכות |
|
||||
| qa_review pass | → שלב F (export via מייצא טיוטה d0dc703b) |
|
||||
| qa_review fail — בעיה טכנית | → צור issue תיקון לכותב |
|
||||
| qa_review fail — בעיה מתודולוגית | → חזור לשלב C/D |
|
||||
| qa_review fail | → צור issue תיקון לכותב |
|
||||
|
||||
## כללים
|
||||
|
||||
@@ -307,7 +170,6 @@ tools:
|
||||
- **לא לכתוב בלוקים** — רק כותב ההחלטה
|
||||
- **תמיד לדווח** — כל פעולה = comment ב-Paperclip
|
||||
- **לשאול כשלא בטוח** — אם משהו לא ברור, שאל את חיים
|
||||
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
|
||||
|
||||
## איך לקרוא comments של חיים
|
||||
|
||||
@@ -320,6 +182,5 @@ curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
חפש ב-comment:
|
||||
- מספר (1/2/3) → בחירה
|
||||
- "כיוון" + מספר → אישור כיוון
|
||||
- טבלת טיפול בטענות → סימון claim_handling
|
||||
- שאלה → ענה
|
||||
- הערה → שלב בתהליך
|
||||
|
||||
@@ -43,7 +43,7 @@ tools:
|
||||
### שלב 1: זיהוי התיק
|
||||
1. קבל את מספר התיק מה-issue או מהמשתמש
|
||||
2. קרא פרטי תיק (`case_get`)
|
||||
3. בדוק סטטוס workflow (`workflow_status`) — ודא שהכתיבה הושלמה **ושבדיקת QA עברה בהצלחה**
|
||||
3. בדוק סטטוס workflow (`workflow_status`) — ודא שהכתיבה הושלמה
|
||||
|
||||
### שלב 2: בדיקה סופית מהירה
|
||||
1. הרץ `validate_decision` — בדוק שאין כשלים קריטיים
|
||||
@@ -51,7 +51,6 @@ tools:
|
||||
3. בדוק רצף מספור — שהמספור רציף מ-1 עד סוף ללא קפיצות או כפילויות
|
||||
4. בדוק שאין placeholders ריקים (כמו `[...]`, `XXX`, `___`)
|
||||
5. אם יש בעיות קריטיות — דווח למשתמש ואל תייצא
|
||||
6. בדוק שסטטוס ה-QA הוא "passed" — אם ה-QA לא רץ או נכשל, **אל תייצא**
|
||||
|
||||
### שלב 3: ייצוא DOCX
|
||||
1. קרא את סקייל legal-docx (SKILL.md) כדי להבין את דרישות העיצוב
|
||||
|
||||
@@ -37,10 +37,9 @@ tools:
|
||||
- רק עובדות: תיאור נכס, היסטוריה תכנונית, החלטת ועדה
|
||||
|
||||
### 3. כיסוי טענות (claims_coverage)
|
||||
- כל טענה מהותית מבלוק ז קיבלה מענה בבלוק י (ישיר, קיבוץ, או ציון שנבחנה)
|
||||
- טענות שסומנו [skip] ב-chair_directions — לא נספרות
|
||||
- טענות שסומנו [bundle] — נבדקות כקבוצה: אם הנושא טופל, כולן עוברות
|
||||
- **קריטי** — אם טענה מהותית ללא סימון לא נענתה, ה-QA נכשל
|
||||
- כל טענה מבלוק ז נענתה בבלוק י
|
||||
- גם אם בניסוח שונה — העיקר שנדונה
|
||||
- **קריטי** — אם טענה לא נענתה, ה-QA נכשל
|
||||
|
||||
### 4. משקלות בטווח (weight_compliance)
|
||||
- בלוק ו (רקע): 15-40%
|
||||
@@ -57,15 +56,6 @@ tools:
|
||||
- סעיפים 1, 2, 3... ללא איפוס בין בלוקים
|
||||
- ללא כפילויות במספור
|
||||
|
||||
### 7. עמידה במתודולוגיה (methodology_compliance)
|
||||
ראה `docs/decision-methodology.md` לעקרונות המלאים. בדוק:
|
||||
- לכל סוגיה בבלוק י — ניתן לזהות מבנה סילוגיסטי: כלל + עובדות + מסקנה?
|
||||
- ממצאים עובדתיים מופרדים ממסקנות משפטיות (לא מעורבבים)?
|
||||
- טענה מרכזית של הצד המפסיד קיבלה מענה הוגן (Steel-Man — הוצגה בחוזקתה)?
|
||||
- כשנדרש איזון — יש ניתוח מפורש (אינטרסים, השלכות, הכרעה)?
|
||||
- אין "נוסחאות ריקות" (משפטים שמחיקתם לא משנה כלום)?
|
||||
- ציטוטים עטופים בסנדוויץ' (הקדמה → ציטוט → ניתוח)?
|
||||
|
||||
## חומרה
|
||||
|
||||
| בדיקה | חומרה | משמעות |
|
||||
@@ -76,7 +66,6 @@ tools:
|
||||
| משקלות | warning | מדווח, לא חוסם |
|
||||
| כפילות | warning | מדווח, לא חוסם |
|
||||
| מספור | warning | מדווח, לא חוסם |
|
||||
| מתודולוגיה | warning | מדווח, לא חוסם |
|
||||
|
||||
## תהליך עבודה
|
||||
|
||||
@@ -85,19 +74,11 @@ tools:
|
||||
2. הרץ בדיקת איכות (`validate_decision`)
|
||||
3. קבל מדדים (`get_metrics`)
|
||||
|
||||
### שלב 2: בדיקה ידנית — חיובית
|
||||
### שלב 2: בדיקה ידנית
|
||||
1. קרא את בלוק ו — בדוק ניטרליות
|
||||
2. השווה טענות בבלוק ז מול דיון בבלוק י — בדוק כיסוי
|
||||
3. בדוק מספור רציף
|
||||
|
||||
### שלב 2ב: בדיקות שליליות — מה חסר? מה לא הגיוני?
|
||||
1. האם יש סוגיה מה-analysis-and-research.md שלא קיבלה מענה בדיון?
|
||||
2. האם יש ציטוט ארוך ללא סנדוויץ' (הקדמה + ציטוט + ניתוח)?
|
||||
3. האם יש "נוסחאות ריקות" — משפטים שמחיקתם לא משנה כלום?
|
||||
4. האם יש פסקה בדיון ללא משפט נושא (פתיחה שלא מודיעה על הנקודה)?
|
||||
5. האם יש ממצא עובדתי ומסקנה משפטית מעורבבים באותו משפט?
|
||||
6. האם יש אנלוגיה לתקדים ללא הסבר מדיניות (למה הדמיון רלוונטי)?
|
||||
|
||||
### שלב 3: דיווח — חובה!
|
||||
פרסם comment ב-Paperclip עם:
|
||||
- תוצאת כל בדיקה (pass/fail)
|
||||
|
||||
@@ -27,11 +27,6 @@ tools:
|
||||
|
||||
עבוד תמיד בעברית.
|
||||
|
||||
## לפני שאתה מתחיל — קרא!
|
||||
|
||||
1. **מתודולוגיה אנליטית**: `docs/decision-methodology.md` — במיוחד סעיפים ד.2 (התחל מלשון הטקסט), ד.3 (שלושה מקורות להנחה עליונה), ז (ציטוטים ואזכורי פסיקה)
|
||||
2. לקחים מהחלטות קודמות: `docs/legal-decision-lessons.md`
|
||||
|
||||
## סוגי מסמכים שאתה מטפל בהם
|
||||
|
||||
| סוג מסמך | מה לעשות |
|
||||
@@ -57,18 +52,12 @@ tools:
|
||||
לכל פסק דין:
|
||||
1. קרא את הטקסט (`document_get_text`)
|
||||
2. סכם: עובדות, שאלה משפטית, הכרעה, רלוונטיות לתיק שלנו
|
||||
3. בנוסף ציין:
|
||||
- **רמת התקדים**: עליון / מנהלי / ועדת ערר ארצית / ועדת ערר מחוזית
|
||||
- **הלכה מחייבת או אמרת אגב**
|
||||
- **כיצד ישרת את מבנה ההנמקה**: כ"כלל" (הנחה עליונה), כ"הרחבה" (Explanation ב-CREAC), או כאנלוגיה
|
||||
4. הפק הפניות (`extract_references`)
|
||||
3. הפק הפניות (`extract_references`)
|
||||
|
||||
### שלב 3: מיפוי תכנית
|
||||
1. קרא הוראות התכנית **במלואן** — לא רק את הסעיף הנטען
|
||||
1. קרא הוראות התכנית
|
||||
2. זהה סעיפים רלוונטיים למחלוקת
|
||||
3. **צטט את לשון ההוראות הרלוונטיות** — הנוסח המדויק, לא סיכום (המתודולוגיה דורשת: "התחל מלשון הטקסט")
|
||||
4. סמן **עמימויות או סתירות** בין הוראות באותה תכנית
|
||||
5. ציין: ייעוד, זכויות בנייה, מגבלות, תנאים
|
||||
3. ציין: ייעוד, זכויות בנייה, מגבלות, חניה
|
||||
|
||||
### שלב 4: סיכום פרוטוקולים והחלטות
|
||||
1. קרא כל פרוטוקול והחלטת ביניים
|
||||
@@ -79,10 +68,7 @@ tools:
|
||||
- סיכום כל פסק דין (2-3 שורות לכל אחד)
|
||||
- מיפוי הוראות תכנית רלוונטיות
|
||||
- ציר זמן ההליך
|
||||
- **המלצה מובנית לפי מקורות הנמקה:**
|
||||
- **טקסט**: אילו סעיפי תכנית/חוק מרכזיים (ציטוט הנוסח)
|
||||
- **תקדים**: אילו פסקי דין הכי חזקים (עם ציון היררכיה ומעמד — הלכה/אגב)
|
||||
- **מדיניות**: אילו שיקולים תכנוניים עולים מהחומר
|
||||
- המלצה: אילו תקדימים הכי חזקים, אילו סעיפי תכנית מרכזיים
|
||||
|
||||
## כללים
|
||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||
|
||||
@@ -34,10 +34,9 @@ tools:
|
||||
|
||||
## לפני שאתה מתחיל — קרא!
|
||||
|
||||
1. **מתודולוגיה אנליטית: `docs/decision-methodology.md`** — איך לחשוב על החלטה
|
||||
2. מדריך סגנון: `skills/decision/SKILL.md` — איך דפנה כותבת
|
||||
3. ארכיטקטורת 12 בלוקים: `docs/block-schema.md`
|
||||
4. לקחים מהחלטות קודמות: `docs/legal-decision-lessons.md`
|
||||
1. מדריך סגנון: `skills/decision/SKILL.md`
|
||||
2. ארכיטקטורת 12 בלוקים: `docs/block-schema.md`
|
||||
3. לקחים מהחלטות קודמות: `docs/legal-decision-lessons.md`
|
||||
|
||||
## ארכיטקטורת 12 בלוקים
|
||||
|
||||
@@ -146,35 +145,11 @@ case_update(case_number, status="drafted")
|
||||
|
||||
## בלוק י — דיון (הבלוק החשוב ביותר)
|
||||
|
||||
**עקוב אחר `docs/decision-methodology.md` — שלבי הניתוח:**
|
||||
|
||||
### שלב א: פסקת מפה
|
||||
פתח בפסקה שמודיעה מה ייבחן: "שלוש שאלות עומדות להכרעה: (1)...; (2)...; (3)..."
|
||||
|
||||
### שלב ב: סוגיות סף (אם רלוונטיות)
|
||||
אם עולה שאלת סף — היא נדונה ראשונה. אם נדחית — פסקה אחת ועבור לגוף.
|
||||
|
||||
### שלב ג: לכל סוגיה — מבנה סילוגיסטי (CREAC)
|
||||
1. **מסקנה** — פתח בתשובה
|
||||
2. **כלל** — ציטוט הוראת תכנית/חוק (התחל מלשון הטקסט, לא מפסיקה)
|
||||
3. **הרחבה** — תקדים רלוונטי אחד (טכניקת סנדוויץ': הקדמה→ציטוט→ניתוח)
|
||||
4. **יישום** — החל את הכלל על העובדות. הפרד ממצא עובדתי ממסקנה משפטית. השתמש בנתונים (מספרים, מידות, אחוזים).
|
||||
5. **Steel-Man** — הצג את הטענה הטובה ביותר של הצד המפסיד: "אמנם צודק העורר כי..., אולם..."
|
||||
6. **מסקנה חוזרת** — סגור
|
||||
|
||||
### שלב ד: איזון (כשנדרש)
|
||||
אם אין כלל ברור — בנה איזון: זהה אינטרסים קונקרטיים → בחן השלכות לכל כיוון → שקול השלכות מערכתיות → הכרע.
|
||||
|
||||
### שלב ה: טענות נותרות
|
||||
- טענות מרכזיות ללא סימון: מענה פרטני
|
||||
- טענות שסומנו [bundle] ב-chair_directions: קבץ ודון יחד
|
||||
- טענות שסומנו [skip] ב-chair_directions: "נבחנה ולא מצאנו בה ממש"
|
||||
- טענות חלשות: קיבוץ. "באשר לטענות הנוספות — לא מצאנו בהן ממש"
|
||||
|
||||
### כללים נוספים
|
||||
- אל תחזור על עובדות מבלוק ו — הפנה: "כאמור בסעיף X לעיל"
|
||||
- כל מילה עובדת — אין "לאחר ששקלנו את כלל השיקולים"
|
||||
- כנות לגבי קושי — "הדבר אינו נקי מספקות, אולם..."
|
||||
- מבנה CREAC: מסקנה בפתיחה → כלל → הסבר → יישום → מסקנה
|
||||
- ענה על כל טענה מבלוק ז
|
||||
- השתמש בציטוטים ארוכים (200-600 מילים) מפסיקה
|
||||
- אל תחזור על עובדות מבלוק ו
|
||||
- אל תשתמש בכותרות משנה (למעט נושאים נפרדים לחלוטין)
|
||||
|
||||
### חובה: שימוש בעמדות יו"ר מ-`get_chair_directions`
|
||||
|
||||
|
||||
3
.taskmaster/state.json
Normal file
3
.taskmaster/state.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"migrationNoticeShown": true
|
||||
}
|
||||
@@ -703,13 +703,189 @@
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-03T10:19:26.779Z"
|
||||
},
|
||||
{
|
||||
"id": "83",
|
||||
"title": "Phase 1 — Project setup (legal-ai UI rewrite)",
|
||||
"description": "הקמת scaffold של Next.js עם TypeScript + Tailwind v4 + App Router ב-web-ui/. התקנת כל התלויות: @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript. העברת design-system.css tokens (navy/gold/parchment, Heebo) ל-Tailwind theme דרך @theme ו-CSS variables. הגדרת RTL עברית עם Heebo via next/font/google. בניית AppShell עם navy header + gold rule + nav.",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "high",
|
||||
"details": "**השלמות (2026-04-11):**\n\n✅ **Scaffold:** Next.js 16.2.3 (חדש יותר מ-v15 שתוכנן), React 19.2.4, Tailwind v4, Turbopack.\n\n✅ **תלויות מותקנות** (`web-ui/package.json:11-21`):\n- @tanstack/react-query ^5.97.0\n- @tanstack/react-table ^8.21.3\n- react-hook-form ^7.72.1 + @hookform/resolvers ^5.2.2\n- zod ^4.3.6\n- lucide-react ^1.8.0\n- react-dropzone ^15.0.0\n- openapi-typescript ^7.13.0 (devDep)\n\n✅ **Design tokens** (`web-ui/src/app/globals.css:10-107`): Tailwind v4 @theme עם כל הצבעים (navy, cream, parchment, gold, ink, status colors), radii, shadows, fonts, dark mode preserved.\n\n✅ **RTL Hebrew** (`web-ui/src/app/layout.tsx:5-10, 23`): Heebo עם hebrew+latin subsets, `lang=\"he\" dir=\"rtl\"` on html.\n\n✅ **AppShell** (`web-ui/src/components/app-shell.tsx:29-70`): Navy header עם gold border-b-3, RTL nav, parchment body.\n\n✅ **Home page placeholder** (`web-ui/src/app/page.tsx`).\n\n✅ **Build:** `npm run build` עובר ב-3.8s, 0 errors, static.\n\n**נותר:** אישור ויזואלי של המשתמש עם `npm run dev`.\n\n**תוכנית מלאה:** `~/.claude/plans/joyful-marinating-sutton.md`",
|
||||
"testStrategy": "1. `cd web-ui && npm run dev` — פתיחה ב-http://localhost:3000\n2. וידוא ויזואלי: Header navy עם gold rule, RTL rendering, פונט Heebo טעון\n3. השוואה ל-legal-ai.nautilus.marcusgroup.org — אותו מראה header\n4. בדיקת dark mode (toggle class על html)\n5. אישור סופי מהמשתמש",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "יצירת Next.js 16 scaffold עם TypeScript + Tailwind v4 + App Router",
|
||||
"description": "הרצת create-next-app ב-web-ui/ עם App Router, TypeScript, Tailwind v4, ESLint",
|
||||
"dependencies": [],
|
||||
"details": "Next.js 16.2.3 (חדש יותר מ-v15), React 19.2.4, Tailwind v4, Turbopack. קבצים: web-ui/package.json, tsconfig.json, next.config.ts",
|
||||
"status": "done",
|
||||
"testStrategy": "npm run build succeeds",
|
||||
"parentId": "undefined"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "התקנת כל התלויות הנדרשות",
|
||||
"description": "npm install של @tanstack/react-query, @tanstack/react-table, react-hook-form, @hookform/resolvers, zod, lucide-react, react-dropzone, openapi-typescript",
|
||||
"dependencies": [
|
||||
1
|
||||
],
|
||||
"details": "ראה web-ui/package.json:11-21 לגרסאות המותקנות. כולל openapi-typescript כ-devDep לשלב 2.",
|
||||
"status": "done",
|
||||
"testStrategy": "npm ls shows all packages",
|
||||
"parentId": "undefined"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "העברת design tokens ל-Tailwind v4 @theme",
|
||||
"description": "פורט מלא של design-system.css ל-globals.css עם Tailwind v4 @theme syntax",
|
||||
"dependencies": [
|
||||
1
|
||||
],
|
||||
"details": "web-ui/src/app/globals.css:10-107 כולל כל הצבעים (navy, cream, parchment, gold, ink), status colors, radii, shadows, fonts, dark mode. CSS variables עובדים עם Tailwind classes.",
|
||||
"status": "done",
|
||||
"testStrategy": "Tailwind classes like bg-navy, text-gold work correctly",
|
||||
"parentId": "undefined"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "הגדרת RTL Hebrew עם Heebo font",
|
||||
"description": "next/font/google Heebo עם hebrew+latin, lang=he dir=rtl על html",
|
||||
"dependencies": [
|
||||
1
|
||||
],
|
||||
"details": "web-ui/src/app/layout.tsx:5-10 — Heebo עם weights 300-900, display swap. שורה 23: html lang=he dir=rtl.",
|
||||
"status": "done",
|
||||
"testStrategy": "Page renders RTL, Heebo font loaded",
|
||||
"parentId": "undefined"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "בניית AppShell component עם navy header + gold rule",
|
||||
"description": "רכיב shell עם header navy, gold border, RTL nav, parchment body",
|
||||
"dependencies": [
|
||||
3,
|
||||
4
|
||||
],
|
||||
"details": "web-ui/src/components/app-shell.tsx:29-70 — Header עם bg-navy, border-b-3 border-gold, nav links (בית, העלאת מסמכים, אימון סגנון, מיומנויות, אבחון), main content area עם max-w-1400px.",
|
||||
"status": "done",
|
||||
"testStrategy": "Visual match to current header",
|
||||
"parentId": "undefined"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "יצירת דף בית placeholder",
|
||||
"description": "דף page.tsx עם AppShell ו-placeholder content",
|
||||
"dependencies": [
|
||||
5
|
||||
],
|
||||
"details": "web-ui/src/app/page.tsx:1-27 — דף בית עם כותרת 'עוזר משפטי', תיאור המערכת, gold gradient divider, כרטיס סטטוס.",
|
||||
"status": "done",
|
||||
"testStrategy": "npm run build succeeds (done: 3.8s, 0 errors)",
|
||||
"parentId": "undefined"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "אישור ויזואלי מהמשתמש — npm run dev",
|
||||
"description": "הרצת dev server ואישור סופי שה-UI תואם לציפיות — header, RTL, fonts, colors",
|
||||
"dependencies": [
|
||||
6
|
||||
],
|
||||
"details": "המשתמש צריך להריץ 'cd web-ui && npm run dev' ולאשר שהכל נראה כמו legal-ai.nautilus.marcusgroup.org. בדיקת dark mode אופציונלית.",
|
||||
"status": "pending",
|
||||
"testStrategy": "User confirms visual parity with current site, RTL works, Heebo font loads",
|
||||
"parentId": "undefined"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-04-11T13:50:47.941Z"
|
||||
},
|
||||
{
|
||||
"id": "84",
|
||||
"title": "Phase 2 — API client + generated TypeScript types",
|
||||
"description": "Add npm run api:types script that runs openapi-typescript against FastAPI's /openapi.json -> src/lib/api/types.ts. Build lib/api/client.ts (typed fetch wrapper + TanStack Query client with default retry/staleTime). Create one lib/api/<domain>.ts per endpoint category (cases, upload, compose, training, system), each exporting typed useQuery/useMutation hooks. Build lib/sse.ts as EventSource -> Query cache adapter. 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 2 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||
"testStrategy": "useCases() hook returns typed array from live FastAPI. TypeScript errors if backend endpoint changes without frontend update.",
|
||||
"status": "done",
|
||||
"dependencies": [
|
||||
"83"
|
||||
],
|
||||
"priority": "high",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-11T15:51:34.020Z"
|
||||
},
|
||||
{
|
||||
"id": "85",
|
||||
"title": "Phase 3 — Core read views (home, case detail, compose)",
|
||||
"description": "Port the 3 highest-value screens. Use the frontend-design Claude Code skill to generate layout + composition, passing design tokens (navy/gold/parchment, Heebo), editorial voice, and typed API hooks. Use shadcn Card/Badge/Tabs/Sheet/ScrollArea as primitives. Port the custom donut chart into <DonutChart> component. TanStack Query staleTime:5000 for case detail replaces manual 5s polling. 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 3 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||
"testStrategy": "Users can browse case list, open a case detail, and view the compose screen with live data from FastAPI. All 3 screens visually match the existing legal-ai identity.",
|
||||
"status": "in-progress",
|
||||
"dependencies": [
|
||||
"84"
|
||||
],
|
||||
"priority": "high",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-11T15:55:49.327Z"
|
||||
},
|
||||
{
|
||||
"id": "86",
|
||||
"title": "Phase 4 — Forms and wizards (new case, upload, inline edits)",
|
||||
"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": "pending",
|
||||
"dependencies": [
|
||||
"85"
|
||||
],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "87",
|
||||
"title": "Phase 5 — Secondary screens (compare, training, style report, skills, diagnostics)",
|
||||
"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",
|
||||
"dependencies": [
|
||||
"86"
|
||||
],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "88",
|
||||
"title": "Phase 6 — Polish & testing",
|
||||
"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",
|
||||
"dependencies": [
|
||||
"87"
|
||||
],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
},
|
||||
{
|
||||
"id": "89",
|
||||
"title": "Phase 7 — Deployment & cutover",
|
||||
"description": "Add multi-stage Dockerfile for web-ui/ (Node 20 build -> nginx serve of out/). Add web-ui as new app in Coolify project pointing to staging subdomain legal-ai-next.nautilus.marcusgroup.org. Run full smoke test against staging. Cutover: DNS flip legal-ai.nautilus.marcusgroup.org to new app, keep old on rollback subdomain for 1 week. Follow-up PR removes legal-ai/web/static/index.html + design-system.css once stable. 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 7 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
|
||||
"testStrategy": "legal-ai.nautilus.marcusgroup.org serves the new Next.js UI in production. Old UI accessible on rollback subdomain for 7 days. SSE streams working through Coolify proxy.",
|
||||
"status": "pending",
|
||||
"dependencies": [
|
||||
"88"
|
||||
],
|
||||
"priority": "medium",
|
||||
"subtasks": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-04-04T07:50:59.999Z",
|
||||
"taskCount": 51,
|
||||
"completedCount": 49,
|
||||
"lastModified": "2026-04-11T15:55:49.330Z",
|
||||
"taskCount": 58,
|
||||
"completedCount": 51,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
| [`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) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||
|
||||
|
||||
53
Dockerfile
53
Dockerfile
@@ -1,27 +1,40 @@
|
||||
FROM python:3.12-slim
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Dockerfile — Next.js 16 web-ui (ui-rewrite branch only)
|
||||
#
|
||||
# This file REPLACES the FastAPI Dockerfile on this branch so that
|
||||
# Coolify's default /Dockerfile lookup builds the new Next.js staging
|
||||
# UI. The FastAPI Dockerfile lives on `main` and is unaffected.
|
||||
#
|
||||
# When the rewrite is merged to main, decide between:
|
||||
# (a) keeping both via separate Dockerfiles + dockerfile_location config, or
|
||||
# (b) a multi-stage Dockerfile that serves both, or
|
||||
# (c) fully replacing FastAPI's StaticFiles with this Next.js front end.
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY web-ui/package.json web-ui/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# System deps for PyMuPDF and document processing
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc libmupdf-dev libfreetype6-dev libharfbuzz-dev libjpeg62-turbo-dev \
|
||||
libopenjp2-7-dev curl git && rm -rf /var/lib/apt/lists/* && \
|
||||
git config --global init.defaultBranch main
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY web-ui/ ./
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# Copy Ezer Mishpati MCP server source
|
||||
COPY mcp-server/pyproject.toml /app/mcp-server/pyproject.toml
|
||||
COPY mcp-server/src/ /app/mcp-server/src/
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Install MCP server + web deps
|
||||
RUN pip install --no-cache-dir /app/mcp-server && \
|
||||
pip install --no-cache-dir fastapi uvicorn python-multipart
|
||||
# next.config.ts uses output: 'standalone', so we copy only the minimal runtime
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Copy web app
|
||||
COPY web/ /app/web/
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PYTHONPATH=/app/mcp-server/src
|
||||
ENV DOTENV_PATH=/dev/null
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
# ניתוח שיטתי של קורפוס ההחלטות — מפת תוכן
|
||||
|
||||
> נוצר: 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 ~~פער: הפרומפט הנוכחי לא מכיל "צ'קליסט תוכן"~~ — **נסגר (2026-04-12)**
|
||||
נוספו:
|
||||
- ✅ צ'קליסטים תוכניים לפי סוג ערר (`lessons.py: CONTENT_CHECKLISTS`) — מוזרקים לפרומפט
|
||||
- ✅ מתודולוגיה אנליטית (`docs/decision-methodology.md`) — מלמדת איך לחשוב, לא רק מה לכסות
|
||||
- ✅ טיפול גמיש בטענות (bundle/skip דרך chair_directions)
|
||||
- ✅ בדיקת QA חדשה (methodology compliance)
|
||||
|
||||
### 5.4 פער: הבחנה לא מספיקה בין תת-סוגי רישוי
|
||||
תיקי רישוי שונים מאוד זה מזה:
|
||||
- **סמכות/סף** — דיון משפטי טהור, אין צורך בתכנון
|
||||
- **קנייני** — תימוכין קנייניים, אין צורך בתכנון
|
||||
- **תכנוני מובהק** — ייעוד, חניה, גובה — דיון תכנוני מקיף
|
||||
- **שימוש חורג** — פרשנות תכניות, דיון תכנוני עמוק
|
||||
- **הקלה** — מידתיות + תכנון
|
||||
- **תמ"א 38** — איזון אינטרסים + תכנון
|
||||
|
||||
---
|
||||
|
||||
## 6. המלצות לשלב הבא
|
||||
|
||||
### 6.1 צ'קליסט תוכן מוצע לערר רישוי ובנייה
|
||||
```
|
||||
בהתאם לנושא הערר, הדיון צריך לכלול:
|
||||
|
||||
□ הקשר תכנוני רחב (תמיד כשהערר מגיע למריט):
|
||||
- תכניות חלות (מקומית, מחוזית, ארצית — לפי הצורך)
|
||||
- ייעוד הקרקע
|
||||
- אופי הסביבה
|
||||
|
||||
□ ניתוח הוראות תכנית (כשיש שאלה של התאמה/סטייה):
|
||||
- ציטוט ישיר מהוראות רלוונטיות
|
||||
- פרשנות — תכלית ההוראה
|
||||
- יישום על המקרה
|
||||
|
||||
□ חניה (כשרלוונטי):
|
||||
- הוראות תכנית + נספח תנועה
|
||||
- חישוב מקומות נדרשים vs. מסופקים
|
||||
- חלופות (קרן חניה, חפיפה, תח"צ)
|
||||
|
||||
□ שכנות/פגיעה (כשרלוונטי):
|
||||
- ממצאי סיור
|
||||
- צל, פרטיות, רעש, נוף
|
||||
- מידתיות
|
||||
|
||||
□ קווי בניין (כשרלוונטי):
|
||||
- הוראת תכנית
|
||||
- סטייה ניכרת — תקנה 2(19)
|
||||
- הצדקה/מידתיות
|
||||
|
||||
□ גובה/קומות (כשרלוונטי):
|
||||
- הוראת תכנית + נספח בינוי
|
||||
- מטרת ההגבלה
|
||||
- סטייה ניכרת — תקנה 2(10)
|
||||
```
|
||||
|
||||
### 6.2 הבחנה בין תת-סוגים
|
||||
הפרומפט צריך לזהות את סוג הערר ולהתאים את הצ'קליסט:
|
||||
- **ערר סמכות/סף** → ללא דיון תכנוני
|
||||
- **ערר קנייני** → דיון משפטי, ללא תכנון
|
||||
- **ערר מהותי** → דיון תכנוני מקיף + משפטי
|
||||
|
||||
### 6.3 צורך דחוף: החלטות היטל השבחה
|
||||
צריך להוסיף לקורפוס לפחות 5-10 החלטות של היטל השבחה לפני שהמערכת יכולה לכתוב החלטות בתחום הזה.
|
||||
@@ -161,94 +161,3 @@ 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)
|
||||
|
||||
---
|
||||
|
||||
## Lessons from External Expertise Research (April 2026)
|
||||
|
||||
### Source
|
||||
- Federal Judicial Center, *Judicial Writing Manual* (1991, 2nd ed. 2020)
|
||||
- Bryan Garner, *Legal Writing in Plain English* (2001)
|
||||
- Scalia & Garner, *Making Your Case: The Art of Persuading Judges* (2008)
|
||||
- Richard Posner, *How Judges Think* (2008)
|
||||
- Full texts stored in: `docs/sources/`
|
||||
|
||||
### 17. Methodology Document Created — Separating "How to Think" from "How to Write"
|
||||
|
||||
**Problem:** The system knew Dafna's STYLE (SKILL.md) and WHAT TOPICS to cover (content checklists), but had no formal methodology for HOW TO REASON through a decision — the analytical stages, when to balance, how to structure arguments, how to handle counterarguments.
|
||||
|
||||
**Fix:** Created `docs/decision-methodology.md` — a standalone analytical methodology document based on synthesis of all four external sources. 3,400 words, 12 sections, 10 guiding principles. Covers: pre-analysis, threshold questions, issue ordering, syllogistic structure (CREAC), balancing/proportionality, claims handling (steel-man, bundling), quotation technique (sandwich), factual findings vs. legal conclusions, disposition, writing techniques, analogy/precedent, editing checklist.
|
||||
|
||||
**Key principle:** Methodology is UNIVERSAL — it teaches how to think about any quasi-judicial decision. It does not contain case-specific content (parking, building lines, etc.). Case-specific content stays in the content checklists.
|
||||
|
||||
**Applied to:**
|
||||
- `docs/decision-methodology.md` — new document
|
||||
- `lessons.py` — new function `get_methodology_summary()` injected into block-yod prompt
|
||||
- `block_writer.py` — new `{methodology_guidance}` placeholder in block-yod prompt
|
||||
- `.claude/agents/legal-writer.md` — restructured block-yod workflow to follow methodology stages
|
||||
- `.claude/agents/legal-qa.md` — new check #7 (methodology compliance)
|
||||
|
||||
### 18. "Answer All Claims" Made Flexible
|
||||
|
||||
**Problem:** The block-yod prompt hardcoded "answer every claim individually" and the QA check enforced it. But Dafna sometimes bundles weak claims, skips irrelevant ones, and focuses on what matters.
|
||||
|
||||
**Fix:**
|
||||
- Block-yod prompt changed from "חובה לענות על כל אחת" to flexible handling: address substantive claims; bundle [bundle]; skip [skip]
|
||||
- Chair can mark claims in `chair_directions` as bundle or skip
|
||||
- QA check #3 updated to respect these markings
|
||||
- Methodology teaches WHEN to address individually vs. bundle vs. skip (methodology §ו)
|
||||
|
||||
### 19. Source Library Established
|
||||
|
||||
Downloaded and converted to text 5 authoritative sources for the methodology:
|
||||
- `docs/sources/fjc-judicial-writing-manual-1991.txt` (13,567 words)
|
||||
- `docs/sources/fjc-judicial-writing-manual-2nd-ed-2020.txt` (15,912 words)
|
||||
- `docs/sources/garner-legal-writing-plain-english.txt` (97,475 words)
|
||||
- `docs/sources/posner-how-judges-think.txt` (156,789 words)
|
||||
- `docs/sources/scalia-garner-making-your-case.txt` (54,683 words)
|
||||
Total: ~340,000 words of source material.
|
||||
|
||||
Intermediate extraction documents also saved:
|
||||
- `docs/fjc-principles-extraction.md` — 38 principles from FJC
|
||||
- `docs/garner-methodology-extraction.md` — ~50 principles from Garner/Scalia
|
||||
|
||||
@@ -45,9 +45,7 @@ mcp = FastMCP(
|
||||
|
||||
# ── Import and register tools ───────────────────────────────────────
|
||||
|
||||
from legal_mcp.tools import ( # noqa: E402
|
||||
cases, documents, search, drafting, workflow, precedents,
|
||||
)
|
||||
from legal_mcp.tools import cases, documents, search, drafting, workflow # noqa: E402
|
||||
|
||||
|
||||
# Case management
|
||||
@@ -104,48 +102,6 @@ async def case_update(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
||||
"""מחיקת תיק ערר. קבצים בדיסק נשארים אלא אם remove_files=true."""
|
||||
return await cases.case_delete(case_number, remove_files)
|
||||
|
||||
|
||||
# Precedent attachments (user-supplied legal support for the compose phase)
|
||||
@mcp.tool()
|
||||
async def precedent_attach(
|
||||
case_number: str,
|
||||
quote: str,
|
||||
citation: str,
|
||||
section_id: str = "",
|
||||
chair_note: str = "",
|
||||
pdf_document_id: str = "",
|
||||
) -> str:
|
||||
"""צירוף פסיקה תומכת לתיק. section_id ריק = כללי לתיק; אחרת threshold_1/issue_3."""
|
||||
return await precedents.precedent_attach(
|
||||
case_number, quote, citation, section_id, chair_note, pdf_document_id,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_list(case_number: str) -> str:
|
||||
"""רשימת כל הפסיקות שצורפו לתיק."""
|
||||
return await precedents.precedent_list(case_number)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_remove(precedent_id: str) -> str:
|
||||
"""הסרת פסיקה מצורפת."""
|
||||
return await precedents.precedent_remove(precedent_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def precedent_search_library(
|
||||
query: str, practice_area: str = "", limit: int = 10,
|
||||
) -> str:
|
||||
"""חיפוש בספרייה הרוחבית של ציטוטים שנצברו בין תיקים."""
|
||||
return await precedents.precedent_search_library(query, practice_area, limit)
|
||||
|
||||
|
||||
# Documents
|
||||
@mcp.tool()
|
||||
async def document_upload(
|
||||
@@ -204,16 +160,6 @@ async def get_claims(
|
||||
return await documents.get_claims(case_number, party_role)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def document_update_status(
|
||||
case_number: str,
|
||||
doc_title: str,
|
||||
status: str,
|
||||
) -> str:
|
||||
"""עדכון סטטוס עיבוד מסמך. status: pending/extracted/proofread/error."""
|
||||
return await documents.document_update_status(case_number, doc_title, status)
|
||||
|
||||
|
||||
# References
|
||||
@mcp.tool()
|
||||
async def extract_references(
|
||||
@@ -271,22 +217,6 @@ async def draft_section(
|
||||
return await drafting.draft_section(case_number, section, instructions)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_research_findings(case_number: str) -> str:
|
||||
"""שליפת ממצאי מחקר — סיכומי פסיקה, מיפוי תכניות, ציר זמן, והמלצות.
|
||||
קורא מ-research-findings.md שנוצר ע"י חוקר התקדימים.
|
||||
"""
|
||||
return await drafting.get_research_findings(case_number)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_full_analysis(case_number: str) -> str:
|
||||
"""שליפת הניתוח המשפטי המלא — טענות, תשובות, חוזקות/חולשות, שאלות מחקר,
|
||||
חקיקה, תקדימים ועמדות יו"ר. הכלי המרכזי לכותב לפני כתיבת בלוק י.
|
||||
"""
|
||||
return await drafting.get_full_analysis(case_number)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_chair_directions(case_number: str) -> str:
|
||||
"""שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר כ-direction_doc לכותב.
|
||||
@@ -322,15 +252,6 @@ async def save_block_content(
|
||||
return await drafting.save_block_content(case_number, block_id, content)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_decision_blocks(
|
||||
case_number: str,
|
||||
block_id: str = "",
|
||||
) -> str:
|
||||
"""שליפת בלוקים שנכתבו — תוכן, מילים, משקלות. ריק = כל הבלוקים."""
|
||||
return await drafting.get_decision_blocks(case_number, block_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def validate_decision(case_number: str) -> str:
|
||||
"""בדיקת QA — 6 בדיקות איכות על ההחלטה. אם בדיקה קריטית נכשלת — ייצוא חסום."""
|
||||
@@ -425,29 +346,6 @@ async def ingest_final_version(
|
||||
return await workflow.ingest_final_version(case_number, file_path, final_text)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def record_chair_feedback(
|
||||
case_number: str,
|
||||
feedback_text: str,
|
||||
block_id: str = "block-yod",
|
||||
category: str = "missing_content",
|
||||
lesson_extracted: str = "",
|
||||
) -> str:
|
||||
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה — חסר, שגיאה, סגנון."""
|
||||
return await workflow.record_chair_feedback(
|
||||
case_number, feedback_text, block_id, category, lesson_extracted,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_chair_feedback(
|
||||
case_number: str = "",
|
||||
category: str = "",
|
||||
unresolved_only: bool = True,
|
||||
) -> str:
|
||||
"""הצגת הערות יו"ר שתועדו — אפשר לסנן לפי תיק, קטגוריה, מטופלות."""
|
||||
return await workflow.list_chair_feedback(case_number, category, unresolved_only)
|
||||
|
||||
|
||||
def main():
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
@@ -20,7 +20,6 @@ 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__)
|
||||
|
||||
@@ -216,8 +215,6 @@ BLOCK_PROMPTS = {
|
||||
- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין)
|
||||
- מספור רציף
|
||||
|
||||
{content_checklist}
|
||||
|
||||
## כיוון מאושר (חובה):
|
||||
{direction_context}
|
||||
|
||||
@@ -313,15 +310,6 @@ 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%"
|
||||
@@ -335,7 +323,6 @@ 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
|
||||
@@ -489,17 +476,12 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
|
||||
case = await db.get_case(case_id)
|
||||
case_number = case.get("case_number", "") if case else ""
|
||||
subject = case.get("subject", "") if case else ""
|
||||
practice_area = case.get("practice_area") if case else None
|
||||
appeal_subtype = case.get("appeal_subtype") if case else None
|
||||
query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר"
|
||||
query_emb = await embeddings.embed_query(query)
|
||||
|
||||
# Search 1: paragraph_embeddings (from other decisions by Dafna).
|
||||
# Filter by practice_area + appeal_subtype so we don't pull a
|
||||
# betterment-levy paragraph when writing a building-permit decision.
|
||||
# Search 1: paragraph_embeddings (from other decisions by Dafna)
|
||||
para_results = await db.search_similar_paragraphs(
|
||||
query_embedding=query_emb, limit=10, block_type="block-yod",
|
||||
practice_area=practice_area, appeal_subtype=appeal_subtype,
|
||||
)
|
||||
# Filter out same case
|
||||
para_results = [r for r in para_results if r.get("case_number", "") != case_number]
|
||||
@@ -525,31 +507,14 @@ async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
|
||||
text = r["key_quote"] or r["summary"] or ""
|
||||
if text:
|
||||
parts.append(
|
||||
f"[פ<EFBFBD><EFBFBD>יקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
|
||||
f"[פסיקה: {r['case_number']} {r['case_name']} ({r.get('court', '')})] "
|
||||
f"score={r['score']:.3f}\n{text[:400]}"
|
||||
)
|
||||
|
||||
# Search 3: case_precedents (user-attached quotes by Daphna)
|
||||
# These are hand-picked citations — highest priority.
|
||||
attached = await db.list_case_precedents(case_id)
|
||||
if attached:
|
||||
parts.insert(0, "## תקדימים שצורפו ידנית ע\"י יו\"ר הוועדה\n")
|
||||
for i, prec in enumerate(attached):
|
||||
section = prec.get("section_id") or "כללי"
|
||||
citation = prec.get("citation", "")
|
||||
quote = prec.get("quote", "")
|
||||
note = prec.get("chair_note", "")
|
||||
entry = f"[תקדים מצורף #{i+1} — סוגיה: {section}] {citation}"
|
||||
if quote:
|
||||
entry += f"\nציטוט: {quote[:600]}"
|
||||
if note:
|
||||
entry += f"\nהערת יו\"ר: {note}"
|
||||
parts.insert(i + 1, entry)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch precedents: %s", e)
|
||||
|
||||
return "\n\n".join(parts) if parts else "(אין תקד<EFBFBD><EFBFBD>מים)"
|
||||
return "\n\n".join(parts) if parts else "(אין תקדימים)"
|
||||
|
||||
|
||||
async def _build_style_context() -> str:
|
||||
|
||||
@@ -200,110 +200,6 @@ CREATE TABLE IF NOT EXISTS appeal_type_rules (
|
||||
ALTER TABLE decision_blocks ADD COLUMN IF NOT EXISTS image_placeholders JSONB DEFAULT '[]';
|
||||
"""
|
||||
|
||||
# ── Phase 4: Practice area separation (multi-tenant axis) ──────────
|
||||
|
||||
SCHEMA_V4_SQL = """
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- practice_area = top-level legal domain (multi-tenant axis):
|
||||
-- appeals_committee | national_insurance | labor_law | ...
|
||||
-- appeal_subtype = refines within practice_area:
|
||||
-- building_permit | betterment_levy | compensation_197 | unknown
|
||||
-- Both columns are denormalized to documents/chunks/decisions/style_corpus
|
||||
-- so vector searches can filter without expensive JOINs.
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
||||
ALTER TABLE document_chunks ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
||||
ALTER TABLE document_chunks ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
||||
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
||||
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
||||
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS practice_area TEXT;
|
||||
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS appeal_subtype TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cases_practice
|
||||
ON cases(practice_area, appeal_subtype);
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_practice
|
||||
ON document_chunks(practice_area);
|
||||
CREATE INDEX IF NOT EXISTS idx_corpus_practice
|
||||
ON style_corpus(practice_area, appeal_subtype);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_practice
|
||||
ON decisions(practice_area);
|
||||
|
||||
-- Backfill (idempotent — only fills NULLs)
|
||||
UPDATE cases SET practice_area = 'appeals_committee' WHERE practice_area IS NULL;
|
||||
UPDATE cases SET appeal_subtype = CASE
|
||||
WHEN case_number ~ '^1[0-9]{3}' THEN 'building_permit'
|
||||
WHEN case_number ~ '^8[0-9]{3}' THEN 'betterment_levy'
|
||||
WHEN case_number ~ '^9[0-9]{3}' THEN 'compensation_197'
|
||||
ELSE 'unknown'
|
||||
END WHERE appeal_subtype IS NULL;
|
||||
|
||||
UPDATE documents d
|
||||
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
|
||||
FROM cases c
|
||||
WHERE d.case_id = c.id AND d.practice_area IS NULL;
|
||||
|
||||
UPDATE document_chunks dc
|
||||
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
|
||||
FROM cases c
|
||||
WHERE dc.case_id = c.id AND dc.practice_area IS NULL;
|
||||
|
||||
UPDATE decisions de
|
||||
SET practice_area = c.practice_area, appeal_subtype = c.appeal_subtype
|
||||
FROM cases c
|
||||
WHERE de.case_id = c.id AND de.practice_area IS NULL;
|
||||
|
||||
-- All existing style_corpus entries are דפנה's appeals-committee decisions
|
||||
UPDATE style_corpus SET practice_area = 'appeals_committee' WHERE practice_area IS NULL;
|
||||
|
||||
-- Training corpus documents/chunks have case_id = NULL. All historical
|
||||
-- training material is from דפנה's appeals committee, so default them.
|
||||
UPDATE documents SET practice_area = 'appeals_committee'
|
||||
WHERE case_id IS NULL AND practice_area IS NULL;
|
||||
|
||||
UPDATE document_chunks dc
|
||||
SET practice_area = d.practice_area, appeal_subtype = d.appeal_subtype
|
||||
FROM documents d
|
||||
WHERE dc.document_id = d.id AND dc.practice_area IS NULL;
|
||||
"""
|
||||
|
||||
# ── Phase 5: case_precedents (user-attached legal quotes) ──────────
|
||||
|
||||
SCHEMA_V5_SQL = """
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- case_precedents: legal support the chair attaches to a case / section
|
||||
-- during the compose phase. Self-contained — quote + citation are
|
||||
-- stored inline, with an optional FK to an archived PDF in documents.
|
||||
-- Not linked to case_law (which has UNIQUE(case_number)) to keep the
|
||||
-- citation as free-text. A backfill pass into case_law is a future
|
||||
-- follow-up once the UI stabilizes.
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS case_precedents (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
|
||||
section_id TEXT, -- NULL = case-level
|
||||
-- else "threshold_1" / "issue_3"
|
||||
quote TEXT NOT NULL,
|
||||
citation TEXT NOT NULL, -- free-text "מראה מקום"
|
||||
chair_note TEXT DEFAULT '',
|
||||
pdf_document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
|
||||
practice_area TEXT, -- denormalized from cases
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_case
|
||||
ON case_precedents(case_id, section_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_library
|
||||
ON case_precedents(citation);
|
||||
CREATE INDEX IF NOT EXISTS idx_case_precedents_area
|
||||
ON case_precedents(practice_area);
|
||||
"""
|
||||
|
||||
# ── Phase 2: Decision + Knowledge + RAG layers ────────────────────
|
||||
|
||||
SCHEMA_V2_SQL = """
|
||||
@@ -462,22 +358,6 @@ 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
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
@@ -508,9 +388,7 @@ async def init_schema() -> None:
|
||||
await conn.execute(MIGRATIONS_SQL)
|
||||
await conn.execute(SCHEMA_V2_SQL)
|
||||
await conn.execute(SCHEMA_V3_SQL)
|
||||
await conn.execute(SCHEMA_V4_SQL)
|
||||
await conn.execute(SCHEMA_V5_SQL)
|
||||
logger.info("Database schema initialized (v1 + v2 + v3 + v4 + v5)")
|
||||
logger.info("Database schema initialized (v1 + v2 + v3)")
|
||||
|
||||
|
||||
# ── Case CRUD ───────────────────────────────────────────────────────
|
||||
@@ -527,8 +405,6 @@ async def create_case(
|
||||
hearing_date: date | None = None,
|
||||
notes: str = "",
|
||||
expected_outcome: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
appeal_subtype: str | None = None,
|
||||
) -> dict:
|
||||
pool = await get_pool()
|
||||
case_id = uuid4()
|
||||
@@ -536,43 +412,17 @@ async def create_case(
|
||||
await conn.execute(
|
||||
"""INSERT INTO cases (id, case_number, title, appellants, respondents,
|
||||
subject, property_address, permit_number, committee_type,
|
||||
hearing_date, notes, expected_outcome,
|
||||
practice_area, appeal_subtype)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)""",
|
||||
hearing_date, notes, expected_outcome)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""",
|
||||
case_id, case_number, title,
|
||||
json.dumps(appellants or []),
|
||||
json.dumps(respondents or []),
|
||||
subject, property_address, permit_number, committee_type,
|
||||
hearing_date, notes, expected_outcome,
|
||||
practice_area, appeal_subtype,
|
||||
)
|
||||
return await get_case(case_id)
|
||||
|
||||
|
||||
async def get_case_practice_area(case_id: UUID) -> tuple[str | None, str | None]:
|
||||
"""Return (practice_area, appeal_subtype) for a case, or (None, None) if missing."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1", case_id
|
||||
)
|
||||
if row is None:
|
||||
return None, None
|
||||
return row["practice_area"], row["appeal_subtype"]
|
||||
|
||||
|
||||
async def get_case_practice_area_by_number(case_number: str) -> tuple[str | None, str | None]:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT practice_area, appeal_subtype FROM cases WHERE case_number = $1",
|
||||
case_number,
|
||||
)
|
||||
if row is None:
|
||||
return None, None
|
||||
return row["practice_area"], row["appeal_subtype"]
|
||||
|
||||
|
||||
async def get_case(case_id: UUID) -> dict | None:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
@@ -608,16 +458,6 @@ async def list_cases(status: str | None = None, limit: int = 50) -> list[dict]:
|
||||
return [_row_to_case(r) for r in rows]
|
||||
|
||||
|
||||
async def delete_case(case_id: UUID) -> bool:
|
||||
"""Delete a case. Dependent rows in documents/document_chunks/qa_results
|
||||
cascade automatically (schema-level ON DELETE CASCADE); audit_log rows
|
||||
nullify their case_id reference. Returns True if a row was deleted."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute("DELETE FROM cases WHERE id = $1", case_id)
|
||||
return result.endswith(" 1")
|
||||
|
||||
|
||||
async def update_case(case_id: UUID, **fields) -> dict | None:
|
||||
if not fields:
|
||||
return await get_case(case_id)
|
||||
@@ -648,34 +488,19 @@ def _row_to_case(row: asyncpg.Record) -> dict:
|
||||
# ── Document CRUD ───────────────────────────────────────────────────
|
||||
|
||||
async def create_document(
|
||||
case_id: UUID | None,
|
||||
case_id: UUID,
|
||||
doc_type: str,
|
||||
title: str,
|
||||
file_path: str,
|
||||
page_count: int | None = None,
|
||||
practice_area: str | None = None,
|
||||
appeal_subtype: str | None = None,
|
||||
) -> dict:
|
||||
pool = await get_pool()
|
||||
doc_id = uuid4()
|
||||
async with pool.acquire() as conn:
|
||||
# If practice_area not explicitly given, inherit from the parent case
|
||||
# (for case-bound documents). Training corpus passes case_id=None and
|
||||
# provides the practice_area directly.
|
||||
if practice_area is None and case_id is not None:
|
||||
case_row = await conn.fetchrow(
|
||||
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1",
|
||||
case_id,
|
||||
)
|
||||
if case_row:
|
||||
practice_area = case_row["practice_area"]
|
||||
appeal_subtype = case_row["appeal_subtype"]
|
||||
await conn.execute(
|
||||
"""INSERT INTO documents (id, case_id, doc_type, title, file_path,
|
||||
page_count, practice_area, appeal_subtype)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)""",
|
||||
"""INSERT INTO documents (id, case_id, doc_type, title, file_path, page_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||
doc_id, case_id, doc_type, title, file_path, page_count,
|
||||
practice_area, appeal_subtype,
|
||||
)
|
||||
row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id)
|
||||
return _row_to_doc(row)
|
||||
@@ -731,113 +556,6 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
||||
return d
|
||||
|
||||
|
||||
# ── case_precedents CRUD ───────────────────────────────────────────
|
||||
|
||||
def _row_to_precedent(row: asyncpg.Record) -> dict:
|
||||
d = dict(row)
|
||||
for k in ("id", "case_id"):
|
||||
if d.get(k) is not None:
|
||||
d[k] = str(d[k])
|
||||
if d.get("pdf_document_id") is not None:
|
||||
d["pdf_document_id"] = str(d["pdf_document_id"])
|
||||
for ts in ("created_at", "updated_at"):
|
||||
if d.get(ts) is not None:
|
||||
d[ts] = d[ts].isoformat()
|
||||
return d
|
||||
|
||||
|
||||
async def create_case_precedent(
|
||||
case_id: UUID,
|
||||
quote: str,
|
||||
citation: str,
|
||||
section_id: str | None = None,
|
||||
chair_note: str = "",
|
||||
pdf_document_id: UUID | None = None,
|
||||
practice_area: str | None = None,
|
||||
) -> dict:
|
||||
"""Insert a new attached precedent. practice_area is inherited from
|
||||
the parent case when not explicitly supplied, so the cross-case
|
||||
library search can filter without a JOIN."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if practice_area is None:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT practice_area FROM cases WHERE id = $1", case_id
|
||||
)
|
||||
practice_area = row["practice_area"] if row else None
|
||||
inserted = await conn.fetchrow(
|
||||
"""INSERT INTO case_precedents
|
||||
(case_id, section_id, quote, citation, chair_note,
|
||||
pdf_document_id, practice_area)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *""",
|
||||
case_id, section_id, quote, citation, chair_note,
|
||||
pdf_document_id, practice_area,
|
||||
)
|
||||
return _row_to_precedent(inserted)
|
||||
|
||||
|
||||
async def list_case_precedents(case_id: UUID) -> list[dict]:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM case_precedents WHERE case_id = $1 "
|
||||
"ORDER BY section_id NULLS FIRST, created_at",
|
||||
case_id,
|
||||
)
|
||||
return [_row_to_precedent(r) for r in rows]
|
||||
|
||||
|
||||
async def delete_case_precedent(precedent_id: UUID) -> bool:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM case_precedents WHERE id = $1", precedent_id
|
||||
)
|
||||
return result.endswith(" 1")
|
||||
|
||||
|
||||
async def search_precedent_library(
|
||||
query: str, practice_area: str = "", limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""Cross-case typeahead for the citation field. Returns one row per
|
||||
distinct citation so the user sees each precedent once even if they
|
||||
previously attached it to multiple cases/sections. No embeddings —
|
||||
simple ILIKE is fine at this scale."""
|
||||
pool = await get_pool()
|
||||
pattern = f"%{query}%"
|
||||
async with pool.acquire() as conn:
|
||||
if practice_area:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT DISTINCT ON (citation)
|
||||
id, citation, quote, chair_note, practice_area, created_at
|
||||
FROM case_precedents
|
||||
WHERE practice_area = $1
|
||||
AND (citation ILIKE $2 OR quote ILIKE $2)
|
||||
ORDER BY citation, created_at DESC
|
||||
LIMIT $3""",
|
||||
practice_area, pattern, limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""SELECT DISTINCT ON (citation)
|
||||
id, citation, quote, chair_note, practice_area, created_at
|
||||
FROM case_precedents
|
||||
WHERE citation ILIKE $1 OR quote ILIKE $1
|
||||
ORDER BY citation, created_at DESC
|
||||
LIMIT $2""",
|
||||
pattern, limit,
|
||||
)
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["id"] = str(d["id"])
|
||||
if d.get("created_at"):
|
||||
d["created_at"] = d["created_at"].isoformat()
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# ── Claims ─────────────────────────────────────────────────────────
|
||||
|
||||
async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int:
|
||||
@@ -904,20 +622,12 @@ async def create_decision(
|
||||
)
|
||||
version = (existing["version"] + 1) if existing else 1
|
||||
|
||||
case_row = await conn.fetchrow(
|
||||
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1", case_id
|
||||
)
|
||||
practice_area = case_row["practice_area"] if case_row else None
|
||||
appeal_subtype = case_row["appeal_subtype"] if case_row else None
|
||||
|
||||
await conn.execute(
|
||||
"""INSERT INTO decisions (id, case_id, version, outcome, outcome_summary,
|
||||
outcome_reasoning, direction_doc,
|
||||
practice_area, appeal_subtype)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
||||
outcome_reasoning, direction_doc)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
||||
decision_id, case_id, version, outcome, outcome_summary,
|
||||
outcome_reasoning, json.dumps(direction_doc) if direction_doc else None,
|
||||
practice_area, appeal_subtype,
|
||||
)
|
||||
return await get_decision(decision_id)
|
||||
|
||||
@@ -991,37 +701,12 @@ async def store_chunks(
|
||||
document_id: UUID,
|
||||
case_id: UUID | None,
|
||||
chunks: list[dict],
|
||||
practice_area: str | None = None,
|
||||
appeal_subtype: str | None = None,
|
||||
) -> int:
|
||||
"""Store document chunks with embeddings. Each chunk dict has:
|
||||
content, section_type, embedding (list[float]), page_number, chunk_index.
|
||||
|
||||
practice_area defaults to the parent case's value, or — when case_id is
|
||||
None (training corpus) — falls back to the parent document's value so
|
||||
vector search can still filter cleanly.
|
||||
content, section_type, embedding (list[float]), page_number, chunk_index
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
# Resolve practice_area in priority order: explicit > case > document.
|
||||
if practice_area is None:
|
||||
if case_id is not None:
|
||||
case_row = await conn.fetchrow(
|
||||
"SELECT practice_area, appeal_subtype FROM cases WHERE id = $1",
|
||||
case_id,
|
||||
)
|
||||
if case_row:
|
||||
practice_area = case_row["practice_area"]
|
||||
appeal_subtype = case_row["appeal_subtype"]
|
||||
if practice_area is None:
|
||||
doc_row = await conn.fetchrow(
|
||||
"SELECT practice_area, appeal_subtype FROM documents WHERE id = $1",
|
||||
document_id,
|
||||
)
|
||||
if doc_row:
|
||||
practice_area = doc_row["practice_area"]
|
||||
appeal_subtype = doc_row["appeal_subtype"]
|
||||
|
||||
# Delete existing chunks for this document
|
||||
await conn.execute(
|
||||
"DELETE FROM document_chunks WHERE document_id = $1", document_id
|
||||
@@ -1029,16 +714,14 @@ async def store_chunks(
|
||||
for chunk in chunks:
|
||||
await conn.execute(
|
||||
"""INSERT INTO document_chunks
|
||||
(document_id, case_id, chunk_index, content, section_type,
|
||||
embedding, page_number, practice_area, appeal_subtype)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
||||
(document_id, case_id, chunk_index, content, section_type, embedding, page_number)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)""",
|
||||
document_id, case_id,
|
||||
chunk["chunk_index"],
|
||||
chunk["content"],
|
||||
chunk.get("section_type", "other"),
|
||||
chunk["embedding"],
|
||||
chunk.get("page_number"),
|
||||
practice_area, appeal_subtype,
|
||||
)
|
||||
return len(chunks)
|
||||
|
||||
@@ -1048,15 +731,8 @@ async def search_similar(
|
||||
limit: int = 10,
|
||||
case_id: UUID | None = None,
|
||||
section_type: str | None = None,
|
||||
practice_area: str | None = None,
|
||||
appeal_subtype: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Cosine similarity search on document chunks.
|
||||
|
||||
Filter by practice_area to keep precedents from the same legal domain
|
||||
(e.g. don't surface betterment-levy chunks when working on building
|
||||
permits). Uses the denormalized column on document_chunks — no JOIN.
|
||||
"""
|
||||
"""Cosine similarity search on document chunks."""
|
||||
pool = await get_pool()
|
||||
conditions = []
|
||||
params: list = [query_embedding, limit]
|
||||
@@ -1070,14 +746,6 @@ async def search_similar(
|
||||
conditions.append(f"dc.section_type = ${param_idx}")
|
||||
params.append(section_type)
|
||||
param_idx += 1
|
||||
if practice_area:
|
||||
conditions.append(f"dc.practice_area = ${param_idx}")
|
||||
params.append(practice_area)
|
||||
param_idx += 1
|
||||
if appeal_subtype:
|
||||
conditions.append(f"dc.appeal_subtype = ${param_idx}")
|
||||
params.append(appeal_subtype)
|
||||
param_idx += 1
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
@@ -1110,8 +778,6 @@ async def add_to_style_corpus(
|
||||
summary: str = "",
|
||||
outcome: str = "",
|
||||
key_principles: list[str] | None = None,
|
||||
practice_area: str = "appeals_committee",
|
||||
appeal_subtype: str | None = None,
|
||||
) -> UUID:
|
||||
pool = await get_pool()
|
||||
corpus_id = uuid4()
|
||||
@@ -1119,13 +785,11 @@ async def add_to_style_corpus(
|
||||
await conn.execute(
|
||||
"""INSERT INTO style_corpus
|
||||
(id, document_id, decision_number, decision_date,
|
||||
subject_categories, full_text, summary, outcome, key_principles,
|
||||
practice_area, appeal_subtype)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""",
|
||||
subject_categories, full_text, summary, outcome, key_principles)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
||||
corpus_id, document_id, decision_number, decision_date,
|
||||
json.dumps(subject_categories), full_text, summary, outcome,
|
||||
json.dumps(key_principles or []),
|
||||
practice_area, appeal_subtype,
|
||||
)
|
||||
return corpus_id
|
||||
|
||||
@@ -1229,15 +893,8 @@ async def search_similar_paragraphs(
|
||||
query_embedding: list[float],
|
||||
limit: int = 10,
|
||||
block_type: str | None = None,
|
||||
practice_area: str | None = None,
|
||||
appeal_subtype: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Search decision paragraphs by semantic similarity.
|
||||
|
||||
Filtering by practice_area uses the denormalized column on `decisions`
|
||||
so we don't pull, e.g., betterment-levy paragraphs when writing a
|
||||
building-permit decision.
|
||||
"""
|
||||
"""Search decision paragraphs by semantic similarity."""
|
||||
pool = await get_pool()
|
||||
conditions = []
|
||||
params: list = [query_embedding, limit]
|
||||
@@ -1247,14 +904,6 @@ async def search_similar_paragraphs(
|
||||
conditions.append(f"db.block_id = ${param_idx}")
|
||||
params.append(block_type)
|
||||
param_idx += 1
|
||||
if practice_area:
|
||||
conditions.append(f"d.practice_area = ${param_idx}")
|
||||
params.append(practice_area)
|
||||
param_idx += 1
|
||||
if appeal_subtype:
|
||||
conditions.append(f"d.appeal_subtype = ${param_idx}")
|
||||
params.append(appeal_subtype)
|
||||
param_idx += 1
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
@@ -1337,72 +986,3 @@ async def search_precedents(
|
||||
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
# ── Chair feedback ────────────────────────────────────────────────
|
||||
|
||||
async def record_chair_feedback(
|
||||
case_id: UUID | None,
|
||||
block_id: str,
|
||||
feedback_text: str,
|
||||
category: str = "other",
|
||||
lesson_extracted: str = "",
|
||||
) -> UUID:
|
||||
"""Record feedback from the chair (Dafna) on a draft block."""
|
||||
pool = await get_pool()
|
||||
feedback_id = uuid4()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""INSERT INTO chair_feedback
|
||||
(id, case_id, block_id, feedback_text, category, lesson_extracted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||
feedback_id, case_id, block_id, feedback_text, category,
|
||||
lesson_extracted,
|
||||
)
|
||||
return feedback_id
|
||||
|
||||
|
||||
async def list_chair_feedback(
|
||||
case_id: UUID | None = None,
|
||||
category: str | None = None,
|
||||
unresolved_only: bool = False,
|
||||
) -> list[dict]:
|
||||
"""List chair feedback, optionally filtered."""
|
||||
pool = await get_pool()
|
||||
conditions = []
|
||||
params: list = []
|
||||
idx = 1
|
||||
|
||||
if case_id:
|
||||
conditions.append(f"case_id = ${idx}")
|
||||
params.append(case_id)
|
||||
idx += 1
|
||||
if category:
|
||||
conditions.append(f"category = ${idx}")
|
||||
params.append(category)
|
||||
idx += 1
|
||||
if unresolved_only:
|
||||
conditions.append("resolved = FALSE")
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC",
|
||||
*params,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def resolve_chair_feedback(
|
||||
feedback_id: UUID,
|
||||
applied_to: list[str],
|
||||
) -> None:
|
||||
"""Mark feedback as resolved and record where it was applied."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""UPDATE chair_feedback
|
||||
SET resolved = TRUE, applied_to = $2
|
||||
WHERE id = $1""",
|
||||
feedback_id, applied_to,
|
||||
)
|
||||
|
||||
@@ -329,193 +329,3 @@ 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"]
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Practice area + appeal subtype: derivation, validation, constants.
|
||||
|
||||
Two orthogonal axes used to separate legal domains across the system:
|
||||
|
||||
practice_area — top-level domain (multi-tenant axis). Examples:
|
||||
appeals_committee, national_insurance, labor_law.
|
||||
appeal_subtype — refines within a domain. For appeals_committee:
|
||||
building_permit (1xxx), betterment_levy (8xxx),
|
||||
compensation_197 (9xxx), unknown.
|
||||
|
||||
Both columns are denormalized into documents/chunks/decisions/style_corpus
|
||||
so vector searches can filter cheaply.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# ── Enums ──────────────────────────────────────────────────────────
|
||||
|
||||
PRACTICE_AREAS: set[str] = {
|
||||
"appeals_committee",
|
||||
"national_insurance",
|
||||
"labor_law",
|
||||
}
|
||||
|
||||
APPEALS_COMMITTEE_SUBTYPES: set[str] = {
|
||||
"building_permit",
|
||||
"betterment_levy",
|
||||
"compensation_197",
|
||||
"unknown",
|
||||
}
|
||||
|
||||
DEFAULT_PRACTICE_AREA = "appeals_committee"
|
||||
|
||||
# Subtypes per practice_area (extend when adding domains)
|
||||
SUBTYPES_BY_AREA: dict[str, set[str]] = {
|
||||
"appeals_committee": APPEALS_COMMITTEE_SUBTYPES,
|
||||
"national_insurance": {"unknown"},
|
||||
"labor_law": {"unknown"},
|
||||
}
|
||||
|
||||
|
||||
# ── Derivation ─────────────────────────────────────────────────────
|
||||
|
||||
_FIRST_DIGIT = re.compile(r"^\s*(\d)")
|
||||
|
||||
_APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE = {
|
||||
"1": "building_permit",
|
||||
"8": "betterment_levy",
|
||||
"9": "compensation_197",
|
||||
}
|
||||
|
||||
|
||||
def derive_subtype(case_number: str, practice_area: str = DEFAULT_PRACTICE_AREA) -> str:
|
||||
"""Infer the appeal_subtype from case_number.
|
||||
|
||||
For appeals_committee, the convention is:
|
||||
1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197.
|
||||
|
||||
For other practice areas there is no public numbering convention yet,
|
||||
so we return 'unknown' until a real rule is defined.
|
||||
"""
|
||||
if practice_area != "appeals_committee":
|
||||
return "unknown"
|
||||
m = _FIRST_DIGIT.match(case_number or "")
|
||||
if not m:
|
||||
return "unknown"
|
||||
return _APPEALS_COMMITTEE_DIGIT_TO_SUBTYPE.get(m.group(1), "unknown")
|
||||
|
||||
|
||||
# ── Validation ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate(practice_area: str, appeal_subtype: str | None) -> None:
|
||||
"""Raise ValueError on unknown values. appeal_subtype=None is allowed."""
|
||||
if practice_area not in PRACTICE_AREAS:
|
||||
raise ValueError(
|
||||
f"unknown practice_area: {practice_area!r}. "
|
||||
f"expected one of {sorted(PRACTICE_AREAS)}"
|
||||
)
|
||||
if appeal_subtype is None:
|
||||
return
|
||||
allowed = SUBTYPES_BY_AREA.get(practice_area, {"unknown"})
|
||||
if appeal_subtype not in allowed:
|
||||
raise ValueError(
|
||||
f"unknown appeal_subtype {appeal_subtype!r} for practice_area "
|
||||
f"{practice_area!r}. expected one of {sorted(allowed)}"
|
||||
)
|
||||
|
||||
|
||||
def is_override(case_number: str, practice_area: str, appeal_subtype: str) -> bool:
|
||||
"""True iff the user-supplied subtype disagrees with what derive_subtype
|
||||
would have produced (and the derived value is not 'unknown')."""
|
||||
derived = derive_subtype(case_number, practice_area)
|
||||
return derived != "unknown" and derived != appeal_subtype
|
||||
@@ -26,13 +26,6 @@ CHAIR_POSITION_PLACEHOLDERS = (
|
||||
"[טרם מולא]",
|
||||
)
|
||||
|
||||
# Any text starting with these prefixes is also a placeholder
|
||||
# (the analyst sometimes adds explanatory text after the bracket)
|
||||
CHAIR_POSITION_PLACEHOLDER_PREFIXES = (
|
||||
"[ימולא",
|
||||
"ימולא ע",
|
||||
)
|
||||
|
||||
CHAIR_POSITION_LABEL = "עמדת ועדת הערר"
|
||||
|
||||
# Matches "## N. title" or "## title" for main sections
|
||||
@@ -54,9 +47,6 @@ CASE_NUMBER_RE = re.compile(r"#\s*ניתוח.*?ערר\s+([\d/\-]+)", re.MULTILIN
|
||||
DATE_RE = re.compile(r"^תאריך:\s*(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
RESEARCH_FINDINGS_FILENAME = "research-findings.md"
|
||||
|
||||
|
||||
def _is_placeholder(text: str) -> bool:
|
||||
"""Check if a field value is one of the placeholder strings (empty)."""
|
||||
stripped = text.strip()
|
||||
@@ -65,9 +55,6 @@ def _is_placeholder(text: str) -> bool:
|
||||
for ph in CHAIR_POSITION_PLACEHOLDERS:
|
||||
if ph in stripped:
|
||||
return True
|
||||
for prefix in CHAIR_POSITION_PLACEHOLDER_PREFIXES:
|
||||
if stripped.startswith(prefix):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -447,199 +434,3 @@ def extract_chair_directions(file_path: Path) -> dict[str, Any]:
|
||||
"threshold_claims": threshold,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
# ── Full analysis extraction (for legal-writer) ──────────────────
|
||||
|
||||
|
||||
# Map Hebrew field labels → stable English keys for JSON output
|
||||
_FIELD_KEY_MAP = {
|
||||
"טענה": "claims",
|
||||
"טענה (claim)": "claims",
|
||||
"טענות": "claims",
|
||||
"תשובה": "responses",
|
||||
"תשובה (response)": "responses",
|
||||
"תשובות": "responses",
|
||||
"תגובה": "replies",
|
||||
"תגובה (reply)": "replies",
|
||||
"תגובות": "replies",
|
||||
# Analyst sometimes appends party name to the label
|
||||
# e.g. "תגובה (reply — קובר)" — catch the pattern dynamically below
|
||||
"ניתוח אסטרטגי": "strategic_analysis",
|
||||
"חוזקות": "strengths",
|
||||
"חולשות": "weaknesses",
|
||||
"הזדמנויות": "opportunities",
|
||||
"שאלות משפטיות": "legal_questions",
|
||||
"חיפוש תקדימים": "precedent_search",
|
||||
"חקיקה רלוונטית": "relevant_legislation",
|
||||
"תקדימים מהקורפוס הפנימי": "internal_precedents",
|
||||
}
|
||||
|
||||
|
||||
def _fields_to_dict(fields: list[dict]) -> dict[str, str]:
|
||||
"""Convert ordered field list to a dict with stable English keys.
|
||||
|
||||
Unknown labels are kept as-is (Hebrew) so no data is lost.
|
||||
Handles dynamic labels like "תגובה (reply — קובר)" by matching prefix.
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
for f in fields:
|
||||
label = f["label"]
|
||||
key = _FIELD_KEY_MAP.get(label)
|
||||
if key is None:
|
||||
# Try prefix matching for dynamic labels (e.g. "תגובה (reply — name)")
|
||||
if label.startswith("תגובה"):
|
||||
key = "replies"
|
||||
elif label.startswith("טענה"):
|
||||
key = "claims"
|
||||
elif label.startswith("תשובה"):
|
||||
key = "responses"
|
||||
else:
|
||||
key = label
|
||||
result[key] = f["content"]
|
||||
return result
|
||||
|
||||
|
||||
def extract_full_analysis(file_path: Path) -> dict[str, Any]:
|
||||
"""Extract the complete strategic analysis from analysis-and-research.md.
|
||||
|
||||
Unlike extract_chair_directions (which returns only chair positions),
|
||||
this returns ALL fields per issue: claims, responses, replies,
|
||||
strengths/weaknesses/opportunities, legal questions, legislation,
|
||||
and internal precedents — everything the legal-writer needs to
|
||||
produce block-yod (discussion).
|
||||
|
||||
Returns the same envelope as extract_chair_directions (status, counts)
|
||||
plus full field data in each item.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return {
|
||||
"file_exists": False,
|
||||
"status": "missing",
|
||||
"error": "analysis-and-research.md not found",
|
||||
"procedural_background": "",
|
||||
"agreed_facts": "",
|
||||
"disputed_facts": "",
|
||||
"conclusions": "",
|
||||
"threshold_claims": [],
|
||||
"issues": [],
|
||||
"total_items": 0,
|
||||
"filled_count": 0,
|
||||
"empty_count": 0,
|
||||
}
|
||||
|
||||
parsed = parse(file_path)
|
||||
|
||||
def enrich_item(item: dict) -> dict:
|
||||
"""Return full item with all fields as a flat dict."""
|
||||
enriched = {
|
||||
"id": item["id"],
|
||||
"number": item["number"],
|
||||
"title": item["title"],
|
||||
"direction": item.get("chair_position", "") or "",
|
||||
}
|
||||
# Add all extracted fields with stable keys
|
||||
enriched.update(_fields_to_dict(item.get("fields", [])))
|
||||
return enriched
|
||||
|
||||
threshold = [enrich_item(t) for t in parsed.get("threshold_claims", [])]
|
||||
issues = [enrich_item(i) for i in parsed.get("issues", [])]
|
||||
|
||||
all_items = threshold + issues
|
||||
total = len(all_items)
|
||||
filled = sum(1 for x in all_items if x["direction"].strip())
|
||||
empty = total - filled
|
||||
|
||||
if total == 0:
|
||||
status = "missing"
|
||||
elif filled == 0:
|
||||
status = "empty"
|
||||
elif filled == total:
|
||||
status = "complete"
|
||||
else:
|
||||
status = "partial"
|
||||
|
||||
return {
|
||||
"file_exists": True,
|
||||
"file_path": str(file_path),
|
||||
"case_number": parsed.get("header", {}).get("case_number", ""),
|
||||
"modified_at": parsed.get("header", {}).get("modified_at", ""),
|
||||
"status": status,
|
||||
"total_items": total,
|
||||
"filled_count": filled,
|
||||
"empty_count": empty,
|
||||
"procedural_background": parsed.get("procedural_background", ""),
|
||||
"agreed_facts": parsed.get("agreed_facts", ""),
|
||||
"disputed_facts": parsed.get("disputed_facts", ""),
|
||||
"conclusions": parsed.get("conclusions", ""),
|
||||
"threshold_claims": threshold,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
# ── Research findings extraction ──────────────────────────────────
|
||||
|
||||
|
||||
def extract_research_findings(file_path: Path) -> dict[str, Any]:
|
||||
"""Extract structured research findings from research-findings.md.
|
||||
|
||||
The file is produced by the legal-researcher agent and contains:
|
||||
precedent summaries, plan mappings, timeline, and recommendations.
|
||||
Returns a structured dict or a status-only dict if file is missing.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return {
|
||||
"file_exists": False,
|
||||
"status": "missing",
|
||||
"error": "research-findings.md not found",
|
||||
}
|
||||
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
stat = file_path.stat()
|
||||
mtime_iso = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
|
||||
sections = _split_main_sections(content)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"file_exists": True,
|
||||
"file_path": str(file_path),
|
||||
"modified_at": mtime_iso,
|
||||
"file_size": stat.st_size,
|
||||
"precedent_summaries": [],
|
||||
"plan_mappings": [],
|
||||
"timeline": "",
|
||||
"recommendations": "",
|
||||
"other_sections": [],
|
||||
}
|
||||
|
||||
for _number, title, body in sections:
|
||||
title_norm = title.strip()
|
||||
if "סיכום פסיקה" in title_norm or "פסיקה" in title_norm:
|
||||
subs = _split_subsections(body)
|
||||
for sub_title, sub_body in subs:
|
||||
fields = _extract_fields(sub_body)
|
||||
result["precedent_summaries"].append({
|
||||
"title": sub_title,
|
||||
"fields": {f["label"]: f["content"] for f in fields},
|
||||
"raw": sub_body if not fields else "",
|
||||
})
|
||||
elif "מיפוי תכנית" in title_norm or "תכנית" in title_norm:
|
||||
subs = _split_subsections(body)
|
||||
for sub_title, sub_body in subs:
|
||||
fields = _extract_fields(sub_body)
|
||||
result["plan_mappings"].append({
|
||||
"title": sub_title,
|
||||
"fields": {f["label"]: f["content"] for f in fields},
|
||||
"raw": sub_body if not fields else "",
|
||||
})
|
||||
elif "ציר זמן" in title_norm:
|
||||
result["timeline"] = body
|
||||
elif "המלצות" in title_norm:
|
||||
result["recommendations"] = body
|
||||
else:
|
||||
result["other_sections"].append({
|
||||
"title": title_norm,
|
||||
"body": body,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import audit, db, practice_area as pa
|
||||
from legal_mcp.services import db
|
||||
|
||||
|
||||
async def case_create(
|
||||
@@ -24,8 +23,6 @@ async def case_create(
|
||||
hearing_date: str = "",
|
||||
notes: str = "",
|
||||
expected_outcome: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
appeal_subtype: str = "",
|
||||
) -> str:
|
||||
"""יצירת תיק ערר חדש.
|
||||
|
||||
@@ -41,9 +38,6 @@ async def case_create(
|
||||
hearing_date: תאריך דיון (YYYY-MM-DD)
|
||||
notes: הערות
|
||||
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
|
||||
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
|
||||
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
||||
ריק = יוסק אוטומטית ממספר התיק
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
|
||||
@@ -51,12 +45,6 @@ async def case_create(
|
||||
if hearing_date:
|
||||
h_date = date_type.fromisoformat(hearing_date)
|
||||
|
||||
# Resolve appeal_subtype: explicit override > auto-derive > 'unknown'
|
||||
derived_subtype = pa.derive_subtype(case_number, practice_area)
|
||||
if not appeal_subtype:
|
||||
appeal_subtype = derived_subtype
|
||||
pa.validate(practice_area, appeal_subtype)
|
||||
|
||||
case = await db.create_case(
|
||||
case_number=case_number,
|
||||
title=title,
|
||||
@@ -69,24 +57,8 @@ async def case_create(
|
||||
hearing_date=h_date,
|
||||
notes=notes,
|
||||
expected_outcome=expected_outcome,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
)
|
||||
|
||||
# If the user overrode the case-number convention (e.g. case 8500 marked
|
||||
# as building_permit), record it so we can audit later.
|
||||
if pa.is_override(case_number, practice_area, appeal_subtype):
|
||||
await audit.log_action(
|
||||
action="case_subtype_override",
|
||||
case_id=UUID(case["id"]),
|
||||
details={
|
||||
"case_number": case_number,
|
||||
"derived_subtype": derived_subtype,
|
||||
"chosen_subtype": appeal_subtype,
|
||||
"practice_area": practice_area,
|
||||
},
|
||||
)
|
||||
|
||||
# Initialize git repo for the case
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -215,37 +187,3 @@ async def case_update(
|
||||
)
|
||||
|
||||
return json.dumps(updated, default=str, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def case_delete(case_number: str, remove_files: bool = False) -> str:
|
||||
"""מחיקת תיק ערר. מסיר את התיק מ-DB עם cascade לכל המסמכים והטענות.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
remove_files: האם למחוק גם את תיקיית הדיסק (drafts, git repo).
|
||||
ברירת מחדל False — ה-DB נמחק אבל הקבצים נשמרים לגיבוי.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return json.dumps(
|
||||
{"deleted": False, "reason": f"תיק {case_number} לא נמצא."},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
ok = await db.delete_case(case_id)
|
||||
|
||||
result = {
|
||||
"deleted": ok,
|
||||
"case_number": case_number,
|
||||
"case_id": str(case_id),
|
||||
"removed_files": False,
|
||||
}
|
||||
|
||||
if ok and remove_files:
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
shutil.rmtree(case_dir, ignore_errors=True)
|
||||
result["removed_files"] = True
|
||||
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -105,8 +105,6 @@ async def document_upload_training(
|
||||
decision_date: str = "",
|
||||
subject_categories: list[str] | None = None,
|
||||
title: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
appeal_subtype: str = "",
|
||||
) -> str:
|
||||
"""העלאת החלטה קודמת של דפנה לקורפוס הסגנון (training).
|
||||
|
||||
@@ -116,13 +114,10 @@ async def document_upload_training(
|
||||
decision_date: תאריך ההחלטה (YYYY-MM-DD)
|
||||
subject_categories: קטגוריות - אפשר לבחור כמה (בנייה, שימוש חורג, תכנית, היתר, הקלה, חלוקה, תמ"א 38, היטל השבחה, פיצויים 197)
|
||||
title: שם המסמך
|
||||
practice_area: תחום משפטי (appeals_committee / national_insurance / labor_law)
|
||||
appeal_subtype: סוג ערר (building_permit / betterment_levy / compensation_197).
|
||||
ריק = יוסק אוטומטית ממספר ההחלטה
|
||||
"""
|
||||
from datetime import date as date_type
|
||||
|
||||
from legal_mcp.services import chunker, embeddings, extractor, practice_area as pa
|
||||
from legal_mcp.services import extractor, embeddings, chunker
|
||||
|
||||
source = Path(file_path)
|
||||
if not source.exists():
|
||||
@@ -131,11 +126,6 @@ async def document_upload_training(
|
||||
if not title:
|
||||
title = source.stem
|
||||
|
||||
# Resolve subtype: explicit > derived from decision_number > 'unknown'
|
||||
if not appeal_subtype:
|
||||
appeal_subtype = pa.derive_subtype(decision_number, practice_area)
|
||||
pa.validate(practice_area, appeal_subtype)
|
||||
|
||||
# Copy to training directory (skip if already there)
|
||||
config.TRAINING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dest = config.TRAINING_DIR / source.name
|
||||
@@ -150,29 +140,25 @@ async def document_upload_training(
|
||||
if decision_date:
|
||||
d_date = date_type.fromisoformat(decision_date)
|
||||
|
||||
# Add to style corpus (tagged by domain so block-writer can filter)
|
||||
# Add to style corpus
|
||||
corpus_id = await db.add_to_style_corpus(
|
||||
document_id=None,
|
||||
decision_number=decision_number,
|
||||
decision_date=d_date,
|
||||
subject_categories=subject_categories or [],
|
||||
full_text=text,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
)
|
||||
|
||||
# Chunk and embed for RAG search over training corpus
|
||||
chunks = chunker.chunk_document(text)
|
||||
if chunks:
|
||||
# Create a document record (no case association — tag explicitly)
|
||||
# Create a document record (no case association)
|
||||
doc = await db.create_document(
|
||||
case_id=None,
|
||||
doc_type="decision",
|
||||
title=f"[קורפוס] {title}",
|
||||
file_path=str(dest),
|
||||
page_count=page_count,
|
||||
practice_area=practice_area,
|
||||
appeal_subtype=appeal_subtype,
|
||||
)
|
||||
doc_id = UUID(doc["id"])
|
||||
await db.update_document(doc_id, extracted_text=text, extraction_status="completed")
|
||||
@@ -190,10 +176,7 @@ async def document_upload_training(
|
||||
}
|
||||
for c, emb in zip(chunks, embs)
|
||||
]
|
||||
await db.store_chunks(
|
||||
doc_id, None, chunk_dicts,
|
||||
practice_area=practice_area, appeal_subtype=appeal_subtype,
|
||||
)
|
||||
await db.store_chunks(doc_id, None, chunk_dicts)
|
||||
|
||||
return json.dumps({
|
||||
"corpus_id": str(corpus_id),
|
||||
@@ -383,49 +366,3 @@ async def get_claims(case_number: str, party_role: str = "") -> str:
|
||||
})
|
||||
|
||||
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def document_update_status(
|
||||
case_number: str,
|
||||
doc_title: str,
|
||||
status: str,
|
||||
) -> str:
|
||||
"""עדכון סטטוס עיבוד מסמך (extraction_status).
|
||||
|
||||
ערכים אפשריים: pending, extracted, proofread, error.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
doc_title: שם/כותרת המסמך (או חלק ממנו)
|
||||
status: הסטטוס החדש
|
||||
"""
|
||||
valid_statuses = ("pending", "extracted", "proofread", "error")
|
||||
if status not in valid_statuses:
|
||||
return f"סטטוס לא חוקי: {status}. ערכים אפשריים: {', '.join(valid_statuses)}"
|
||||
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return f"תיק {case_number} לא נמצא."
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
docs = await db.get_documents(case_id)
|
||||
|
||||
# Find matching document by title (partial match)
|
||||
matched = None
|
||||
for d in docs:
|
||||
if doc_title.lower() in d.get("title", "").lower():
|
||||
matched = d
|
||||
break
|
||||
|
||||
if not matched:
|
||||
titles = [d.get("title", "?") for d in docs]
|
||||
return f"מסמך '{doc_title}' לא נמצא בתיק {case_number}. מסמכים קיימים: {titles}"
|
||||
|
||||
doc_id = UUID(matched["id"])
|
||||
await db.update_document(doc_id, extraction_status=status)
|
||||
|
||||
return json.dumps({
|
||||
"updated": True,
|
||||
"document": matched["title"],
|
||||
"new_status": status,
|
||||
}, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -281,39 +281,6 @@ async def draft_section(
|
||||
return json.dumps(context, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def get_research_findings(case_number: str) -> str:
|
||||
"""שליפת ממצאי מחקר — סיכומי פסיקה, מיפוי תכניות, ציר זמן, והמלצות.
|
||||
|
||||
קורא מ-research-findings.md שנוצר ע"י סוכן חוקר התקדימים (legal-researcher).
|
||||
מחזיר JSON מובנה עם הממצאים, או status=missing אם הקובץ לא קיים עדיין.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
"""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
file_path = case_dir / "documents" / "research" / research_md.RESEARCH_FINDINGS_FILENAME
|
||||
result = research_md.extract_research_findings(file_path)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def get_full_analysis(case_number: str) -> str:
|
||||
"""שליפת הניתוח המשפטי המלא מ-analysis-and-research.md — כולל טענות, תשובות,
|
||||
תגובות, ניתוח אסטרטגי (חוזקות/חולשות/הזדמנויות), שאלות מחקר, חקיקה רלוונטית,
|
||||
תקדימים פנימיים, ועמדות יו"ר הוועדה.
|
||||
|
||||
זה הכלי המרכזי שכותב ההחלטה צריך לקרוא **לפני** כתיבת בלוק י (דיון).
|
||||
מחזיר JSON מובנה עם כל השדות שהניתוח המשפטי הפיק, ולא רק עמדות כמו
|
||||
get_chair_directions.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
"""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
file_path = case_dir / "documents" / "research" / "analysis-and-research.md"
|
||||
result = research_md.extract_full_analysis(file_path)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def get_chair_directions(case_number: str) -> str:
|
||||
"""שליפת עמדות יו"ר הוועדה (דפנה) על סוגיות הערר, לצורך יצירת direction_doc
|
||||
לכותב. קורא מ-analysis-and-research.md (שנוצר ע"י legal-analyst ומולא ע"י
|
||||
@@ -487,71 +454,6 @@ async def save_block_content(case_number: str, block_id: str, content: str) -> s
|
||||
return str(e)
|
||||
|
||||
|
||||
async def get_decision_blocks(case_number: str, block_id: str = "") -> str:
|
||||
"""שליפת בלוקים שנכתבו בהחלטה — תוכן, ספירת מילים, משקלות.
|
||||
|
||||
אם block_id ריק — מחזיר את כל הבלוקים. אם מצוין — רק בלוק ספציפי.
|
||||
שימושי לבודק איכות (QA) שצריך לקרוא בלוקים בודדים.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
block_id: מזהה בלוק (ריק = כולם). למשל: block-yod, block-vav
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return f"תיק {case_number} לא נמצא."
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
decision = await db.get_decision_by_case(case_id)
|
||||
if not decision:
|
||||
return f"אין החלטה בתיק {case_number}."
|
||||
|
||||
decision_id = UUID(decision["id"])
|
||||
pool = await db.get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if block_id:
|
||||
rows = await conn.fetch(
|
||||
"SELECT block_id, block_index, title, content, word_count, "
|
||||
"weight_percent, status FROM decision_blocks "
|
||||
"WHERE decision_id = $1 AND block_id = $2 "
|
||||
"ORDER BY block_index",
|
||||
decision_id, block_id,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"SELECT block_id, block_index, title, content, word_count, "
|
||||
"weight_percent, status FROM decision_blocks "
|
||||
"WHERE decision_id = $1 ORDER BY block_index",
|
||||
decision_id,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
if block_id:
|
||||
return f"בלוק {block_id} לא נמצא בהחלטה."
|
||||
return "אין בלוקים בהחלטה."
|
||||
|
||||
blocks = []
|
||||
total_words = 0
|
||||
for r in rows:
|
||||
total_words += r["word_count"] or 0
|
||||
blocks.append({
|
||||
"block_id": r["block_id"],
|
||||
"index": r["block_index"],
|
||||
"title": r["title"],
|
||||
"content": r["content"],
|
||||
"word_count": r["word_count"],
|
||||
"weight_percent": float(r["weight_percent"] or 0),
|
||||
"status": r["status"],
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"case_number": case_number,
|
||||
"total_blocks": len(blocks),
|
||||
"total_words": total_words,
|
||||
"blocks": blocks,
|
||||
}, default=str, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def analyze_style() -> str:
|
||||
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם."""
|
||||
from legal_mcp.services.style_analyzer import analyze_corpus
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""MCP tools for attached legal precedents (user-supplied case-law quotes).
|
||||
|
||||
These complement the existing `case_law` table (which is populated from
|
||||
structured sources and is what the block-writer RAG searches) by storing
|
||||
free-text citations the chair attaches during the compose phase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db
|
||||
|
||||
|
||||
async def precedent_attach(
|
||||
case_number: str,
|
||||
quote: str,
|
||||
citation: str,
|
||||
section_id: str = "",
|
||||
chair_note: str = "",
|
||||
pdf_document_id: str = "",
|
||||
) -> str:
|
||||
"""צירוף פסיקה תומכת לתיק ערר.
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר
|
||||
quote: הציטוט המדויק שיוכנס להחלטה
|
||||
citation: מראה המקום (ערר 1126-08-25 ... נ' ... (נבו 9.3.2026))
|
||||
section_id: מזהה הטענה/סוגיה (threshold_1, issue_3); ריק = כללי לתיק
|
||||
chair_note: הערה אופציונלית — למה הציטוט תומך בעמדה
|
||||
pdf_document_id: מזהה קובץ PDF מצורף (אופציונלי)
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
|
||||
|
||||
pdf_uuid: UUID | None = None
|
||||
if pdf_document_id:
|
||||
try:
|
||||
pdf_uuid = UUID(pdf_document_id)
|
||||
except ValueError:
|
||||
return json.dumps({"error": "pdf_document_id לא תקין"}, ensure_ascii=False)
|
||||
|
||||
row = await db.create_case_precedent(
|
||||
case_id=UUID(case["id"]),
|
||||
quote=quote,
|
||||
citation=citation,
|
||||
section_id=section_id or None,
|
||||
chair_note=chair_note,
|
||||
pdf_document_id=pdf_uuid,
|
||||
practice_area=case.get("practice_area"),
|
||||
)
|
||||
return json.dumps(row, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def precedent_list(case_number: str) -> str:
|
||||
"""רשימת כל הפסיקות שצורפו לתיק, ממוינות לפי סעיף ואז לפי זמן יצירה."""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
|
||||
|
||||
rows = await db.list_case_precedents(UUID(case["id"]))
|
||||
return json.dumps(rows, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def precedent_remove(precedent_id: str) -> str:
|
||||
"""הסרת פסיקה מצורפת. קובץ ה-PDF (אם צורף) נשאר ב-documents לצורך audit."""
|
||||
try:
|
||||
pid = UUID(precedent_id)
|
||||
except ValueError:
|
||||
return json.dumps({"error": "precedent_id לא תקין"}, ensure_ascii=False)
|
||||
|
||||
ok = await db.delete_case_precedent(pid)
|
||||
return json.dumps(
|
||||
{"deleted": ok, "precedent_id": precedent_id}, ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
async def precedent_search_library(
|
||||
query: str, practice_area: str = "", limit: int = 10,
|
||||
) -> str:
|
||||
"""חיפוש בספרייה הרוחבית — כל הפסיקות שצורפו אי-פעם בכל התיקים.
|
||||
|
||||
Args:
|
||||
query: מחרוזת חיפוש (מתחרה מול citation ומול quote)
|
||||
practice_area: אופציונלי — סינון לתחום משפטי מסוים
|
||||
limit: מספר תוצאות מקסימלי
|
||||
"""
|
||||
if not query or len(query.strip()) < 2:
|
||||
return json.dumps([], ensure_ascii=False)
|
||||
|
||||
rows = await db.search_precedent_library(query.strip(), practice_area, limit)
|
||||
return json.dumps(rows, ensure_ascii=False, indent=2)
|
||||
@@ -3,52 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from legal_mcp.services import db, embeddings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def search_decisions(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
section_type: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
case_number: str = "",
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי.
|
||||
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים.
|
||||
|
||||
Args:
|
||||
query: שאילתת חיפוש בעברית
|
||||
query: שאילתת חיפוש בעברית (לדוגמה: "שימוש חורג למסחר באזור מגורים")
|
||||
limit: מספר תוצאות מקסימלי
|
||||
section_type: סינון לפי סוג סעיף (facts, legal_analysis, ...)
|
||||
practice_area: תחום משפטי לסינון (appeals_committee/national_insurance/...)
|
||||
appeal_subtype: סוג ערר לסינון (building_permit/betterment_levy/compensation_197)
|
||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
||||
section_type: סינון לפי סוג סעיף (facts, legal_analysis, conclusion, ruling, וכו'). ריק = הכל
|
||||
"""
|
||||
# Auto-resolve practice_area from case_number if available
|
||||
if case_number and not practice_area:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
practice_area = case.get("practice_area") or ""
|
||||
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
||||
|
||||
if not practice_area:
|
||||
logger.warning(
|
||||
"search_decisions called without practice_area filter — "
|
||||
"results may mix legal domains"
|
||||
)
|
||||
|
||||
query_emb = await embeddings.embed_query(query)
|
||||
results = await db.search_similar(
|
||||
query_embedding=query_emb,
|
||||
limit=limit,
|
||||
section_type=section_type or None,
|
||||
practice_area=practice_area or None,
|
||||
appeal_subtype=appeal_subtype or None,
|
||||
)
|
||||
|
||||
if not results:
|
||||
@@ -85,7 +61,6 @@ async def search_case_documents(
|
||||
return f"תיק {case_number} לא נמצא."
|
||||
|
||||
query_emb = await embeddings.embed_query(query)
|
||||
# Restricted to case_id — practice_area filter would be redundant.
|
||||
results = await db.search_similar(
|
||||
query_embedding=query_emb,
|
||||
limit=limit,
|
||||
@@ -111,37 +86,17 @@ async def search_case_documents(
|
||||
async def find_similar_cases(
|
||||
description: str,
|
||||
limit: int = 5,
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
case_number: str = "",
|
||||
) -> str:
|
||||
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי.
|
||||
"""מציאת תיקים דומים על בסיס תיאור.
|
||||
|
||||
Args:
|
||||
description: תיאור התיק או הנושא
|
||||
description: תיאור התיק או הנושא (לדוגמה: "ערר על סירוב להיתר בנייה לתוספת קומה")
|
||||
limit: מספר תוצאות מקסימלי
|
||||
practice_area: תחום משפטי לסינון
|
||||
appeal_subtype: סוג ערר לסינון
|
||||
case_number: אם סופק, ה-practice_area/subtype יוסקו אוטומטית מהתיק
|
||||
"""
|
||||
if case_number and not practice_area:
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if case:
|
||||
practice_area = case.get("practice_area") or ""
|
||||
appeal_subtype = appeal_subtype or (case.get("appeal_subtype") or "")
|
||||
|
||||
if not practice_area:
|
||||
logger.warning(
|
||||
"find_similar_cases called without practice_area filter — "
|
||||
"results may mix legal domains"
|
||||
)
|
||||
|
||||
query_emb = await embeddings.embed_query(description)
|
||||
results = await db.search_similar(
|
||||
query_embedding=query_emb,
|
||||
limit=limit * 3, # Get more to deduplicate by case
|
||||
practice_area=practice_area or None,
|
||||
appeal_subtype=appeal_subtype or None,
|
||||
)
|
||||
|
||||
if not results:
|
||||
|
||||
@@ -318,97 +318,3 @@ 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)
|
||||
|
||||
41
web-ui/.gitignore
vendored
Normal file
41
web-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
5
web-ui/AGENTS.md
Normal file
5
web-ui/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
web-ui/CLAUDE.md
Normal file
1
web-ui/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
36
web-ui/README.md
Normal file
36
web-ui/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
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).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
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.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [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.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
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.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
25
web-ui/components.json
Normal file
25
web-ui/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": true,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
18
web-ui/eslint.config.mjs
Normal file
18
web-ui/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
33
web-ui/next.config.ts
Normal file
33
web-ui/next.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
/**
|
||||
* Staging config — proxies /api/* and /openapi.json to the production FastAPI
|
||||
* at legal-ai.nautilus.marcusgroup.org. This lets the new Next.js UI call the
|
||||
* existing backend without CORS and without running a second FastAPI instance.
|
||||
*
|
||||
* When the rewrite branch is cut over to production, set NEXT_PUBLIC_API_BASE_URL
|
||||
* and/or move the FastAPI in front of this app via traefik routing.
|
||||
*/
|
||||
|
||||
const API_ORIGIN =
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||
"https://legal-ai.nautilus.marcusgroup.org";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${API_ORIGIN}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/openapi.json",
|
||||
destination: `${API_ORIGIN}/openapi.json`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
11759
web-ui/package-lock.json
generated
Normal file
11759
web-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
web-ui/package.json
Normal file
41
web-ui/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "web-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"api:types": "openapi-typescript https://legal-ai.nautilus.marcusgroup.org/openapi.json -o src/lib/api/types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-query": "^5.97.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"shadcn": "^4.2.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
web-ui/postcss.config.mjs
Normal file
7
web-ui/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
web-ui/public/file.svg
Normal file
1
web-ui/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
web-ui/public/globe.svg
Normal file
1
web-ui/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
web-ui/public/next.svg
Normal file
1
web-ui/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
web-ui/public/vercel.svg
Normal file
1
web-ui/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
web-ui/public/window.svg
Normal file
1
web-ui/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
198
web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Normal file
198
web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SubsectionCard } from "@/components/compose/subsection-card";
|
||||
import { useCase } from "@/lib/api/cases";
|
||||
import { useResearchAnalysis } from "@/lib/api/research";
|
||||
|
||||
function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
if (!content?.trim()) return null;
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-ink-soft leading-relaxed whitespace-pre-line prose">
|
||||
{content.trim()}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComposePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ caseNumber: string }>;
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const caseQuery = useCase(caseNumber);
|
||||
const analysis = useResearchAnalysis(caseNumber);
|
||||
|
||||
const isNotFound =
|
||||
analysis.error instanceof Error &&
|
||||
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
{/* Header strip */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden>·</span>
|
||||
<Link
|
||||
href={`/cases/${caseNumber}`}
|
||||
className="hover:text-gold-deep"
|
||||
>
|
||||
ערר {caseNumber}
|
||||
</Link>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="text-navy">עורך החלטה</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">ניתוח משפטי וכתיבת עמדה</h1>
|
||||
{caseQuery.data?.title && (
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
{caseQuery.data.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{analysis.isPending ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isNotFound ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-12 text-center space-y-3">
|
||||
<div className="text-gold text-3xl" aria-hidden>❦</div>
|
||||
<h2 className="text-navy text-lg mb-0">
|
||||
טרם בוצע ניתוח משפטי לתיק זה
|
||||
</h2>
|
||||
<p className="text-ink-muted text-sm max-w-md mx-auto">
|
||||
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
|
||||
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-5 text-center">
|
||||
<p className="text-danger">{analysis.error.message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : analysis.data ? (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||
{/* Main editable column */}
|
||||
<div className="space-y-6">
|
||||
{/* Threshold claims */}
|
||||
{analysis.data.threshold_claims &&
|
||||
analysis.data.threshold_claims.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-navy text-xl mb-0">טענות סף</h2>
|
||||
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||
{analysis.data.threshold_claims.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{analysis.data.threshold_claims.map((tc, i) => (
|
||||
<SubsectionCard
|
||||
key={tc.id}
|
||||
caseNumber={caseNumber}
|
||||
item={tc}
|
||||
defaultOpen={i === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues */}
|
||||
{analysis.data.issues && analysis.data.issues.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-navy text-xl mb-0">סוגיות להכרעה</h2>
|
||||
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
|
||||
{analysis.data.issues.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{analysis.data.issues.map((iss) => (
|
||||
<SubsectionCard
|
||||
key={iss.id}
|
||||
caseNumber={caseNumber}
|
||||
item={iss}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!analysis.data.threshold_claims?.length &&
|
||||
!analysis.data.issues?.length) && (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-6 py-10 text-center text-ink-muted">
|
||||
לא נמצאו טענות סף או סוגיות בניתוח זה.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side column: background prose + conclusions */}
|
||||
<aside className="space-y-5">
|
||||
<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>
|
||||
<ProseSection
|
||||
title="צד מיוצג"
|
||||
content={analysis.data.represented_party}
|
||||
/>
|
||||
<ProseSection
|
||||
title="רקע דיוני"
|
||||
content={analysis.data.procedural_background}
|
||||
/>
|
||||
<ProseSection
|
||||
title="עובדות מוסכמות"
|
||||
content={analysis.data.agreed_facts}
|
||||
/>
|
||||
<ProseSection
|
||||
title="עובדות במחלוקת"
|
||||
content={analysis.data.disputed_facts}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{analysis.data.conclusions?.trim() && (
|
||||
<Card className="bg-gold-wash border-gold/40 shadow-sm">
|
||||
<CardContent className="px-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>
|
||||
</Card>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
125
web-ui/src/app/cases/[caseNumber]/page.tsx
Normal file
125
web-ui/src/app/cases/[caseNumber]/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { CaseHeader } from "@/components/cases/case-header";
|
||||
import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { useCase } from "@/lib/api/cases";
|
||||
|
||||
/*
|
||||
* Next 16 breaking change: route params are now a Promise.
|
||||
* The `use()` hook unwraps them inside a client component.
|
||||
*/
|
||||
export default function CaseDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ caseNumber: string }>;
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const { data, isPending, error } = useCase(caseNumber);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
{error ? (
|
||||
<Card className="bg-danger-bg border-danger/40">
|
||||
<CardContent className="px-6 py-6 text-center space-y-3">
|
||||
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
|
||||
<p className="text-sm text-ink-muted">{error.message}</p>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/">חזרה לרשימת התיקים</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{isPending ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-3">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-6 w-96" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<CaseHeader data={data} />
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<Tabs defaultValue="overview" dir="rtl">
|
||||
<TabsList className="bg-rule-soft/60">
|
||||
<TabsTrigger value="overview">סקירה</TabsTrigger>
|
||||
<TabsTrigger value="documents">
|
||||
מסמכים
|
||||
{data?.documents && (
|
||||
<span className="ms-1.5 text-[0.7rem] text-ink-muted tabular-nums">
|
||||
({data.documents.length})
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="actions">פעולות</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
|
||||
<p className="text-ink-soft text-sm leading-relaxed">
|
||||
{data?.expected_outcome ?? "לא נקבעה תוצאה צפויה."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-navy text-base mb-2">סיכום מהיר</h3>
|
||||
<dl className="grid grid-cols-2 gap-y-2 gap-x-6 text-sm">
|
||||
<dt className="text-ink-muted">מסמכים בתיק</dt>
|
||||
<dd className="text-ink tabular-nums">
|
||||
{data?.documents?.length ?? 0}
|
||||
</dd>
|
||||
<dt className="text-ink-muted">בעיבוד</dt>
|
||||
<dd className="text-ink tabular-nums">
|
||||
{data?.processing_count ?? 0}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents" className="mt-5">
|
||||
<DocumentsPanel data={data} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions" className="mt-5">
|
||||
<div className="space-y-3">
|
||||
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||
<Link href={`/cases/${caseNumber}/compose`}>
|
||||
פתח בעורך ההחלטה
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-xs text-ink-muted">
|
||||
מעבר לעורך 12 הבלוקים לכתיבת טיוטה.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm h-fit">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
|
||||
<WorkflowTimeline status={data?.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
BIN
web-ui/src/app/favicon.ico
Normal file
BIN
web-ui/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
248
web-ui/src/app/globals.css
Normal file
248
web-ui/src/app/globals.css
Normal file
@@ -0,0 +1,248 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
* Ezer Mishpati — Design Tokens
|
||||
* Ported from legal-ai/web/static/design-system.css.
|
||||
* Editorial/judicial aesthetic for a Hebrew RTL judicial tool.
|
||||
* Palette: Navy + Cream + Gold. Typography: Heebo.
|
||||
*
|
||||
* shadcn semantic tokens (background, foreground, primary, etc.)
|
||||
* are mapped onto this palette in the @theme inline block + :root
|
||||
* further down, so shadcn primitives inherit the editorial voice.
|
||||
* ══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@theme {
|
||||
/* ── Colors ─────────────────────────────────────────── */
|
||||
--color-navy: #0f172a;
|
||||
--color-navy-soft: #1e293b;
|
||||
--color-navy-dim: #334155;
|
||||
|
||||
--color-cream: #f5f1e8;
|
||||
--color-cream-deep: #ede8d8;
|
||||
--color-parchment: #fbf8f0;
|
||||
|
||||
--color-gold: #a97d3a;
|
||||
--color-gold-deep: #8b6428;
|
||||
--color-gold-soft: #c89a56;
|
||||
--color-gold-wash: #fdf6e8;
|
||||
|
||||
--color-ink: #1a1a2e;
|
||||
--color-ink-soft: #3a3a52;
|
||||
--color-ink-muted: #6b7280;
|
||||
--color-ink-light: #9ca3af;
|
||||
|
||||
--color-rule: #e5dfd0;
|
||||
--color-rule-soft: #f0ead8;
|
||||
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-raised: #fbf8f0;
|
||||
--color-bg: #f5f1e8;
|
||||
|
||||
/* Status colors — tuned to the palette */
|
||||
--color-success: #4a7c59;
|
||||
--color-success-bg: #e8efe7;
|
||||
--color-warn: #b8894a;
|
||||
--color-warn-bg: #faf0dc;
|
||||
--color-danger: #a54242;
|
||||
--color-danger-bg: #f5e6e6;
|
||||
--color-info: #4e6a8c;
|
||||
--color-info-bg: #e6ecf3;
|
||||
|
||||
/* ── Typography ─────────────────────────────────────── */
|
||||
--font-sans: var(--font-heebo), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: var(--font-heebo), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-body: var(--font-heebo), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
|
||||
|
||||
/* ── Shadows — soft, editorial ──────────────────────── */
|
||||
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05);
|
||||
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow: 0 2px 6px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08), 0 2px 4px rgba(15, 23, 42, 0.04);
|
||||
--shadow-lg: 0 10px 30px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.05);
|
||||
|
||||
/* ── Transitions ────────────────────────────────────── */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
|
||||
/* shadcn/ui semantic tokens — wired to CSS vars defined in :root / .dark */
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-heading: var(--font-display);
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
}
|
||||
|
||||
/*
|
||||
* shadcn token values — mapped onto the editorial palette.
|
||||
* background/foreground/primary all reference the navy-cream-gold system
|
||||
* so shadcn primitives (Button, Card, Badge…) fit the judicial tone
|
||||
* without per-component overrides.
|
||||
*/
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
|
||||
--background: var(--color-bg); /* cream */
|
||||
--foreground: var(--color-ink); /* near-black */
|
||||
--card: var(--color-surface); /* white */
|
||||
--card-foreground: var(--color-ink);
|
||||
--popover: var(--color-surface);
|
||||
--popover-foreground: var(--color-ink);
|
||||
--primary: var(--color-navy); /* navy buttons */
|
||||
--primary-foreground: var(--color-parchment);
|
||||
--secondary: var(--color-cream-deep);
|
||||
--secondary-foreground: var(--color-navy);
|
||||
--muted: var(--color-rule-soft);
|
||||
--muted-foreground: var(--color-ink-muted);
|
||||
--accent: var(--color-gold-wash); /* subtle gold backgrounds */
|
||||
--accent-foreground: var(--color-gold-deep);
|
||||
--destructive: var(--color-danger);
|
||||
--border: var(--color-rule);
|
||||
--input: var(--color-rule);
|
||||
--ring: var(--color-gold); /* gold focus ring */
|
||||
|
||||
--chart-1: var(--color-navy);
|
||||
--chart-2: var(--color-gold);
|
||||
--chart-3: var(--color-info);
|
||||
--chart-4: var(--color-success);
|
||||
--chart-5: var(--color-warn);
|
||||
|
||||
--sidebar: var(--color-parchment);
|
||||
--sidebar-foreground: var(--color-ink);
|
||||
--sidebar-primary: var(--color-navy);
|
||||
--sidebar-primary-foreground: var(--color-parchment);
|
||||
--sidebar-accent: var(--color-gold-wash);
|
||||
--sidebar-accent-foreground: var(--color-gold-deep);
|
||||
--sidebar-border: var(--color-rule);
|
||||
--sidebar-ring: var(--color-gold);
|
||||
}
|
||||
|
||||
/* Dark theme — class-based, toggled on <html> or <body>.
|
||||
Inverts navy ↔ parchment and keeps gold as accent. */
|
||||
.dark {
|
||||
--color-navy: #f5f1e8;
|
||||
--color-navy-soft: #e8e0c8;
|
||||
--color-navy-dim: #c7bc9a;
|
||||
|
||||
--color-cream: #0a0f1c;
|
||||
--color-cream-deep: #121a2e;
|
||||
--color-parchment: #161f36;
|
||||
|
||||
--color-gold: #d4a55a;
|
||||
--color-gold-deep: #e8bc6f;
|
||||
--color-gold-soft: #c89a56;
|
||||
--color-gold-wash: rgba(212, 165, 90, 0.08);
|
||||
|
||||
--color-ink: #f5f1e8;
|
||||
--color-ink-soft: #d8d2c0;
|
||||
--color-ink-muted: #9a9380;
|
||||
--color-ink-light: #6a6458;
|
||||
|
||||
--color-rule: #2a3352;
|
||||
--color-rule-soft: #1e2a45;
|
||||
|
||||
--color-surface: #141b2f;
|
||||
--color-surface-raised: #1a2238;
|
||||
--color-bg: #0a0f1c;
|
||||
|
||||
--color-success: #5a9a6a;
|
||||
--color-success-bg: rgba(90, 154, 106, 0.12);
|
||||
--color-warn: #c79956;
|
||||
--color-warn-bg: rgba(199, 153, 86, 0.12);
|
||||
--color-danger: #c16565;
|
||||
--color-danger-bg: rgba(193, 101, 101, 0.12);
|
||||
--color-info: #6d8bab;
|
||||
--color-info-bg: rgba(109, 139, 171, 0.12);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
* Base layer — editorial typography + Hebrew prose defaults
|
||||
* ══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: "kern", "liga", "clig", "calt";
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: var(--color-navy);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h1 { font-size: 2.3rem; font-weight: 900; }
|
||||
h2 { font-size: 1.8rem; }
|
||||
h3 { font-size: 1.45rem; }
|
||||
h4 { font-size: 1.2rem; }
|
||||
h5 { font-size: 1.05rem; }
|
||||
h6 { font-size: 0.95rem; }
|
||||
|
||||
/* Prose paragraphs — justified for Hebrew legal text */
|
||||
p.prose,
|
||||
.prose p {
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
hyphens: auto;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: var(--color-gold-wash);
|
||||
color: var(--color-navy);
|
||||
}
|
||||
}
|
||||
33
web-ui/src/app/layout.tsx
Normal file
33
web-ui/src/app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Heebo, Geist } from "next/font/google";
|
||||
import { Providers } from "@/lib/providers";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
const heebo = Heebo({
|
||||
variable: "--font-heebo",
|
||||
subsets: ["hebrew", "latin"],
|
||||
weight: ["300", "400", "500", "600", "700", "800", "900"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "עוזר משפטי — ניהול תיקים",
|
||||
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="he" dir="rtl" className={cn("h-full", "antialiased", heebo.variable, "font-sans", geist.variable)}>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
56
web-ui/src/app/page.tsx
Normal file
56
web-ui/src/app/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { KPICards } from "@/components/cases/kpi-cards";
|
||||
import { StatusDonut } from "@/components/cases/status-donut";
|
||||
import { CasesTable } from "@/components/cases/cases-table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useCases } from "@/lib/api/cases";
|
||||
|
||||
export default function HomePage() {
|
||||
const { data, isPending, error } = useCases(true);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-8">
|
||||
<header className="flex items-end justify-between gap-6 flex-wrap">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
|
||||
ועדת ערר לתכנון ובנייה · ירושלים
|
||||
</div>
|
||||
<h1 className="text-navy">עוזר משפטי</h1>
|
||||
<p className="text-ink-muted text-base max-w-2xl leading-relaxed">
|
||||
לוח בקרה לניהול תיקי ערר, ניתוח סגנון, וכתיבת החלטות לפי ארכיטקטורת
|
||||
12 הבלוקים.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
<KPICards cases={data} loading={isPending} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_auto]">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-navy text-xl mb-0">רשימת תיקים</h2>
|
||||
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||
מעודכן חי
|
||||
</span>
|
||||
</div>
|
||||
<CasesTable cases={data} loading={isPending} error={error} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm lg:w-[320px]">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-lg mb-4">פיזור סטטוסים</h2>
|
||||
<StatusDonut cases={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
71
web-ui/src/components/app-shell.tsx
Normal file
71
web-ui/src/components/app-shell.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Ezer Mishpati navigation shell.
|
||||
*
|
||||
* Editorial/judicial aesthetic:
|
||||
* - Navy header with a gold hairline rule (border-b-3)
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "בית" },
|
||||
{ href: "/upload", label: "העלאת מסמכים" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/skills", label: "מיומנויות" },
|
||||
{ href: "/diagnostics", label: "אבחון" },
|
||||
];
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="
|
||||
relative z-10 flex items-center gap-4
|
||||
px-10 py-[18px]
|
||||
bg-navy text-parchment
|
||||
border-b-[3px] border-gold
|
||||
shadow-md
|
||||
"
|
||||
>
|
||||
<Link href="/" className="flex items-baseline gap-3 hover:text-parchment">
|
||||
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment">
|
||||
עוזר משפטי
|
||||
</span>
|
||||
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
|
||||
</Link>
|
||||
|
||||
<nav className="me-auto flex items-center gap-1">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<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
|
||||
"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
web-ui/src/components/cases/case-header.tsx
Normal file
67
web-ui/src/components/cases/case-header.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/cases/status-badge";
|
||||
import type { CaseDetail } from "@/lib/api/cases";
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso ?? "—";
|
||||
}
|
||||
}
|
||||
|
||||
export function CaseHeader({ data }: { data?: CaseDetail }) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-3 flex items-center gap-2">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden>·</span>
|
||||
<span>תיקי ערר</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="text-navy tabular-nums">{data?.case_number ?? "…"}</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-start justify-between gap-6 flex-wrap">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
|
||||
ערר {data?.case_number ?? "—"}
|
||||
</span>
|
||||
{data?.status && <StatusBadge status={data.status} />}
|
||||
</div>
|
||||
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
||||
{data?.title ?? "טוען…"}
|
||||
</h1>
|
||||
{data?.subject && (
|
||||
<p className="text-ink-muted text-sm max-w-2xl leading-relaxed">
|
||||
{data.subject}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
|
||||
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
תאריך דיון
|
||||
</dt>
|
||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.hearing_date)}</dd>
|
||||
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
עודכן
|
||||
</dt>
|
||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
||||
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
ועדה
|
||||
</dt>
|
||||
<dd className="text-ink-soft">{data?.committee_type ?? "—"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
191
web-ui/src/components/cases/cases-table.tsx
Normal file
191
web-ui/src/components/cases/cases-table.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { StatusBadge } from "@/components/cases/status-badge";
|
||||
import type { Case } from "@/lib/api/cases";
|
||||
|
||||
function formatDate(iso?: string) {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("he-IL", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<Case>[] = [
|
||||
{
|
||||
accessorKey: "case_number",
|
||||
header: "מס׳ ערר",
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/cases/${row.original.case_number}`}
|
||||
className="text-navy font-semibold hover:text-gold-deep tabular-nums"
|
||||
>
|
||||
{row.original.case_number}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "כותרת",
|
||||
cell: ({ row }) => (
|
||||
<div className="text-ink max-w-[420px] truncate" title={row.original.title}>
|
||||
{row.original.title}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "סטטוס",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "document_count",
|
||||
header: "מסמכים",
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums text-ink-soft">
|
||||
{row.original.document_count ?? "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "עודכן",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-ink-muted text-sm">{formatDate(row.original.updated_at)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function CasesTable({
|
||||
cases,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
cases?: Case[];
|
||||
loading?: boolean;
|
||||
error?: Error | null;
|
||||
}) {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "updated_at", desc: true },
|
||||
]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
|
||||
const data = useMemo(() => cases ?? [], [cases]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: { sorting, globalFilter },
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
globalFilterFn: (row, _colId, filterValue: string) => {
|
||||
if (!filterValue) return true;
|
||||
const needle = filterValue.toLowerCase();
|
||||
return (
|
||||
row.original.case_number.toLowerCase().includes(needle) ||
|
||||
row.original.title.toLowerCase().includes(needle)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
placeholder="חיפוש לפי מס׳ ערר או כותרת…"
|
||||
className="max-w-sm bg-surface"
|
||||
dir="rtl"
|
||||
/>
|
||||
<span className="text-sm text-ink-muted me-auto">
|
||||
{table.getFilteredRowModel().rows.length} תיקים
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-rule-soft/60">
|
||||
{table.getHeaderGroups().map((hg) => (
|
||||
<TableRow key={hg.id} className="border-rule">
|
||||
{hg.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className="text-navy font-semibold cursor-pointer select-none text-right"
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{{ asc: " ▲", desc: " ▼" }[header.column.getIsSorted() as string] ?? ""}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRow key={i} className="border-rule">
|
||||
{columns.map((_c, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : error ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center text-danger py-8">
|
||||
שגיאה בטעינת תיקים: {error.message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center text-ink-muted py-12">
|
||||
<div className="text-gold text-2xl mb-2" aria-hidden>❦</div>
|
||||
{globalFilter ? "אין תיקים תואמים לחיפוש" : "עדיין אין תיקי ערר"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="border-rule hover:bg-gold-wash/40 transition-colors"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-3">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
web-ui/src/components/cases/documents-panel.tsx
Normal file
73
web-ui/src/components/cases/documents-panel.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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`;
|
||||
}
|
||||
|
||||
function categoryTone(category?: string | null) {
|
||||
switch (category) {
|
||||
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";
|
||||
case "protocol": return "bg-warn-bg text-warn border-warn/40";
|
||||
default: return "bg-rule-soft text-ink-muted border-rule";
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<p className="text-sm">אין מסמכים בתיק זה</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="max-h-[520px]">
|
||||
<ul className="divide-y divide-rule">
|
||||
{docs.map((doc) => (
|
||||
<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"
|
||||
>
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="text-ink font-medium truncate" title={doc.filename}>
|
||||
{doc.filename}
|
||||
</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>
|
||||
)}
|
||||
{doc.uploaded_at && (
|
||||
<span>
|
||||
{new Date(doc.uploaded_at).toLocaleDateString("he-IL")}
|
||||
</span>
|
||||
)}
|
||||
{doc.status && doc.status !== "ready" && (
|
||||
<span className="text-warn">{doc.status}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{doc.category && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2 py-0.5 text-[0.7rem] ${categoryTone(doc.category)}`}
|
||||
>
|
||||
{doc.category}
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
65
web-ui/src/components/cases/kpi-cards.tsx
Normal file
65
web-ui/src/components/cases/kpi-cards.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { Case } from "@/lib/api/cases";
|
||||
|
||||
type Bucket = {
|
||||
label: string;
|
||||
caption: string;
|
||||
value: number;
|
||||
tone: "navy" | "gold" | "warn" | "success";
|
||||
};
|
||||
|
||||
const TONE_STYLES: Record<Bucket["tone"], string> = {
|
||||
navy: "before:bg-navy text-navy",
|
||||
gold: "before:bg-gold text-gold-deep",
|
||||
warn: "before:bg-warn text-warn",
|
||||
success: "before:bg-success text-success",
|
||||
};
|
||||
|
||||
function bucketize(cases: Case[] | undefined): Bucket[] {
|
||||
const c = cases ?? [];
|
||||
const inProgress = c.filter((x) =>
|
||||
["processing", "documents_ready", "outcome_set", "brainstorming", "direction_approved"].includes(x.status),
|
||||
).length;
|
||||
const drafting = c.filter((x) =>
|
||||
["drafting", "qa_review", "drafted"].includes(x.status),
|
||||
).length;
|
||||
const done = c.filter((x) =>
|
||||
["exported", "reviewed", "final"].includes(x.status),
|
||||
).length;
|
||||
|
||||
return [
|
||||
{ label: "סה״כ תיקי ערר", caption: "בכל הסטטוסים", value: c.length, tone: "navy" },
|
||||
{ label: "בהכנה", caption: "מסמכים וניתוח", value: inProgress, tone: "gold" },
|
||||
{ label: "בכתיבה", caption: "טיוטות ו-QA", value: drafting, tone: "warn" },
|
||||
{ label: "מוכנים", caption: "יוצאו או סופיים", value: done, tone: "success" },
|
||||
];
|
||||
}
|
||||
|
||||
export function KPICards({ cases, loading }: { cases?: Case[]; loading?: boolean }) {
|
||||
const buckets = bucketize(cases);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{buckets.map((b) => (
|
||||
<Card
|
||||
key={b.label}
|
||||
className={`
|
||||
relative overflow-hidden bg-surface shadow-sm border-rule
|
||||
before:content-[''] before:absolute before:top-0 before:right-0 before:h-full before:w-[3px]
|
||||
${TONE_STYLES[b.tone]}
|
||||
`}
|
||||
>
|
||||
<CardContent className="px-5 py-4 flex flex-col gap-1">
|
||||
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
|
||||
{b.label}
|
||||
</span>
|
||||
<span className="font-display text-[2.3rem] font-black leading-none">
|
||||
{loading ? "—" : b.value}
|
||||
</span>
|
||||
<span className="text-[0.78rem] text-ink-muted mt-0.5">{b.caption}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
web-ui/src/components/cases/status-badge.tsx
Normal file
53
web-ui/src/components/cases/status-badge.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { CaseStatus } from "@/lib/api/cases";
|
||||
|
||||
const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||
new: "חדש",
|
||||
uploading: "מעלה",
|
||||
processing: "בעיבוד",
|
||||
documents_ready: "מסמכים מוכנים",
|
||||
outcome_set: "תוצאה נקבעה",
|
||||
brainstorming: "סיעור מוחות",
|
||||
direction_approved: "כיוון אושר",
|
||||
drafting: "בכתיבה",
|
||||
qa_review: "QA",
|
||||
drafted: "טיוטה",
|
||||
exported: "יוצא",
|
||||
reviewed: "נבדק",
|
||||
final: "סופי",
|
||||
};
|
||||
|
||||
/* Status color groups:
|
||||
* intake → new, uploading, processing (muted parchment)
|
||||
* prep → documents_ready, outcome_set (info blue)
|
||||
* thinking→ brainstorming, direction_approved (gold)
|
||||
* writing → drafting, qa_review, drafted (warn amber)
|
||||
* done → exported, reviewed, final (success green) */
|
||||
const STATUS_TONE: Record<CaseStatus, string> = {
|
||||
new: "bg-rule-soft text-ink-muted border-rule",
|
||||
uploading: "bg-rule-soft text-ink-muted border-rule",
|
||||
processing: "bg-info-bg text-info border-info/30",
|
||||
documents_ready: "bg-info-bg text-info border-info/40",
|
||||
outcome_set: "bg-info-bg text-info border-info/40",
|
||||
brainstorming: "bg-gold-wash text-gold-deep border-gold/40",
|
||||
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
|
||||
drafting: "bg-warn-bg text-warn border-warn/40",
|
||||
qa_review: "bg-warn-bg text-warn border-warn/40",
|
||||
drafted: "bg-warn-bg text-warn border-warn/50",
|
||||
exported: "bg-success-bg text-success border-success/40",
|
||||
reviewed: "bg-success-bg text-success border-success/50",
|
||||
final: "bg-success-bg text-success border-success/60 font-semibold",
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: CaseStatus }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium ${STATUS_TONE[status] ?? ""}`}
|
||||
>
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export { STATUS_LABELS };
|
||||
91
web-ui/src/components/cases/status-donut.tsx
Normal file
91
web-ui/src/components/cases/status-donut.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import type { Case, CaseStatus } from "@/lib/api/cases";
|
||||
import { STATUS_LABELS } from "@/components/cases/status-badge";
|
||||
|
||||
/*
|
||||
* Conic-gradient donut — ported from legal-ai/web/static/index.html renderHero().
|
||||
* Kept deliberately dependency-free (no D3/recharts) — a single background-image.
|
||||
* Five status groups map onto the navy/gold/info/warn/success palette.
|
||||
*/
|
||||
|
||||
type GroupKey = "intake" | "prep" | "thinking" | "writing" | "done";
|
||||
|
||||
const GROUP_OF: Record<CaseStatus, GroupKey> = {
|
||||
new: "intake", uploading: "intake", processing: "intake",
|
||||
documents_ready: "prep", outcome_set: "prep",
|
||||
brainstorming: "thinking", direction_approved: "thinking",
|
||||
drafting: "writing", qa_review: "writing", drafted: "writing",
|
||||
exported: "done", reviewed: "done", final: "done",
|
||||
};
|
||||
|
||||
const GROUP_META: Record<GroupKey, { label: string; color: string }> = {
|
||||
intake: { label: "חדש / בעיבוד", color: "var(--color-ink-muted)" },
|
||||
prep: { label: "הכנה", color: "var(--color-info)" },
|
||||
thinking: { label: "ניתוח וכיוון", color: "var(--color-gold)" },
|
||||
writing: { label: "בכתיבה", color: "var(--color-warn)" },
|
||||
done: { label: "מוכן", color: "var(--color-success)" },
|
||||
};
|
||||
|
||||
export function StatusDonut({ cases }: { cases?: Case[] }) {
|
||||
const counts: Record<GroupKey, number> = {
|
||||
intake: 0, prep: 0, thinking: 0, writing: 0, done: 0,
|
||||
};
|
||||
(cases ?? []).forEach((c) => {
|
||||
const g = GROUP_OF[c.status];
|
||||
if (g) counts[g] += 1;
|
||||
});
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
const segments: { key: GroupKey; start: number; end: number }[] = [];
|
||||
let pct = 0;
|
||||
(Object.keys(counts) as GroupKey[]).forEach((k) => {
|
||||
if (counts[k] === 0) return;
|
||||
const start = total === 0 ? 0 : (pct / total) * 360;
|
||||
pct += counts[k];
|
||||
const end = total === 0 ? 360 : (pct / total) * 360;
|
||||
segments.push({ key: k, start, end });
|
||||
});
|
||||
|
||||
const background =
|
||||
total === 0
|
||||
? "conic-gradient(var(--color-rule-soft) 0deg 360deg)"
|
||||
: `conic-gradient(${segments
|
||||
.map((s) => `${GROUP_META[s.key].color} ${s.start}deg ${s.end}deg`)
|
||||
.join(", ")})`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<div
|
||||
className="relative w-[140px] h-[140px] rounded-full shadow-sm"
|
||||
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">
|
||||
{(Object.keys(GROUP_META) as GroupKey[]).map((k) => (
|
||||
<li key={k} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: GROUP_META[k].color }}
|
||||
/>
|
||||
<span className="text-ink-soft">{GROUP_META[k].label}</span>
|
||||
<span className="text-ink-muted tabular-nums me-auto ms-1">
|
||||
{counts[k]}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Exported for the legend tests / docs if ever needed */
|
||||
export { STATUS_LABELS };
|
||||
77
web-ui/src/components/cases/workflow-timeline.tsx
Normal file
77
web-ui/src/components/cases/workflow-timeline.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import type { CaseStatus } from "@/lib/api/cases";
|
||||
import { STATUS_LABELS } from "@/components/cases/status-badge";
|
||||
|
||||
/*
|
||||
* Vertical RTL workflow timeline showing the 13-status case pipeline.
|
||||
* Groups the raw statuses into the 5 visual phases used across the app
|
||||
* (intake → prep → thinking → writing → done) so the user sees
|
||||
* "where am I in the process" rather than 13 micro-steps.
|
||||
*/
|
||||
|
||||
type Phase = {
|
||||
key: string;
|
||||
label: string;
|
||||
statuses: CaseStatus[];
|
||||
};
|
||||
|
||||
const PHASES: Phase[] = [
|
||||
{ key: "intake", label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] },
|
||||
{ key: "prep", label: "הכנת תיק", statuses: ["documents_ready", "outcome_set"] },
|
||||
{ key: "thinking", label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved"] },
|
||||
{ key: "writing", label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] },
|
||||
{ key: "done", label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
||||
];
|
||||
|
||||
function phaseIndexOf(status?: CaseStatus): number {
|
||||
if (!status) return -1;
|
||||
return PHASES.findIndex((p) => p.statuses.includes(status));
|
||||
}
|
||||
|
||||
export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
|
||||
const currentIdx = phaseIndexOf(status);
|
||||
|
||||
return (
|
||||
<ol className="relative space-y-4">
|
||||
<div
|
||||
className="absolute top-2 bottom-2 right-[11px] w-px bg-rule"
|
||||
aria-hidden
|
||||
/>
|
||||
{PHASES.map((phase, i) => {
|
||||
const state =
|
||||
currentIdx === -1 ? "pending"
|
||||
: i < currentIdx ? "done"
|
||||
: i === currentIdx ? "current"
|
||||
: "pending";
|
||||
|
||||
const dotTone =
|
||||
state === "done" ? "bg-success border-success"
|
||||
: state === "current" ? "bg-gold border-gold shadow-[0_0_0_4px_color-mix(in_oklab,var(--color-gold)_20%,transparent)]"
|
||||
: "bg-surface border-rule";
|
||||
|
||||
const labelTone =
|
||||
state === "done" ? "text-ink-soft"
|
||||
: state === "current" ? "text-navy font-semibold"
|
||||
: "text-ink-muted";
|
||||
|
||||
return (
|
||||
<li key={phase.key} className="relative flex items-start gap-3 ps-7">
|
||||
<span
|
||||
className={`absolute right-[5px] top-1 inline-block w-3 h-3 rounded-full border-2 ${dotTone}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className={`text-sm ${labelTone}`}>{phase.label}</span>
|
||||
{state === "current" && status && (
|
||||
<span className="text-[0.72rem] text-gold-deep mt-0.5">
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
96
web-ui/src/components/compose/chair-editor.tsx
Normal file
96
web-ui/src/components/compose/chair-editor.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSaveChairPosition } from "@/lib/api/research";
|
||||
|
||||
/*
|
||||
* Chair-position editor for a single threshold claim or issue.
|
||||
*
|
||||
* Autosaves on blur, with an optimistic in-memory "last saved" value so the
|
||||
* user sees immediate feedback. No debounced per-keystroke save — the user
|
||||
* writes in long paragraphs and the backend writes to a file, so per-blur
|
||||
* is the right granularity (matches the vanilla UI's behavior).
|
||||
*/
|
||||
|
||||
type SaveState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "saving" }
|
||||
| { kind: "saved"; at: Date }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export function ChairEditor({
|
||||
caseNumber,
|
||||
sectionId,
|
||||
initialValue,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
sectionId: string;
|
||||
initialValue: string;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [state, setState] = useState<SaveState>({ kind: "idle" });
|
||||
const lastSaved = useRef(initialValue);
|
||||
const mutate = useSaveChairPosition(caseNumber);
|
||||
|
||||
/* Reset when the upstream analysis refetches (e.g. after initial load) */
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
lastSaved.current = initialValue;
|
||||
}, [initialValue]);
|
||||
|
||||
const save = async () => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === lastSaved.current.trim()) return;
|
||||
setState({ kind: "saving" });
|
||||
try {
|
||||
await mutate.mutateAsync({ sectionId, position: trimmed });
|
||||
lastSaved.current = trimmed;
|
||||
setState({ kind: "saved", at: new Date() });
|
||||
} catch (e) {
|
||||
setState({
|
||||
kind: "error",
|
||||
message: e instanceof Error ? e.message : "שגיאה בשמירה",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[0.78rem] font-semibold text-navy">
|
||||
עמדת ועדת הערר
|
||||
</span>
|
||||
<SaveIndicator state={state} />
|
||||
</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={save}
|
||||
rows={6}
|
||||
dir="rtl"
|
||||
placeholder="כתבי כאן את עמדתך לגבי סוגיה זו. הטקסט נשמר אוטומטית כשעוזבת את השדה."
|
||||
className="
|
||||
w-full resize-y rounded border border-rule bg-parchment
|
||||
px-3 py-2 text-sm leading-relaxed text-ink
|
||||
shadow-inner focus:border-gold focus:outline-none
|
||||
focus:ring-2 focus:ring-gold/30
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveIndicator({ state }: { state: SaveState }) {
|
||||
if (state.kind === "idle") return null;
|
||||
if (state.kind === "saving") {
|
||||
return <span className="text-[0.72rem] text-ink-muted">⏳ שומר…</span>;
|
||||
}
|
||||
if (state.kind === "saved") {
|
||||
const time = state.at.toLocaleTimeString("he-IL", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
||||
}
|
||||
return <span className="text-[0.72rem] text-danger">⚠ {state.message}</span>;
|
||||
}
|
||||
88
web-ui/src/components/compose/subsection-card.tsx
Normal file
88
web-ui/src/components/compose/subsection-card.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChairEditor } from "@/components/compose/chair-editor";
|
||||
import type { ResearchSubsection } from "@/lib/api/research";
|
||||
|
||||
export function SubsectionCard({
|
||||
caseNumber,
|
||||
item,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
item: ResearchSubsection;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isFilled = Boolean(item.chair_position?.trim());
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="
|
||||
w-full flex items-center gap-3 px-4 py-3 text-right
|
||||
hover:bg-gold-wash/30 transition-colors
|
||||
focus:outline-none focus-visible:bg-gold-wash/40
|
||||
"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span
|
||||
className="
|
||||
inline-flex items-center justify-center shrink-0
|
||||
w-7 h-7 rounded-full
|
||||
bg-navy text-parchment font-display font-bold text-sm
|
||||
tabular-nums
|
||||
"
|
||||
>
|
||||
{item.number}
|
||||
</span>
|
||||
<span className="flex-1 text-navy font-semibold text-base leading-snug">
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
text-[0.72rem] rounded-full px-2.5 py-0.5 border shrink-0
|
||||
${
|
||||
isFilled
|
||||
? "bg-success-bg text-success border-success/40"
|
||||
: "bg-rule-soft text-ink-muted border-rule"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isFilled ? "✓ עמדה נקבעה" : "ממתין לעמדה"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-ink-muted transition-transform ${open ? "rotate-180" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-rule px-5 py-4 space-y-4 bg-parchment/40">
|
||||
{item.fields.length > 0 && (
|
||||
<dl className="space-y-3">
|
||||
{item.fields.map((f, i) => (
|
||||
<div key={i}>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
<ChairEditor
|
||||
caseNumber={caseNumber}
|
||||
sectionId={item.id}
|
||||
initialValue={item.chair_position ?? ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
49
web-ui/src/components/ui/badge.tsx
Normal file
49
web-ui/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
67
web-ui/src/components/ui/button.tsx
Normal file
67
web-ui/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
web-ui/src/components/ui/card.tsx
Normal file
103
web-ui/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
269
web-ui/src/components/ui/dropdown-menu.tsx
Normal file
269
web-ui/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 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-[state=closed]:overflow-hidden 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}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute end-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute end-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:ps-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ms-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:ps-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="rtl:rotate-180 ms-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
19
web-ui/src/components/ui/input.tsx
Normal file
19
web-ui/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
55
web-ui/src/components/ui/scroll-area.tsx
Normal file
55
web-ui/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-s data-vertical:border-s-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
28
web-ui/src/components/ui/separator.tsx
Normal file
28
web-ui/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
147
web-ui/src/components/ui/sheet.tsx
Normal file
147
web-ui/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-e data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-s data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 end-3"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
13
web-ui/src/components/ui/skeleton.tsx
Normal file
13
web-ui/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
116
web-ui/src/components/ui/table.tsx
Normal file
116
web-ui/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-start align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pe-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
90
web-ui/src/components/ui/tabs.tsx
Normal file
90
web-ui/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pe-1 has-data-[icon=inline-start]:ps-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-end-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
107
web-ui/src/lib/api/cases.ts
Normal file
107
web-ui/src/lib/api/cases.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Cases domain hooks.
|
||||
*
|
||||
* Note on types: the FastAPI `/api/cases` endpoint doesn't declare a response
|
||||
* model, so openapi-typescript emits `unknown` for its payload. Until the
|
||||
* backend is annotated (see out-of-scope in the rewrite plan), we maintain a
|
||||
* small local type that matches what the running API returns today. Any drift
|
||||
* surfaces as a runtime TypeScript error the first time a property is touched.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type CaseStatus =
|
||||
| "new"
|
||||
| "uploading"
|
||||
| "processing"
|
||||
| "documents_ready"
|
||||
| "outcome_set"
|
||||
| "brainstorming"
|
||||
| "direction_approved"
|
||||
| "drafting"
|
||||
| "qa_review"
|
||||
| "drafted"
|
||||
| "exported"
|
||||
| "reviewed"
|
||||
| "final";
|
||||
|
||||
export type Case = {
|
||||
case_number: string;
|
||||
title: string;
|
||||
status: CaseStatus;
|
||||
subject?: string | null;
|
||||
expected_outcome?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
/* Present when loaded with detail=true */
|
||||
document_count?: number;
|
||||
processing_count?: number;
|
||||
committee_type?: string | null;
|
||||
hearing_date?: string | null;
|
||||
};
|
||||
|
||||
export type CaseDetail = Case & {
|
||||
documents?: Array<{
|
||||
id: number | string;
|
||||
filename: string;
|
||||
category?: string | null;
|
||||
status?: string;
|
||||
uploaded_at?: string;
|
||||
size_bytes?: number;
|
||||
}>;
|
||||
blocks?: Array<{ code: string; status?: string; char_count?: number }>;
|
||||
};
|
||||
|
||||
export const casesKeys = {
|
||||
all: ["cases"] as const,
|
||||
list: (detail: boolean) => [...casesKeys.all, "list", { detail }] as const,
|
||||
detail: (caseNumber: string) =>
|
||||
[...casesKeys.all, "detail", caseNumber] as const,
|
||||
};
|
||||
|
||||
export function useCases(detail = false) {
|
||||
return useQuery({
|
||||
queryKey: casesKeys.list(detail),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<Case[]>(`/api/cases${detail ? "?detail=true" : ""}`, {
|
||||
signal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCase(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: casesKeys.detail(caseNumber ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CaseDetail>(`/api/cases/${caseNumber}/details`, { signal }),
|
||||
enabled: Boolean(caseNumber),
|
||||
/* Replaces the old 5s polling from vanilla index.html */
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type WorkflowStatus = {
|
||||
case_number?: string;
|
||||
status?: CaseStatus | string;
|
||||
current_step?: string;
|
||||
steps?: Array<{
|
||||
key: string;
|
||||
label?: string;
|
||||
status: "done" | "current" | "pending" | string;
|
||||
}>;
|
||||
/* FastAPI returns free-form JSON; keep it permissive */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function useWorkflowStatus(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<WorkflowStatus>(`/api/cases/${caseNumber}/status`, { signal }),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
}
|
||||
77
web-ui/src/lib/api/client.ts
Normal file
77
web-ui/src/lib/api/client.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* API client — typed fetch wrapper + TanStack Query setup.
|
||||
*
|
||||
* All requests hit relative URLs (e.g. `/api/cases`) which next.config.ts
|
||||
* rewrites transparently proxy to legal-ai.nautilus.marcusgroup.org. No CORS
|
||||
* gymnastics, no direct public-URL references in component code.
|
||||
*/
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly body: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Typed JSON request. Throws ApiError on non-2xx responses with the parsed body.
|
||||
* Always returns parsed JSON — callers pass the expected type parameter.
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
{ method = "GET", body, signal }: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
const parsed = contentType.includes("application/json")
|
||||
? await res.json().catch(() => null)
|
||||
: await res.text().catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
`${method} ${path} failed with ${res.status}`,
|
||||
res.status,
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared TanStack Query client. Defaults are tuned for a dashboard that polls
|
||||
* for case progress — stale for 5 seconds, 1 automatic retry, no background
|
||||
* refetch on window focus (preserves editor state during long sessions).
|
||||
*/
|
||||
export function makeQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
95
web-ui/src/lib/api/research.ts
Normal file
95
web-ui/src/lib/api/research.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Research analysis hooks — reads and mutates the
|
||||
* `analysis-and-research.md` file that backs each case's compose screen.
|
||||
*
|
||||
* Schema mirrors research_md.parse() in the FastAPI backend. Kept as
|
||||
* hand-typed interfaces because the endpoint does not declare a
|
||||
* response_model in the OpenAPI schema.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type ResearchField = {
|
||||
label: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ResearchSubsection = {
|
||||
id: string; // e.g. "threshold_1" or "issue_3"
|
||||
number: string;
|
||||
title: string;
|
||||
fields: ResearchField[];
|
||||
chair_position?: string;
|
||||
};
|
||||
|
||||
export type ResearchAnalysis = {
|
||||
header?: {
|
||||
date?: string;
|
||||
modified_at?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
represented_party?: string;
|
||||
procedural_background?: string;
|
||||
agreed_facts?: string;
|
||||
disputed_facts?: string;
|
||||
threshold_claims?: ResearchSubsection[];
|
||||
issues?: ResearchSubsection[];
|
||||
conclusions?: string;
|
||||
other_sections?: Array<{ title: string; body: string }>;
|
||||
};
|
||||
|
||||
export const researchKeys = {
|
||||
all: ["research"] as const,
|
||||
analysis: (caseNumber: string) =>
|
||||
[...researchKeys.all, "analysis", caseNumber] as const,
|
||||
};
|
||||
|
||||
export function useResearchAnalysis(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: researchKeys.analysis(caseNumber ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<ResearchAnalysis>(
|
||||
`/api/cases/${caseNumber}/research/analysis`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(caseNumber),
|
||||
/* No polling — the user is editing; refetching would clobber focus */
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveChairPosition(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (vars: { sectionId: string; position: string }) =>
|
||||
apiRequest<unknown>(
|
||||
`/api/cases/${caseNumber}/research/analysis/chair-position`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: { section_id: vars.sectionId, position: vars.position },
|
||||
},
|
||||
),
|
||||
onSuccess: (_res, vars) => {
|
||||
/* Locally patch the cached analysis so other consumers stay in sync
|
||||
without an immediate refetch that would steal focus from the editor. */
|
||||
qc.setQueryData<ResearchAnalysis | undefined>(
|
||||
researchKeys.analysis(caseNumber ?? ""),
|
||||
(prev) => {
|
||||
if (!prev) return prev;
|
||||
const patch = (arr?: ResearchSubsection[]) =>
|
||||
arr?.map((s) =>
|
||||
s.id === vars.sectionId
|
||||
? { ...s, chair_position: vars.position }
|
||||
: s,
|
||||
);
|
||||
return {
|
||||
...prev,
|
||||
threshold_claims: patch(prev.threshold_claims),
|
||||
issues: patch(prev.issues),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
2972
web-ui/src/lib/api/types.ts
Normal file
2972
web-ui/src/lib/api/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
17
web-ui/src/lib/providers.tsx
Normal file
17
web-ui/src/lib/providers.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { makeQueryClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* Client-side providers. Creates ONE QueryClient instance per browser session
|
||||
* via useState — Next.js App Router recreates the function on every render,
|
||||
* so a naive `const client = makeQueryClient()` would dump the cache each time.
|
||||
*/
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(() => makeQueryClient());
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
6
web-ui/src/lib/utils.ts
Normal file
6
web-ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
34
web-ui/tsconfig.json
Normal file
34
web-ui/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
263
web/app.py
263
web/app.py
@@ -29,7 +29,7 @@ import asyncpg
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import chunker, db, embeddings, extractor, processor, proofreader, research_md
|
||||
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools, precedents as precedents_tools
|
||||
from legal_mcp.tools import cases as cases_tools, search as search_tools, workflow as workflow_tools, drafting as drafting_tools
|
||||
|
||||
# Import integration clients (same directory)
|
||||
_web_dir = Path(__file__).resolve().parent
|
||||
@@ -1069,8 +1069,6 @@ class CaseCreateRequest(BaseModel):
|
||||
hearing_date: str = ""
|
||||
notes: str = ""
|
||||
expected_outcome: str = ""
|
||||
practice_area: str = "appeals_committee"
|
||||
appeal_subtype: str = ""
|
||||
|
||||
|
||||
class CaseUpdateRequest(BaseModel):
|
||||
@@ -1099,8 +1097,6 @@ async def api_case_create(req: CaseCreateRequest):
|
||||
hearing_date=req.hearing_date,
|
||||
notes=req.notes,
|
||||
expected_outcome=req.expected_outcome,
|
||||
practice_area=req.practice_area,
|
||||
appeal_subtype=req.appeal_subtype,
|
||||
)
|
||||
return json.loads(result)
|
||||
|
||||
@@ -1135,22 +1131,6 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest):
|
||||
raise HTTPException(404, result)
|
||||
|
||||
|
||||
@app.delete("/api/cases")
|
||||
async def api_case_delete(case_number: str, remove_files: bool = False):
|
||||
"""Delete a case, identified by case_number in the query string.
|
||||
|
||||
Uses a query param (not a path segment) because case numbers may contain
|
||||
characters like `/` that FastAPI path routing cannot capture even when
|
||||
URL-encoded (%2F). Dependent documents/chunks/qa_results cascade via
|
||||
FK ON DELETE CASCADE; audit_log rows nullify their case_id.
|
||||
Pass `remove_files=true` to also rm -rf the on-disk case directory."""
|
||||
result = await cases_tools.case_delete(case_number, remove_files)
|
||||
data = json.loads(result)
|
||||
if not data.get("deleted"):
|
||||
raise HTTPException(404, data.get("reason", f"תיק {case_number} לא נמצא"))
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/status")
|
||||
async def api_case_status(case_number: str):
|
||||
"""Get full workflow status for a case."""
|
||||
@@ -1643,120 +1623,6 @@ async def api_research_chair_position(case_number: str, req: ChairPositionReques
|
||||
raise HTTPException(500, f"שגיאה בשמירה: {e}")
|
||||
|
||||
|
||||
# ── Precedents API — attached case-law quotes for the compose phase ──
|
||||
|
||||
|
||||
class PrecedentCreateRequest(BaseModel):
|
||||
quote: str
|
||||
citation: str
|
||||
section_id: str = "" # empty = case-level / general discussion
|
||||
chair_note: str = ""
|
||||
pdf_document_id: str = "" # UUID string, empty = no PDF
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/precedents")
|
||||
async def api_precedent_attach(case_number: str, req: PrecedentCreateRequest):
|
||||
"""Attach a legal precedent (quote + citation) to a case, optionally
|
||||
scoped to a specific threshold_claim / issue section. Cross-case
|
||||
library reuse happens at the search endpoint — this one always
|
||||
inserts a new row."""
|
||||
if req.section_id and not re.match(r"^(threshold|issue)_\d+$", req.section_id):
|
||||
raise HTTPException(400, "section_id לא תקין")
|
||||
if not req.quote.strip() or not req.citation.strip():
|
||||
raise HTTPException(400, "quote ו-citation חובה")
|
||||
|
||||
result = await precedents_tools.precedent_attach(
|
||||
case_number=case_number,
|
||||
quote=req.quote,
|
||||
citation=req.citation,
|
||||
section_id=req.section_id,
|
||||
chair_note=req.chair_note,
|
||||
pdf_document_id=req.pdf_document_id,
|
||||
)
|
||||
data = json.loads(result)
|
||||
if data.get("error"):
|
||||
raise HTTPException(404, data["error"])
|
||||
return data
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/precedents/upload-pdf")
|
||||
async def api_precedent_upload_pdf(
|
||||
case_number: str,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""One-shot PDF upload for a precedent attachment. Stores the file
|
||||
on disk alongside other case documents and creates a `documents`
|
||||
row with doc_type='precedent_archive'. Returns {document_id} so the
|
||||
frontend can pass it into POST /precedents. No SSE / background
|
||||
processing — archive only, no text extraction."""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "No filename provided")
|
||||
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in {".pdf", ".docx", ".doc"}:
|
||||
raise HTTPException(400, f"סוג קובץ לא נתמך לפסיקה: {ext}")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE:
|
||||
raise HTTPException(400, f"קובץ גדול מדי. מקסימום: {MAX_FILE_SIZE // (1024*1024)}MB")
|
||||
|
||||
# Save under a dedicated precedents/ subdirectory so they don't mix
|
||||
# with extracted originals.
|
||||
case_dir = config.find_case_dir(case_number) / "documents" / "precedents"
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = re.sub(r"[^\w\u0590-\u05FF\s.\-()]", "", Path(file.filename).stem).strip()
|
||||
dest = case_dir / f"{safe_name or 'precedent'}{ext}"
|
||||
counter = 1
|
||||
while dest.exists():
|
||||
dest = case_dir / f"{safe_name or 'precedent'}-{counter}{ext}"
|
||||
counter += 1
|
||||
dest.write_bytes(content)
|
||||
|
||||
case_id = UUID(case["id"])
|
||||
doc = await db.create_document(
|
||||
case_id=case_id,
|
||||
doc_type="precedent_archive",
|
||||
title=safe_name or "precedent",
|
||||
file_path=str(dest),
|
||||
)
|
||||
return {"document_id": doc["id"], "filename": dest.name}
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/precedents")
|
||||
async def api_precedent_list(case_number: str):
|
||||
"""List all precedents attached to a case, grouped client-side by section_id."""
|
||||
result = await precedents_tools.precedent_list(case_number)
|
||||
data = json.loads(result)
|
||||
if isinstance(data, dict) and data.get("error"):
|
||||
raise HTTPException(404, data["error"])
|
||||
return data
|
||||
|
||||
|
||||
@app.delete("/api/precedents/{precedent_id}")
|
||||
async def api_precedent_delete(precedent_id: str):
|
||||
"""Delete a precedent attachment. The archived PDF (if any) stays
|
||||
in the documents table — orphaned references nullify via FK
|
||||
ON DELETE SET NULL — so we keep the audit trail of the file."""
|
||||
result = await precedents_tools.precedent_remove(precedent_id)
|
||||
data = json.loads(result)
|
||||
if data.get("error"):
|
||||
raise HTTPException(400, data["error"])
|
||||
if not data.get("deleted"):
|
||||
raise HTTPException(404, "לא נמצא")
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/precedents/search")
|
||||
async def api_precedent_search(q: str, practice_area: str = "", limit: int = 10):
|
||||
"""Cross-case library typeahead. Returns one row per distinct citation."""
|
||||
result = await precedents_tools.precedent_search_library(q, practice_area, limit)
|
||||
return json.loads(result)
|
||||
|
||||
|
||||
# ── Exports API — drafts, versions, download, upload, mark-final ──
|
||||
|
||||
|
||||
@@ -2436,133 +2302,6 @@ 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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -76,14 +76,6 @@ async def create_project(
|
||||
VALUES ($1, $2::uuid, $3, $4, 'backlog', $5)""",
|
||||
project_id, company_id, project_name, description[:500] if description else "", color,
|
||||
)
|
||||
# Create workspace pointing to the legal-ai repo root
|
||||
workspace_id = str(uuid.uuid4())
|
||||
await conn.execute(
|
||||
"""INSERT INTO project_workspaces (id, company_id, project_id, name, cwd)
|
||||
VALUES ($1, $2::uuid, $3::uuid, 'legal-ai', '/home/chaim/legal-ai')""",
|
||||
workspace_id, company_id, project_id,
|
||||
)
|
||||
|
||||
# Create initial issue linked to the project
|
||||
issue_id, identifier = await _create_issue(
|
||||
conn, company_id, project_id, case_number, title, prefix,
|
||||
|
||||
@@ -1964,26 +1964,14 @@ kbd {
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="max-width:200px">
|
||||
<label>תחום משפטי</label>
|
||||
<select id="wiz-practice-area">
|
||||
<option value="appeals_committee">ועדת ערר</option>
|
||||
<option value="national_insurance" disabled>ביטוח לאומי (בקרוב)</option>
|
||||
<option value="labor_law" disabled>דיני עבודה (בקרוב)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>סוג ערר <span style="color:#888;font-size:0.85em">(מוסק אוטומטית ממספר התיק)</span></label>
|
||||
<select id="wiz-appeal-subtype">
|
||||
<option value="building_permit">רישוי ובנייה</option>
|
||||
<option value="betterment_levy">היטל השבחה</option>
|
||||
<option value="compensation_197">פיצויים (ס' 197)</option>
|
||||
<option value="unknown">לא ידוע</option>
|
||||
<label>סוג ערר</label>
|
||||
<select id="wiz-committee-type">
|
||||
<option value="רישוי">רישוי ובניה</option>
|
||||
<option value="היטל השבחה">היטל השבחה</option>
|
||||
<option value="פיצויים">פיצויים (ס' 197)</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" id="wiz-committee-type" value="רישוי">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>כתובת נכס</label>
|
||||
<input type="text" id="wiz-address" placeholder="רח' אבינדב 23, קריית יערים">
|
||||
@@ -2742,14 +2730,11 @@ function getListValues(listId) {
|
||||
function buildSummary() {
|
||||
const data = getWizardData();
|
||||
const OUTCOME_LABELS = { rejection: 'דחייה', partial_acceptance: 'קבלה חלקית', full_acceptance: 'קבלה מלאה', betterment_levy: 'היטל השבחה' };
|
||||
const PRACTICE_AREA_LABELS = { appeals_committee: 'ועדת ערר', national_insurance: 'ביטוח לאומי', labor_law: 'דיני עבודה' };
|
||||
const SUBTYPE_LABELS = { building_permit: 'רישוי ובנייה', betterment_levy: 'היטל השבחה', compensation_197: "פיצויים (ס' 197)", unknown: 'לא ידוע' };
|
||||
document.getElementById('wizSummary').innerHTML = `
|
||||
<table style="width:100%;font-size:0.88em;border-collapse:collapse">
|
||||
<tr><td style="padding:6px;color:#888;width:120px">מספר תיק</td><td style="padding:6px;font-weight:600">${esc(data.case_number)}</td></tr>
|
||||
<tr><td style="padding:6px;color:#888">כותרת</td><td style="padding:6px">${esc(data.title)}</td></tr>
|
||||
<tr><td style="padding:6px;color:#888">תחום</td><td style="padding:6px">${esc(PRACTICE_AREA_LABELS[data.practice_area] || data.practice_area)}</td></tr>
|
||||
<tr><td style="padding:6px;color:#888">סוג ערר</td><td style="padding:6px">${esc(SUBTYPE_LABELS[data.appeal_subtype] || data.appeal_subtype)}</td></tr>
|
||||
<tr><td style="padding:6px;color:#888">סוג</td><td style="padding:6px">${esc(data.committee_type)}</td></tr>
|
||||
<tr><td style="padding:6px;color:#888">כתובת</td><td style="padding:6px">${esc(data.property_address || '—')}</td></tr>
|
||||
<tr><td style="padding:6px;color:#888">עוררים</td><td style="padding:6px">${data.appellants.length ? data.appellants.map(esc).join(', ') : '—'}</td></tr>
|
||||
<tr><td style="padding:6px;color:#888">משיבים</td><td style="padding:6px">${data.respondents.length ? data.respondents.map(esc).join(', ') : '—'}</td></tr>
|
||||
@@ -2758,44 +2743,11 @@ function buildSummary() {
|
||||
`;
|
||||
}
|
||||
|
||||
// 1xxx → building_permit, 8xxx → betterment_levy, 9xxx → compensation_197
|
||||
function deriveSubtypeFromCaseNumber(caseNumber) {
|
||||
const m = (caseNumber || '').trim().match(/^(\d)/);
|
||||
if (!m) return 'unknown';
|
||||
return ({1: 'building_permit', 8: 'betterment_levy', 9: 'compensation_197'})[m[1]] || 'unknown';
|
||||
}
|
||||
|
||||
// Auto-fill subtype + committee_type when the user types/edits the case number.
|
||||
// User can override the dropdown manually afterwards.
|
||||
function wireSubtypeAutofill() {
|
||||
const cnInput = document.getElementById('wiz-case-number');
|
||||
const subtypeSel = document.getElementById('wiz-appeal-subtype');
|
||||
const committeeHidden = document.getElementById('wiz-committee-type');
|
||||
if (!cnInput || !subtypeSel) return;
|
||||
const SUBTYPE_TO_COMMITTEE = {
|
||||
building_permit: 'רישוי',
|
||||
betterment_levy: 'היטל השבחה',
|
||||
compensation_197: 'פיצויים',
|
||||
unknown: 'רישוי',
|
||||
};
|
||||
let userOverrode = false;
|
||||
subtypeSel.addEventListener('change', () => { userOverrode = true; });
|
||||
cnInput.addEventListener('input', () => {
|
||||
if (userOverrode) return;
|
||||
const derived = deriveSubtypeFromCaseNumber(cnInput.value);
|
||||
subtypeSel.value = derived;
|
||||
if (committeeHidden) committeeHidden.value = SUBTYPE_TO_COMMITTEE[derived];
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', wireSubtypeAutofill);
|
||||
|
||||
function getWizardData() {
|
||||
return {
|
||||
case_number: document.getElementById('wiz-case-number').value.trim(),
|
||||
title: document.getElementById('wiz-title').value.trim(),
|
||||
committee_type: document.getElementById('wiz-committee-type').value,
|
||||
practice_area: document.getElementById('wiz-practice-area').value,
|
||||
appeal_subtype: document.getElementById('wiz-appeal-subtype').value,
|
||||
property_address: document.getElementById('wiz-address').value.trim(),
|
||||
permit_number: document.getElementById('wiz-permit').value.trim(),
|
||||
appellants: getListValues('appellantsList'),
|
||||
@@ -2895,18 +2847,9 @@ async function loadCaseView(caseNumber) {
|
||||
new: 'חדש', in_progress: 'בתהליך', documents_ready: 'מסמכים מוכנים',
|
||||
drafted: 'טיוטה', final: 'סופי',
|
||||
};
|
||||
const PRACTICE_AREA_LABELS = { appeals_committee: 'ועדת ערר', national_insurance: 'ביטוח לאומי', labor_law: 'דיני עבודה' };
|
||||
const SUBTYPE_LABELS = { building_permit: 'רישוי ובנייה', betterment_levy: 'היטל השבחה', compensation_197: "פיצויים (ס' 197)", unknown: 'לא ידוע' };
|
||||
const meta = [];
|
||||
meta.push(`<span class="badge ${data.status}">${STATUS_LABELS[data.status] || data.status}</span>`);
|
||||
if (data.practice_area || data.appeal_subtype) {
|
||||
const parts = [];
|
||||
if (data.practice_area) parts.push(PRACTICE_AREA_LABELS[data.practice_area] || data.practice_area);
|
||||
if (data.appeal_subtype) parts.push(SUBTYPE_LABELS[data.appeal_subtype] || data.appeal_subtype);
|
||||
meta.push(`<span class="badge" style="background:#e8f0fe;color:#1a56db" title="תחום משפטי / סוג ערר">${parts.join(' · ')}</span>`);
|
||||
} else if (data.committee_type) {
|
||||
meta.push(data.committee_type);
|
||||
}
|
||||
if (data.committee_type) meta.push(data.committee_type);
|
||||
if (data.property_address) meta.push(data.property_address);
|
||||
if (data.appellants?.length) meta.push('עוררים: ' + data.appellants.join(', '));
|
||||
document.getElementById('caseViewMeta').innerHTML = meta.map(m => `<span>${m}</span>`).join('');
|
||||
|
||||
Reference in New Issue
Block a user