Compare commits
25 Commits
v1.0.0
...
6228846223
| Author | SHA1 | Date | |
|---|---|---|---|
| 6228846223 | |||
| 82ba4663ba | |||
| 7509d7e580 | |||
| 2a7174b15d | |||
| ce64766f6d | |||
| 2d349cf817 | |||
| 598df0dc8c | |||
| bb6f5e9eff | |||
| 45d52a74d2 | |||
| 1133272e34 | |||
| b755620542 | |||
| 089a8b3a08 | |||
| 34fa923a2b | |||
| d9948045f1 | |||
| 23f6b5d825 | |||
| a093944967 | |||
| e698419faf | |||
| 5028f677f1 | |||
| 2faae002e7 | |||
| 140a2e442d | |||
| ce61b88438 | |||
| e5eee596bc | |||
| bd974f7791 | |||
| b248e1414d | |||
| 9da8dd2c4f |
@@ -29,6 +29,37 @@ curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$PAPERCLIP_API_URL/api/ag
|
||||
- תעדוף: `in_progress` קודם, אחר כך `todo`
|
||||
- אם `PAPERCLIP_TASK_ID` מוגדר — תעדף אותו
|
||||
|
||||
## 2b. קרא תגובות אחרונות על ה-issue
|
||||
|
||||
לפני שאתה מתחיל לעבוד, בדוק אם יש comments חדשים מחיים:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
||||
```
|
||||
|
||||
- אם יש comment מחיים (authorUserId, לא authorAgentId) שנכתב **אחרי** ה-comment האחרון שלך — **קרא אותו בתשומת לב**
|
||||
- אם ה-comment מכיל הוראות עבודה — **עקוב אחריהן**
|
||||
- אם ה-comment מזכיר קובץ שהועלה — בדוק attachments (ראה 2c)
|
||||
- אם ה-comment מבקש להעביר לסוכן אחר — **עצור**, פרסם comment שמאשר, והעֵר את ה-CEO
|
||||
|
||||
## 2c. בדוק קבצים מצורפים
|
||||
|
||||
אם comment מחיים מזכיר קובץ או טיוטה:
|
||||
|
||||
```bash
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
SELECT a.original_filename, a.content_type, a.object_key, a.byte_size
|
||||
FROM issue_attachments ia
|
||||
JOIN assets a ON a.id = ia.asset_id
|
||||
WHERE ia.issue_id = '{issue-id}'
|
||||
ORDER BY ia.created_at DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
- נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
|
||||
- קבצי DOCX — קרא אותם עם `Read`
|
||||
- השתמש בתוכן הקובץ כקלט לעבודתך
|
||||
|
||||
## 3. Checkout ועבודה
|
||||
|
||||
```bash
|
||||
@@ -75,23 +106,23 @@ curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
```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'
|
||||
);"
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"source":"automation","triggerDetail":"system","reason":"סוכן [שמך] סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||
```
|
||||
|
||||
**⚠️ כללי ברזל — Paperclip API:**
|
||||
1. **אסור** `INSERT INTO agent_wakeup_requests` — לא יוצר heartbeat_run, הסוכן לא יתעורר לעולם
|
||||
2. **חובה** `payload.issueId` בכל wakeup — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי cwd)
|
||||
3. **agent JWT לא יכול להעיר סוכנים אחרים** — רק את עצמו. כדי להעיר סוכן אחר → צור issue + הקצה אליו (Paperclip מפעיל wakeup אוטומטי)
|
||||
|
||||
**נתיבי API:**
|
||||
| פעולה | נתיב |
|
||||
|-------|-------|
|
||||
| פרסום comment | `POST /api/issues/{issue-id}/comments` |
|
||||
| יצירת issue | `POST /api/companies/{company-id}/issues` |
|
||||
| עדכון issue | `PATCH /api/issues/{issue-id}` |
|
||||
| wakeup עצמי/CEO | `POST /api/agents/{agent-id}/wakeup` (עם payload!) |
|
||||
|
||||
## 5. התראת מייל — כשנדרשת תשובה אנושית
|
||||
|
||||
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:
|
||||
|
||||
@@ -210,21 +210,11 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
||||
```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" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "מנתח משפטי סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
```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'
|
||||
);"
|
||||
```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
|
||||
**אם בדיקות שלב 6 נכשלו** — סטטוס issue = "blocked", פרסם comment עם פירוט מה נכשל, שלח מייל לחיים.
|
||||
|
||||
@@ -329,6 +319,72 @@ X שאלות עומדות להכרעה:
|
||||
- **הערכה כללית**: לאן נוטה הניתוח ומהם הסיכויים הכלליים של הערר
|
||||
```
|
||||
|
||||
## שלב 8: העמקת ניתוח (pass 2) — אחרי אישור כיוון
|
||||
|
||||
שלב זה מופעל כשהמנתח מקבל משימה עם הוראה "pass 2" או כשסטטוס התיק הוא `direction_approved`.
|
||||
הפעם, מסמך הניתוח חוזר עם עמדות יו"ר מולאות — כלומר יש כיוון מאושר.
|
||||
**אל תשנה את עמדות היו"ר. תפקידך להעשיר את הניתוח סביבן.**
|
||||
|
||||
### 8א. אימות פסיקה
|
||||
סרוק את עמדות היו"ר וזהה כל אזכור פסיקה (בג"ץ, עע"מ, עת"מ, ע"א, ערר וכו').
|
||||
לכל פסק דין שמוזכר:
|
||||
1. חפש בקורפוס הפנימי (`search_decisions`, `find_similar_cases`)
|
||||
2. חפש במסמכי התיק (`search_case_documents`) — אולי מצוטט בכתבי הטענות
|
||||
3. **אם נמצא** — חלץ ציטוט מדויק, הקשר, רלוונטיות
|
||||
4. **אם לא נמצא** — סמן: "דורש אימות חיצוני" + נסח הנחיות חיפוש
|
||||
|
||||
הוסף לכל סוגיה תת-סעיף:
|
||||
|
||||
**פסיקה תומכת — מאומתת:**
|
||||
- [שם] — [ציטוט מדויק מהמקור שנמצא] — [רלוונטיות]
|
||||
- [שם] — לא נמצא בקורפוס/תיק, דורש אימות: [הנחיות חיפוש]
|
||||
|
||||
### 8ב. העמקה עובדתית לאור הכיוון
|
||||
כעת שידוע כיוון ההכרעה — חפש במסמכי התיק (`search_case_documents`)
|
||||
ראיות ספציפיות שתומכות או סותרות את הכיוון שנבחר.
|
||||
עדכן "ממצאים עובדתיים" עם ציטוטים ישירים מחומרי המקור.
|
||||
|
||||
### 8ג. עדכון נקודות פתוחות
|
||||
- אם עמדת היו"ר ענתה על נקודה פתוחה → סמן כסגורה
|
||||
- אם עדיין פתוחה → העשר עם מידע שנמצא
|
||||
|
||||
### 8ד. עדכון הכנה ל-CREAC
|
||||
עדכן עם פסיקה מאומתת וציטוטים מדויקים.
|
||||
|
||||
### 8ה. שמירה ודיווח
|
||||
1. גבה גרסה קודמת: `cp {case_dir}/documents/research/analysis-and-research.md {case_dir}/documents/research/backup/analysis-and-research-pass1.md`
|
||||
2. שמור מסמך מעודכן: `{case_dir}/documents/research/analysis-and-research.md`
|
||||
3. עדכן סטטוס: `case_update(status=analysis_enriched)`
|
||||
4. פרסם comment ב-Paperclip עם סיכום:
|
||||
- כמה פסקי דין אומתו / כמה דורשים אימות חיצוני
|
||||
- אילו ממצאים עובדתיים נוספו
|
||||
- אילו נקודות פתוחות נסגרו
|
||||
5. שלח מייל:
|
||||
```bash
|
||||
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
"העמקת ניתוח הושלמה — ערר {case_number}" \
|
||||
"סיכום: X פסקי דין אומתו, Y דורשים אימות חיצוני. ממצאים עובדתיים הועשרו."
|
||||
```
|
||||
6. **העֵר את ה-CEO — חובה!**
|
||||
```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/wakeup" \
|
||||
-d '{"reason": "מנתח משפטי סיים העמקת ניתוח (pass 2) [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
```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',
|
||||
'מנתח משפטי סיים העמקת ניתוח (pass 2) — נדרשת בדיקה',
|
||||
'queued', 'agent'
|
||||
);"
|
||||
```
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
1. **נאמנות למקור** — כל טענה חייבת לשקף את מה שנכתב, לא לפרש
|
||||
|
||||
@@ -13,6 +13,10 @@ tools:
|
||||
- mcp__legal-ai__case_update
|
||||
- mcp__legal-ai__document_list
|
||||
- mcp__legal-ai__get_claims
|
||||
- mcp__legal-ai__get_chair_directions
|
||||
- mcp__legal-ai__record_chair_feedback
|
||||
- mcp__legal-ai__list_chair_feedback
|
||||
- mcp__legal-ai__search_case_documents
|
||||
- mcp__legal-ai__workflow_status
|
||||
- mcp__legal-ai__processing_status
|
||||
- mcp__legal-ai__get_metrics
|
||||
@@ -58,9 +62,16 @@ tools:
|
||||
|
||||
## תהליך אינטראקטיבי — שלב אחר שלב
|
||||
|
||||
### שלב 0: בדוק למה התעוררת
|
||||
|
||||
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
||||
- אם ה-reason מכיל `user_commented` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
|
||||
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||
- אחרת → המשך לשלב A (heartbeat רגיל)
|
||||
|
||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||
|
||||
בכל heartbeat:
|
||||
בכל heartbeat **רגיל** (לא comment routing):
|
||||
1. בדוק תיקים פעילים (`case_list`)
|
||||
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
||||
3. בדוק comments מחיים שממתינים לתגובה
|
||||
@@ -253,14 +264,29 @@ tools:
|
||||
- [ ] תקן ביקורת מצוין
|
||||
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
|
||||
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
||||
5. צור issue חדש ב-Paperclip:
|
||||
- כותרת: `[ערר {case_number}] כתיבת החלטה`
|
||||
- הקצה ל: **כותב החלטה** (7ed8686f-24bc-49a3-bc02-67ca15b895a9)
|
||||
6. פרסם comment: "כיוון אושר. הועבר לכותב החלטה."
|
||||
7. עדכן סטטוס: `case_update(status=direction_approved)`
|
||||
5. עדכן סטטוס: `case_update(status=direction_approved)`
|
||||
6. צור issue חדש ב-Paperclip:
|
||||
- כותרת: `[ערר {case_number}] העמקת ניתוח (pass 2)`
|
||||
- הקצה ל: **מנתח משפטי** (c26e9439-a88a-49dc-9e67-2262c95db65c)
|
||||
- תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר."
|
||||
7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה."
|
||||
|
||||
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם.
|
||||
|
||||
### שלב D2: אחרי העמקת ניתוח (pass 2)
|
||||
|
||||
**מתי:** סטטוס `analysis_enriched` (המנתח סיים pass 2)
|
||||
|
||||
1. קרא comment של המנתח — כמה פסקי דין אומתו, מה נוסף, מה דורש אימות חיצוני
|
||||
2. **בנה תיאור issue מלא לכותב** — ראה "תבנית issue לכותב ההחלטה" למטה
|
||||
3. צור issue חדש עם התיאור המלא:
|
||||
- כותרת: `[ערר {case_number}] כתיבת החלטה`
|
||||
- הקצה ל: **כותב החלטה** (7ed8686f-24bc-49a3-bc02-67ca15b895a9)
|
||||
4. פרסם comment עם סיכום מה הועבר
|
||||
5. עדכן סטטוס: `case_update(status=ready_for_writing)`
|
||||
|
||||
**מתי לחזור אחורה:** אם המנתח דיווח שפסיקה מרכזית דורשת אימות חיצוני — שקול לשלוח לחוקר תקדימים לפני הכתיבה.
|
||||
|
||||
### שלב E: מעקב כתיבה
|
||||
|
||||
**מתי:** כותב החלטה עובד
|
||||
@@ -294,7 +320,9 @@ tools:
|
||||
| `analyst_verified` | CEO (אחרי שלב A) | → האם יש מחקר תקדימים? אם לא → צור issue לחוקר (35022af0). אם כן → שלב B |
|
||||
| `research_complete` | חוקר | → שלב B (סיכום + סיווג + שאלת תוצאה לחיים) |
|
||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → בדוק chair_directions שלם? אם כן → צור issue לכותב (7ed8686f). אם חסר → חזור לחיים |
|
||||
| `direction_approved` | CEO (אחרי שחיים אישר) | → צור issue למנתח (c26e9439) ל-pass 2: העמקת ניתוח ואימות פסיקה |
|
||||
| `analysis_enriched` | מנתח (pass 2) | → שלב D2: צור issue לכותב (7ed8686f) |
|
||||
| `ready_for_writing` | CEO (אחרי D2) | → כותב עובד |
|
||||
| `drafted` | כותב | → צור issue לבודק איכות (1a5b229e) |
|
||||
| `qa_passed` | QA | → צור issue למייצא (d0dc703b) |
|
||||
| `qa_failed` | QA | → בעיה טכנית → issue תיקון לכותב. בעיה מתודולוגית → חזור לשלב C/D |
|
||||
@@ -304,6 +332,48 @@ tools:
|
||||
|
||||
---
|
||||
|
||||
**תבנית issue לכותב ההחלטה — חובה בכל issue שמוקצה לכותב:**
|
||||
|
||||
כל issue לכותב חייב לכלול את **כל** הסעיפים הבאים. אסור לשלוח issue עם משפט כמו "הועבר לכתיבה" — זה חסר תועלת. הכותב צריך הכל מוכן מראש.
|
||||
|
||||
```markdown
|
||||
## הנחיות כתיבה — ערר {case_number}
|
||||
|
||||
### 1. תוצאה ומצב
|
||||
- **תוצאה:** {דחייה / קבלה חלקית / קבלה מלאה}
|
||||
- **טיוטה קיימת:** {כן/לא}. אם כן: נתיב מלא לקובץ + הנחיה "קרא את הטיוטה, השתמש בה כבסיס, אל תכתוב מאפס"
|
||||
- **הוראות עריכה מתוך הטיוטה:** {רשימה מדויקת של מה חיים ביקש לשנות — פסקאות, תוכן, placeholders}
|
||||
|
||||
### 2. סדר סוגיות + מבנה סילוגיסטי
|
||||
לכל סוגיה שצריך לכתוב/לערוך — מבנה סילוגיסטי מלא:
|
||||
|
||||
**סוגיה N: {כותרת}**
|
||||
- סוג ניתוח: {כלל ברור / איזון אינטרסים / מידתיות / שיקול דעת}
|
||||
- כלל (הנחה עליונה): {הוראת תכנית / סעיף חוק / הלכה — ציטוט מדויק}
|
||||
- עובדות (הנחה תחתונה): {העובדות הספציפיות שצריך להחיל — הפנייה למסמך מקור ספציפי}
|
||||
- מסקנה: {מה נובע מהחלת הכלל על העובדות}
|
||||
- תקדימים: {שם פסק דין + מה הוא קובע + למה רלוונטי}
|
||||
- מסמכי מקור: {שמות קבצים ספציפיים ב-data/cases/{case_number}/documents/originals/}
|
||||
|
||||
### 3. טיפול בטענות
|
||||
| # | טענה | טיפול | סוגיה |
|
||||
|---|------|-------|-------|
|
||||
| 1 | {טענה} | דיון מלא / קיבוץ / דילוג | {באיזו סוגיה} |
|
||||
...
|
||||
|
||||
### 4. chair directions
|
||||
- העתק מלא של עמדות הוועדה מ-analysis-and-research.md (או הפנייה: "קרא get_chair_directions")
|
||||
|
||||
### 5. הנחיות סגנון
|
||||
- ניטרליות: בלוק ו = עובדות בלבד, בלי ציטוטים מצדדים
|
||||
- ללא כפילות: בלוק י מפנה לבלוקים קודמים
|
||||
- טענות מקוריות: בלוק ז = כתבי טענות מקוריים
|
||||
- אורך מינימלי לדיון: 1,500 מילים לבלוק י
|
||||
- פסיקה: חובה לצטט לפחות 3 תקדימים בדיון
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**תבנית issue למנתח — חובה בכל תיק:**
|
||||
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, claim_type, party_role. בנה מ-`document_list`.
|
||||
2. **רשימת מסמכים שלא לחלץ מהם** (reference, plan, decision, court_decision)
|
||||
@@ -320,15 +390,102 @@ tools:
|
||||
- **לשאול כשלא בטוח** — אם משהו לא ברור, שאל את חיים
|
||||
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
|
||||
|
||||
## איך לקרוא comments של חיים
|
||||
## טיפול בתגובות חדשות מחיים (comment routing)
|
||||
|
||||
כשאתה מתעורר בגלל תגובה חדשה (reason מכיל "user_commented"):
|
||||
|
||||
1. **קרא את ה-comments האחרונים** על ה-issue שצוין ב-prompt:
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
||||
```
|
||||
|
||||
2. **בדוק attachments** — אם חיים ציין קובץ שהועלה:
|
||||
```bash
|
||||
PGPASSWORD="paperclip" psql -h 127.0.0.1 -p 54329 -U paperclip -d paperclip -c "
|
||||
SELECT a.original_filename, a.content_type, a.object_key
|
||||
FROM issue_attachments ia
|
||||
JOIN assets a ON a.id = ia.asset_id
|
||||
WHERE ia.issue_id = '{issue-id}'
|
||||
ORDER BY ia.created_at DESC LIMIT 5;"
|
||||
```
|
||||
נתיב מלא לקובץ: `/home/chaim/.paperclip/instances/default/data/storage/{object_key}`
|
||||
|
||||
3. **אם יש טיוטה/קובץ — קרא אותו מילה במילה.** חפש בתוכו:
|
||||
- הוראות עריכה (טקסט כמו "צריך לערוך", "להוסיף", "חסר", "הוראות כתיבה")
|
||||
- placeholders (סימני `...`, `בשנת..`, `[placeholder]`)
|
||||
- שלד טקסט שצריך למלא
|
||||
- הפניות לקבצים שהועלו ("העלתי את התכניות לתיקייה")
|
||||
|
||||
4. **⚠️ לפני שאתה יוצר issue — נתח את הבקשה דרך המתודולוגיה ועדכן chair_directions:**
|
||||
|
||||
גם בקשת עריכה של פסקאות בודדות היא עדיין כתיבה בתוך החלטה מעין-שיפוטית. **אל תעביר לכותב לפני שעדכנת chair_directions וחיים אישר.**
|
||||
|
||||
א. **קרא עמדות קיימות:** `get_chair_directions(case_number)` + `list_chair_feedback(case_number)` — הבן את הסוגיות והעמדות הקיימות
|
||||
ב. **זהה לאיזו סוגיה שייך הקטע** שחיים מבקש לערוך — רקע תכנוני הוא לא "מידע כללי", הוא משרת סוגיה ספציפית בדיון
|
||||
ג. **תרגם את ההערות מהטיוטה למבנה מתודולוגי:**
|
||||
- לכל קטע שצריך לכתוב/לערוך, בנה סילוגיזם:
|
||||
- כלל: מה הוראת התכנית/החוק/ההלכה הרלוונטית?
|
||||
- עובדות: מה העובדות שצריך להציג (ומאיזה מסמך מקור ספציפי — עמוד, פסקה)
|
||||
- מסקנה: מה נובע מהחלת הכלל על העובדות
|
||||
- ציין סוג ניתוח: כלל ברור / איזון / מידתיות / שיקול דעת
|
||||
- ציין תקן ביקורת
|
||||
ד. **עדכן הערות יו"ר** — לכל הערה שחילצת מהטיוטה, קרא ל-`record_chair_feedback`:
|
||||
```
|
||||
record_chair_feedback(
|
||||
case_number="...",
|
||||
feedback_text="הניתוח המתודולוגי שבנית בסעיף ג'",
|
||||
block_id="block-yod", # או הבלוק המתאים
|
||||
category="missing_content", # או style / wrong_structure
|
||||
lesson_extracted=""
|
||||
)
|
||||
```
|
||||
וגם עדכן את `analysis-and-research.md` (בסוגיה המתאימה, תחת "עמדת ועדת הערר") עם הניתוח מסעיף ג'
|
||||
ה. **פרסם comment לחיים** עם סיכום של מה שהבנת + הפניה ל-chair_directions המעודכנים:
|
||||
```
|
||||
## הבנת ההערות מהטיוטה — ערר {case_number}
|
||||
|
||||
קראתי את ההערות בפסקאות {X-Y}. הבנתי שהן משרתות את סוגיית {שם הסוגיה}.
|
||||
עדכנתי chair_directions:
|
||||
- {סיכום מה נוסף / שונה}
|
||||
|
||||
אנא בדוק ואשר לפני שמעביר לכותב.
|
||||
```
|
||||
ו. **המתן לאישור חיים** — לא ליצור issue לכותב עד שחיים מאשר שהוא הבין נכון
|
||||
|
||||
5. **אחרי אישור חיים** → צור issue לכותב לפי "תבנית issue לכותב ההחלטה" למטה — התבנית חייבת לכלול את הניתוח המתודולוגי מסעיף 4
|
||||
|
||||
6. **דווח** — פרסם comment שמאשר שהועבר לכותב
|
||||
|
||||
## נתיבי API — חובה!
|
||||
|
||||
```bash
|
||||
# קרא comments על issue
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '.[-1].body'
|
||||
|
||||
# פרסם comment
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" \
|
||||
-d '{"body": "..."}'
|
||||
|
||||
# צור issue חדש (עם הקצאה לסוכן → מפעיל wakeup אוטומטי!)
|
||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/companies/42a7acd0-30c5-4cbd-ac97-7424f65df294/issues" \
|
||||
-d '{"title":"...","projectId":"25c1b4a1-2c0e-4a2d-9938-8ae56ccda6f1","assigneeAgentId":"{agent-id}","description":"...","status":"todo"}'
|
||||
|
||||
# עדכן issue
|
||||
curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}" \
|
||||
-d '{"status": "done"}'
|
||||
```
|
||||
|
||||
חפש ב-comment:
|
||||
**⚠️ agent JWT לא יכול להעיר סוכנים אחרים ישירות.** כדי להעיר סוכן → **צור issue חדש + הקצה אליו** (Paperclip מפעיל wakeup אוטומטי על assignment).
|
||||
|
||||
חפש ב-comment של חיים:
|
||||
- מספר (1/2/3) → בחירה
|
||||
- "כיוון" + מספר → אישור כיוון
|
||||
- טבלת טיפול בטענות → סימון claim_handling
|
||||
|
||||
@@ -78,21 +78,11 @@ tools:
|
||||
```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" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
```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'
|
||||
);"
|
||||
```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
|
||||
@@ -62,60 +62,4 @@ tools:
|
||||
1. **גיבוי**: העתק את הקובץ המקורי מ-`extracted/` לתיקיית `documents/backup/` עם סיומת `.pre-proofread.txt`
|
||||
2. **כתוב** את הגרסה המתוקנת לתיקיית `documents/proofread/` (עם אותו שם קובץ כמו ב-`extracted/`)
|
||||
3. עדכן את מסד הנתונים — שנה `extraction_status` ל-`proofread`:
|
||||
```bash
|
||||
PGPASSWORD="${PGPASSWORD:-$(grep DB_PASSWORD /home/chaim/.env | cut -d= -f2)}" \
|
||||
psql -h localhost -p 5432 -U "${DB_USER:-legal_ai}" -d "${DB_NAME:-legal_ai}" \
|
||||
-c "UPDATE documents SET extraction_status = 'proofread', extracted_text = pg_read_file('/path/to/file.txt') WHERE id = '{doc_id}';"
|
||||
```
|
||||
אם עדכון DB לא אפשרי, עדכן רק את הקובץ ודווח.
|
||||
|
||||
### שלב 5: עדכון סטטוס ודיווח
|
||||
|
||||
1. **עדכן סטטוס**: `case_update(case_number, status='proofread')`
|
||||
|
||||
2. פרסם comment ב-Paperclip עם:
|
||||
```
|
||||
## דוח הגהת מסמכים — תיק {case_number}
|
||||
|
||||
### סיכום
|
||||
- **מסמכים שנבדקו:** {count}
|
||||
- **מסמכים שתוקנו:** {fixed_count}
|
||||
- **סה"כ תיקונים:** {total_fixes}
|
||||
|
||||
### פירוט לכל מסמך
|
||||
| מסמך | ראשי תיבות | שגיאות OCR | הערות |
|
||||
|------|------------|-----------|-------|
|
||||
| {title} | {abbr_count} | {ocr_count} | {notes} |
|
||||
|
||||
### מקומות לא ברורים
|
||||
- {document}: סעיף {n} — [?] "{problematic_text}"
|
||||
```
|
||||
|
||||
## כללים קריטיים
|
||||
|
||||
1. **אל תשנה תוכן משפטי** — רק תיקוני OCR. אם מילה נראית מוזרה אבל היא מונח משפטי — אל תגע
|
||||
2. **אל תדרוס בלי גיבוי** — תמיד העתק ל-`backup/` לפני שינוי
|
||||
3. **ראשי תיבות ארוכים קודם** — `נתבייע` (5 תווים) לפני `עייד` (3 תווים)
|
||||
4. **דווח מקומות מסופקים** — סמן `[?]` ותן לאדם להחליט
|
||||
5. **אל תמציא טקסט** — אם חסר משהו, סמן `[...]` ואל תנחש
|
||||
6. **קרא את כל המסמך** — לפעמים הקשר ממסמך שלם עוזר להבין מילה שבורה
|
||||
|
||||
### העֵר את העוזר המשפטי (CEO) — חובה!
|
||||
```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 לא עובד:
|
||||
```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'
|
||||
);"
|
||||
```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
|
||||
@@ -109,18 +109,8 @@ tools:
|
||||
```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" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
```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'
|
||||
);"
|
||||
```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
|
||||
@@ -98,21 +98,11 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||
```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" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
```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'
|
||||
);"
|
||||
```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
|
||||
## כללים
|
||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||
|
||||
@@ -70,6 +70,19 @@ tools:
|
||||
|
||||
## תהליך עבודה
|
||||
|
||||
### שלב 0: בדיקת הוראות וטיוטות
|
||||
|
||||
לפני שתתחיל לכתוב, בדוק אם יש הנחיות ספציפיות:
|
||||
|
||||
1. **קרא comments אחרונים על ה-issue** — חפש הוראות מה-CEO או מחיים:
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '[.[] | select(.authorUserId != null)] | .[-3:]'
|
||||
```
|
||||
2. **בדוק attachments** (ראה HEARTBEAT שלב 2c) — אם יש קובץ DOCX מצורף, קרא אותו
|
||||
3. **אם יש טיוטת DOCX** — קרא אותה, השתמש בה כבסיס. **אל תכתוב מאפס אם יש טיוטה.**
|
||||
4. **אם ה-CEO או חיים כתבו הנחיות ב-comment** (למשל "ערוך בהתאם ל...") — **עקוב אחריהן**
|
||||
|
||||
### שלב 1: הכנה
|
||||
1. **קרא את המתודולוגיה**: `Read docs/decision-methodology.md` — חובה לפני כל כתיבה
|
||||
2. קרא פרטי התיק (`case_get`)
|
||||
@@ -147,21 +160,11 @@ case_update(case_number, status="drafted")
|
||||
```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" \
|
||||
"$PAPERCLIP_API_URL/api/agents/752cebdd-6748-4a04-aacd-c7ab0294ef33/wakeup" \
|
||||
-d '{"reason": "כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||
```
|
||||
אם ה-API לא עובד:
|
||||
```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'
|
||||
);"
|
||||
```
|
||||
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||
|
||||
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
||||
|
||||
|
||||
@@ -54,5 +54,5 @@ jobs:
|
||||
- name: Trigger Coolify redeploy
|
||||
run: |
|
||||
curl -sf \
|
||||
"http://coolify:8080/api/v1/deploy?uuid=my85gabx37ele9aouub8t8ju&force=true" \
|
||||
"http://coolify:8080/api/v1/deploy?uuid=gyjo0mtw2c42ej3xxvbz8zio&force=true" \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -58,7 +58,8 @@
|
||||
| Redis | תור משימות | `legal-ai-redis` |
|
||||
| n8n | אוטומציית workflows | להגדרה |
|
||||
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
|
||||
| ezer-mishpati-web | ממשק העלאת מסמכים | `legal-ai.nautilus.marcusgroup.org` |
|
||||
| ezer-mishpati-web | ממשק העלאת מסמכים (Docker/Coolify) | `legal-ai.nautilus.marcusgroup.org` |
|
||||
| Paperclip | סוכן AI — מריץ Claude Code agents (pm2, מקומי) | `localhost:3100` |
|
||||
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
|
||||
|
||||
---
|
||||
@@ -102,6 +103,26 @@
|
||||
|
||||
---
|
||||
|
||||
## Paperclip — כללי אינטגרציה קריטיים
|
||||
|
||||
### Wakeup API — תמיד דרך API, לעולם לא דרך DB
|
||||
- **הנתיב הנכון**: `POST /api/agents/{agent-id}/wakeup` (לא `/wake`!)
|
||||
- **⚠️ אסור**: `INSERT INTO agent_wakeup_requests` ישירות — זה יוצר רק רשומה בלי `heartbeat_run`, והסוכן **לא יתעורר לעולם**
|
||||
- **⚠️ חובה לשלוח `payload` עם `issueId`** — בלי זה הסוכן מתעורר בלי הקשר (בלי תיק, בלי issue, בלי cwd נכון)
|
||||
- דוגמה נכונה:
|
||||
```json
|
||||
{"source": "automation", "triggerDetail": "system", "reason": "...",
|
||||
"payload": {"issueId": "...", "mutation": "comment", "commentId": "..."}}
|
||||
```
|
||||
- **Board API Key**: שמור ב-DB (`board_api_keys`), auth: `Authorization: Bearer pbk_...`
|
||||
|
||||
### ניתוב comments דרך CEO
|
||||
- כשמשתמש כותב תגובה על issue ב-Paperclip, הפלאגין (`plugin-legal-ai`) מעיר את ה-CEO דרך `ctx.agents.invoke()`
|
||||
- ה-CEO קורא את ה-comment, מחליט על ניתוב, ויוצר issue לסוכן המתאים
|
||||
- כל הסוכנים חייבים לקרוא comments אחרונים לפני שהם מתחילים לעבוד (HEARTBEAT שלבים 2b-2c)
|
||||
|
||||
---
|
||||
|
||||
## עקרונות כתיבה קריטיים
|
||||
|
||||
1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק
|
||||
|
||||
@@ -34,10 +34,9 @@ WORKDIR /app
|
||||
|
||||
# Install Node.js 20.x
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates \
|
||||
curl ca-certificates git \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& apt-get purge -y curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies = [
|
||||
"google-cloud-vision>=3.7.0",
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -220,9 +220,14 @@ async def search_decisions(
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
section_type: str = "",
|
||||
practice_area: str = "",
|
||||
appeal_subtype: str = "",
|
||||
case_number: str = "",
|
||||
) -> str:
|
||||
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים."""
|
||||
return await search.search_decisions(query, limit, section_type)
|
||||
"""חיפוש סמנטי בהחלטות קודמות ובמסמכים — מסונן לפי תחום משפטי."""
|
||||
return await search.search_decisions(
|
||||
query, limit, section_type, practice_area, appeal_subtype, case_number,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -239,9 +244,14 @@ 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:
|
||||
"""מציאת תיקים דומים על בסיס תיאור."""
|
||||
return await search.find_similar_cases(description, limit)
|
||||
"""מציאת תיקים דומים על בסיס תיאור — מסונן לפי תחום משפטי."""
|
||||
return await search.find_similar_cases(
|
||||
description, limit, practice_area, appeal_subtype, case_number,
|
||||
)
|
||||
|
||||
|
||||
# Drafting
|
||||
|
||||
@@ -156,6 +156,8 @@ ALTER TABLE decisions ADD COLUMN IF NOT EXISTS outcome_reasoning TEXT DEFAULT ''
|
||||
|
||||
-- הרחבת cases עם appeal_type (אם לא קיים)
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_type TEXT DEFAULT '';
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT 'appeals_committee';
|
||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
|
||||
|
||||
-- טבלת qa_results
|
||||
CREATE TABLE IF NOT EXISTS qa_results (
|
||||
@@ -374,6 +376,16 @@ CREATE TABLE IF NOT EXISTS chair_feedback (
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tag_company_mappings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tag TEXT NOT NULL, -- appeal_subtype value (e.g. building_permit)
|
||||
tag_label TEXT NOT NULL DEFAULT '', -- Hebrew display label
|
||||
company_id TEXT NOT NULL, -- Paperclip company UUID
|
||||
company_name TEXT NOT NULL DEFAULT '', -- cached company name for display
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(tag, company_id)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
-- Indexes
|
||||
-- ═══════════════════════════════════════════════════════════════════
|
||||
@@ -467,6 +479,8 @@ async def create_case(
|
||||
hearing_date: date | None = None,
|
||||
notes: str = "",
|
||||
expected_outcome: str = "",
|
||||
practice_area: str = "appeals_committee",
|
||||
appeal_subtype: str = "",
|
||||
) -> dict:
|
||||
pool = await get_pool()
|
||||
case_id = uuid4()
|
||||
@@ -474,13 +488,15 @@ 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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""",
|
||||
hearing_date, notes, expected_outcome,
|
||||
practice_area, appeal_subtype)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)""",
|
||||
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)
|
||||
|
||||
@@ -809,6 +825,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."""
|
||||
pool = await get_pool()
|
||||
@@ -824,6 +842,14 @@ 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"c.practice_area = ${param_idx}")
|
||||
params.append(practice_area)
|
||||
param_idx += 1
|
||||
if appeal_subtype:
|
||||
conditions.append(f"c.appeal_subtype = ${param_idx}")
|
||||
params.append(appeal_subtype)
|
||||
param_idx += 1
|
||||
|
||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
|
||||
@@ -3,14 +3,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from legal_mcp import config
|
||||
from legal_mcp.services import audit, db, practice_area as pa
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITEA_ORG = "cases"
|
||||
|
||||
|
||||
def _gitea_host() -> str:
|
||||
return os.environ.get("GITEA_HOST", "https://gitea.nautilus.marcusgroup.org")
|
||||
|
||||
|
||||
def _gitea_token() -> str:
|
||||
return os.environ.get("GITEA_ACCESS_TOKEN") or os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
|
||||
async def _setup_gitea_remote(case_number: str, title: str, case_dir: Path) -> bool:
|
||||
"""Create Gitea repo and configure git remote. Best-effort — returns False on failure."""
|
||||
token = _gitea_token()
|
||||
if not token:
|
||||
logger.info("No GITEA_TOKEN — skipping Gitea repo creation for %s", case_number)
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(verify=False, timeout=30) as client:
|
||||
resp = await client.post(
|
||||
f"{_gitea_host()}/api/v1/orgs/{GITEA_ORG}/repos",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
json={
|
||||
"name": case_number,
|
||||
"description": f"ערר {case_number} — {title}"[:255],
|
||||
"private": True,
|
||||
"auto_init": False,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 409:
|
||||
resp2 = await client.get(
|
||||
f"{_gitea_host()}/api/v1/repos/{GITEA_ORG}/{case_number}",
|
||||
headers={"Authorization": f"token {token}"},
|
||||
)
|
||||
resp2.raise_for_status()
|
||||
repo = resp2.json()
|
||||
else:
|
||||
resp.raise_for_status()
|
||||
repo = resp.json()
|
||||
|
||||
clone_url = repo.get("clone_url", "")
|
||||
if not clone_url:
|
||||
return False
|
||||
|
||||
auth_url = clone_url.replace("https://", f"https://chaim:{token}@")
|
||||
|
||||
git_env = {
|
||||
"GIT_AUTHOR_NAME": "Ezer Mishpati",
|
||||
"GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati",
|
||||
"GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
}
|
||||
|
||||
# Add or update remote
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
cwd=case_dir, capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
subprocess.run(
|
||||
["git", "remote", "set-url", "origin", auth_url],
|
||||
cwd=case_dir, capture_output=True, env=git_env,
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", auth_url],
|
||||
cwd=case_dir, capture_output=True, env=git_env,
|
||||
)
|
||||
|
||||
# Push
|
||||
push = subprocess.run(
|
||||
["git", "push", "-u", "origin", "HEAD"],
|
||||
cwd=case_dir, capture_output=True, text=True, env=git_env,
|
||||
)
|
||||
if push.returncode != 0:
|
||||
logger.warning("Gitea push failed for %s: %s", case_number, push.stderr)
|
||||
return False
|
||||
|
||||
logger.info("Gitea repo created and pushed for %s", case_number)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Gitea setup failed for %s: %s", case_number, exc)
|
||||
return False
|
||||
|
||||
|
||||
async def case_create(
|
||||
case_number: str,
|
||||
@@ -92,7 +185,7 @@ async def case_create(
|
||||
case_dir.mkdir(parents=True, exist_ok=True)
|
||||
docs_dir = case_dir / "documents"
|
||||
docs_dir.mkdir(exist_ok=True)
|
||||
(docs_dir / "original").mkdir(exist_ok=True)
|
||||
(docs_dir / "originals").mkdir(exist_ok=True)
|
||||
(docs_dir / "extracted").mkdir(exist_ok=True)
|
||||
(docs_dir / "proofread").mkdir(exist_ok=True)
|
||||
(docs_dir / "backup").mkdir(exist_ok=True)
|
||||
@@ -106,17 +199,26 @@ async def case_create(
|
||||
notes_file = case_dir / "notes.md"
|
||||
notes_file.write_text(f"# הערות - תיק {case_number}\n\n{notes}\n")
|
||||
|
||||
# Initialize git repo
|
||||
subprocess.run(["git", "init"], cwd=case_dir, capture_output=True)
|
||||
subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"אתחול תיק {case_number}: {title}"],
|
||||
cwd=case_dir,
|
||||
capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
# Initialize git repo (best-effort)
|
||||
try:
|
||||
subprocess.run(["git", "init"], cwd=case_dir, capture_output=True)
|
||||
subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"אתחול תיק {case_number}: {title}"],
|
||||
cwd=case_dir,
|
||||
capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
except Exception:
|
||||
pass # git not available — non-critical
|
||||
|
||||
# Create Gitea repo and configure remote (best-effort)
|
||||
try:
|
||||
await _setup_gitea_remote(case_number, title, case_dir)
|
||||
except Exception:
|
||||
pass # Gitea not available — non-critical
|
||||
|
||||
return json.dumps(case, default=str, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -199,20 +301,23 @@ async def case_update(
|
||||
|
||||
updated = await db.update_case(UUID(case["id"]), **fields)
|
||||
|
||||
# Git commit the update
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
case_json = case_dir / "case.json"
|
||||
case_json.write_text(json.dumps(updated, default=str, ensure_ascii=False, indent=2))
|
||||
subprocess.run(["git", "add", "case.json"], cwd=case_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"עדכון תיק: {', '.join(fields.keys())}"],
|
||||
cwd=case_dir,
|
||||
capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
# Git commit the update (best-effort)
|
||||
try:
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
if case_dir.exists():
|
||||
case_json = case_dir / "case.json"
|
||||
case_json.write_text(json.dumps(updated, default=str, ensure_ascii=False, indent=2))
|
||||
subprocess.run(["git", "add", "case.json"], cwd=case_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"עדכון תיק: {', '.join(fields.keys())}"],
|
||||
cwd=case_dir,
|
||||
capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
except Exception:
|
||||
pass # git not available — non-critical
|
||||
|
||||
return json.dumps(updated, default=str, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@@ -67,31 +67,34 @@ async def document_upload(
|
||||
await db.update_document(UUID(doc["id"]), doc_type=classified_type)
|
||||
doc["doc_type"] = classified_type
|
||||
|
||||
# Git commit
|
||||
repo_dir = config.find_case_dir(case_number)
|
||||
if repo_dir.exists():
|
||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||
doc_type_hebrew = {
|
||||
"appeal": "כתב ערר",
|
||||
"response": "תשובה",
|
||||
"protocol": "פרוטוקול",
|
||||
"plan": "תכנית",
|
||||
"permit": "היתר",
|
||||
"court_decision": "פסק דין",
|
||||
"decision": "החלטה",
|
||||
"appraisal": "שומה",
|
||||
"objection": "התנגדות",
|
||||
"exhibit": "נספח",
|
||||
"reference": "מסמך עזר",
|
||||
}.get(actual_doc_type, actual_doc_type)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
# Git commit (best-effort — don't fail upload on git errors)
|
||||
try:
|
||||
repo_dir = config.find_case_dir(case_number)
|
||||
if repo_dir.exists():
|
||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||
doc_type_hebrew = {
|
||||
"appeal": "כתב ערר",
|
||||
"response": "תשובה",
|
||||
"protocol": "פרוטוקול",
|
||||
"plan": "תכנית",
|
||||
"permit": "היתר",
|
||||
"court_decision": "פסק דין",
|
||||
"decision": "החלטה",
|
||||
"appraisal": "שומה",
|
||||
"objection": "התנגדות",
|
||||
"exhibit": "נספח",
|
||||
"reference": "מסמך עזר",
|
||||
}.get(actual_doc_type, actual_doc_type)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
|
||||
cwd=repo_dir,
|
||||
capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
except Exception:
|
||||
pass # git not available in container — non-critical
|
||||
|
||||
return json.dumps({
|
||||
"document": doc,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# Bug: Skill import from Gitea — wrong raw URL format causes empty SKILL.md
|
||||
|
||||
**File at:** https://github.com/paperclipai/paperclip/issues/new
|
||||
|
||||
## Title
|
||||
Skill import from Gitea: wrong raw URL format causes empty SKILL.md
|
||||
|
||||
## Body
|
||||
|
||||
### Bug Summary
|
||||
|
||||
When importing skills from a **Gitea** instance (self-hosted), Paperclip fetches the git tree successfully via the `/api/v3/` endpoint (which Gitea supports), but then uses the **wrong raw file URL format** to download `SKILL.md` content, resulting in a 404 and an almost-empty stub being saved.
|
||||
|
||||
### Environment
|
||||
|
||||
- Paperclip server: `@paperclipai/server@2026.403.0`
|
||||
- Gitea instance: self-hosted Gitea
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. Host a skill repo on a Gitea instance with a `SKILL.md` (32KB+), `scripts/`, and `references/` directories
|
||||
2. Import the skill via URL: `https://my-gitea.example.com/org/skill-name.git`
|
||||
3. Observe that only a stub SKILL.md (~283 bytes) is saved, and subdirectories are missing
|
||||
|
||||
### Root Cause
|
||||
|
||||
In `server/dist/services/github-fetch.js`, the `resolveRawGitHubUrl()` function builds:
|
||||
|
||||
```
|
||||
https://{hostname}/raw/{owner}/{repo}/{ref}/{file}
|
||||
```
|
||||
|
||||
This format works for **GitHub Enterprise**, but **not for Gitea**. Gitea expects:
|
||||
|
||||
```
|
||||
https://{hostname}/{owner}/{repo}/raw/branch/{ref}/{file}
|
||||
```
|
||||
|
||||
### Proof
|
||||
|
||||
```bash
|
||||
# Paperclip's URL format -> 404
|
||||
$ curl -s -o /dev/null -w "%{http_code}" "https://my-gitea.example.com/raw/org/skill-repo/main/SKILL.md"
|
||||
404
|
||||
|
||||
# Correct Gitea format -> 200
|
||||
$ curl -s -o /dev/null -w "%{http_code}" "https://my-gitea.example.com/org/skill-repo/raw/branch/main/SKILL.md"
|
||||
200
|
||||
```
|
||||
|
||||
### Secondary Issue
|
||||
|
||||
When `SKILL.md` is at the repository root, `path.posix.dirname("SKILL.md")` returns `"."`, causing the inventory filter `entry.startsWith("./")` to miss all sibling directories (`scripts/`, `references/`). This means even if the raw URL worked, subdirectories would still be excluded from the file inventory.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
1. **Detect Gitea** vs GitHub Enterprise (e.g., check for `/api/v1/` endpoint which is Gitea-specific, vs `/api/v3/`)
|
||||
2. **Use the correct raw URL format** per platform:
|
||||
- GitHub/GHE: `https://{hostname}/raw/{owner}/{repo}/{ref}/{file}`
|
||||
- Gitea: `https://{hostname}/{owner}/{repo}/raw/branch/{ref}/{file}`
|
||||
3. **Fix root-level SKILL.md inventory**: when `skillDir === "."`, include all files instead of filtering by `entry.startsWith("./")`
|
||||
|
||||
### Workaround
|
||||
|
||||
Manually clone the repo into `~/.paperclip/instances/default/skills/{company_id}/{slug}/` and update the `company_skills` table directly with correct markdown content and file_inventory.
|
||||
@@ -4,34 +4,50 @@
|
||||
|
||||
CASES_DIR="/home/chaim/legal-ai/data/cases"
|
||||
LOG="/home/chaim/legal-ai/data/.auto-sync.log"
|
||||
GIT_ENV="GIT_AUTHOR_NAME=Ezer Mishpati GIT_AUTHOR_EMAIL=legal@local GIT_COMMITTER_NAME=Ezer Mishpati GIT_COMMITTER_EMAIL=legal@local GIT_TERMINAL_PROMPT=0"
|
||||
|
||||
for status_dir in "$CASES_DIR"/new "$CASES_DIR"/in-progress "$CASES_DIR"/completed; do
|
||||
[ -d "$status_dir" ] || continue
|
||||
for case_dir in "$status_dir"/*/; do
|
||||
[ -d "$case_dir/.git" ] || continue
|
||||
export GIT_AUTHOR_NAME="Ezer Mishpati"
|
||||
export GIT_AUTHOR_EMAIL="legal@local"
|
||||
export GIT_COMMITTER_NAME="Ezer Mishpati"
|
||||
export GIT_COMMITTER_EMAIL="legal@local"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
|
||||
cd "$case_dir" || continue
|
||||
for case_dir in "$CASES_DIR"/*/; do
|
||||
[ -d "$case_dir/.git" ] || continue
|
||||
|
||||
# Check for any changes (modified, new, deleted)
|
||||
changes=$(git status --porcelain 2>/dev/null)
|
||||
[ -z "$changes" ] && continue
|
||||
case_name=$(basename "$case_dir")
|
||||
|
||||
# Stage all changes
|
||||
git add -A 2>/dev/null
|
||||
# Ensure safe.directory is set for this repo
|
||||
git config --global --get-all safe.directory | grep -qF "$case_dir" \
|
||||
|| git config --global --add safe.directory "$case_dir"
|
||||
|
||||
# Build commit message from changed files
|
||||
changed_files=$(git diff --cached --name-only 2>/dev/null | head -5)
|
||||
count=$(git diff --cached --name-only 2>/dev/null | wc -l)
|
||||
case_name=$(basename "$case_dir")
|
||||
msg="סנכרון אוטומטי — ${count} קבצים שונו"
|
||||
cd "$case_dir" || continue
|
||||
|
||||
# Commit
|
||||
env $GIT_ENV git commit -m "$msg" --quiet 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
# Push (non-blocking, ignore errors)
|
||||
git push origin main --quiet 2>/dev/null
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced" >> "$LOG"
|
||||
# Check for any changes (modified, new, deleted)
|
||||
changes=$(git status --porcelain 2>/dev/null)
|
||||
[ -z "$changes" ] && continue
|
||||
|
||||
# Stage all changes
|
||||
git add -A 2>/dev/null
|
||||
|
||||
# Count changed files
|
||||
count=$(git diff --cached --name-only 2>/dev/null | wc -l)
|
||||
[ "$count" -eq 0 ] && continue
|
||||
|
||||
msg="סנכרון אוטומטי — ${count} קבצים שונו"
|
||||
|
||||
# Commit
|
||||
if git commit -m "$msg" --quiet 2>/dev/null; then
|
||||
# Push only if remote exists
|
||||
if git remote get-url origin >/dev/null 2>&1; then
|
||||
if git push origin HEAD --quiet 2>/dev/null; then
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced + pushed" >> "$LOG"
|
||||
else
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files committed, push FAILED" >> "$LOG"
|
||||
fi
|
||||
else
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files committed (no remote)" >> "$LOG"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | commit FAILED" >> "$LOG"
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { use, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -25,6 +25,91 @@ function ProseSection({ title, content }: { title: string; content?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisActions({
|
||||
caseNumber,
|
||||
hasAnalysis,
|
||||
onUploaded,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
hasAnalysis: boolean;
|
||||
onUploaded: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploading(true);
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`/api/cases/${caseNumber}/research/analysis/upload`, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setUploadMsg({ ok: false, text: data.detail || "שגיאה בהעלאה" });
|
||||
return;
|
||||
}
|
||||
setUploadMsg({
|
||||
ok: true,
|
||||
text: `הקובץ הועלה בהצלחה — ${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
|
||||
});
|
||||
onUploaded();
|
||||
} catch {
|
||||
setUploadMsg({ ok: false, text: "שגיאת רשת" });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{uploadMsg && (
|
||||
<span className={`text-xs ${uploadMsg.ok ? "text-green-700" : "text-red-600"}`}>
|
||||
{uploadMsg.text}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUpload(f);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{uploading ? "מעלה..." : "העלה ניתוח מעודכן"}
|
||||
</Button>
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
|
||||
a.download = `analysis-${caseNumber}.md`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
הורד ניתוח
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComposePage({
|
||||
params,
|
||||
}: {
|
||||
@@ -78,24 +163,7 @@ export default function ComposePage({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{analysis.data && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
|
||||
a.download = `analysis-${caseNumber}.md`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
הורד ניתוח
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<AnalysisActions caseNumber={caseNumber} hasAnalysis={!!analysis.data} onUploaded={() => analysis.refetch()} />
|
||||
</div>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
@@ -13,9 +13,12 @@ import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||||
import { StatusGuide } from "@/components/cases/status-guide";
|
||||
import { StatusChanger } from "@/components/cases/status-changer";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||
import { useCase } from "@/lib/api/cases";
|
||||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||
import { toast } from "sonner";
|
||||
import { Play, Loader2 } from "lucide-react";
|
||||
|
||||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||
@@ -32,6 +35,8 @@ export default function CaseDetailPage({
|
||||
}) {
|
||||
const { caseNumber } = use(params);
|
||||
const { data, isPending, error } = useCase(caseNumber);
|
||||
const startWorkflow = useStartWorkflow(caseNumber);
|
||||
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
||||
const expectedOutcomeLabel = data?.expected_outcome
|
||||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||
: null;
|
||||
@@ -78,6 +83,9 @@ export default function CaseDetailPage({
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="drafts">
|
||||
טיוטות והערות
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<UploadSheet caseNumber={caseNumber} />
|
||||
</div>
|
||||
@@ -103,6 +111,29 @@ export default function CaseDetailPage({
|
||||
</dl>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap pt-2 border-t border-rule">
|
||||
{canStartWorkflow && (
|
||||
<Button
|
||||
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
|
||||
disabled={startWorkflow.isPending}
|
||||
onClick={() =>
|
||||
startWorkflow.mutate(undefined, {
|
||||
onSuccess: (res) =>
|
||||
toast.success(
|
||||
`תהליך הופעל — ${res.issue_identifier}`,
|
||||
),
|
||||
onError: (err) =>
|
||||
toast.error(`שגיאה: ${err.message}`),
|
||||
})
|
||||
}
|
||||
>
|
||||
{startWorkflow.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 me-1.5" />
|
||||
)}
|
||||
התחל תהליך
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||
<Link href={`/cases/${caseNumber}/compose`}>
|
||||
פתח בעורך ההחלטה
|
||||
@@ -115,6 +146,13 @@ export default function CaseDetailPage({
|
||||
<TabsContent value="documents" className="mt-5">
|
||||
<DocumentsPanel data={data} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts" className="mt-5">
|
||||
<DraftsPanel
|
||||
caseNumber={caseNumber}
|
||||
status={data?.status}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
useFeedbackList,
|
||||
useCreateFeedback,
|
||||
useResolveFeedback,
|
||||
CATEGORY_LABELS,
|
||||
BLOCK_LABELS,
|
||||
type FeedbackCategory,
|
||||
} from "@/lib/api/feedback";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
|
||||
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
factual_error: "bg-red-100 text-red-800 border-red-200",
|
||||
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
other: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
};
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const [showResolved, setShowResolved] = useState(false);
|
||||
const [filterCategory, setFilterCategory] = useState<string>("");
|
||||
|
||||
const { data: feedbacks, isLoading } = useFeedbackList({
|
||||
category: filterCategory || undefined,
|
||||
unresolved_only: !showResolved,
|
||||
});
|
||||
|
||||
const resolveMutation = useResolveFeedback();
|
||||
|
||||
function handleResolve(id: string) {
|
||||
resolveMutation.mutate(
|
||||
{ feedbackId: id, applied_to: [] },
|
||||
{
|
||||
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
||||
onError: () => toast.error("שגיאה בעדכון"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">
|
||||
בית
|
||||
</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">הערות יו״ר</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">הערות יו״ר על טיוטות</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
תיעוד הערות דפנה על טיוטות החלטות. כל הערה מנותחת ומשפיעה על שיפור
|
||||
כתיבת ההחלטות העתידיות.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<NewFeedbackDialog />
|
||||
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="rounded-md border border-rule bg-surface px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">כל הקטגוריות</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-ink-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showResolved}
|
||||
onChange={(e) => setShowResolved(e.target.checked)}
|
||||
className="rounded border-rule"
|
||||
/>
|
||||
הצג גם מטופלות
|
||||
</label>
|
||||
|
||||
{feedbacks && (
|
||||
<span className="text-sm text-ink-muted me-auto">
|
||||
{feedbacks.length} הערות
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback list */}
|
||||
{isLoading ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||
טוען...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : !feedbacks?.length ? (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-8 text-center text-ink-muted">
|
||||
אין הערות{!showResolved ? " פתוחות" : ""}
|
||||
{filterCategory ? ` בקטגוריה ${CATEGORY_LABELS[filterCategory as FeedbackCategory]}` : ""}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{feedbacks.map((fb) => (
|
||||
<Card
|
||||
key={fb.id}
|
||||
className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-60" : ""}`}
|
||||
>
|
||||
<CardHeader className="border-b pb-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
className={`text-[0.7rem] border ${CATEGORY_COLORS[fb.category]}`}
|
||||
>
|
||||
{CATEGORY_LABELS[fb.category]}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[0.7rem]">
|
||||
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
|
||||
</Badge>
|
||||
{fb.case_number && (
|
||||
<Link
|
||||
href={`/cases/${fb.case_number}`}
|
||||
className="text-[0.7rem] text-gold-deep hover:underline"
|
||||
>
|
||||
תיק {fb.case_number}
|
||||
</Link>
|
||||
)}
|
||||
{fb.resolved && (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 text-[0.7rem] border border-emerald-200">
|
||||
טופל
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[0.7rem] text-ink-muted me-auto">
|
||||
{fb.created_at
|
||||
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-6 py-4 space-y-3">
|
||||
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
||||
|
||||
{fb.lesson_extracted && (
|
||||
<div className="bg-gold/5 border border-gold/20 rounded-md px-4 py-3">
|
||||
<p className="text-[0.7rem] font-semibold text-gold-deep mb-1">
|
||||
לקח שהופק:
|
||||
</p>
|
||||
<p className="text-sm text-ink-muted leading-relaxed">
|
||||
{fb.lesson_extracted}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fb.resolved && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleResolve(fb.id)}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
סמן כמטופל
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── New feedback dialog ─────────────────────────────────── */
|
||||
|
||||
function NewFeedbackDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const createMutation = useCreateFeedback();
|
||||
|
||||
const [caseNumber, setCaseNumber] = useState("");
|
||||
const [blockId, setBlockId] = useState("block-yod");
|
||||
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
|
||||
const [feedbackText, setFeedbackText] = useState("");
|
||||
const [lesson, setLesson] = useState("");
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!feedbackText.trim()) return;
|
||||
|
||||
createMutation.mutate(
|
||||
{
|
||||
case_number: caseNumber || undefined,
|
||||
block_id: blockId,
|
||||
feedback_text: feedbackText,
|
||||
category,
|
||||
lesson_extracted: lesson || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("ההערה נרשמה בהצלחה");
|
||||
setOpen(false);
|
||||
setCaseNumber("");
|
||||
setFeedbackText("");
|
||||
setLesson("");
|
||||
},
|
||||
onError: () => toast.error("שגיאה ברישום ההערה"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>+ הערה חדשה</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>רישום הערת יו״ר</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="fb-case">מספר תיק (אופציונלי)</Label>
|
||||
<Input
|
||||
id="fb-case"
|
||||
value={caseNumber}
|
||||
onChange={(e) => setCaseNumber(e.target.value)}
|
||||
placeholder="1130-25"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fb-block">בלוק</Label>
|
||||
<select
|
||||
id="fb-block"
|
||||
value={blockId}
|
||||
onChange={(e) => setBlockId(e.target.value)}
|
||||
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fb-category">קטגוריה</Label>
|
||||
<select
|
||||
id="fb-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as FeedbackCategory)}
|
||||
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fb-text">ההערה</Label>
|
||||
<Textarea
|
||||
id="fb-text"
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value)}
|
||||
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
|
||||
<Textarea
|
||||
id="fb-lesson"
|
||||
value={lesson}
|
||||
onChange={(e) => setLesson(e.target.value)}
|
||||
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
ביטול
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "שומר..." : "שמור הערה"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
252
web-ui/src/app/settings/page.tsx
Normal file
252
web-ui/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Trash2, Tags, Building2 } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useTagMappings,
|
||||
usePaperclipCompanies,
|
||||
useAddTagMapping,
|
||||
useDeleteTagMapping,
|
||||
} from "@/lib/api/settings";
|
||||
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const TAG_SUGGESTIONS = APPEAL_SUBTYPES.filter((s) => s.value !== "unknown");
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data: mappings, isPending: loadingMappings } = useTagMappings();
|
||||
const { data: companies, isPending: loadingCompanies } = usePaperclipCompanies();
|
||||
const addMapping = useAddTagMapping();
|
||||
const deleteMapping = useDeleteTagMapping();
|
||||
|
||||
const [tag, setTag] = useState("");
|
||||
const [tagLabel, setTagLabel] = useState("");
|
||||
const [companyId, setCompanyId] = useState("");
|
||||
|
||||
function handleTagInput(value: string) {
|
||||
setTag(value);
|
||||
const match = TAG_SUGGESTIONS.find((s) => s.value === value);
|
||||
if (match) setTagLabel(match.label);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!tag || !companyId) {
|
||||
toast.error("יש לבחור תגית וחברה");
|
||||
return;
|
||||
}
|
||||
const company = companies?.find((c) => c.id === companyId);
|
||||
addMapping.mutate(
|
||||
{
|
||||
tag,
|
||||
tag_label: tagLabel,
|
||||
company_id: companyId,
|
||||
company_name: company?.name ?? "",
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("מיפוי נוסף בהצלחה");
|
||||
setTag("");
|
||||
setTagLabel("");
|
||||
setCompanyId("");
|
||||
},
|
||||
onError: (err) => toast.error(`שגיאה: ${err.message}`),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleDelete(id: string, tag: string) {
|
||||
deleteMapping.mutate(id, {
|
||||
onSuccess: () => toast.success(`מיפוי "${tag}" נמחק`),
|
||||
onError: (err) => toast.error(`שגיאה: ${err.message}`),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">
|
||||
בית
|
||||
</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">הגדרות</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">הגדרות</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
ניהול מיפוי תגיות ערר לחברות ב-Paperclip. כל תיק חדש ישויך
|
||||
אוטומטית לפרויקט בחברה הנכונה לפי סוג הערר.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
{/* Companies overview */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4" />
|
||||
חברות ב-Paperclip
|
||||
</h2>
|
||||
{loadingCompanies ? (
|
||||
<Skeleton className="h-12 w-full" />
|
||||
) : !companies?.length ? (
|
||||
<p className="text-ink-muted text-sm">לא נמצאו חברות</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{companies.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 rounded-md bg-rule-soft/60 border border-rule px-4 py-2.5"
|
||||
>
|
||||
<span className="text-sm font-medium text-ink">{c.name}</span>
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{c.prefix}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tag mappings */}
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5">
|
||||
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
|
||||
<Tags className="w-4 h-4" />
|
||||
מיפוי תגיות
|
||||
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
|
||||
{mappings?.length ?? 0}
|
||||
</Badge>
|
||||
</h2>
|
||||
|
||||
{/* Add form */}
|
||||
<div className="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-md bg-rule-soft/40 border border-rule">
|
||||
<div className="flex flex-col gap-1.5 min-w-[180px]">
|
||||
<label className="text-[0.72rem] text-ink-muted">
|
||||
תגית
|
||||
</label>
|
||||
<Input
|
||||
list="tag-suggestions"
|
||||
value={tag}
|
||||
onChange={(e) => handleTagInput(e.target.value)}
|
||||
placeholder="סוג ערר או תגית חופשית"
|
||||
className="w-[220px]"
|
||||
/>
|
||||
<datalist id="tag-suggestions">
|
||||
{TAG_SUGGESTIONS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||
<label className="text-[0.72rem] text-ink-muted">תווית</label>
|
||||
<Input
|
||||
value={tagLabel}
|
||||
onChange={(e) => setTagLabel(e.target.value)}
|
||||
placeholder="שם לתצוגה"
|
||||
className="w-[160px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 min-w-[200px]">
|
||||
<label className="text-[0.72rem] text-ink-muted">
|
||||
חברה ב-Paperclip
|
||||
</label>
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder="בחר חברה" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies?.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name} ({c.prefix})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={addMapping.isPending || !tag || !companyId}
|
||||
size="default"
|
||||
>
|
||||
<Plus className="w-4 h-4" data-icon="inline-start" />
|
||||
{addMapping.isPending ? "שומר..." : "הוסף מיפוי"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loadingMappings ? (
|
||||
<Skeleton className="h-32 w-full" />
|
||||
) : !mappings?.length ? (
|
||||
<p className="text-ink-muted text-sm">
|
||||
אין מיפויים. הוסף מיפוי כדי שתיקים חדשים ישויכו אוטומטית
|
||||
לפרויקט בחברה הנכונה.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
<th className="text-start py-2 px-3 font-medium">Tag</th>
|
||||
<th className="text-start py-2 px-3 font-medium">Label</th>
|
||||
<th className="text-start py-2 px-3 font-medium">Company</th>
|
||||
<th className="py-2 px-3 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mappings.map((m) => (
|
||||
<tr
|
||||
key={m.id}
|
||||
className="border-b border-rule/60 hover:bg-rule-soft/40 transition-colors"
|
||||
>
|
||||
<td className="py-2.5 px-3">
|
||||
<Badge variant="outline" className="text-[0.75rem] font-mono">
|
||||
{m.tag}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-ink">{m.tag_label}</td>
|
||||
<td className="py-2.5 px-3 text-ink">{m.company_name}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => handleDelete(m.id, m.tag)}
|
||||
disabled={deleteMapping.isPending}
|
||||
title="מחק מיפוי"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-danger" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -24,11 +24,10 @@ type NavItem = {
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "בית" },
|
||||
{ href: "/cases/new", label: "תיק חדש" },
|
||||
{ href: "/training", label: "אימון סגנון" },
|
||||
{ href: "/feedback", label: "הערות יו״ר" },
|
||||
{ href: "/skills", label: "מיומנויות" },
|
||||
{ href: "/diagnostics", label: "אבחון" },
|
||||
{ href: "/settings", label: "הגדרות" },
|
||||
];
|
||||
|
||||
function isActive(pathname: string, href: string): boolean {
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/cases/status-badge";
|
||||
import { SyncIndicator } from "@/components/cases/sync-indicator";
|
||||
import {
|
||||
PRACTICE_AREA_LABELS,
|
||||
APPEAL_SUBTYPE_LABELS,
|
||||
@@ -71,6 +72,10 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
||||
עודכן
|
||||
</dt>
|
||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
||||
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||
סנכרון
|
||||
</dt>
|
||||
<dd><SyncIndicator caseNumber={data?.case_number} /></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
539
web-ui/src/components/cases/drafts-panel.tsx
Normal file
539
web-ui/src/components/cases/drafts-panel.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
useExports,
|
||||
useExportDocx,
|
||||
useUploadDraft,
|
||||
useMarkFinal,
|
||||
useDeleteDraft,
|
||||
} from "@/lib/api/exports";
|
||||
import {
|
||||
useCaseFeedback,
|
||||
useCreateFeedback,
|
||||
useResolveFeedback,
|
||||
CATEGORY_LABELS,
|
||||
CATEGORY_COLORS,
|
||||
BLOCK_LABELS,
|
||||
type FeedbackCategory,
|
||||
} from "@/lib/api/feedback";
|
||||
import type { CaseStatus } from "@/lib/api/cases";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Upload,
|
||||
Award,
|
||||
Loader2,
|
||||
FileOutput,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
/* Statuses at which a draft is considered ready */
|
||||
const DRAFT_READY: CaseStatus[] = [
|
||||
"drafted",
|
||||
"exported",
|
||||
"reviewed",
|
||||
"final",
|
||||
];
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(0)} KB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(epoch: number): string {
|
||||
return new Date(epoch * 1000).toLocaleDateString("he-IL", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Main component ─────────────────────────────────── */
|
||||
|
||||
export function DraftsPanel({
|
||||
caseNumber,
|
||||
status,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
status?: CaseStatus;
|
||||
}) {
|
||||
const { data: exports, isLoading: exportsLoading } = useExports(caseNumber);
|
||||
const { data: feedbacks, isLoading: feedbackLoading } =
|
||||
useCaseFeedback(caseNumber);
|
||||
const exportDocx = useExportDocx(caseNumber);
|
||||
const uploadDraft = useUploadDraft(caseNumber);
|
||||
const markFinal = useMarkFinal(caseNumber);
|
||||
const deleteDraft = useDeleteDraft(caseNumber);
|
||||
const resolveMutation = useResolveFeedback();
|
||||
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
const isDraftReady = status && DRAFT_READY.includes(status);
|
||||
const openFeedbacks = feedbacks?.filter((f) => !f.resolved) ?? [];
|
||||
|
||||
// Determine draft label based on exports — revised if there are עריכה files or multiple טיוטה versions
|
||||
const draftLabel = (() => {
|
||||
if (!exports?.length) return "טיוטה מוכנה לעיון";
|
||||
const revisions = exports.filter((f) => f.filename.startsWith("עריכה-"));
|
||||
const drafts = exports.filter((f) => f.filename.startsWith("טיוטה-"));
|
||||
if (revisions.length > 0) {
|
||||
const ver = revisions.length + 1;
|
||||
return `טיוטה ${ver} (מתוקנת) מוכנה לעיון`;
|
||||
}
|
||||
if (drafts.length > 1) {
|
||||
return `טיוטה ${drafts.length} מוכנה לעיון`;
|
||||
}
|
||||
return "טיוטה ראשונה מוכנה לעיון";
|
||||
})();
|
||||
|
||||
function handleUpload(file: File) {
|
||||
uploadDraft.mutate(file, {
|
||||
onSuccess: (data) =>
|
||||
toast.success(`הועלה: ${data.filename}`),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"),
|
||||
});
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
exportDocx.mutate(undefined, {
|
||||
onSuccess: () => toast.success("הטיוטה יוצאה בהצלחה"),
|
||||
onError: () => toast.error("שגיאה בייצוא"),
|
||||
});
|
||||
}
|
||||
|
||||
function handleMarkFinal(filename: string) {
|
||||
markFinal.mutate(filename, {
|
||||
onSuccess: () => toast.success("סומן כסופי"),
|
||||
onError: () => toast.error("שגיאה בסימון"),
|
||||
});
|
||||
}
|
||||
|
||||
function handleResolve(id: string) {
|
||||
resolveMutation.mutate(
|
||||
{ feedbackId: id, applied_to: [] },
|
||||
{
|
||||
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
|
||||
onError: () => toast.error("שגיאה בעדכון"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Banner ── */}
|
||||
{isDraftReady && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
|
||||
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
|
||||
<span className="text-sm font-medium text-gold-deep">
|
||||
{draftLabel}
|
||||
</span>
|
||||
<div className="me-auto" />
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={exportDocx.isPending}
|
||||
className="bg-navy hover:bg-navy-soft text-parchment"
|
||||
>
|
||||
{exportDocx.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
||||
) : (
|
||||
<FileOutput className="w-4 h-4 me-1.5" />
|
||||
)}
|
||||
הפק DOCX
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Exports list ── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-navy text-base">קבצי טיוטה</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".docx"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUpload(f);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploadDraft.isPending}
|
||||
>
|
||||
{uploadDraft.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 me-1.5" />
|
||||
)}
|
||||
העלה גרסה מתוקנת
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportsLoading ? (
|
||||
<p className="text-sm text-ink-muted">טוען...</p>
|
||||
) : !exports?.length ? (
|
||||
<p className="text-sm text-ink-muted">
|
||||
אין טיוטות עדיין.{" "}
|
||||
{!isDraftReady && "הטיוטה תופיע כאן כשתהיה מוכנה."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-rule overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
|
||||
<th className="text-start px-4 py-2 font-medium">File</th>
|
||||
<th className="text-start px-4 py-2 font-medium">Size</th>
|
||||
<th className="text-start px-4 py-2 font-medium">Date</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exports.map((file) => (
|
||||
<tr
|
||||
key={file.filename}
|
||||
className="border-t border-rule hover:bg-rule-soft/20"
|
||||
>
|
||||
<td className="px-4 py-2.5 flex items-center gap-2">
|
||||
<span>{file.filename}</span>
|
||||
{file.is_final && (
|
||||
<Badge className="bg-success-bg text-success border-success/40 text-[0.65rem]">
|
||||
<Award className="w-3 h-3 me-0.5" />
|
||||
סופי
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-muted tabular-nums">
|
||||
{formatSize(file.size)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-muted tabular-nums">
|
||||
{formatDate(file.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/api/cases/${caseNumber}/exports/${file.filename}/download`,
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 me-1" />
|
||||
הורד
|
||||
</Button>
|
||||
{!file.is_final && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-ink-muted"
|
||||
onClick={() => handleMarkFinal(file.filename)}
|
||||
disabled={markFinal.isPending}
|
||||
>
|
||||
<Award className="w-3.5 h-3.5 me-1" />
|
||||
סמן כסופי
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => setDeleteTarget(file.filename)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm" dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>מחיקת טיוטה</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-ink-muted">
|
||||
למחוק את הקובץ{" "}
|
||||
<span className="font-medium text-ink">{deleteTarget}</span>?
|
||||
<br />
|
||||
פעולה זו לא ניתנת לביטול.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
>
|
||||
ביטול
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteDraft.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
deleteDraft.mutate(deleteTarget, {
|
||||
onSuccess: () => {
|
||||
toast.success("הקובץ נמחק");
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: () => toast.error("שגיאה במחיקה"),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{deleteDraft.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin me-1" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 me-1" />
|
||||
)}
|
||||
מחק
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
|
||||
{/* ── Chair feedback ── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-navy text-base">
|
||||
הערות יו״ר
|
||||
{openFeedbacks.length > 0 && (
|
||||
<Badge className="ms-2 bg-red-100 text-red-700 border-red-200 text-[0.65rem]">
|
||||
{openFeedbacks.length} פתוחות
|
||||
</Badge>
|
||||
)}
|
||||
</h3>
|
||||
<NewCaseFeedbackDialog caseNumber={caseNumber} />
|
||||
</div>
|
||||
|
||||
{feedbackLoading ? (
|
||||
<p className="text-sm text-ink-muted">טוען...</p>
|
||||
) : !feedbacks?.length ? (
|
||||
<p className="text-sm text-ink-muted">אין הערות לתיק זה.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{feedbacks.map((fb) => (
|
||||
<div
|
||||
key={fb.id}
|
||||
className={`rounded-lg border border-rule bg-surface px-4 py-3 space-y-2 ${
|
||||
fb.resolved ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
className={`text-[0.65rem] border ${CATEGORY_COLORS[fb.category]}`}
|
||||
>
|
||||
{CATEGORY_LABELS[fb.category]}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[0.65rem]">
|
||||
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
|
||||
</Badge>
|
||||
{fb.resolved && (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 text-[0.65rem] border border-emerald-200">
|
||||
טופל
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-[0.65rem] text-ink-muted me-auto">
|
||||
{fb.created_at
|
||||
? new Date(fb.created_at).toLocaleDateString("he-IL")
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
|
||||
|
||||
{fb.lesson_extracted && (
|
||||
<div className="bg-gold/5 border border-gold/20 rounded-md px-3 py-2">
|
||||
<p className="text-[0.65rem] font-semibold text-gold-deep mb-0.5">
|
||||
לקח שהופק:
|
||||
</p>
|
||||
<p className="text-sm text-ink-muted leading-relaxed">
|
||||
{fb.lesson_extracted}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fb.resolved && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[0.75rem]"
|
||||
onClick={() => handleResolve(fb.id)}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
סמן כמטופל
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── New feedback dialog (case-scoped) ─────────────── */
|
||||
|
||||
function NewCaseFeedbackDialog({ caseNumber }: { caseNumber: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const createMutation = useCreateFeedback();
|
||||
|
||||
const [blockId, setBlockId] = useState("block-yod");
|
||||
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
|
||||
const [feedbackText, setFeedbackText] = useState("");
|
||||
const [lesson, setLesson] = useState("");
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!feedbackText.trim()) return;
|
||||
|
||||
createMutation.mutate(
|
||||
{
|
||||
case_number: caseNumber,
|
||||
block_id: blockId,
|
||||
feedback_text: feedbackText,
|
||||
category,
|
||||
lesson_extracted: lesson || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("ההערה נרשמה בהצלחה");
|
||||
setOpen(false);
|
||||
setFeedbackText("");
|
||||
setLesson("");
|
||||
},
|
||||
onError: () => toast.error("שגיאה ברישום ההערה"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Plus className="w-3.5 h-3.5 me-1" />
|
||||
הערה חדשה
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg" dir="rtl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>הערת יו״ר — תיק {caseNumber}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="fb-block">בלוק</Label>
|
||||
<select
|
||||
id="fb-block"
|
||||
value={blockId}
|
||||
onChange={(e) => setBlockId(e.target.value)}
|
||||
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fb-category">קטגוריה</Label>
|
||||
<select
|
||||
id="fb-category"
|
||||
value={category}
|
||||
onChange={(e) =>
|
||||
setCategory(e.target.value as FeedbackCategory)
|
||||
}
|
||||
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fb-text">ההערה</Label>
|
||||
<Textarea
|
||||
id="fb-text"
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value)}
|
||||
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
|
||||
<Textarea
|
||||
id="fb-lesson"
|
||||
value={lesson}
|
||||
onChange={(e) => setLesson(e.target.value)}
|
||||
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
ביטול
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? "שומר..." : "שמור הערה"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const TONE_STYLES: Record<Bucket["tone"], string> = {
|
||||
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),
|
||||
["processing", "documents_ready", "analyst_verified", "research_complete", "outcome_set", "brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"].includes(x.status),
|
||||
).length;
|
||||
const drafting = c.filter((x) =>
|
||||
["drafting", "qa_review", "drafted"].includes(x.status),
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
FilePlus2, Upload, Loader2, FileCheck, Target,
|
||||
Lightbulb, Compass, PenLine, SearchCheck, FileText,
|
||||
FileOutput, CheckCircle2, Award,
|
||||
FileOutput, CheckCircle2, Award, ShieldCheck, BookOpen,
|
||||
Microscope, PlayCircle,
|
||||
} from "lucide-react";
|
||||
import type { CaseStatus } from "@/lib/api/cases";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
@@ -12,11 +13,15 @@ const STATUS_LABELS: Record<CaseStatus, string> = {
|
||||
uploading: "מעלה",
|
||||
processing: "בעיבוד",
|
||||
documents_ready: "מסמכים מוכנים",
|
||||
analyst_verified: "ניתוח אומת",
|
||||
research_complete: "מחקר הושלם",
|
||||
outcome_set: "תוצאה נקבעה",
|
||||
brainstorming: "סיעור מוחות",
|
||||
direction_approved: "כיוון אושר",
|
||||
analysis_enriched: "ניתוח הועמק",
|
||||
ready_for_writing: "מוכן לכתיבה",
|
||||
drafting: "בכתיבה",
|
||||
qa_review: "QA",
|
||||
qa_review: "בדיקת איכות",
|
||||
drafted: "טיוטה",
|
||||
exported: "יוצא",
|
||||
reviewed: "נבדק",
|
||||
@@ -28,9 +33,13 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
|
||||
uploading: Upload,
|
||||
processing: Loader2,
|
||||
documents_ready: FileCheck,
|
||||
analyst_verified: ShieldCheck,
|
||||
research_complete: BookOpen,
|
||||
outcome_set: Target,
|
||||
brainstorming: Lightbulb,
|
||||
direction_approved: Compass,
|
||||
analysis_enriched: Microscope,
|
||||
ready_for_writing: PlayCircle,
|
||||
drafting: PenLine,
|
||||
qa_review: SearchCheck,
|
||||
drafted: FileText,
|
||||
@@ -44,12 +53,16 @@ const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
|
||||
uploading: "מסמכים בתהליך העלאה לשרת",
|
||||
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
||||
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
||||
analyst_verified: "ניתוח ראשוני אומת — ממתין למחקר תקדימים",
|
||||
research_complete: "מחקר תקדימים הושלם — ממתין לבחירת תוצאה",
|
||||
outcome_set: "נקבעה תוצאה צפויה לערר",
|
||||
brainstorming: "ניתוח כיוונים אפשריים להחלטה",
|
||||
direction_approved: "כיוון ההחלטה אושר — ניתן להתחיל כתיבה",
|
||||
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח",
|
||||
analysis_enriched: "ניתוח הועמק ופסיקה אומתה — מוכן לכתיבה",
|
||||
ready_for_writing: "הכל מוכן — ממתין לכותב ההחלטה",
|
||||
drafting: "טיוטת ההחלטה בתהליך כתיבה",
|
||||
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
||||
drafted: "טיוטה ראשונה מוכנה לעיון",
|
||||
drafted: "טיוטה מוכנה לעיון",
|
||||
exported: "ההחלטה יוצאה לקובץ DOCX",
|
||||
reviewed: "ההחלטה נבדקה ע\"י היו\"ר",
|
||||
final: "החלטה סופית — מוכנה להגשה",
|
||||
@@ -66,9 +79,13 @@ const STATUS_TONE: Record<CaseStatus, string> = {
|
||||
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",
|
||||
analyst_verified: "bg-info-bg text-info border-info/40",
|
||||
research_complete:"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",
|
||||
analysis_enriched:"bg-gold-wash text-gold-deep border-gold/50",
|
||||
ready_for_writing:"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",
|
||||
|
||||
@@ -17,8 +17,8 @@ import { useUpdateCase, type CaseStatus } from "@/lib/api/cases";
|
||||
|
||||
const ALL_STATUSES: CaseStatus[] = [
|
||||
"new", "uploading", "processing",
|
||||
"documents_ready", "outcome_set",
|
||||
"brainstorming", "direction_approved",
|
||||
"documents_ready", "analyst_verified", "research_complete", "outcome_set",
|
||||
"brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing",
|
||||
"drafting", "qa_review", "drafted",
|
||||
"exported", "reviewed", "final",
|
||||
];
|
||||
|
||||
@@ -13,8 +13,8 @@ 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",
|
||||
documents_ready: "prep", analyst_verified: "prep", research_complete: "prep", outcome_set: "prep",
|
||||
brainstorming: "thinking", direction_approved: "thinking", analysis_enriched: "thinking", ready_for_writing: "thinking",
|
||||
drafting: "writing", qa_review: "writing", drafted: "writing",
|
||||
exported: "done", reviewed: "done", final: "done",
|
||||
};
|
||||
|
||||
@@ -18,8 +18,8 @@ type PhaseGroup = {
|
||||
|
||||
const PHASE_GROUPS: PhaseGroup[] = [
|
||||
{ label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] },
|
||||
{ label: "הכנת תיק", statuses: ["documents_ready", "outcome_set"] },
|
||||
{ label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved"] },
|
||||
{ label: "הכנת תיק", statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
|
||||
{ label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] },
|
||||
{ label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] },
|
||||
{ label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
||||
];
|
||||
|
||||
62
web-ui/src/components/cases/sync-indicator.tsx
Normal file
62
web-ui/src/components/cases/sync-indicator.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useGitStatus } from "@/lib/api/cases";
|
||||
import { CheckCircle2, AlertCircle, Clock, CloudOff } from "lucide-react";
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return "עכשיו";
|
||||
if (mins < 60) return `לפני ${mins} דק׳`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `לפני ${hours} שע׳`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `לפני ${days} ימים`;
|
||||
}
|
||||
|
||||
export function SyncIndicator({ caseNumber }: { caseNumber?: string }) {
|
||||
const { data, isLoading } = useGitStatus(caseNumber);
|
||||
|
||||
if (isLoading || !data) return null;
|
||||
|
||||
if (data.error === "no_repo") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[0.7rem] text-ink-muted/60" title="אין ריפו מקומי">
|
||||
<CloudOff className="w-3 h-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const synced = data.synced;
|
||||
const pending = data.dirty_files + data.commits_ahead;
|
||||
|
||||
let Icon = synced ? CheckCircle2 : pending > 0 ? Clock : AlertCircle;
|
||||
let color = synced
|
||||
? "text-success"
|
||||
: pending > 0
|
||||
? "text-warn"
|
||||
: "text-ink-muted";
|
||||
let label = synced
|
||||
? "מסונכרן"
|
||||
: pending > 0
|
||||
? `${pending} שינויים ממתינים`
|
||||
: "לא מחובר";
|
||||
|
||||
if (!data.has_remote) {
|
||||
Icon = CloudOff;
|
||||
color = "text-ink-muted/60";
|
||||
label = "אין remote";
|
||||
}
|
||||
|
||||
const time = data.last_commit_time ? formatRelative(data.last_commit_time) : null;
|
||||
const tooltip = [label, time ? `commit אחרון: ${time}` : null, data.last_commit_msg]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[0.7rem] ${color}`} title={tooltip}>
|
||||
<Icon className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">{time ?? label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -23,8 +23,8 @@ type Phase = {
|
||||
|
||||
const PHASES: Phase[] = [
|
||||
{ key: "intake", label: "קליטה ועיבוד", icon: FolderInput, statuses: ["new", "uploading", "processing"] },
|
||||
{ key: "prep", label: "הכנת תיק", icon: ClipboardList, statuses: ["documents_ready", "outcome_set"] },
|
||||
{ key: "thinking", label: "ניתוח וכיוון", icon: Brain, statuses: ["brainstorming", "direction_approved"] },
|
||||
{ key: "prep", label: "הכנת תיק", icon: ClipboardList, statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
|
||||
{ key: "thinking", label: "ניתוח וכיוון", icon: Brain, statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] },
|
||||
{ key: "writing", label: "כתיבת טיוטה", icon: PenLine, statuses: ["drafting", "qa_review", "drafted"] },
|
||||
{ key: "done", label: "סגירה", icon: CheckCircle2, statuses: ["exported", "reviewed", "final"] },
|
||||
];
|
||||
|
||||
@@ -18,9 +18,13 @@ export type CaseStatus =
|
||||
| "uploading"
|
||||
| "processing"
|
||||
| "documents_ready"
|
||||
| "analyst_verified"
|
||||
| "research_complete"
|
||||
| "outcome_set"
|
||||
| "brainstorming"
|
||||
| "direction_approved"
|
||||
| "analysis_enriched"
|
||||
| "ready_for_writing"
|
||||
| "drafting"
|
||||
| "qa_review"
|
||||
| "drafted"
|
||||
@@ -141,6 +145,54 @@ export function useUpdateCase(caseNumber: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export type GitSyncStatus = {
|
||||
synced: boolean;
|
||||
has_remote: boolean;
|
||||
remote_url?: string | null;
|
||||
dirty_files: number;
|
||||
commits_ahead: number;
|
||||
last_commit_time?: string | null;
|
||||
last_commit_msg?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function useGitStatus(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...casesKeys.all, "git-status", caseNumber ?? ""] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<GitSyncStatus>(`/api/cases/${caseNumber}/git-status`, { signal }),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type StartWorkflowResult = {
|
||||
case_number: string;
|
||||
status: string;
|
||||
issue_id: string;
|
||||
issue_identifier: string;
|
||||
project_url: string;
|
||||
wakeup: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function useStartWorkflow(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiRequest<StartWorkflowResult>(
|
||||
`/api/cases/${caseNumber}/start-workflow`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: casesKeys.all });
|
||||
if (caseNumber) {
|
||||
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkflowStatus(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
||||
|
||||
101
web-ui/src/lib/api/exports.ts
Normal file
101
web-ui/src/lib/api/exports.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Exports domain hooks — draft DOCX files for a case.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
import { casesKeys } from "./cases";
|
||||
|
||||
export type ExportFile = {
|
||||
filename: string;
|
||||
size: number;
|
||||
created_at: number;
|
||||
is_final: boolean;
|
||||
};
|
||||
|
||||
export const exportsKeys = {
|
||||
all: ["exports"] as const,
|
||||
list: (caseNumber: string) =>
|
||||
[...exportsKeys.all, "list", caseNumber] as const,
|
||||
};
|
||||
|
||||
export function useExports(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: exportsKeys.list(caseNumber ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<ExportFile[]>(`/api/cases/${caseNumber}/exports`, { signal }),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportDocx(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiRequest<{ status: string; path: string; message: string }>(
|
||||
`/api/cases/${caseNumber}/export-docx`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadDraft(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`/api/cases/${caseNumber}/exports/upload`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "שגיאה בהעלאה" }));
|
||||
throw new Error(err.detail ?? "שגיאה בהעלאה");
|
||||
}
|
||||
return res.json() as Promise<{
|
||||
filename: string;
|
||||
size: number;
|
||||
version: number;
|
||||
}>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDraft(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (filename: string) =>
|
||||
apiRequest<{ deleted: boolean; filename: string }>(
|
||||
`/api/cases/${caseNumber}/exports/${filename}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkFinal(caseNumber: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (filename: string) =>
|
||||
apiRequest<{ final_filename: string; status: string }>(
|
||||
`/api/cases/${caseNumber}/exports/${filename}/mark-final`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
|
||||
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -56,6 +56,19 @@ export function useFeedbackList(filters: {
|
||||
});
|
||||
}
|
||||
|
||||
/** Feedback filtered by case number */
|
||||
export function useCaseFeedback(caseNumber: string | undefined) {
|
||||
const params = caseNumber ? `?case_number=${caseNumber}` : "";
|
||||
return useQuery({
|
||||
queryKey: [...feedbackKeys.all, "case", caseNumber ?? ""] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<ChairFeedback[]>(`/api/feedback${params}`, { signal }),
|
||||
enabled: Boolean(caseNumber),
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateFeedback() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -100,6 +113,16 @@ export const CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
||||
other: "אחר",
|
||||
};
|
||||
|
||||
/** Tailwind color classes per category */
|
||||
export const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
|
||||
missing_content: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
wrong_tone: "bg-purple-100 text-purple-800 border-purple-200",
|
||||
wrong_structure: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
factual_error: "bg-red-100 text-red-800 border-red-200",
|
||||
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
other: "bg-gray-100 text-gray-800 border-gray-200",
|
||||
};
|
||||
|
||||
/** Block ID labels */
|
||||
export const BLOCK_LABELS: Record<string, string> = {
|
||||
"block-he": "ה — פתיחה",
|
||||
|
||||
57
web-ui/src/lib/api/settings.ts
Normal file
57
web-ui/src/lib/api/settings.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Settings hooks: tag → Paperclip company mappings.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
export type PaperclipCompany = {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
export type TagMapping = {
|
||||
id: string;
|
||||
tag: string;
|
||||
tag_label: string;
|
||||
company_id: string;
|
||||
company_name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export function usePaperclipCompanies() {
|
||||
return useQuery({
|
||||
queryKey: ["settings", "paperclip-companies"] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<PaperclipCompany[]>("/api/settings/paperclip-companies", { signal }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTagMappings() {
|
||||
return useQuery({
|
||||
queryKey: ["settings", "tag-mappings"] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<TagMapping[]>("/api/settings/tag-mappings", { signal }),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTagMapping() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { tag: string; tag_label: string; company_id: string; company_name: string }) =>
|
||||
apiRequest<TagMapping>("/api/settings/tag-mappings", { method: "POST", body }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "tag-mappings"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTagMapping() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ ok: boolean }>(`/api/settings/tag-mappings/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["settings", "tag-mappings"] }),
|
||||
});
|
||||
}
|
||||
405
web/app.py
405
web/app.py
@@ -35,7 +35,12 @@ from legal_mcp.tools import cases as cases_tools, search as search_tools, workfl
|
||||
_web_dir = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(_web_dir.parent))
|
||||
from web.gitea_client import create_repo, setup_remote_and_push
|
||||
from web.paperclip_client import create_project as pc_create_project, get_project_url
|
||||
from web.paperclip_client import (
|
||||
create_project as pc_create_project,
|
||||
create_workflow_issue as pc_create_workflow_issue,
|
||||
get_project_url,
|
||||
wake_ceo_agent as pc_wake_ceo,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1102,7 +1107,91 @@ async def api_case_create(req: CaseCreateRequest):
|
||||
practice_area=req.practice_area,
|
||||
appeal_subtype=req.appeal_subtype,
|
||||
)
|
||||
return json.loads(result)
|
||||
parsed = json.loads(result)
|
||||
|
||||
# Auto-create Paperclip project for the new case
|
||||
appeal_type = req.appeal_subtype or "רישוי"
|
||||
try:
|
||||
pc_result = await pc_create_project(
|
||||
case_number=req.case_number,
|
||||
title=req.title,
|
||||
appeal_type=appeal_type,
|
||||
)
|
||||
parsed["paperclip"] = pc_result
|
||||
logger.info("Auto-created Paperclip project for case %s: %s", req.case_number, pc_result.get("url"))
|
||||
except Exception as e:
|
||||
logger.warning("Failed to auto-create Paperclip project for case %s: %s", req.case_number, e)
|
||||
parsed["paperclip_error"] = str(e)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/git-status")
|
||||
async def api_case_git_status(case_number: str):
|
||||
"""Git sync status for a case repo."""
|
||||
case_dir = config.find_case_dir(case_number)
|
||||
git_dir = case_dir / ".git"
|
||||
if not git_dir.exists():
|
||||
return {"synced": False, "error": "no_repo"}
|
||||
|
||||
env = {
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"HOME": os.environ.get("HOME", "/root"),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_CONFIG_GLOBAL": "/dev/null",
|
||||
}
|
||||
# Ensure git trusts the case directory regardless of ownership
|
||||
env["GIT_CONFIG_COUNT"] = "1"
|
||||
env["GIT_CONFIG_KEY_0"] = "safe.directory"
|
||||
env["GIT_CONFIG_VALUE_0"] = str(case_dir)
|
||||
|
||||
# Last commit info
|
||||
log = subprocess.run(
|
||||
["git", "log", "-1", "--format=%H%n%aI%n%s"],
|
||||
cwd=case_dir, capture_output=True, text=True, env=env,
|
||||
)
|
||||
lines = log.stdout.strip().splitlines() if log.returncode == 0 else []
|
||||
last_commit_time = lines[1] if len(lines) > 1 else None
|
||||
last_commit_msg = lines[2] if len(lines) > 2 else None
|
||||
|
||||
# Dirty files count
|
||||
status = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=case_dir, capture_output=True, text=True, env=env,
|
||||
)
|
||||
dirty = len([l for l in status.stdout.splitlines() if l.strip()]) if status.returncode == 0 else 0
|
||||
|
||||
# Check if remote exists and if we're ahead
|
||||
has_remote = False
|
||||
ahead = 0
|
||||
remote_url = None
|
||||
remote_check = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
cwd=case_dir, capture_output=True, text=True, env=env,
|
||||
)
|
||||
if remote_check.returncode == 0:
|
||||
has_remote = True
|
||||
# Sanitize token from URL
|
||||
raw = remote_check.stdout.strip()
|
||||
remote_url = raw.split("@")[-1] if "@" in raw else raw
|
||||
|
||||
ahead_check = subprocess.run(
|
||||
["git", "rev-list", "HEAD", "--not", "--remotes", "--count"],
|
||||
cwd=case_dir, capture_output=True, text=True, env=env,
|
||||
)
|
||||
if ahead_check.returncode == 0:
|
||||
ahead = int(ahead_check.stdout.strip() or "0")
|
||||
|
||||
synced = has_remote and dirty == 0 and ahead == 0
|
||||
return {
|
||||
"synced": synced,
|
||||
"has_remote": has_remote,
|
||||
"remote_url": remote_url,
|
||||
"dirty_files": dirty,
|
||||
"commits_ahead": ahead,
|
||||
"last_commit_time": last_commit_time,
|
||||
"last_commit_msg": last_commit_msg,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/cases/{case_number}/details")
|
||||
@@ -1633,6 +1722,91 @@ async def api_research_analysis_download(case_number: str):
|
||||
)
|
||||
|
||||
|
||||
@app.put("/api/cases/{case_number}/research/analysis/upload")
|
||||
async def api_research_analysis_upload(
|
||||
case_number: str,
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
"""Upload an updated analysis-and-research.md file.
|
||||
|
||||
Validates that:
|
||||
1. The file is markdown (text)
|
||||
2. It can be parsed by the research_md parser
|
||||
3. It contains at least one structural section (issues or threshold_claims)
|
||||
4. The case number in the file matches the URL
|
||||
|
||||
On success, backs up the existing file and replaces it.
|
||||
"""
|
||||
if not file.filename or not file.filename.endswith(".md"):
|
||||
raise HTTPException(400, "הקובץ חייב להיות בפורמט Markdown (.md)")
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > 5 * 1024 * 1024:
|
||||
raise HTTPException(400, "הקובץ גדול מדי — מקסימום 5MB")
|
||||
|
||||
try:
|
||||
text = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "הקובץ חייב להיות בקידוד UTF-8")
|
||||
|
||||
if len(text.strip()) < 100:
|
||||
raise HTTPException(400, "הקובץ ריק מדי — נראה שחסר תוכן")
|
||||
|
||||
# Write to a temp file so parse() can work on it
|
||||
dest = _research_file_path(case_number)
|
||||
tmp = dest.with_suffix(".md.upload-tmp")
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.write_text(text, encoding="utf-8")
|
||||
parsed = research_md.parse(tmp)
|
||||
except Exception as e:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"שגיאה בפרסור הקובץ — המבנה לא תקין: {e}",
|
||||
)
|
||||
|
||||
# Validate structure
|
||||
issues = parsed.get("issues", [])
|
||||
thresholds = parsed.get("threshold_claims", [])
|
||||
if not issues and not thresholds:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
400,
|
||||
"הקובץ חייב להכיל לפחות סעיף אחד של טענות סף או סוגיות להכרעה",
|
||||
)
|
||||
|
||||
# Validate case number matches
|
||||
file_case = parsed.get("header", {}).get("case_number", "")
|
||||
if file_case and file_case != case_number:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"מספר התיק בקובץ ({file_case}) לא תואם לתיק הנוכחי ({case_number})",
|
||||
)
|
||||
|
||||
# Backup existing file
|
||||
if dest.exists():
|
||||
backup_dir = dest.parent / "backup"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
ts = time.strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = backup_dir / f"analysis-and-research-{ts}.md"
|
||||
shutil.copy2(dest, backup_path)
|
||||
|
||||
# Replace with uploaded file
|
||||
tmp.replace(dest)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"sections": {
|
||||
"threshold_claims": len(thresholds),
|
||||
"issues": len(issues),
|
||||
"has_conclusions": bool(parsed.get("conclusions", "").strip()),
|
||||
},
|
||||
"file_size": len(content),
|
||||
}
|
||||
|
||||
|
||||
class ChairPositionRequest(BaseModel):
|
||||
section_id: str
|
||||
position: str = ""
|
||||
@@ -1806,6 +1980,17 @@ async def api_download_export(case_number: str, filename: str):
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/cases/{case_number}/exports/{filename}")
|
||||
async def api_delete_export(case_number: str, filename: str):
|
||||
"""Delete an exported draft file."""
|
||||
export_dir = config.find_case_dir(case_number) / "exports"
|
||||
path = export_dir / filename
|
||||
if not path.exists() or not path.parent.samefile(export_dir):
|
||||
raise HTTPException(404, "קובץ לא נמצא")
|
||||
path.unlink()
|
||||
return {"deleted": True, "filename": filename}
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/exports/upload")
|
||||
async def api_upload_export(case_number: str, file: UploadFile = File(...)):
|
||||
"""Upload a revised version of a draft."""
|
||||
@@ -1989,16 +2174,129 @@ async def api_paperclip_create_project(req: PaperclipProjectRequest):
|
||||
return project
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/start-workflow")
|
||||
async def api_start_workflow(case_number: str):
|
||||
"""Start the CEO agent workflow for a case.
|
||||
|
||||
Creates a workflow issue in Paperclip and wakes the CEO agent.
|
||||
Only works when case status is 'new' or 'documents_ready'.
|
||||
"""
|
||||
# 1. Verify case exists and status is appropriate
|
||||
case_raw = await cases_tools.case_get(case_number)
|
||||
case_data = json.loads(case_raw)
|
||||
if "error" in case_data:
|
||||
raise HTTPException(404, f"תיק {case_number} לא נמצא")
|
||||
|
||||
status = case_data.get("status", "")
|
||||
allowed = {"new", "documents_ready"}
|
||||
if status not in allowed:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"לא ניתן להתחיל תהליך — סטטוס נוכחי: {status}. נדרש: {', '.join(allowed)}",
|
||||
)
|
||||
|
||||
# 2. Create workflow issue in Paperclip
|
||||
try:
|
||||
issue = await pc_create_workflow_issue(case_number, case_data.get("title", ""))
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||
|
||||
# 3. Wake the CEO agent
|
||||
try:
|
||||
wakeup = await pc_wake_ceo(issue["issue_id"], case_number)
|
||||
except Exception as e:
|
||||
logger.warning("CEO wakeup failed for case %s: %s", case_number, e)
|
||||
wakeup = {"error": str(e)}
|
||||
|
||||
# 4. Update case status to processing
|
||||
await cases_tools.case_update(case_number, status="processing")
|
||||
|
||||
return {
|
||||
"case_number": case_number,
|
||||
"status": "processing",
|
||||
"issue_id": issue["issue_id"],
|
||||
"issue_identifier": issue["identifier"],
|
||||
"project_url": issue["project_url"],
|
||||
"wakeup": wakeup,
|
||||
}
|
||||
|
||||
|
||||
# ── Settings: Tag → Company Mappings ──────────────────────────────
|
||||
|
||||
@app.get("/api/settings/paperclip-companies")
|
||||
async def api_paperclip_companies():
|
||||
"""List all companies from Paperclip's DB."""
|
||||
pc_url = os.environ.get(
|
||||
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
|
||||
)
|
||||
try:
|
||||
conn = await asyncpg.connect(pc_url)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, name, issue_prefix FROM companies ORDER BY name"
|
||||
)
|
||||
return [{"id": str(r["id"]), "name": r["name"], "prefix": r.get("issue_prefix", "")} for r in rows]
|
||||
finally:
|
||||
await conn.close()
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Cannot reach Paperclip DB: {e}")
|
||||
|
||||
|
||||
@app.get("/api/settings/tag-mappings")
|
||||
async def api_get_tag_mappings():
|
||||
"""Get all tag → company mappings."""
|
||||
pool = await db.get_pool()
|
||||
rows = await pool.fetch(
|
||||
"SELECT id, tag, tag_label, company_id, company_name, created_at FROM tag_company_mappings ORDER BY tag"
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
class TagMappingRequest(BaseModel):
|
||||
tag: str
|
||||
tag_label: str = ""
|
||||
company_id: str
|
||||
company_name: str = ""
|
||||
|
||||
|
||||
@app.post("/api/settings/tag-mappings")
|
||||
async def api_add_tag_mapping(req: TagMappingRequest):
|
||||
"""Add a tag → company mapping."""
|
||||
pool = await db.get_pool()
|
||||
try:
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO tag_company_mappings (tag, tag_label, company_id, company_name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (tag, company_id) DO UPDATE SET tag_label = $2, company_name = $4
|
||||
RETURNING id, tag, tag_label, company_id, company_name""",
|
||||
req.tag, req.tag_label, req.company_id, req.company_name,
|
||||
)
|
||||
return dict(row)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
|
||||
@app.delete("/api/settings/tag-mappings/{mapping_id}")
|
||||
async def api_delete_tag_mapping(mapping_id: str):
|
||||
"""Delete a tag → company mapping."""
|
||||
pool = await db.get_pool()
|
||||
result = await pool.execute("DELETE FROM tag_company_mappings WHERE id = $1::uuid", mapping_id)
|
||||
if result == "DELETE 0":
|
||||
raise HTTPException(404, "Mapping not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Skill Management API ───────────────────────────────────────────
|
||||
|
||||
|
||||
PAPERCLIP_DB_URL = os.environ.get(
|
||||
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
|
||||
)
|
||||
# In Docker: mounted at /paperclip-skills; locally: ~/.paperclip/instances/default/skills
|
||||
_docker_skills = Path("/paperclip-skills")
|
||||
# Paperclip runs locally via pm2; skills are in ~/.paperclip
|
||||
_local_skills = Path.home() / ".paperclip" / "instances" / "default" / "skills"
|
||||
PAPERCLIP_SKILLS_DIR = _docker_skills if _docker_skills.exists() else _local_skills
|
||||
PAPERCLIP_SKILLS_DIR = _local_skills
|
||||
# Default company ID for skills
|
||||
SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-ac97-7424f65df294")
|
||||
|
||||
@@ -2006,15 +2304,20 @@ SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-a
|
||||
@app.get("/api/admin/skills")
|
||||
async def api_list_skills():
|
||||
"""List installed Paperclip skills with DB sync status."""
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
rows = []
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
|
||||
"FROM company_skills WHERE company_id = $1::uuid ORDER BY slug",
|
||||
SKILLS_COMPANY_ID,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL, timeout=5)
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
|
||||
"FROM company_skills WHERE company_id = $1::uuid ORDER BY slug",
|
||||
SKILLS_COMPANY_ID,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
except (OSError, asyncpg.PostgresError, asyncpg.InterfaceError, TimeoutError):
|
||||
# Paperclip DB unreachable — continue with disk-only skills
|
||||
pass
|
||||
|
||||
skills = []
|
||||
for r in rows:
|
||||
@@ -2317,7 +2620,7 @@ async def api_restart_paperclip():
|
||||
"message": "Restart requested — the host watcher will restart Paperclip shortly.",
|
||||
}
|
||||
except Exception:
|
||||
raise HTTPException(500, "Cannot restart Paperclip from Docker. Run manually: pm2 restart paperclip")
|
||||
raise HTTPException(500, "שגיאה בהפעלת restart. הרץ ידנית: pm2 restart paperclip")
|
||||
|
||||
|
||||
@app.post("/api/cases/{case_number}/documents/upload-tagged")
|
||||
@@ -2394,25 +2697,28 @@ async def _process_tagged_document(task_id: str, dest: Path, case_number: str, c
|
||||
_progress[task_id] = {"status": "processing", "filename": display_name, "step": "extracting"}
|
||||
result = await processor.process_document(doc_id, case_id)
|
||||
|
||||
# Git commit + push
|
||||
repo_dir = config.find_case_dir(case_number)
|
||||
if repo_dir.exists():
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
doc_type_hebrew = DOC_TYPE_NAMES.get(doc_type, doc_type)
|
||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {display_name}"],
|
||||
cwd=repo_dir, capture_output=True, env=env,
|
||||
)
|
||||
# Try to push to Gitea (non-blocking)
|
||||
subprocess.run(["git", "push"], cwd=repo_dir, capture_output=True, env={
|
||||
**env,
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
})
|
||||
# Git commit + push (best-effort — don't fail upload on git errors)
|
||||
try:
|
||||
repo_dir = config.find_case_dir(case_number)
|
||||
if repo_dir.exists():
|
||||
env = {
|
||||
"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin",
|
||||
}
|
||||
doc_type_hebrew = DOC_TYPE_NAMES.get(doc_type, doc_type)
|
||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {display_name}"],
|
||||
cwd=repo_dir, capture_output=True, env=env,
|
||||
)
|
||||
# Try to push to Gitea (non-blocking)
|
||||
subprocess.run(["git", "push"], cwd=repo_dir, capture_output=True, env={
|
||||
**env,
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
})
|
||||
except Exception:
|
||||
logger.warning("Git commit/push failed for %s (non-critical)", display_name)
|
||||
|
||||
_progress[task_id] = {
|
||||
"status": "completed",
|
||||
@@ -2650,21 +2956,24 @@ async def _process_case_document(task_id: str, source: Path, req: ClassifyReques
|
||||
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "extracting"}
|
||||
result = await processor.process_document(UUID(doc["id"]), case_id)
|
||||
|
||||
# Git commit
|
||||
repo_dir = config.find_case_dir(req.case_number)
|
||||
if repo_dir.exists():
|
||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||
doc_type_hebrew = {
|
||||
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
|
||||
"reference": "מסמך עזר", "exhibit": "נספח",
|
||||
}.get(req.doc_type, req.doc_type)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
|
||||
cwd=repo_dir, capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
# Git commit (best-effort)
|
||||
try:
|
||||
repo_dir = config.find_case_dir(req.case_number)
|
||||
if repo_dir.exists():
|
||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||
doc_type_hebrew = {
|
||||
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
|
||||
"reference": "מסמך עזר", "exhibit": "נספח",
|
||||
}.get(req.doc_type, req.doc_type)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
|
||||
cwd=repo_dir, capture_output=True,
|
||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
||||
"PATH": "/usr/bin:/bin"},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Git commit failed for %s (non-critical)", req.filename)
|
||||
|
||||
# Remove from uploads
|
||||
source.unlink(missing_ok=True)
|
||||
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
import uuid
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +21,9 @@ PAPERCLIP_DB_URL = os.environ.get(
|
||||
)
|
||||
|
||||
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
||||
CEO_AGENT_ID = "752cebdd-6748-4a04-aacd-c7ab0294ef33"
|
||||
PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100")
|
||||
PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "")
|
||||
|
||||
# Company IDs from Paperclip DB
|
||||
COMPANIES = {
|
||||
@@ -27,19 +31,41 @@ COMPANIES = {
|
||||
"betterment": "8639e837-4c9d-47fa-a76b-95788d651896", # CMPA — היטלי השבחה
|
||||
}
|
||||
|
||||
APPEAL_TYPE_TO_COMPANY = {
|
||||
"רישוי": "licensing",
|
||||
"licensing": "licensing",
|
||||
"היטל השבחה": "betterment",
|
||||
"betterment_levy": "betterment",
|
||||
"פיצויים": "betterment",
|
||||
"compensation": "betterment",
|
||||
# Fallback mapping — used only when DB lookup returns no results
|
||||
_FALLBACK_APPEAL_TYPE_TO_COMPANY = {
|
||||
"רישוי": COMPANIES["licensing"],
|
||||
"היטל השבחה": COMPANIES["betterment"],
|
||||
"פיצויים": COMPANIES["betterment"],
|
||||
"building_permit": COMPANIES["licensing"],
|
||||
"betterment_levy": COMPANIES["betterment"],
|
||||
"compensation_197": COMPANIES["betterment"],
|
||||
"compensation": COMPANIES["betterment"],
|
||||
"licensing": COMPANIES["licensing"],
|
||||
}
|
||||
|
||||
# Legal-AI DB URL for reading tag_company_mappings
|
||||
_LEGAL_DB_URL = os.environ.get("POSTGRES_URL") or os.environ.get(
|
||||
"DATABASE_URL", "postgresql://legal:legal@127.0.0.1:5432/legal_ai"
|
||||
)
|
||||
|
||||
def _get_company_id(appeal_type: str) -> str:
|
||||
key = APPEAL_TYPE_TO_COMPANY.get(appeal_type, "licensing")
|
||||
return COMPANIES[key]
|
||||
|
||||
async def _get_company_id(appeal_type: str) -> str:
|
||||
"""Resolve appeal_type tag to a Paperclip company ID via DB mappings, with fallback."""
|
||||
try:
|
||||
conn = await asyncpg.connect(_LEGAL_DB_URL)
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT company_id FROM tag_company_mappings WHERE tag = $1 LIMIT 1",
|
||||
appeal_type,
|
||||
)
|
||||
if row:
|
||||
return row["company_id"]
|
||||
finally:
|
||||
await conn.close()
|
||||
except Exception:
|
||||
logger.debug("DB lookup for tag mapping failed, using fallback for '%s'", appeal_type)
|
||||
|
||||
return _FALLBACK_APPEAL_TYPE_TO_COMPANY.get(appeal_type, COMPANIES["licensing"])
|
||||
|
||||
|
||||
async def create_project(
|
||||
@@ -50,11 +76,16 @@ async def create_project(
|
||||
color: str = "#6366f1",
|
||||
) -> dict:
|
||||
"""Create a project in the Paperclip embedded DB, or return existing one."""
|
||||
company_id = _get_company_id(appeal_type)
|
||||
prefix = "CMP" if _get_company_id(appeal_type) == COMPANIES["licensing"] else "CMPA"
|
||||
company_id = await _get_company_id(appeal_type)
|
||||
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
# Resolve prefix from company issue_prefix in Paperclip DB
|
||||
comp_row = await conn.fetchrow(
|
||||
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", company_id,
|
||||
)
|
||||
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
|
||||
|
||||
# Check for existing project with this case number
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id, name FROM projects WHERE name LIKE $1 AND company_id = $2::uuid",
|
||||
@@ -216,8 +247,88 @@ async def get_project_url(case_number: str) -> str | None:
|
||||
f"%{case_number}%",
|
||||
)
|
||||
if row:
|
||||
prefix = "CMP" if row["company_id"] == uuid.UUID(COMPANIES["licensing"]) else "CMPA"
|
||||
comp_row = await conn.fetchrow(
|
||||
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", str(row["company_id"]),
|
||||
)
|
||||
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
|
||||
return f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{row['id']}/issues"
|
||||
return None
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def create_workflow_issue(case_number: str, title: str) -> dict:
|
||||
"""Create a workflow issue in the existing Paperclip project for a case.
|
||||
|
||||
Returns dict with issue_id, identifier, project_url.
|
||||
Raises ValueError if no project found.
|
||||
"""
|
||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||
try:
|
||||
# Find existing project
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id, company_id FROM projects WHERE name LIKE $1",
|
||||
f"%{case_number}%",
|
||||
)
|
||||
if not row:
|
||||
raise ValueError(f"No Paperclip project found for case {case_number}")
|
||||
|
||||
project_id = str(row["id"])
|
||||
company_id = str(row["company_id"])
|
||||
|
||||
# Get company prefix
|
||||
comp_row = await conn.fetchrow(
|
||||
"SELECT issue_prefix FROM companies WHERE id = $1::uuid", company_id,
|
||||
)
|
||||
prefix = comp_row["issue_prefix"] if comp_row and comp_row["issue_prefix"] else "CMP"
|
||||
|
||||
# Create the workflow issue
|
||||
issue_id, identifier = await _create_issue(
|
||||
conn, company_id, project_id, case_number,
|
||||
f"התחל תהליך ניסוח — {title}"[:200], prefix,
|
||||
)
|
||||
|
||||
# Link to legal-ai case via plugin state
|
||||
await _link_case_to_issue(conn, issue_id, case_number)
|
||||
|
||||
project_url = f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{project_id}/issues"
|
||||
logger.info("Created workflow issue %s for case %s", identifier, case_number)
|
||||
|
||||
return {
|
||||
"issue_id": issue_id,
|
||||
"identifier": identifier,
|
||||
"project_url": project_url,
|
||||
}
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def wake_ceo_agent(issue_id: str, case_number: str) -> dict:
|
||||
"""Wake the CEO agent via Paperclip's wakeup API.
|
||||
|
||||
MUST use API, never direct DB insert (agent won't wake from DB insert).
|
||||
"""
|
||||
if not PAPERCLIP_BOARD_API_KEY:
|
||||
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
|
||||
|
||||
url = f"{PAPERCLIP_API_URL}/api/agents/{CEO_AGENT_ID}/wakeup"
|
||||
payload = {
|
||||
"source": "web-ui",
|
||||
"triggerDetail": "user",
|
||||
"reason": f"start_workflow_{case_number}",
|
||||
"payload": {
|
||||
"issueId": issue_id,
|
||||
"mutation": "workflow_start",
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
logger.info("CEO agent wakeup for case %s: %s", case_number, result)
|
||||
return result
|
||||
|
||||
@@ -1881,6 +1881,7 @@ kbd {
|
||||
<a href="#/compose" id="navCompose">כתיבה</a>
|
||||
<a href="#/skills" id="navSkills">Skills</a>
|
||||
<a href="#/diagnostics" id="navDiagnostics">מצב מערכת</a>
|
||||
<a href="#/settings" id="navSettings">הגדרות</a>
|
||||
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="החלף ערכת צבעים (Shift+D)">
|
||||
<span id="themeIcon">🌙</span>
|
||||
</button>
|
||||
@@ -2405,6 +2406,45 @@ kbd {
|
||||
<div class="empty">טוען...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Settings ══ -->
|
||||
<div class="page" id="page-settings">
|
||||
<div class="page-header">
|
||||
<h2>הגדרות</h2>
|
||||
</div>
|
||||
|
||||
<!-- Tag → Company Mappings -->
|
||||
<div class="card">
|
||||
<div class="card-header">שיוך תגי תיקים לחברות Paperclip</div>
|
||||
<div class="card-body">
|
||||
<p style="color:#888;font-size:0.85em;margin-bottom:16px">כל תג ערר (סוג תיק) משויך לחברה ב-Paperclip. כשנפתח תיק חדש, הפרויקט נוצר אוטומטית בחברה המתאימה לפי התג.</p>
|
||||
|
||||
<!-- Add new mapping -->
|
||||
<div style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:24px;padding:16px;background:var(--bg-secondary,#f8f9fa);border-radius:8px">
|
||||
<div style="flex:1;min-width:160px">
|
||||
<label style="display:block;font-size:0.8em;color:#888;margin-bottom:4px">תג ערר</label>
|
||||
<input type="text" id="settingsNewTag" placeholder="לדוגמה: building_permit" style="width:100%;padding:8px;border:1px solid var(--border,#ddd);border-radius:6px;background:var(--bg-primary,#fff);color:var(--text-primary)">
|
||||
</div>
|
||||
<div style="flex:1;min-width:160px">
|
||||
<label style="display:block;font-size:0.8em;color:#888;margin-bottom:4px">תיאור בעברית</label>
|
||||
<input type="text" id="settingsNewTagLabel" placeholder="לדוגמה: רישוי ובנייה" style="width:100%;padding:8px;border:1px solid var(--border,#ddd);border-radius:6px;background:var(--bg-primary,#fff);color:var(--text-primary)">
|
||||
</div>
|
||||
<div style="flex:1.5;min-width:200px">
|
||||
<label style="display:block;font-size:0.8em;color:#888;margin-bottom:4px">חברה ב-Paperclip</label>
|
||||
<select id="settingsCompanySelect" style="width:100%;padding:8px;border:1px solid var(--border,#ddd);border-radius:6px;background:var(--bg-primary,#fff);color:var(--text-primary)">
|
||||
<option value="">טוען חברות...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="addTagMapping()" style="white-space:nowrap">הוסף שיוך</button>
|
||||
</div>
|
||||
|
||||
<!-- Existing mappings table -->
|
||||
<div id="tagMappingsTable">
|
||||
<div class="empty">טוען...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for pattern examples -->
|
||||
@@ -2514,6 +2554,11 @@ function handleRoute() {
|
||||
document.getElementById('navCompose').classList.add('active');
|
||||
subtitle = 'כתיבת החלטה';
|
||||
initComposePage();
|
||||
} else if (hash === '#/settings') {
|
||||
document.getElementById('page-settings').classList.add('active');
|
||||
document.getElementById('navSettings').classList.add('active');
|
||||
subtitle = 'הגדרות';
|
||||
loadSettingsPage();
|
||||
}
|
||||
|
||||
document.getElementById('pageSubtitle').textContent = subtitle;
|
||||
@@ -2893,7 +2938,8 @@ async function loadCaseView(caseNumber) {
|
||||
|
||||
const STATUS_LABELS = {
|
||||
new: 'חדש', in_progress: 'בתהליך', documents_ready: 'מסמכים מוכנים',
|
||||
drafted: 'טיוטה', final: 'סופי',
|
||||
outcome_set: 'תוצאה נקבעה', direction_approved: 'כיוון אושר',
|
||||
drafting: 'בכתיבה', drafted: 'טיוטה', qa_review: 'בבדיקת QA', reviewed: 'נבדק', final: 'סופי',
|
||||
};
|
||||
const PRACTICE_AREA_LABELS = { appeals_committee: 'ועדת ערר', national_insurance: 'ביטוח לאומי', labor_law: 'דיני עבודה' };
|
||||
const SUBTYPE_LABELS = { building_permit: 'רישוי ובנייה', betterment_levy: 'היטל השבחה', compensation_197: "פיצויים (ס' 197)", unknown: 'לא ידוע' };
|
||||
@@ -3913,12 +3959,18 @@ async function loadDiagnostics() {
|
||||
return `<div class="diag-stat"><div class="diag-stat-label">${esc(label)}</div><div class="diag-stat-value">${val}</div></div>`;
|
||||
}).join('');
|
||||
|
||||
const DOC_STATUS_LABELS = {
|
||||
pending: 'ממתין', processing: 'בעיבוד', extracting: 'מחלץ טקסט',
|
||||
chunking: 'מפצל', embedding: 'יוצר embeddings', completed: 'הושלם',
|
||||
failed: 'נכשל', error: 'שגיאה', queued: 'בתור',
|
||||
};
|
||||
|
||||
const failedHtml = (data.failed_documents || []).map(d => `
|
||||
<div class="diag-row diag-row-error">
|
||||
<div class="diag-row-title">${esc(d.title || '(ללא שם)')}</div>
|
||||
<div class="diag-row-meta">
|
||||
${d.case_number ? `תיק ${esc(d.case_number)} · ` : ''}
|
||||
סטטוס: <strong>${esc(d.status)}</strong>
|
||||
סטטוס: <strong>${esc(DOC_STATUS_LABELS[d.status] || d.status)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty" style="padding:16px">אין כישלונות</div>';
|
||||
@@ -3928,7 +3980,7 @@ async function loadDiagnostics() {
|
||||
<div class="diag-row-title">${esc(d.title || '(ללא שם)')}</div>
|
||||
<div class="diag-row-meta">
|
||||
${d.case_number ? `תיק ${esc(d.case_number)} · ` : ''}
|
||||
${esc(d.status)} מאז ${formatRelativeTime(d.created_at)}
|
||||
${esc(DOC_STATUS_LABELS[d.status] || d.status)} מאז ${formatRelativeTime(d.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty" style="padding:16px">אין מסמכים תקועים</div>';
|
||||
@@ -4007,6 +4059,10 @@ const STEP_LABELS = {
|
||||
copying: 'מעתיק',
|
||||
registering: 'רושם',
|
||||
extracting: 'חילוץ טקסט',
|
||||
completed: 'הושלם',
|
||||
failed: 'נכשל',
|
||||
pending: 'ממתין',
|
||||
error: 'שגיאה',
|
||||
};
|
||||
|
||||
function renderProcessPanel(items) {
|
||||
@@ -4974,6 +5030,163 @@ async function loadCorpusList() {
|
||||
container.innerHTML = `<div class="empty">שגיאה בטעינה: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
// ── Settings Page ─────────────────────────────────────────────────
|
||||
let _settingsCompanies = [];
|
||||
|
||||
async function loadSettingsPage() {
|
||||
await Promise.all([loadPaperclipCompanies(), loadTagMappings()]);
|
||||
}
|
||||
|
||||
async function loadPaperclipCompanies() {
|
||||
const sel = document.getElementById('settingsCompanySelect');
|
||||
try {
|
||||
const res = await fetch(`${API}/settings/paperclip-companies`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
_settingsCompanies = await res.json();
|
||||
// Build options safely via DOM
|
||||
sel.textContent = '';
|
||||
const defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = '';
|
||||
defaultOpt.textContent = '— בחר חברה —';
|
||||
sel.appendChild(defaultOpt);
|
||||
for (const c of _settingsCompanies) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.dataset.name = c.name;
|
||||
opt.textContent = c.name + (c.identifier ? ` (${c.identifier})` : '');
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
} catch (e) {
|
||||
sel.textContent = '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'שגיאה: ' + e.message;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTagMappings() {
|
||||
const container = document.getElementById('tagMappingsTable');
|
||||
try {
|
||||
const res = await fetch(`${API}/settings/tag-mappings`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const mappings = await res.json();
|
||||
if (!mappings.length) {
|
||||
container.textContent = '';
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = 'אין שיוכים מוגדרים עדיין';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by company
|
||||
const byCompany = {};
|
||||
for (const m of mappings) {
|
||||
const key = m.company_id;
|
||||
if (!byCompany[key]) byCompany[key] = { company_name: m.company_name || m.company_id, tags: [] };
|
||||
byCompany[key].tags.push(m);
|
||||
}
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.style.cssText = 'width:100%;border-collapse:collapse;font-size:0.9em';
|
||||
|
||||
const thead = table.createTHead();
|
||||
const headerRow = thead.insertRow();
|
||||
headerRow.style.borderBottom = '2px solid var(--border,#ddd)';
|
||||
for (const label of ['חברה', 'תג', 'תיאור', '']) {
|
||||
const th = document.createElement('th');
|
||||
th.style.cssText = 'text-align:right;padding:8px';
|
||||
if (label === '') th.style.width = '60px';
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
}
|
||||
|
||||
const tbody = table.createTBody();
|
||||
for (const [companyId, group] of Object.entries(byCompany)) {
|
||||
for (let i = 0; i < group.tags.length; i++) {
|
||||
const m = group.tags[i];
|
||||
const tr = tbody.insertRow();
|
||||
tr.style.borderBottom = '1px solid var(--border,#eee)';
|
||||
|
||||
if (i === 0) {
|
||||
const tdCompany = tr.insertCell();
|
||||
tdCompany.style.cssText = 'padding:8px;font-weight:600;vertical-align:top';
|
||||
tdCompany.rowSpan = group.tags.length;
|
||||
tdCompany.textContent = group.company_name;
|
||||
}
|
||||
|
||||
const tdTag = tr.insertCell();
|
||||
tdTag.style.padding = '8px';
|
||||
const code = document.createElement('code');
|
||||
code.style.cssText = 'background:var(--bg-secondary,#f0f0f0);padding:2px 6px;border-radius:4px';
|
||||
code.textContent = m.tag;
|
||||
tdTag.appendChild(code);
|
||||
|
||||
const tdLabel = tr.insertCell();
|
||||
tdLabel.style.padding = '8px';
|
||||
tdLabel.textContent = m.tag_label || '—';
|
||||
|
||||
const tdAction = tr.insertCell();
|
||||
tdAction.style.padding = '8px';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-icon btn-icon-danger';
|
||||
btn.title = 'הסר שיוך';
|
||||
btn.textContent = '✕';
|
||||
btn.addEventListener('click', () => deleteTagMapping(m.id));
|
||||
tdAction.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
container.textContent = '';
|
||||
container.appendChild(table);
|
||||
} catch (e) {
|
||||
container.textContent = '';
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.textContent = 'שגיאה: ' + e.message;
|
||||
container.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
async function addTagMapping() {
|
||||
const tag = document.getElementById('settingsNewTag').value.trim();
|
||||
const tagLabel = document.getElementById('settingsNewTagLabel').value.trim();
|
||||
const sel = document.getElementById('settingsCompanySelect');
|
||||
const companyId = sel.value;
|
||||
const companyName = sel.selectedOptions[0]?.dataset?.name || '';
|
||||
|
||||
if (!tag) { showToast('יש להזין תג', 'error'); return; }
|
||||
if (!companyId) { showToast('יש לבחור חברה', 'error'); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/settings/tag-mappings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tag, tag_label: tagLabel, company_id: companyId, company_name: companyName }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
showToast('שיוך נוסף בהצלחה');
|
||||
document.getElementById('settingsNewTag').value = '';
|
||||
document.getElementById('settingsNewTagLabel').value = '';
|
||||
sel.value = '';
|
||||
await loadTagMappings();
|
||||
} catch (e) {
|
||||
showToast('שגיאה: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTagMapping(id) {
|
||||
if (!confirm('להסיר שיוך זה?')) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/settings/tag-mappings/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
showToast('שיוך הוסר');
|
||||
await loadTagMappings();
|
||||
} catch (e) {
|
||||
showToast('שגיאה: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user