Compare commits
40 Commits
v1.0.0
...
684a4cfd3b
| Author | SHA1 | Date | |
|---|---|---|---|
| 684a4cfd3b | |||
| c9a8cca35f | |||
| c9f3fcd012 | |||
| fe7cc40d05 | |||
| 1e4c5c1518 | |||
| 2e2d2d42b6 | |||
| c71d7b3b9c | |||
| 33e265e19c | |||
| 3b260a094d | |||
| 5c9a5d702a | |||
| 38e79bbf92 | |||
| 891f20dbb9 | |||
| 43b8106f55 | |||
| ad3c2b7117 | |||
| 11c73a7c60 | |||
| 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`
|
- תעדוף: `in_progress` קודם, אחר כך `todo`
|
||||||
- אם `PAPERCLIP_TASK_ID` מוגדר — תעדף אותו
|
- אם `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 ועבודה
|
## 3. Checkout ועבודה
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -75,23 +106,23 @@ curl -s -X PATCH -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
|||||||
```bash
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-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]. נדרשת בדיקה והחלטה על הצעד הבא."}'
|
-d '{"source":"automation","triggerDetail":"system","reason":"סוכן [שמך] סיים משימה [issue-id] בסטטוס [done/blocked]","payload":{"issueId":"[issue-id]","mutation":"agent_completion"}}'
|
||||||
```
|
|
||||||
אם ה-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:**
|
||||||
|
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. התראת מייל — כשנדרשת תשובה אנושית
|
## 5. התראת מייל — כשנדרשת תשובה אנושית
|
||||||
|
|
||||||
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:
|
**כשהתוצאה דורשת החלטה או תשובה של חיים**, שלח מייל:
|
||||||
|
|||||||
@@ -210,21 +210,11 @@ FROM documents d WHERE d.case_id = '{case_id}' AND d.doc_type IN ('appeal', 'res
|
|||||||
```bash
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-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]"}'
|
-d '{"reason": "מנתח משפטי סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||||
```
|
```
|
||||||
אם ה-API לא עובד:
|
אם ה-API לא עובד:
|
||||||
```bash
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
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'
|
|
||||||
);"
|
|
||||||
```
|
|
||||||
|
|
||||||
**אם בדיקות שלב 6 נכשלו** — סטטוס issue = "blocked", פרסם comment עם פירוט מה נכשל, שלח מייל לחיים.
|
**אם בדיקות שלב 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. **נאמנות למקור** — כל טענה חייבת לשקף את מה שנכתב, לא לפרש
|
1. **נאמנות למקור** — כל טענה חייבת לשקף את מה שנכתב, לא לפרש
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ tools:
|
|||||||
- mcp__legal-ai__case_update
|
- mcp__legal-ai__case_update
|
||||||
- mcp__legal-ai__document_list
|
- mcp__legal-ai__document_list
|
||||||
- mcp__legal-ai__get_claims
|
- 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__workflow_status
|
||||||
- mcp__legal-ai__processing_status
|
- mcp__legal-ai__processing_status
|
||||||
- mcp__legal-ai__get_metrics
|
- mcp__legal-ai__get_metrics
|
||||||
@@ -56,11 +60,60 @@ tools:
|
|||||||
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
| בודק איכות | 1a5b229e-9220-4b13-940c-f8eb7285fc29 | QA לפני ייצוא |
|
||||||
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
| מייצא טיוטה | d0dc703b-ca83-4883-bca7-c9449e8713cd | בדיקה סופית + ייצוא DOCX מגורסת |
|
||||||
|
|
||||||
|
## כלל: כל issue חדש = תת-משימה
|
||||||
|
|
||||||
|
כשאתה יוצר issue חדש לסוכן, **תמיד** כלול `parentId` עם ה-issue ID הראשי של התיק.
|
||||||
|
ה-issue הראשי הוא ה-issue שבו אתה עובד — `$PAPERCLIP_TASK_ID`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
||||||
|
-d '{"title": "...", "description": "...", "parentId": "'$PAPERCLIP_TASK_ID'", "assigneeAgentId": "..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**אם** ה-issue שלך הוא בעצמו תת-משימה (יש לו parent), השתמש ב-parent של ה-parent — כלומר ה-issue הראשי של התיק. לקבלת ה-parent:
|
||||||
|
```bash
|
||||||
|
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
|
"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('parentId') or d['id'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## התראת מייל — חובה
|
||||||
|
|
||||||
|
**בכל פעם שאתה מפרסם comment שמצפה לתשובה מחיים**, שלח מייל:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /home/chaim/legal-ai/scripts/notify.py \
|
||||||
|
"נדרשת תשובתך — [תיאור קצר]" \
|
||||||
|
"[סיכום: מה בוצע, מה נדרש ממך, קישור ל-issue]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**מתי לשלוח — תמיד:**
|
||||||
|
- סיום כל שלב (B, C, D, F) — עם סיכום מה בוצע
|
||||||
|
- כל comment שמבקש בחירה (תוצאה, כיוון, טיפול בטענות)
|
||||||
|
- שגיאה שדורשת התערבות
|
||||||
|
- החלטה מוכנה לביקורת דפנה
|
||||||
|
|
||||||
|
**מתי לא לשלוח:**
|
||||||
|
- עדכוני סטטוס ביניים (רק בסיום שלב)
|
||||||
|
- שגיאות טכניות שאפשר לפתור לבד
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## תהליך אינטראקטיבי — שלב אחר שלב
|
## תהליך אינטראקטיבי — שלב אחר שלב
|
||||||
|
|
||||||
|
### שלב 0: בדוק למה התעוררת
|
||||||
|
|
||||||
|
**לפני כל דבר אחר** — בדוק את סיבת ההתעוררות (`$PAPERCLIP_WAKE_REASON`):
|
||||||
|
- אם ה-reason מכיל `user_commented` → **דלג ישירות לסעיף "טיפול בתגובות חדשות מחיים"**. אל תסרוק תיקים אחרים, אל תבדוק issues, אל תעשה heartbeat רגיל. **טפל רק בתגובה.**
|
||||||
|
- אם ה-reason מכיל `agent_completion` → דלג לשלב E/F בהתאם לסוכן שסיים
|
||||||
|
- אחרת → המשך לשלב A (heartbeat רגיל)
|
||||||
|
|
||||||
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
### שלב A: בדיקת מצב — שלמות, בדיקות שליליות, תאימות מתודולוגיה
|
||||||
|
|
||||||
בכל heartbeat:
|
בכל heartbeat **רגיל** (לא comment routing):
|
||||||
1. בדוק תיקים פעילים (`case_list`)
|
1. בדוק תיקים פעילים (`case_list`)
|
||||||
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
2. בדוק אם יש issues ב-"blocked" — אם כן, טפל בהם קודם
|
||||||
3. בדוק comments מחיים שממתינים לתגובה
|
3. בדוק comments מחיים שממתינים לתגובה
|
||||||
@@ -253,14 +306,29 @@ tools:
|
|||||||
- [ ] תקן ביקורת מצוין
|
- [ ] תקן ביקורת מצוין
|
||||||
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
|
- אם חסר פריט כלשהו — **שאל את חיים** לפני שממשיכים
|
||||||
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
4. הרץ `approve_direction(case_number, direction_index, additional_notes)`
|
||||||
5. צור issue חדש ב-Paperclip:
|
5. עדכן סטטוס: `case_update(status=direction_approved)`
|
||||||
- כותרת: `[ערר {case_number}] כתיבת החלטה`
|
6. צור issue חדש ב-Paperclip:
|
||||||
- הקצה ל: **כותב החלטה** (7ed8686f-24bc-49a3-bc02-67ca15b895a9)
|
- כותרת: `[ערר {case_number}] העמקת ניתוח (pass 2)`
|
||||||
6. פרסם comment: "כיוון אושר. הועבר לכותב החלטה."
|
- הקצה ל: **מנתח משפטי** (c26e9439-a88a-49dc-9e67-2262c95db65c)
|
||||||
7. עדכן סטטוס: `case_update(status=direction_approved)`
|
- תיאור: "כיוון אושר. בצע pass 2: אמת פסיקה מעמדות היו"ר, העמק עובדות לאור הכיוון שנבחר."
|
||||||
|
7. פרסם comment: "כיוון אושר. הועבר למנתח להעמקת ניתוח לפני כתיבה."
|
||||||
|
|
||||||
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב B או C בהתאם.
|
**מתי לחזור אחורה:** אם חיים שינה דעתו לגבי התוצאה או הכיוון, או אם חסר מידע — חזור לשלב 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: מעקב כתיבה
|
### שלב E: מעקב כתיבה
|
||||||
|
|
||||||
**מתי:** כותב החלטה עובד
|
**מתי:** כותב החלטה עובד
|
||||||
@@ -294,7 +362,9 @@ tools:
|
|||||||
| `analyst_verified` | CEO (אחרי שלב A) | → האם יש מחקר תקדימים? אם לא → צור issue לחוקר (35022af0). אם כן → שלב B |
|
| `analyst_verified` | CEO (אחרי שלב A) | → האם יש מחקר תקדימים? אם לא → צור issue לחוקר (35022af0). אם כן → שלב B |
|
||||||
| `research_complete` | חוקר | → שלב B (סיכום + סיווג + שאלת תוצאה לחיים) |
|
| `research_complete` | חוקר | → שלב B (סיכום + סיווג + שאלת תוצאה לחיים) |
|
||||||
| `outcome_set` | CEO (אחרי שחיים בחר) | → האם יש claim_handling? אם לא → שלב B המשך (טבלת bundle/skip). אם כן → שלב C |
|
| `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) |
|
| `drafted` | כותב | → צור issue לבודק איכות (1a5b229e) |
|
||||||
| `qa_passed` | QA | → צור issue למייצא (d0dc703b) |
|
| `qa_passed` | QA | → צור issue למייצא (d0dc703b) |
|
||||||
| `qa_failed` | QA | → בעיה טכנית → issue תיקון לכותב. בעיה מתודולוגית → חזור לשלב C/D |
|
| `qa_failed` | QA | → בעיה טכנית → issue תיקון לכותב. בעיה מתודולוגית → חזור לשלב C/D |
|
||||||
@@ -304,6 +374,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 למנתח — חובה בכל תיק:**
|
**תבנית issue למנתח — חובה בכל תיק:**
|
||||||
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, claim_type, party_role. בנה מ-`document_list`.
|
1. **טבלת מיפוי מסמכים** — לכל מסמך: שם, claim_type, party_role. בנה מ-`document_list`.
|
||||||
2. **רשימת מסמכים שלא לחלץ מהם** (reference, plan, decision, court_decision)
|
2. **רשימת מסמכים שלא לחלץ מהם** (reference, plan, decision, court_decision)
|
||||||
@@ -320,15 +432,102 @@ tools:
|
|||||||
- **לשאול כשלא בטוח** — אם משהו לא ברור, שאל את חיים
|
- **לשאול כשלא בטוח** — אם משהו לא ברור, שאל את חיים
|
||||||
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), chair_directions שלם (טיפול בטענות + כיוון + סדר סוגיות + תקן ביקורת), התאמה ל-`decision-methodology.md`
|
- **ודא עקביות מתודולוגית** — כיוונים סילוגיסטיים (כלל + עובדות + מסקנה), 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
|
```bash
|
||||||
# קרא comments על issue
|
# קרא comments על issue
|
||||||
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
"$PAPERCLIP_API_URL/api/issues/{issue-id}/comments" | jq '.[-1].body'
|
"$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) → בחירה
|
- מספר (1/2/3) → בחירה
|
||||||
- "כיוון" + מספר → אישור כיוון
|
- "כיוון" + מספר → אישור כיוון
|
||||||
- טבלת טיפול בטענות → סימון claim_handling
|
- טבלת טיפול בטענות → סימון claim_handling
|
||||||
|
|||||||
@@ -78,21 +78,11 @@ tools:
|
|||||||
```bash
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-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]"}'
|
-d '{"reason": "מייצא טיוטה סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||||
```
|
```
|
||||||
אם ה-API לא עובד:
|
אם ה-API לא עובד:
|
||||||
```bash
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
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'
|
|
||||||
);"
|
|
||||||
```
|
|
||||||
|
|
||||||
## כללים קריטיים
|
## כללים קריטיים
|
||||||
|
|
||||||
|
|||||||
@@ -62,60 +62,4 @@ tools:
|
|||||||
1. **גיבוי**: העתק את הקובץ המקורי מ-`extracted/` לתיקיית `documents/backup/` עם סיומת `.pre-proofread.txt`
|
1. **גיבוי**: העתק את הקובץ המקורי מ-`extracted/` לתיקיית `documents/backup/` עם סיומת `.pre-proofread.txt`
|
||||||
2. **כתוב** את הגרסה המתוקנת לתיקיית `documents/proofread/` (עם אותו שם קובץ כמו ב-`extracted/`)
|
2. **כתוב** את הגרסה המתוקנת לתיקיית `documents/proofread/` (עם אותו שם קובץ כמו ב-`extracted/`)
|
||||||
3. עדכן את מסד הנתונים — שנה `extraction_status` ל-`proofread`:
|
3. עדכן את מסד הנתונים — שנה `extraction_status` ל-`proofread`:
|
||||||
```bash
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
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'
|
|
||||||
);"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -109,18 +109,8 @@ tools:
|
|||||||
```bash
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-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]"}'
|
-d '{"reason": "בודק איכות סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||||
```
|
```
|
||||||
אם ה-API לא עובד:
|
אם ה-API לא עובד:
|
||||||
```bash
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
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'
|
|
||||||
);"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -98,21 +98,11 @@ python3 /home/chaim/legal-ai/scripts/notify.py \
|
|||||||
```bash
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-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]"}'
|
-d '{"reason": "חוקר תקדימים סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||||
```
|
```
|
||||||
אם ה-API לא עובד:
|
אם ה-API לא עובד:
|
||||||
```bash
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
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'
|
|
||||||
);"
|
|
||||||
```
|
|
||||||
|
|
||||||
## כללים
|
## כללים
|
||||||
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
- **דיוק** — ציין מספרי סעיפים, תאריכים, שמות שופטים
|
||||||
|
|||||||
@@ -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: הכנה
|
||||||
1. **קרא את המתודולוגיה**: `Read docs/decision-methodology.md` — חובה לפני כל כתיבה
|
1. **קרא את המתודולוגיה**: `Read docs/decision-methodology.md` — חובה לפני כל כתיבה
|
||||||
2. קרא פרטי התיק (`case_get`)
|
2. קרא פרטי התיק (`case_get`)
|
||||||
@@ -147,21 +160,11 @@ case_update(case_number, status="drafted")
|
|||||||
```bash
|
```bash
|
||||||
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-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]"}'
|
-d '{"reason": "כותב החלטה סיים משימה [issue-id] בסטטוס [done/blocked]"}'
|
||||||
```
|
```
|
||||||
אם ה-API לא עובד:
|
אם ה-API לא עובד:
|
||||||
```bash
|
**⚠️ אסור להשתמש ב-INSERT INTO agent_wakeup_requests ישירות!** הכנסה ישירה ל-DB יוצרת רק את הבקשה בלי heartbeat_run — והסוכן לא יתעורר לעולם. **תמיד להשתמש ב-API בלבד.**
|
||||||
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'
|
|
||||||
);"
|
|
||||||
```
|
|
||||||
|
|
||||||
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
**אם לא תעדכן סטטוס ל-drafted — בודק האיכות לא יוכל לרוץ!**
|
||||||
|
|
||||||
|
|||||||
@@ -54,5 +54,5 @@ jobs:
|
|||||||
- name: Trigger Coolify redeploy
|
- name: Trigger Coolify redeploy
|
||||||
run: |
|
run: |
|
||||||
curl -sf \
|
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 }}"
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@ data/uploads/
|
|||||||
data/cases/
|
data/cases/
|
||||||
data/training/
|
data/training/
|
||||||
data/exports/
|
data/exports/
|
||||||
|
data/backups/
|
||||||
|
data/.auto-sync.log
|
||||||
mcp-server/.venv/
|
mcp-server/.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
63
CLAUDE.md
63
CLAUDE.md
@@ -30,7 +30,7 @@
|
|||||||
- לקחים מהשוואת טיוטות לגרסאות סופיות
|
- לקחים מהשוואת טיוטות לגרסאות סופיות
|
||||||
- סקריפט ייצוא DOCX
|
- סקריפט ייצוא DOCX
|
||||||
|
|
||||||
כל החומר הועבר לתיקיית `legacy/` כקריאה בלבד. **הפרויקט הנוכחי** מעביר את הידע הזה למערכת מובנית עם PostgreSQL + pgvector + n8n.
|
הידע שהופק מה-vault הוטמע במערכת הנוכחית — מסמכי ייחוס (`docs/`), קורפוס אימון (`data/training/`), ומבנה 12 בלוקים. ה-vault המקורי נמחק; הפרויקט הנוכחי עובד עם PostgreSQL + pgvector.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,6 +43,8 @@
|
|||||||
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
| [`docs/migration-plan.md`](docs/migration-plan.md) | תוכנית מעבר vault → DB — טבלאות, עדיפויות, כמויות | לפני ייבוא נתונים |
|
||||||
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
|
| [`docs/legal-decision-lessons.md`](docs/legal-decision-lessons.md) | לקחים מ-3 החלטות — מה עבד, מה השתנה, ביטויי מעבר חדשים | **לפני כל כתיבת החלטה** |
|
||||||
| [`docs/decision-methodology.md`](docs/decision-methodology.md) | **מתודולוגיה אנליטית — איך לחשוב על החלטה מעין-שיפוטית** | **לפני כל כתיבת החלטה** |
|
| [`docs/decision-methodology.md`](docs/decision-methodology.md) | **מתודולוגיה אנליטית — איך לחשוב על החלטה מעין-שיפוטית** | **לפני כל כתיבת החלטה** |
|
||||||
|
| `docs/garner-methodology-extraction.md` | חומר מקור: מיצוי מספרי Garner על כתיבה משפטית | רק לבדיקת מקור |
|
||||||
|
| `docs/fjc-principles-extraction.md` | חומר מקור: מיצוי מ-Judicial Writing Manual (FJC) | רק לבדיקת מקור |
|
||||||
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
| [`docs/corpus-analysis.md`](docs/corpus-analysis.md) | ניתוח שיטתי של 24 החלטות — מפת תוכן, דפוסי דיון תכנוני, פערים | **לפני כל כתיבת החלטה** |
|
||||||
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
|
| [`docs/memory.md`](docs/memory.md) | הקשר כללי — skills, פרויקטים שהושלמו, מבנה vault | להתמצאות כללית |
|
||||||
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
| [`skills/decision/SKILL.md`](skills/decision/SKILL.md) | מדריך סגנון מלא של דפנה — טון, מבנה, ביטויים, מתודולוגיה | **לפני כל כתיבת החלטה** |
|
||||||
@@ -58,9 +60,27 @@
|
|||||||
| Redis | תור משימות | `legal-ai-redis` |
|
| Redis | תור משימות | `legal-ai-redis` |
|
||||||
| n8n | אוטומציית workflows | להגדרה |
|
| n8n | אוטומציית workflows | להגדרה |
|
||||||
| Gitea | מאגר קוד | `gitea.nautilus.marcusgroup.org/ezer-mishpati` |
|
| 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` |
|
| Infisical | ניהול סודות | `secret.dev.marcus-law.co.il` |
|
||||||
|
|
||||||
|
### ⚠️ ארכיטקטורת Deploy — חובה לקרוא
|
||||||
|
|
||||||
|
**עוזר משפטי (Legal-AI)** — רץ כ-**Docker container דרך Coolify**:
|
||||||
|
- UUID: `gyjo0mtw2c42ej3xxvbz8zio`
|
||||||
|
- שינוי קוד ב-`web/` או `web-ui/` **לא נכנס לתוקף** עד ש:
|
||||||
|
1. עושים `git commit` + `git push origin main`
|
||||||
|
2. מריצים deploy דרך Coolify (`mcp__coolify__deploy`)
|
||||||
|
3. ממתינים ~2-4 דקות לבנייה
|
||||||
|
- **אסור** לנסות להריץ uvicorn מקומית — אין סביבת Python על המכונה
|
||||||
|
- ה-container מריץ Next.js (`:3000`, חשוף) + FastAPI (`:8000`, פנימי)
|
||||||
|
- בדיקה: `curl https://legal-ai.nautilus.marcusgroup.org/api/...`
|
||||||
|
|
||||||
|
**Paperclip** — רץ **מקומית דרך pm2**:
|
||||||
|
- פורט: `localhost:3100`, DB: `localhost:54329`
|
||||||
|
- שינויי קוד נכנסים לתוקף אחרי `pm2 restart paperclip`
|
||||||
|
- **אין צורך ב-Docker או Coolify**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## מבנה תיקיות
|
## מבנה תיקיות
|
||||||
@@ -81,15 +101,28 @@
|
|||||||
│ └── docx/ עיצוב DOCX
|
│ └── docx/ עיצוב DOCX
|
||||||
├── data/
|
├── data/
|
||||||
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
│ ├── training/ ← 4 החלטות לאימון (DOCX)
|
||||||
│ ├── exports/ ← ייצוא legacy (תיקים ישנים)
|
│ ├── exports/ ← טיוטות DOCX מיוצאות
|
||||||
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
│ └── cases/{case-number}/ ← תיקי עררים (מבנה שטוח, סטטוס ב-DB)
|
||||||
├── web/ ← UI + API + integration clients
|
├── web/ ← FastAPI backend (Python): 75 API endpoints
|
||||||
|
│ ├── app.py ← API ראשי
|
||||||
|
│ ├── paperclip_client.py ← אינטגרציית Paperclip
|
||||||
|
│ └── gitea_client.py ← אינטגרציית Gitea
|
||||||
|
├── web-ui/ ← Next.js frontend (TypeScript/React): ממשק המשתמש
|
||||||
|
│ └── next.config.ts ← proxy: /api/* → FastAPI :8000
|
||||||
├── mcp-server/ ← MCP server + services + tools
|
├── mcp-server/ ← MCP server + services + tools
|
||||||
└── scripts/ ← סקריפטים וכלי עזר
|
└── scripts/ ← סקריפטים וכלי עזר (ראה scripts/SCRIPTS.md)
|
||||||
|
└── .archive/ ← סקריפטים שהושלמו (לא להריץ)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## כלל: עדכון `scripts/SCRIPTS.md`
|
||||||
|
|
||||||
|
בכל פעם שנוצר, נמחק, או משתנה סקריפט בתיקיית `scripts/` — **חובה לעדכן את `scripts/SCRIPTS.md`** בהתאם.
|
||||||
|
הקובץ מתעד את התפקיד, הסטטוס, וההחלפה (אם יש) של כל סקריפט.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ניהול משימות — TaskMaster AI
|
## ניהול משימות — TaskMaster AI
|
||||||
|
|
||||||
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
|
הפרויקט משתמש ב-**TaskMaster AI** (MCP server) לניהול משימות מובנה:
|
||||||
@@ -102,6 +135,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. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק
|
1. **"מבחן השופט"** — כל החלטה חייבת להיות קריאה לשופט שלא מכיר את התיק
|
||||||
|
|||||||
@@ -34,10 +34,9 @@ WORKDIR /app
|
|||||||
|
|
||||||
# Install Node.js 20.x
|
# Install Node.js 20.x
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
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 - \
|
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& apt-get purge -y curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
403
docs/new-company-setup-guide.md
Normal file
403
docs/new-company-setup-guide.md
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
# מדריך הקמת חברה חדשה — היטלי השבחה (CMPA)
|
||||||
|
|
||||||
|
> נוצר: 2026-04-15
|
||||||
|
> מטרה: תיעוד מפורט של התהליך להקמת קורפוס אימון והגדרת חברה בשתי המערכות
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## רקע
|
||||||
|
|
||||||
|
המערכת שלנו בנויה מ-**2 חברות** (boards) ב-Paperclip, שמייצגות את שני תחומי העבודה העיקריים:
|
||||||
|
|
||||||
|
| # | חברה | קוד | Prefix | סוגי תיקים | סטטוס קורפוס |
|
||||||
|
|---|-------|------|--------|------------|---------------|
|
||||||
|
| 1 | רישוי ובנייה | CMP | `42a7acd0...` | 1xxx | 24 החלטות אימון, ניתוח סגנון מלא |
|
||||||
|
| 2 | היטלי השבחה + פיצויים | CMPA | `8639e837...` | 8xxx, 9xxx | **ריק — אין אף החלטת אימון** |
|
||||||
|
|
||||||
|
**המצב היום**: חברת CMPA כבר קיימת ב-Paperclip ומופתה בקוד (ניתוב אוטומטי לפי מספר תיק). אבל אין לה **קורפוס אימון** — המערכת לא מכירה את הסגנון של דפנה בהחלטות היטל השבחה ולא יכולה לחפש תקדימים.
|
||||||
|
|
||||||
|
**מה שצריך לעשות**: להעלות את ההחלטות, לעבד אותן, ולהריץ ניתוח סגנון — בדיוק כמו שנעשה עם 24 ההחלטות של רישוי ובנייה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## שתי המערכות — הגדרת תפקידים
|
||||||
|
|
||||||
|
### מערכת 1: עוזר משפטי (Legal-AI)
|
||||||
|
|
||||||
|
**תפקיד**: מערכת הידע, הניתוח והניסוח — מחזיקה את כל התוכן המשפטי ומספקת כלים לכתיבת החלטות.
|
||||||
|
|
||||||
|
**מה חי רק במערכת הזו**:
|
||||||
|
|
||||||
|
| רכיב | תיאור | טבלת DB |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| תיקים (Cases) | מספר תיק, כותרת, סטטוס, צדדים | `cases` |
|
||||||
|
| מסמכי מקור | כתבי ערר, תגובות, פרוטוקולים (PDF/DOCX) | `documents` + filesystem |
|
||||||
|
| חלקים סמנטיים (Chunks) | embeddings לחיפוש RAG (Voyage AI, 1024 ממדים) | `document_chunks` + pgvector |
|
||||||
|
| קורפוס אימון | החלטות קודמות של דפנה — גרסאות מנוקות | `style_corpus` |
|
||||||
|
| דפוסי סגנון | ביטויי מעבר, נוסחאות פתיחה/סיום, מבנה ניתוח | `style_patterns` |
|
||||||
|
| בלוקי החלטה | 12 בלוקים (מבנה ההחלטה) + פסקאות | `decision_blocks`, `decision_paragraphs` |
|
||||||
|
| טענות צדדים | טענות שחולצו מכתבי טענות | `claims` |
|
||||||
|
| תקדימים (פסיקה) | ספריית case law + embeddings | `case_law`, `case_law_embeddings` |
|
||||||
|
| חקיקה | סעיפי חוק שאוזכרו | `statutory_provisions` |
|
||||||
|
| הערות יו"ר | feedback של דפנה על טיוטות | `chair_feedback` |
|
||||||
|
| לקחים | תובנות שחולצו מ-feedback | `lessons_learned` |
|
||||||
|
| צ'קליסטים | רשימות בדיקה לבלוק דיון (לפי סוג ערר) | hardcoded ב-`lessons.py` |
|
||||||
|
| מיפוי חברות | קישור appeal_subtype ← company_id | `tag_company_mappings` |
|
||||||
|
|
||||||
|
**שירותי הליבה**:
|
||||||
|
- **RAG** — חיפוש סמנטי בתקדימים ובמסמכי מקור, מסונן לפי `appeal_subtype`
|
||||||
|
- **Proofreading** — ניקוי מסמכי נבו מ-artifacts
|
||||||
|
- **Style Analysis** — ניתוח קורפוס וחילוץ דפוסי כתיבה
|
||||||
|
- **Decision Drafting** — ייצור טיוטות לפי ארכיטקטורת 12 בלוקים
|
||||||
|
- **DOCX Export** — מסמך מעוצב מוכן להגשה
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### מערכת 2: Paperclip
|
||||||
|
|
||||||
|
**תפקיד**: מערכת התזמור והסוכנים — מנהלת את תהליך העבודה, מפעילה סוכני AI, ומספקת ממשק Kanban.
|
||||||
|
|
||||||
|
**מה חי רק במערכת הזו**:
|
||||||
|
|
||||||
|
| רכיב | תיאור | טבלת DB |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| חברות (Companies) | CMP (רישוי), CMPA (היטלי השבחה) — boards נפרדים | `companies` |
|
||||||
|
| פרויקטים | כרטיס Kanban לכל תיק | `projects` |
|
||||||
|
| Issues | משימות עבודה (CMP-123, CMPA-456) | `issues` |
|
||||||
|
| תגובות | דיון בין סוכנים ומשתמשים | `issue_comments` |
|
||||||
|
| סוכנים (Agents) | CEO, Researcher, Writer — Claude Code agents | מערכת agents |
|
||||||
|
| SOUL.md | הנחיות לכל סוכן | קונפיגורציית agent |
|
||||||
|
| Skills | workflows לשימוש חוזר (SKILL.md) | `company_skills` + filesystem |
|
||||||
|
| Plugin state | נתוני plugin (case_number ← issue) | `plugin_state` |
|
||||||
|
|
||||||
|
**תפקידי הליבה**:
|
||||||
|
- **תזמור** — CEO agent מקבל בקשות, מנתב לסוכן המתאים
|
||||||
|
- **ניהול משימות** — Kanban board עם issues, מעקב סטטוס
|
||||||
|
- **הפעלת סוכנים** — wakeup mechanism, heartbeat cycle
|
||||||
|
- **ממשק דיון** — comments על issues (משתמש ← agent ← agent)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### תהליכי גומלין — מי מדבר עם מי
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ תהליכי גומלין │
|
||||||
|
│ │
|
||||||
|
│ LEGAL-AI PAPERCLIP │
|
||||||
|
│ ════════ ═════════ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ יצירת project+issue ┌─────────┐ │
|
||||||
|
│ │ Cases │ ─────── DB insert ──────→ │Projects │ │
|
||||||
|
│ │ │ ─────── DB insert ──────→ │ Issues │ │
|
||||||
|
│ └─────────┘ └─────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ wakeup signal ┌─────────┐ │
|
||||||
|
│ │Workflow │ ─────── HTTP POST ───────→ │ CEO │ │
|
||||||
|
│ │ Start │ (issueId + mutation) │ Agent │ │
|
||||||
|
│ └─────────┘ └─────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ קריאת case_number ┌─────────┐ │
|
||||||
|
│ │ Data │ ←──── plugin_state ────── │ Plugin │ │
|
||||||
|
│ │ (API) │ ←──── HTTP GET/POST ───── │legal-ai │ │
|
||||||
|
│ └─────────┘ (תקדימים, טענות, סגנון) └─────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ skill sync ┌─────────┐ │
|
||||||
|
│ │ Skills │ ──── DB + filesystem ────→ │company_ │ │
|
||||||
|
│ │ (disk) │ │ skills │ │
|
||||||
|
│ └─────────┘ └─────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────┐ שאילתת חברות ┌─────────┐ │
|
||||||
|
│ │Settings │ ←──── DB query ────────── │companies│ │
|
||||||
|
│ │ UI │ │ table │ │
|
||||||
|
│ └─────────┘ └─────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### כיוון 1: Legal-AI → Paperclip (יצירה ושליטה)
|
||||||
|
|
||||||
|
| פעולה | מנגנון | מתי |
|
||||||
|
|-------|--------|-----|
|
||||||
|
| יצירת Project | DB insert ישיר ב-Paperclip | יצירת תיק חדש |
|
||||||
|
| יצירת Issue | DB insert ישיר ב-Paperclip | יצירת תיק / התחלת workflow |
|
||||||
|
| קישור case ← issue | DB insert ב-`plugin_state` | יצירת project |
|
||||||
|
| הערת אימות | DB insert ב-`issue_comments` | אחרי יצירת project |
|
||||||
|
| הפעלת CEO | **HTTP POST** ל-`/api/agents/{id}/wakeup` | התחלת workflow |
|
||||||
|
| סנכרון skill | DB insert/update ב-`company_skills` | התקנת/עדכון skill |
|
||||||
|
|
||||||
|
#### כיוון 2: Paperclip → Legal-AI (שאילתות וקריאות חזרה)
|
||||||
|
|
||||||
|
| פעולה | מנגנון | מתי |
|
||||||
|
|-------|--------|-----|
|
||||||
|
| קריאת case_number | plugin קורא `plugin_state` | סוכן מקבל issue |
|
||||||
|
| שליפת מסמכים | HTTP GET/POST ל-API של legal-ai | סוכן עובד על תיק |
|
||||||
|
| חיפוש תקדימים | HTTP ל-`/api/precedents/search` | researcher מחפש |
|
||||||
|
| קריאת style guide | HTTP ל-MCP / API | writer כותב טיוטה |
|
||||||
|
| רשימת חברות | DB query ישיר מ-`companies` | UI הגדרות |
|
||||||
|
|
||||||
|
#### החוליה המקשרת: `plugin_state`
|
||||||
|
|
||||||
|
```
|
||||||
|
plugin_state:
|
||||||
|
plugin_id = "53461b5a..." (marcusgroup.legal-ai)
|
||||||
|
scope_kind = "issue"
|
||||||
|
scope_id = "{issue-uuid}"
|
||||||
|
state_key = "legal-case-number"
|
||||||
|
value_json = "\"1234\""
|
||||||
|
```
|
||||||
|
|
||||||
|
זו ה"כתובת" שמאפשרת לסוכן Paperclip לדעת איזה תיק ב-Legal-AI שייך ל-issue שהוא עובד עליו.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### מצב קיים לכל חברה
|
||||||
|
|
||||||
|
#### CMP — רישוי ובנייה (מוכן לעבודה)
|
||||||
|
|
||||||
|
**ב-Legal-AI**:
|
||||||
|
- 24 החלטות אימון בקורפוס
|
||||||
|
- ניתוח סגנון מלא (דפוסים, ביטויים, יחסי אורך)
|
||||||
|
- content checklists ל-3 סוגי משנה (substantive, threshold, property)
|
||||||
|
- RAG פעיל עם chunks + embeddings
|
||||||
|
|
||||||
|
**ב-Paperclip**:
|
||||||
|
- חברה CMP פעילה
|
||||||
|
- סוכנים מוגדרים ופעילים
|
||||||
|
- Plugin פעיל
|
||||||
|
- Skills מותקנים
|
||||||
|
|
||||||
|
#### CMPA — היטלי השבחה (דורש הקמה)
|
||||||
|
|
||||||
|
**ב-Legal-AI**:
|
||||||
|
- appeal_subtype `betterment_levy` מוגדר בקוד
|
||||||
|
- ניתוב אוטומטי (8xxx → CMPA) עובד
|
||||||
|
- **חסר**: 0 החלטות אימון, 0 style patterns, 0 chunks, אין content checklist
|
||||||
|
|
||||||
|
**ב-Paperclip**:
|
||||||
|
- חברה CMPA קיימת
|
||||||
|
- **לוודא**: סוכנים מקושרים, plugin פעיל, skills מותקנים
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## התהליך המלא — צעד אחר צעד
|
||||||
|
|
||||||
|
### שלב 1: הכנת הקבצים
|
||||||
|
|
||||||
|
**מיקום**: הנח את כל קבצי ה-DOCX בתיקייה נגישה (למשל `~/Downloads/hitlei-hashbacha/`)
|
||||||
|
|
||||||
|
**בדיקות מקדימות**:
|
||||||
|
1. וודא שכל הקבצים בפורמט DOCX או PDF
|
||||||
|
2. וודא שהשמות כוללים מספר תיק (לצורך metadata)
|
||||||
|
3. ספור כמה החלטות יש — זה ישפיע על זמן העיבוד
|
||||||
|
|
||||||
|
**דגשים**:
|
||||||
|
- ההחלטות מגיעות מנבו — יש להן watermarks, headers, footnotes שצריך לנקות
|
||||||
|
- מערכת ה-proofreading שלנו מטפלת בזה אוטומטית
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### שלב 2: העלאה — 3 נתיבים אפשריים
|
||||||
|
|
||||||
|
#### נתיב א: ממשק Web (מומלץ להעלאה המונית)
|
||||||
|
|
||||||
|
```
|
||||||
|
כתובת: https://legal-ai.nautilus.marcusgroup.org
|
||||||
|
נתיב: /api/training/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
**מה קורה מאחורי הקלעים**:
|
||||||
|
1. הקובץ נשמר כ-temp file
|
||||||
|
2. **Proofreading** — ניקוי אוטומטי של תוספות נבו:
|
||||||
|
- הסרת watermarks ("ספרות:", "חקיקה שאוזכרה:")
|
||||||
|
- הסרת headers/footers של עמודים
|
||||||
|
- הסרת קודי נבו inline
|
||||||
|
- הסרת URLs וזכויות יוצרים
|
||||||
|
3. **שמירת גרסה מנוקה** → `data/training/proofread/{filename}.md`
|
||||||
|
4. **שמירת מקור** → `data/training/{filename}.docx`
|
||||||
|
5. **הוספה ל-DB** → טבלת `style_corpus` עם metadata
|
||||||
|
6. **חיתוך לחלקים** → chunks סמנטיים
|
||||||
|
7. **יצירת embeddings** → Voyage AI → וקטורים 1024 ממדים
|
||||||
|
8. **שמירה ב-RAG** → טבלת `document_chunks` (עם practice_area + appeal_subtype)
|
||||||
|
|
||||||
|
#### נתיב ב: MCP Tool (מ-Claude Code)
|
||||||
|
|
||||||
|
```
|
||||||
|
tool: document_upload_training
|
||||||
|
params:
|
||||||
|
file_path: "/path/to/file.docx"
|
||||||
|
decision_number: "ARAR-24-8001"
|
||||||
|
decision_date: "2024-06-15"
|
||||||
|
subject_categories: ["היטל השבחה"]
|
||||||
|
title: "שם ההחלטה"
|
||||||
|
practice_area: "appeals_committee"
|
||||||
|
appeal_subtype: "betterment_levy"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### נתיב ג: Skill Command (אינטראקטיבי)
|
||||||
|
|
||||||
|
```
|
||||||
|
/upload-training
|
||||||
|
```
|
||||||
|
עונים על שאלות: נתיב קובץ, מספר החלטה, תאריך, קטגוריות.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### שלב 3: ביקורת (Proofreading QA)
|
||||||
|
|
||||||
|
**קריטי**: לפני שממשיכים לניתוח — **לבדוק כל החלטה שהועלתה**.
|
||||||
|
|
||||||
|
**מה לבדוק**:
|
||||||
|
- [ ] הטקסט המנוקה (`data/training/proofread/`) קריא ושלם
|
||||||
|
- [ ] לא נחתכו חלקים מהותיים
|
||||||
|
- [ ] ה-metadata נכון (מספר תיק, תאריך, קטגוריה)
|
||||||
|
- [ ] אין שאריות של artifacts מנבו
|
||||||
|
- [ ] appeal_subtype = `betterment_levy` (ולא `building_permit`)
|
||||||
|
|
||||||
|
**כלי בדיקה**:
|
||||||
|
```
|
||||||
|
GET /api/training/status — סטטוס העלאה ועיבוד
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### שלב 4: ניתוח סגנון (Style Analysis)
|
||||||
|
|
||||||
|
אחרי שכל ההחלטות הועלו ונבדקו, מריצים ניתוח סגנון:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/training/analyze-style
|
||||||
|
```
|
||||||
|
|
||||||
|
**מה קורה**:
|
||||||
|
1. שליפת כל ההחלטות מ-`style_corpus` (לפי practice_area/subtype)
|
||||||
|
2. בדיקת תקציב tokens:
|
||||||
|
- עד 900K tokens → pass יחיד (הכל ל-Claude בבת אחת)
|
||||||
|
- מעל 900K → multi-pass (כל החלטה בנפרד + סינתזה)
|
||||||
|
3. **חילוץ דפוסים** באמצעות Claude:
|
||||||
|
- נוסחאות פתיחה
|
||||||
|
- ביטויי מעבר
|
||||||
|
- סגנון ציטוט פסיקה
|
||||||
|
- מבנה ניתוח
|
||||||
|
- נוסחאות סיום
|
||||||
|
- ביטויים אופייניים
|
||||||
|
- זרימת טיעון
|
||||||
|
- טיפול בראיות
|
||||||
|
4. שמירה בטבלת `style_patterns` עם תדירות, הקשר, ודוגמאות
|
||||||
|
|
||||||
|
**תוצר**: מדריך סגנון מבוסס-נתונים ספציפי להיטלי השבחה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### שלב 5: ניתוח קורפוס (Corpus Analysis)
|
||||||
|
|
||||||
|
בדומה ל-`docs/corpus-analysis.md` שנבנה עבור רישוי ובנייה, צריך ליצור ניתוח מקביל:
|
||||||
|
|
||||||
|
**מה לנתח**:
|
||||||
|
- הרכב הקורפוס: כמה החלטות, תוצאות (קבלה/דחייה/חלקית)
|
||||||
|
- אורך פרק דיון טיפוסי
|
||||||
|
- נושאים ייחודיים להיטלי השבחה:
|
||||||
|
- שומות (שומה מוסכמת, שומה אחרת, שמאי מכריע)
|
||||||
|
- תכנית משביחה — זיהוי, פרשנות
|
||||||
|
- מועד השבחה / "מועד אישור התכנית"
|
||||||
|
- חישוב עליית ערך (לפני/אחרי)
|
||||||
|
- פטורים (ס' 19 לתוספת השלישית)
|
||||||
|
- שיעור היטל
|
||||||
|
- דיני ראיות שמאיים
|
||||||
|
- ביטויי מעבר ייחודיים
|
||||||
|
- סגנון דיון — "קר ומקצועי" (לפי CLAUDE.md)
|
||||||
|
- השוואה לרישוי ובנייה (מה שונה)
|
||||||
|
|
||||||
|
**תוצר**: מסמך `docs/corpus-analysis-betterment.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### שלב 6: עדכון Content Checklists
|
||||||
|
|
||||||
|
הקובץ `lessons.py` מכיל צ'קליסטים לבלוק י (דיון) לפי סוג ערר.
|
||||||
|
|
||||||
|
**מה צריך**:
|
||||||
|
- ליצור `CONTENT_CHECKLISTS["betterment_levy"]` עם נושאים ייחודיים
|
||||||
|
- נושאים צפויים: שומות, תכנית משביחה, מועד, חישוב, פטורים, ראיות שמאיות
|
||||||
|
- הצ'קליסט ייבנה מתוך ניתוח הקורפוס (שלב 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### שלב 7: אימות Paperclip
|
||||||
|
|
||||||
|
לוודא שחברת CMPA מוגדרת נכון:
|
||||||
|
|
||||||
|
**בדיקות**:
|
||||||
|
- [ ] חברה CMPA קיימת ופעילה ב-Paperclip DB
|
||||||
|
- [ ] Issue prefix = CMPA
|
||||||
|
- [ ] Plugin `legal-ai` פעיל בחברה
|
||||||
|
- [ ] סוכנים (CEO, researcher, writer) מוגדרים
|
||||||
|
- [ ] tag_company_mappings נכון ב-legal-ai DB:
|
||||||
|
- `betterment_levy` → `8639e837...`
|
||||||
|
- `compensation_197` → `8639e837...`
|
||||||
|
- [ ] יצירת תיק 8xxx מנותבת נכון
|
||||||
|
|
||||||
|
**כלי בדיקה**:
|
||||||
|
```
|
||||||
|
GET /api/settings/tag-mappings
|
||||||
|
GET /api/paperclip/companies
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## סיכום — סדר פעולות
|
||||||
|
|
||||||
|
| # | שלב | מה | כלי | זמן משוער |
|
||||||
|
|---|------|----|------|-----------|
|
||||||
|
| 1 | הכנה | איסוף קבצי DOCX, בדיקת פורמט | ידני | — |
|
||||||
|
| 2 | העלאה | העלאת כל ההחלטות + proofreading אוטומטי | Web API / MCP | דקות לכל החלטה |
|
||||||
|
| 3 | ביקורת | בדיקת כל טקסט מנוקה + metadata | ידני / Claude | כמה שעות |
|
||||||
|
| 4 | ניתוח סגנון | חילוץ דפוסים מהקורפוס | API analyze-style | ~30 דק |
|
||||||
|
| 5 | ניתוח קורפוס | מפת תוכן + נושאים + השוואה | Claude + מסמך | כמה שעות |
|
||||||
|
| 6 | צ'קליסט | יצירת content checklist להיטלי השבחה | עדכון קוד | — |
|
||||||
|
| 7 | אימות Paperclip | בדיקת הגדרות חברה + ניתוב | API / DB | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## הערות חשובות
|
||||||
|
|
||||||
|
### ההבדל בין רישוי ובנייה להיטלי השבחה (מ-CLAUDE.md)
|
||||||
|
|
||||||
|
| מאפיין | רישוי ובנייה (1xxx) | היטלי השבחה (8xxx) |
|
||||||
|
|---------|---------------------|-------------------|
|
||||||
|
| טון | חם יחסית | קר ומקצועי |
|
||||||
|
| תוכן | הקשר תכנוני רחב, אלמנטים אנושיים | יבש, ללא רגשות |
|
||||||
|
| נושאי דיון | תכניות, חניה, קווי בניין, שכנים | שומות, חישובי השבחה, פטורים |
|
||||||
|
| פסיקה | ס' 152, הלכת שפר, דיני הקלה | ס' 196-198, תוספת שלישית, שמאי מכריע |
|
||||||
|
|
||||||
|
### סינון RAG לפי סוג
|
||||||
|
כל ה-chunks נשמרים עם `appeal_subtype`, כך שחיפוש סמנטי בתיק היטל השבחה ימצא רק תקדימים רלוונטיים מהתחום — לא יערבב עם רישוי ובנייה.
|
||||||
|
|
||||||
|
### ניתוח סגנון נפרד
|
||||||
|
ייתכן שנצטרך **מדריך סגנון נפרד** להיטלי השבחה, כי הטון שונה מהותית. הניתוח בשלב 4 יחשוף את ההבדלים.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## סוכנים — שיתוף בין החברות
|
||||||
|
|
||||||
|
### עיקרון: אותם סוכנים, הקשר שונה
|
||||||
|
|
||||||
|
**אין צורך בסוכנים נפרדים** לכל חברה. הסוכנים (CEO, researcher, writer) עובדים לפי **מתודולוגיה** — ארכיטקטורת 12 בלוקים, CREAC, מבחן השופט — שחלה על כל סוגי העררים.
|
||||||
|
|
||||||
|
**מה שמשתנה אוטומטית לפי `appeal_subtype`**:
|
||||||
|
|
||||||
|
| רכיב | מקור | מנגנון הפרדה |
|
||||||
|
|-------|------|--------------|
|
||||||
|
| Style patterns | טבלת `style_patterns` | ניתוח סגנון נפרד per-subtype |
|
||||||
|
| Content checklists | `lessons.py` | key שונה: `building_permit` vs `betterment_levy` |
|
||||||
|
| תקדימים (RAG) | טבלת `document_chunks` | סינון לפי `appeal_subtype` בחיפוש |
|
||||||
|
| טון | style guide + patterns | דפוסים שונים מהקורפוס |
|
||||||
|
|
||||||
|
**למה שיתוף סוכנים עדיף**:
|
||||||
|
1. שיפור במתודולוגיה חל אוטומטית על שני התחומים
|
||||||
|
2. אין כפילות בתחזוקת סוכנים
|
||||||
|
3. ההפרדה היא **ברמת הנתונים**, לא ברמת הלוגיקה
|
||||||
|
|
||||||
|
**מה כן צריך לוודא**:
|
||||||
|
- [ ] הסוכנים ב-Paperclip מקושרים לשתי החברות (CMP + CMPA)
|
||||||
|
- [ ] כש-issue נפתח ב-CMPA, הסוכנים מופעלים באותו אופן
|
||||||
|
- [ ] ה-context שהסוכן מקבל כולל את ה-`appeal_subtype` הנכון
|
||||||
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"google-cloud-vision>=3.7.0",
|
"google-cloud-vision>=3.7.0",
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.115.0",
|
||||||
"uvicorn[standard]>=0.30.0",
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -220,9 +220,14 @@ async def search_decisions(
|
|||||||
query: str,
|
query: str,
|
||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
section_type: str = "",
|
section_type: str = "",
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
case_number: str = "",
|
||||||
) -> 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()
|
@mcp.tool()
|
||||||
@@ -239,9 +244,14 @@ async def search_case_documents(
|
|||||||
async def find_similar_cases(
|
async def find_similar_cases(
|
||||||
description: str,
|
description: str,
|
||||||
limit: int = 5,
|
limit: int = 5,
|
||||||
|
practice_area: str = "",
|
||||||
|
appeal_subtype: str = "",
|
||||||
|
case_number: str = "",
|
||||||
) -> 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
|
# Drafting
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ ALTER TABLE decisions ADD COLUMN IF NOT EXISTS outcome_reasoning TEXT DEFAULT ''
|
|||||||
|
|
||||||
-- הרחבת cases עם appeal_type (אם לא קיים)
|
-- הרחבת cases עם appeal_type (אם לא קיים)
|
||||||
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_type TEXT DEFAULT '';
|
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
|
-- טבלת qa_results
|
||||||
CREATE TABLE IF NOT EXISTS 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()
|
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
|
-- Indexes
|
||||||
-- ═══════════════════════════════════════════════════════════════════
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -467,6 +479,8 @@ async def create_case(
|
|||||||
hearing_date: date | None = None,
|
hearing_date: date | None = None,
|
||||||
notes: str = "",
|
notes: str = "",
|
||||||
expected_outcome: str = "",
|
expected_outcome: str = "",
|
||||||
|
practice_area: str = "appeals_committee",
|
||||||
|
appeal_subtype: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
case_id = uuid4()
|
case_id = uuid4()
|
||||||
@@ -474,13 +488,15 @@ async def create_case(
|
|||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""INSERT INTO cases (id, case_number, title, appellants, respondents,
|
"""INSERT INTO cases (id, case_number, title, appellants, respondents,
|
||||||
subject, property_address, permit_number, committee_type,
|
subject, property_address, permit_number, committee_type,
|
||||||
hearing_date, notes, expected_outcome)
|
hearing_date, notes, expected_outcome,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)""",
|
practice_area, appeal_subtype)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)""",
|
||||||
case_id, case_number, title,
|
case_id, case_number, title,
|
||||||
json.dumps(appellants or []),
|
json.dumps(appellants or []),
|
||||||
json.dumps(respondents or []),
|
json.dumps(respondents or []),
|
||||||
subject, property_address, permit_number, committee_type,
|
subject, property_address, permit_number, committee_type,
|
||||||
hearing_date, notes, expected_outcome,
|
hearing_date, notes, expected_outcome,
|
||||||
|
practice_area, appeal_subtype,
|
||||||
)
|
)
|
||||||
return await get_case(case_id)
|
return await get_case(case_id)
|
||||||
|
|
||||||
@@ -809,6 +825,8 @@ async def search_similar(
|
|||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
case_id: UUID | None = None,
|
case_id: UUID | None = None,
|
||||||
section_type: str | None = None,
|
section_type: str | None = None,
|
||||||
|
practice_area: str | None = None,
|
||||||
|
appeal_subtype: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Cosine similarity search on document chunks."""
|
"""Cosine similarity search on document chunks."""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
@@ -824,6 +842,14 @@ async def search_similar(
|
|||||||
conditions.append(f"dc.section_type = ${param_idx}")
|
conditions.append(f"dc.section_type = ${param_idx}")
|
||||||
params.append(section_type)
|
params.append(section_type)
|
||||||
param_idx += 1
|
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 ""
|
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,107 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
from legal_mcp import config
|
from legal_mcp import config
|
||||||
from legal_mcp.services import audit, db, practice_area as pa
|
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(
|
async def case_create(
|
||||||
case_number: str,
|
case_number: str,
|
||||||
@@ -92,7 +185,7 @@ async def case_create(
|
|||||||
case_dir.mkdir(parents=True, exist_ok=True)
|
case_dir.mkdir(parents=True, exist_ok=True)
|
||||||
docs_dir = case_dir / "documents"
|
docs_dir = case_dir / "documents"
|
||||||
docs_dir.mkdir(exist_ok=True)
|
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 / "extracted").mkdir(exist_ok=True)
|
||||||
(docs_dir / "proofread").mkdir(exist_ok=True)
|
(docs_dir / "proofread").mkdir(exist_ok=True)
|
||||||
(docs_dir / "backup").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 = case_dir / "notes.md"
|
||||||
notes_file.write_text(f"# הערות - תיק {case_number}\n\n{notes}\n")
|
notes_file.write_text(f"# הערות - תיק {case_number}\n\n{notes}\n")
|
||||||
|
|
||||||
# Initialize git repo
|
# Initialize git repo (best-effort)
|
||||||
subprocess.run(["git", "init"], cwd=case_dir, capture_output=True)
|
try:
|
||||||
subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True)
|
subprocess.run(["git", "init"], cwd=case_dir, capture_output=True)
|
||||||
subprocess.run(
|
subprocess.run(["git", "add", "."], cwd=case_dir, capture_output=True)
|
||||||
["git", "commit", "-m", f"אתחול תיק {case_number}: {title}"],
|
subprocess.run(
|
||||||
cwd=case_dir,
|
["git", "commit", "-m", f"אתחול תיק {case_number}: {title}"],
|
||||||
capture_output=True,
|
cwd=case_dir,
|
||||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
capture_output=True,
|
||||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||||
"PATH": "/usr/bin:/bin"},
|
"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)
|
return json.dumps(case, default=str, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
@@ -175,13 +277,27 @@ async def case_update(
|
|||||||
"""
|
"""
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
|
||||||
|
# Ordered workflow statuses — regression protection
|
||||||
|
STATUS_ORDER = [
|
||||||
|
"new", "uploading", "processing", "documents_ready",
|
||||||
|
"analyst_verified", "research_complete", "outcome_set",
|
||||||
|
"brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing",
|
||||||
|
"drafting", "qa_review", "drafted",
|
||||||
|
"exported", "reviewed", "final",
|
||||||
|
]
|
||||||
|
|
||||||
case = await db.get_case_by_number(case_number)
|
case = await db.get_case_by_number(case_number)
|
||||||
if not case:
|
if not case:
|
||||||
return f"תיק {case_number} לא נמצא."
|
return f"תיק {case_number} לא נמצא."
|
||||||
|
|
||||||
fields = {}
|
fields = {}
|
||||||
if status:
|
if status:
|
||||||
fields["status"] = status
|
current = case.get("status", "")
|
||||||
|
cur_idx = STATUS_ORDER.index(current) if current in STATUS_ORDER else -1
|
||||||
|
new_idx = STATUS_ORDER.index(status) if status in STATUS_ORDER else -1
|
||||||
|
# Only update if advancing or status is unknown to the order
|
||||||
|
if new_idx >= cur_idx or new_idx == -1:
|
||||||
|
fields["status"] = status
|
||||||
if title:
|
if title:
|
||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if subject:
|
if subject:
|
||||||
@@ -199,20 +315,23 @@ async def case_update(
|
|||||||
|
|
||||||
updated = await db.update_case(UUID(case["id"]), **fields)
|
updated = await db.update_case(UUID(case["id"]), **fields)
|
||||||
|
|
||||||
# Git commit the update
|
# Git commit the update (best-effort)
|
||||||
case_dir = config.find_case_dir(case_number)
|
try:
|
||||||
if case_dir.exists():
|
case_dir = config.find_case_dir(case_number)
|
||||||
case_json = case_dir / "case.json"
|
if case_dir.exists():
|
||||||
case_json.write_text(json.dumps(updated, default=str, ensure_ascii=False, indent=2))
|
case_json = case_dir / "case.json"
|
||||||
subprocess.run(["git", "add", "case.json"], cwd=case_dir, capture_output=True)
|
case_json.write_text(json.dumps(updated, default=str, ensure_ascii=False, indent=2))
|
||||||
subprocess.run(
|
subprocess.run(["git", "add", "case.json"], cwd=case_dir, capture_output=True)
|
||||||
["git", "commit", "-m", f"עדכון תיק: {', '.join(fields.keys())}"],
|
subprocess.run(
|
||||||
cwd=case_dir,
|
["git", "commit", "-m", f"עדכון תיק: {', '.join(fields.keys())}"],
|
||||||
capture_output=True,
|
cwd=case_dir,
|
||||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
capture_output=True,
|
||||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||||
"PATH": "/usr/bin:/bin"},
|
"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)
|
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)
|
await db.update_document(UUID(doc["id"]), doc_type=classified_type)
|
||||||
doc["doc_type"] = classified_type
|
doc["doc_type"] = classified_type
|
||||||
|
|
||||||
# Git commit
|
# Git commit (best-effort — don't fail upload on git errors)
|
||||||
repo_dir = config.find_case_dir(case_number)
|
try:
|
||||||
if repo_dir.exists():
|
repo_dir = config.find_case_dir(case_number)
|
||||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
if repo_dir.exists():
|
||||||
doc_type_hebrew = {
|
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||||
"appeal": "כתב ערר",
|
doc_type_hebrew = {
|
||||||
"response": "תשובה",
|
"appeal": "כתב ערר",
|
||||||
"protocol": "פרוטוקול",
|
"response": "תשובה",
|
||||||
"plan": "תכנית",
|
"protocol": "פרוטוקול",
|
||||||
"permit": "היתר",
|
"plan": "תכנית",
|
||||||
"court_decision": "פסק דין",
|
"permit": "היתר",
|
||||||
"decision": "החלטה",
|
"court_decision": "פסק דין",
|
||||||
"appraisal": "שומה",
|
"decision": "החלטה",
|
||||||
"objection": "התנגדות",
|
"appraisal": "שומה",
|
||||||
"exhibit": "נספח",
|
"objection": "התנגדות",
|
||||||
"reference": "מסמך עזר",
|
"exhibit": "נספח",
|
||||||
}.get(actual_doc_type, actual_doc_type)
|
"reference": "מסמך עזר",
|
||||||
subprocess.run(
|
}.get(actual_doc_type, actual_doc_type)
|
||||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
|
subprocess.run(
|
||||||
cwd=repo_dir,
|
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
|
||||||
capture_output=True,
|
cwd=repo_dir,
|
||||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
capture_output=True,
|
||||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||||
"PATH": "/usr/bin:/bin"},
|
"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({
|
return json.dumps({
|
||||||
"document": doc,
|
"document": doc,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async def precedent_attach(
|
|||||||
pdf_document_id=pdf_uuid,
|
pdf_document_id=pdf_uuid,
|
||||||
practice_area=case.get("practice_area"),
|
practice_area=case.get("practice_area"),
|
||||||
)
|
)
|
||||||
return json.dumps(row, ensure_ascii=False, indent=2)
|
return json.dumps(row, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
async def precedent_list(case_number: str) -> str:
|
async def precedent_list(case_number: str) -> str:
|
||||||
@@ -62,7 +62,7 @@ async def precedent_list(case_number: str) -> str:
|
|||||||
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
|
return json.dumps({"error": f"תיק {case_number} לא נמצא."}, ensure_ascii=False)
|
||||||
|
|
||||||
rows = await db.list_case_precedents(UUID(case["id"]))
|
rows = await db.list_case_precedents(UUID(case["id"]))
|
||||||
return json.dumps(rows, ensure_ascii=False, indent=2)
|
return json.dumps(rows, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
async def precedent_remove(precedent_id: str) -> str:
|
async def precedent_remove(precedent_id: str) -> str:
|
||||||
@@ -92,4 +92,4 @@ async def precedent_search_library(
|
|||||||
return json.dumps([], ensure_ascii=False)
|
return json.dumps([], ensure_ascii=False)
|
||||||
|
|
||||||
rows = await db.search_precedent_library(query.strip(), practice_area, limit)
|
rows = await db.search_precedent_library(query.strip(), practice_area, limit)
|
||||||
return json.dumps(rows, ensure_ascii=False, indent=2)
|
return json.dumps(rows, ensure_ascii=False, indent=2, default=str)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
51
scripts/SCRIPTS.md
Normal file
51
scripts/SCRIPTS.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# scripts/ — מדריך סקריפטים
|
||||||
|
|
||||||
|
> **כלל:** כל עדכון, יצירה, או מחיקה של סקריפט בתיקייה זו מחייב עדכון של קובץ זה.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## סקריפטים פעילים
|
||||||
|
|
||||||
|
| Script | Type | Purpose | Scheduled |
|
||||||
|
|--------|------|---------|-----------|
|
||||||
|
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||||
|
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||||
|
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||||
|
| `notify.py` | python | שליחת מייל התראה מסוכנים via SMTP (Gmail) | נקרא ע"י סוכנים |
|
||||||
|
| `bidi_table.py` | python | יצירת טבלאות box-drawing עם תמיכה ב-BiDi (עברית+אנגלית) | ספריית עזר |
|
||||||
|
|
||||||
|
## תיקיית `.archive/` — סקריפטים שהושלמו
|
||||||
|
|
||||||
|
סקריפטים חד-פעמיים שהפונקציונליות שלהם הוטמעה ב-MCP server או ב-API.
|
||||||
|
נשמרים ב-git לצורך היסטוריה — **אין להריץ אותם**.
|
||||||
|
|
||||||
|
| Script | Original Purpose | Superseded By |
|
||||||
|
|--------|-----------------|---------------|
|
||||||
|
| `backfill_pattern_frequency.py` | עדכון תדירות דפוסי סגנון ב-DB | `web/app.py::_extract_pattern_variants()` |
|
||||||
|
| `batch_upload_training.py` | העלאת קורפוס אימון (16 קבצים) | Web UI: `/api/training/upload` |
|
||||||
|
| `benchmark_embeddings.py` | השוואת מודלי embeddings (voyage-3 vs voyage-4) | הושלם — voyage-3-large נבחר |
|
||||||
|
| `benchmark_new_vs_old.py` | השוואת Google Vision vs markdown קיים | הושלם — בדיקה חד-פעמית לתיק 1130-25 |
|
||||||
|
| `decompose-decisions.py` | פירוק החלטות סופיות ל-12 בלוקים | MCP: `write_block()`, `write_all_blocks()` |
|
||||||
|
| `export-decision-docx.py` | ייצוא החלטה ל-DOCX | MCP: `export_docx()` |
|
||||||
|
| `extract-citations.py` | חילוץ ציטוטי פסיקה מבלוק י | MCP service: `references_extractor.py` |
|
||||||
|
| `extract-claims.py` | חילוץ טענות מבלוק ז | MCP: `extract_claims()` + `claims_extractor.py` |
|
||||||
|
| `extract_all_google_vision.py` | OCR בכמות עם Google Vision | MCP: `document_upload()` pipeline |
|
||||||
|
| `extract_originals.py` | חילוץ טקסט מ-PDF עם Claude Opus | MCP service: `extractor.py` |
|
||||||
|
| `extract_originals_ocr.py` | חילוץ OCR מלא מ-PDF | MCP service: `extractor.py` |
|
||||||
|
| `generate-embeddings.py` | יצירת embeddings לבלוקים ופסיקה | אוטומטי — נוצרים עם יצירת בלוקים |
|
||||||
|
| `link-claims-to-discussion.py` | קישור טענות לפסקאות דיון | MCP service: `qa_validator.py` |
|
||||||
|
| `proofread_training_corpus.py` | ניקוי Nevo מ-DOCX/PDF ל-Markdown | MCP service: `proofreader.py` + Web UI |
|
||||||
|
| `seed-appeals.py` | seeding תיקי ערר ראשוניים ל-DB | MCP: `case_create()` |
|
||||||
|
| `seed-knowledge.py` | seeding לקחים, ביטויי מעבר, פסיקה | MCP: `record_chair_feedback()`, `precedent_attach()` |
|
||||||
|
| `validate-decision.py` | ולידציה מול block-schema | MCP: `validate_decision()` + `qa_validator.py` |
|
||||||
|
|
||||||
|
## סקריפטים שנמחקו (git history בלבד)
|
||||||
|
|
||||||
|
| Script | Reason |
|
||||||
|
|--------|--------|
|
||||||
|
| `import-final-decisions.py` | מיגרציה הושלמה — כל ההחלטות ב-`data/training/` |
|
||||||
|
| `compare_extractions.py` | בדיקה חד-פעמית לתיק 1130-25 |
|
||||||
|
| `decompose-decisions-v2.py` | כפילות של v1 |
|
||||||
|
| `extract_google_vision.py` | hardcoded לתיק בודד |
|
||||||
|
| `extract_google_vision_single.py` | wrapper חד-פעמי |
|
||||||
|
| `test-search.py` | סקריפט דיבאג |
|
||||||
@@ -4,34 +4,50 @@
|
|||||||
|
|
||||||
CASES_DIR="/home/chaim/legal-ai/data/cases"
|
CASES_DIR="/home/chaim/legal-ai/data/cases"
|
||||||
LOG="/home/chaim/legal-ai/data/.auto-sync.log"
|
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
|
export GIT_AUTHOR_NAME="Ezer Mishpati"
|
||||||
[ -d "$status_dir" ] || continue
|
export GIT_AUTHOR_EMAIL="legal@local"
|
||||||
for case_dir in "$status_dir"/*/; do
|
export GIT_COMMITTER_NAME="Ezer Mishpati"
|
||||||
[ -d "$case_dir/.git" ] || continue
|
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)
|
case_name=$(basename "$case_dir")
|
||||||
changes=$(git status --porcelain 2>/dev/null)
|
|
||||||
[ -z "$changes" ] && continue
|
|
||||||
|
|
||||||
# Stage all changes
|
# Ensure safe.directory is set for this repo
|
||||||
git add -A 2>/dev/null
|
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
|
cd "$case_dir" || continue
|
||||||
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} קבצים שונו"
|
|
||||||
|
|
||||||
# Commit
|
# Check for any changes (modified, new, deleted)
|
||||||
env $GIT_ENV git commit -m "$msg" --quiet 2>/dev/null
|
changes=$(git status --porcelain 2>/dev/null)
|
||||||
if [ $? -eq 0 ]; then
|
[ -z "$changes" ] && continue
|
||||||
# Push (non-blocking, ignore errors)
|
|
||||||
git push origin main --quiet 2>/dev/null
|
# Stage all changes
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | $count files synced" >> "$LOG"
|
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
|
fi
|
||||||
done
|
else
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') | $case_name | commit FAILED" >> "$LOG"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
"""Compare existing MD files with freshly extracted text from PDFs."""
|
|
||||||
|
|
||||||
import difflib
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
DOCS_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents")
|
|
||||||
EXTRACTED_DIR = DOCS_DIR / "extracted"
|
|
||||||
|
|
||||||
# Map: existing MD -> extracted MD
|
|
||||||
PAIRS = [
|
|
||||||
("2025-08-14-כתב-ערר-קובר.md", "מרק קובר-כתב ערר.md", "Appeal - Kuber"),
|
|
||||||
("2025-09-01-כתב-תשובה-ליבמן-לערר.md", "תשובה לערר מטעם המשיבים.md", "Response - Livman"),
|
|
||||||
("2025-09-02-כתב-תשובה-ועדת-הראל-לערר.md", "תשובת הועדה המרחבית לערר.md", "Response - Committee"),
|
|
||||||
("2025-10-22-כתב-ערר-מטמון.md", "תשובת המשיב-יצחק מטמון.md", "Response - Matmon"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def normalize(text: str) -> str:
|
|
||||||
"""Normalize text for comparison."""
|
|
||||||
# Remove markdown formatting, extra whitespace
|
|
||||||
lines = text.strip().split("\n")
|
|
||||||
lines = [l.strip() for l in lines if l.strip()]
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def word_overlap(a: str, b: str) -> float:
|
|
||||||
"""Calculate word-level overlap ratio."""
|
|
||||||
words_a = set(a.split())
|
|
||||||
words_b = set(b.split())
|
|
||||||
if not words_a or not words_b:
|
|
||||||
return 0.0
|
|
||||||
intersection = words_a & words_b
|
|
||||||
return len(intersection) / max(len(words_a), len(words_b))
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print(f"{'=' * 70}")
|
|
||||||
print("COMPARISON: Existing MD vs Fresh PDF Extraction")
|
|
||||||
print(f"{'=' * 70}\n")
|
|
||||||
|
|
||||||
summary = []
|
|
||||||
|
|
||||||
for existing_name, extracted_name, label in PAIRS:
|
|
||||||
existing_path = DOCS_DIR / existing_name
|
|
||||||
extracted_path = EXTRACTED_DIR / extracted_name
|
|
||||||
|
|
||||||
if not existing_path.exists():
|
|
||||||
print(f"SKIP: {existing_name} not found")
|
|
||||||
continue
|
|
||||||
if not extracted_path.exists():
|
|
||||||
print(f"SKIP: {extracted_name} not found")
|
|
||||||
continue
|
|
||||||
|
|
||||||
existing_text = existing_path.read_text(encoding="utf-8")
|
|
||||||
extracted_text = extracted_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
existing_norm = normalize(existing_text)
|
|
||||||
extracted_norm = normalize(extracted_text)
|
|
||||||
|
|
||||||
# Stats
|
|
||||||
existing_chars = len(existing_text)
|
|
||||||
extracted_chars = len(extracted_text)
|
|
||||||
existing_words = len(existing_text.split())
|
|
||||||
extracted_words = len(extracted_text.split())
|
|
||||||
|
|
||||||
# Similarity
|
|
||||||
overlap = word_overlap(existing_norm, extracted_norm)
|
|
||||||
|
|
||||||
# Sequence matcher ratio (slower but more accurate)
|
|
||||||
# Use first 5000 chars for speed
|
|
||||||
sm = difflib.SequenceMatcher(None, existing_norm[:5000], extracted_norm[:5000])
|
|
||||||
seq_ratio = sm.ratio()
|
|
||||||
|
|
||||||
# Find lines in extracted but not in existing (new content)
|
|
||||||
existing_lines = set(existing_norm.split("\n"))
|
|
||||||
extracted_lines = set(extracted_norm.split("\n"))
|
|
||||||
new_lines = extracted_lines - existing_lines
|
|
||||||
missing_lines = existing_lines - extracted_lines
|
|
||||||
|
|
||||||
print(f"{'=' * 70}")
|
|
||||||
print(f" {label}")
|
|
||||||
print(f" Existing: {existing_name}")
|
|
||||||
print(f" Extracted: {extracted_name}")
|
|
||||||
print(f"{'=' * 70}")
|
|
||||||
print(f" {'Metric':<30} {'Existing MD':>15} {'Fresh PDF':>15} {'Diff':>10}")
|
|
||||||
print(f" {'-' * 70}")
|
|
||||||
print(f" {'Characters':<30} {existing_chars:>15,} {extracted_chars:>15,} {extracted_chars - existing_chars:>+10,}")
|
|
||||||
print(f" {'Words':<30} {existing_words:>15,} {extracted_words:>15,} {extracted_words - existing_words:>+10,}")
|
|
||||||
print(f" {'Lines':<30} {len(existing_lines):>15,} {len(extracted_lines):>15,} {len(extracted_lines) - len(existing_lines):>+10,}")
|
|
||||||
print(f" {'Word overlap':<30} {overlap:>15.1%}")
|
|
||||||
print(f" {'Sequence similarity':<30} {seq_ratio:>15.1%}")
|
|
||||||
print(f" {'Lines only in fresh PDF':<30} {len(new_lines):>15}")
|
|
||||||
print(f" {'Lines only in existing MD':<30} {len(missing_lines):>15}")
|
|
||||||
|
|
||||||
# Show sample differences
|
|
||||||
if new_lines:
|
|
||||||
print(f"\n Sample lines ONLY in fresh extraction (first 3):")
|
|
||||||
for line in sorted(new_lines)[:3]:
|
|
||||||
print(f" + {line[:100]}")
|
|
||||||
if missing_lines:
|
|
||||||
print(f"\n Sample lines ONLY in existing MD (first 3):")
|
|
||||||
for line in sorted(missing_lines)[:3]:
|
|
||||||
print(f" - {line[:100]}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
summary.append({
|
|
||||||
"label": label,
|
|
||||||
"existing_words": existing_words,
|
|
||||||
"extracted_words": extracted_words,
|
|
||||||
"word_overlap": overlap,
|
|
||||||
"seq_similarity": seq_ratio,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Summary table
|
|
||||||
print(f"\n{'=' * 70}")
|
|
||||||
print("SUMMARY")
|
|
||||||
print(f"{'=' * 70}")
|
|
||||||
print(f" {'Document':<25} {'Existing':>10} {'Fresh':>10} {'Overlap':>10} {'Similarity':>12}")
|
|
||||||
print(f" {'-' * 67}")
|
|
||||||
for s in summary:
|
|
||||||
print(f" {s['label']:<25} {s['existing_words']:>10,} {s['extracted_words']:>10,} {s['word_overlap']:>10.1%} {s['seq_similarity']:>12.1%}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Decompose final decisions into 12-block structure — V2 calibrated on הכט.
|
|
||||||
|
|
||||||
Key insight: DOCX extraction strips header blocks (א-ד). The real content
|
|
||||||
starts at block ה (opening "לפנינו"). We identify blocks by known section
|
|
||||||
headers and line-by-line analysis.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "mcp-server" / "src"))
|
|
||||||
|
|
||||||
from legal_mcp.services.db import get_pool, init_schema, close_pool
|
|
||||||
|
|
||||||
|
|
||||||
BLOCK_DEFS = [
|
|
||||||
("block-alef", 1, "כותרת מוסדית", "template-fill"),
|
|
||||||
("block-bet", 2, "הרכב הוועדה", "template-fill"),
|
|
||||||
("block-gimel", 3, "צדדים", "template-fill"),
|
|
||||||
("block-dalet", 4, "כותרת החלטה", "template-fill"),
|
|
||||||
("block-he", 5, "פתיחה", "paraphrase"),
|
|
||||||
("block-vav", 6, "רקע עובדתי", "reproduction"),
|
|
||||||
("block-zayin", 7, "טענות הצדדים", "paraphrase"),
|
|
||||||
("block-chet", 8, "הליכים בפני ועדת הערר", "reproduction"),
|
|
||||||
("block-tet", 9, "תכניות חלות", "guided-synthesis"),
|
|
||||||
("block-yod", 10, "דיון והכרעה", "rhetorical-construction"),
|
|
||||||
("block-yod-alef", 11, "סיכום", "paraphrase"),
|
|
||||||
("block-yod-bet", 12, "חתימות", "template-fill"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def find_line(lines: list[str], pattern: str, start: int = 0) -> int:
|
|
||||||
"""Find first line matching pattern (substring or regex). Returns -1 if not found."""
|
|
||||||
pat = re.compile(pattern)
|
|
||||||
for i in range(start, len(lines)):
|
|
||||||
if pat.search(lines[i]):
|
|
||||||
return i
|
|
||||||
return -1
|
|
||||||
|
|
||||||
|
|
||||||
def slice_text(lines: list[str], start: int, end: int) -> str:
|
|
||||||
"""Join lines[start:end] into text."""
|
|
||||||
if start < 0 or end <= start:
|
|
||||||
return ""
|
|
||||||
return "\n".join(lines[start:end]).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def count_words(text: str) -> int:
|
|
||||||
return len(text.split()) if text else 0
|
|
||||||
|
|
||||||
|
|
||||||
def decompose(text: str) -> dict[str, str]:
|
|
||||||
"""Parse decision into blocks. Returns {block_id: content}."""
|
|
||||||
lines = text.split("\n")
|
|
||||||
n = len(lines)
|
|
||||||
blocks = {}
|
|
||||||
|
|
||||||
# Find key section headers
|
|
||||||
# Style 1: רישוי — descriptive headers ("תמצית טענות הצדדים", "דיון והכרעה")
|
|
||||||
# Style 2: היטל השבחה — numbered headers ("א. רקע עובדתי", "ו. דיון והכרעה")
|
|
||||||
opening = find_line(lines, r"^לפנינו\s|^בפנינו\s|^בפני\s*ועדת|^בפני\s*בקשה")
|
|
||||||
|
|
||||||
claims = find_line(lines, r"תמצית\s*טענות|טענות\s*הצדדים|טענות\s*העוררי")
|
|
||||||
if claims == -1:
|
|
||||||
claims = find_line(lines, r"^טענות\s*העוררי")
|
|
||||||
if claims == -1:
|
|
||||||
# היטל השבחה style: "ב. טענות העורר"
|
|
||||||
claims = find_line(lines, r"^[א-ת][\.\)]\s*טענות")
|
|
||||||
|
|
||||||
background = find_line(lines, r"^[א-ת][\.\)]\s*רקע\s*עובדתי")
|
|
||||||
|
|
||||||
proceedings = find_line(lines, r"ההליכים\s*בפני|הליכים\s*בפני|הדיון\s*בפני\s*ועדת\s*הערר")
|
|
||||||
if proceedings == -1:
|
|
||||||
# היטל השבחה: "ד. הבהרות השמאית" or similar procedural sections
|
|
||||||
proceedings = find_line(lines, r"^[א-ת][\.\)]\s*הבהרות|^[א-ת][\.\)]\s*ההליך")
|
|
||||||
|
|
||||||
plans = find_line(lines, r"תכניות\s*חלות|המסגרת\s*הנורמטיבית|הוראות\s*התכנית")
|
|
||||||
if plans == -1:
|
|
||||||
plans = find_line(lines, r"^[א-ת][\.\)]\s*המסגרת\s*הנורמטיבית")
|
|
||||||
|
|
||||||
discussion = find_line(lines, r"^דיון\s*והכרעה|^דיון$|^הכרעה$")
|
|
||||||
if discussion == -1:
|
|
||||||
discussion = find_line(lines, r"^[א-ת][\.\)]\s*דיון\s*והכרעה")
|
|
||||||
|
|
||||||
summary = find_line(lines, r"^סיכום\s*$|^סוף\s*דבר\s*$")
|
|
||||||
if summary == -1:
|
|
||||||
summary = find_line(lines, r"^[א-ת][\.\)]\s*סיכום")
|
|
||||||
signature = find_line(lines, r"^ניתנה?\s*(היום|פה\s*אחד|ביום)")
|
|
||||||
|
|
||||||
# If no explicit discussion header, look for the opening formula
|
|
||||||
if discussion == -1:
|
|
||||||
discussion = find_line(lines, r"לאחר\s*שבחנו\s*את\s*טענות")
|
|
||||||
|
|
||||||
# ── Header blocks (א-ד): everything before opening ──
|
|
||||||
if opening >= 0:
|
|
||||||
header_text = slice_text(lines, 0, opening)
|
|
||||||
if header_text:
|
|
||||||
# Try to split header, but usually DOCX extraction loses these
|
|
||||||
blocks["block-alef"] = header_text
|
|
||||||
else:
|
|
||||||
blocks["block-alef"] = ""
|
|
||||||
else:
|
|
||||||
blocks["block-alef"] = ""
|
|
||||||
|
|
||||||
blocks["block-bet"] = "" # Usually lost in extraction
|
|
||||||
blocks["block-gimel"] = ""
|
|
||||||
blocks["block-dalet"] = "החלטה"
|
|
||||||
|
|
||||||
# ── Block ה: Opening — first 1-3 paragraphs from "לפנינו" ──
|
|
||||||
if opening >= 0:
|
|
||||||
next_section = claims if claims > opening else discussion if discussion > opening else n
|
|
||||||
opening_end = opening + 1
|
|
||||||
for i in range(opening + 1, min(opening + 5, next_section)):
|
|
||||||
line = lines[i].strip()
|
|
||||||
if not line:
|
|
||||||
break
|
|
||||||
opening_end = i + 1
|
|
||||||
blocks["block-he"] = slice_text(lines, opening, opening_end)
|
|
||||||
else:
|
|
||||||
blocks["block-he"] = ""
|
|
||||||
|
|
||||||
# ── Block ו: Background ──
|
|
||||||
# Style 1 (רישוי): after opening, before claims
|
|
||||||
# Style 2 (היטל השבחה): explicit "א. רקע עובדתי" section
|
|
||||||
if background >= 0:
|
|
||||||
# Explicit background header (היטל השבחה style)
|
|
||||||
bg_end = claims if claims > background else (proceedings if proceedings > background else (discussion if discussion > background else n))
|
|
||||||
blocks["block-vav"] = slice_text(lines, background, bg_end)
|
|
||||||
# In this case, opening (ה) might not exist — "לפנינו" may be absent
|
|
||||||
elif opening >= 0 and claims > opening:
|
|
||||||
bg_start = opening + 1
|
|
||||||
he_lines = count_words(blocks.get("block-he", ""))
|
|
||||||
if he_lines > 0:
|
|
||||||
he_end = opening
|
|
||||||
for i in range(opening, min(opening + 5, claims)):
|
|
||||||
if lines[i].strip():
|
|
||||||
he_end = i + 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
bg_start = he_end
|
|
||||||
blocks["block-vav"] = slice_text(lines, bg_start, claims)
|
|
||||||
elif opening >= 0 and discussion > opening:
|
|
||||||
blocks["block-vav"] = slice_text(lines, opening + 1, discussion)
|
|
||||||
else:
|
|
||||||
blocks["block-vav"] = ""
|
|
||||||
|
|
||||||
# ── Block ז: Claims — from claims header to next section ──
|
|
||||||
if claims >= 0:
|
|
||||||
claims_end = min(
|
|
||||||
x for x in [proceedings, plans, discussion, summary, n]
|
|
||||||
if x > claims
|
|
||||||
)
|
|
||||||
blocks["block-zayin"] = slice_text(lines, claims, claims_end)
|
|
||||||
else:
|
|
||||||
blocks["block-zayin"] = ""
|
|
||||||
|
|
||||||
# ── Block ח: Proceedings (optional) ──
|
|
||||||
if proceedings >= 0:
|
|
||||||
proc_end = min(
|
|
||||||
x for x in [plans, discussion, summary, n]
|
|
||||||
if x > proceedings
|
|
||||||
)
|
|
||||||
blocks["block-chet"] = slice_text(lines, proceedings, proc_end)
|
|
||||||
else:
|
|
||||||
blocks["block-chet"] = ""
|
|
||||||
|
|
||||||
# ── Block ט: Plans (optional) ──
|
|
||||||
if plans >= 0 and (discussion == -1 or plans < discussion):
|
|
||||||
plans_end = min(
|
|
||||||
x for x in [discussion, summary, n]
|
|
||||||
if x > plans
|
|
||||||
)
|
|
||||||
blocks["block-tet"] = slice_text(lines, plans, plans_end)
|
|
||||||
else:
|
|
||||||
blocks["block-tet"] = ""
|
|
||||||
|
|
||||||
# ── Block י: Discussion ──
|
|
||||||
if discussion >= 0:
|
|
||||||
disc_end = summary if summary > discussion else (signature if signature > discussion else n)
|
|
||||||
blocks["block-yod"] = slice_text(lines, discussion, disc_end)
|
|
||||||
else:
|
|
||||||
blocks["block-yod"] = ""
|
|
||||||
|
|
||||||
# ── Block יא: Summary ──
|
|
||||||
if summary >= 0:
|
|
||||||
summ_end = signature if signature > summary else n
|
|
||||||
blocks["block-yod-alef"] = slice_text(lines, summary, summ_end)
|
|
||||||
else:
|
|
||||||
blocks["block-yod-alef"] = ""
|
|
||||||
|
|
||||||
# ── Block יב: Signatures ──
|
|
||||||
if signature >= 0:
|
|
||||||
blocks["block-yod-bet"] = slice_text(lines, signature, n)
|
|
||||||
else:
|
|
||||||
blocks["block-yod-bet"] = ""
|
|
||||||
|
|
||||||
return blocks
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
await init_schema()
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
decisions = await conn.fetch(
|
|
||||||
"""SELECT d.id as decision_id, c.case_number, c.title,
|
|
||||||
doc.extracted_text
|
|
||||||
FROM decisions d
|
|
||||||
JOIN cases c ON c.id = d.case_id
|
|
||||||
JOIN documents doc ON doc.case_id = d.case_id AND doc.doc_type = 'decision'
|
|
||||||
WHERE d.status = 'final'
|
|
||||||
ORDER BY c.case_number"""
|
|
||||||
)
|
|
||||||
|
|
||||||
for dec in decisions:
|
|
||||||
decision_id = dec["decision_id"]
|
|
||||||
case_number = dec["case_number"]
|
|
||||||
text = dec["extracted_text"]
|
|
||||||
total_words = count_words(text)
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"מפרק: {case_number} — {dec['title']}")
|
|
||||||
print(f"סה\"כ מילים: {total_words}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
parsed = decompose(text)
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Delete existing blocks
|
|
||||||
await conn.execute(
|
|
||||||
"DELETE FROM decision_blocks WHERE decision_id = $1", decision_id
|
|
||||||
)
|
|
||||||
|
|
||||||
total_parsed_words = 0
|
|
||||||
for block_id, block_index, title, gen_type in BLOCK_DEFS:
|
|
||||||
content = parsed.get(block_id, "")
|
|
||||||
wc = count_words(content)
|
|
||||||
weight = round(wc / total_words * 100, 1) if total_words > 0 and wc > 0 else 0
|
|
||||||
status = "final" if wc > 0 else "empty"
|
|
||||||
total_parsed_words += wc
|
|
||||||
|
|
||||||
await conn.execute(
|
|
||||||
"""INSERT INTO decision_blocks
|
|
||||||
(decision_id, block_id, block_index, title, content,
|
|
||||||
word_count, weight_percent, generation_type, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)""",
|
|
||||||
decision_id, block_id, block_index, title,
|
|
||||||
content, wc, weight, gen_type, status,
|
|
||||||
)
|
|
||||||
|
|
||||||
marker = "✅" if wc > 0 else "⬜"
|
|
||||||
print(f" {marker} {block_id:18s} | {title:25s} | {wc:5d} מילים | {weight:5.1f}%")
|
|
||||||
|
|
||||||
# Update decision totals
|
|
||||||
disc_words = count_words(parsed.get("block-yod", ""))
|
|
||||||
disc_paras = len([p for p in parsed.get("block-yod", "").split("\n") if p.strip() and len(p.strip()) > 20])
|
|
||||||
await conn.execute(
|
|
||||||
"UPDATE decisions SET total_words = $1, total_paragraphs = $2, updated_at = now() WHERE id = $3",
|
|
||||||
total_words, disc_paras, decision_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
coverage = round(total_parsed_words / total_words * 100, 1) if total_words > 0 else 0
|
|
||||||
print(f" --- כיסוי: {total_parsed_words}/{total_words} מילים ({coverage}%)")
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
stats = await conn.fetch(
|
|
||||||
"""SELECT block_id, count(*) as decisions,
|
|
||||||
avg(word_count) as avg_words,
|
|
||||||
avg(weight_percent) as avg_weight
|
|
||||||
FROM decision_blocks
|
|
||||||
WHERE word_count > 0
|
|
||||||
GROUP BY block_id ORDER BY block_id"""
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print("סטטיסטיקה לפי בלוק (רק בלוקים עם תוכן):")
|
|
||||||
for s in stats:
|
|
||||||
print(f" {s['block_id']:18s} | {s['decisions']} החלטות | ממוצע {s['avg_words']:.0f} מילים | {s['avg_weight']:.1f}%")
|
|
||||||
|
|
||||||
await close_pool()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Extract text from PDF using Google Cloud Vision API."""
|
|
||||||
|
|
||||||
import io
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import fitz # PyMuPDF for rendering pages to images
|
|
||||||
from google.cloud import vision
|
|
||||||
|
|
||||||
API_KEY = "AIzaSyDZgUsxsy_FHkkREU7R_oQLJALU3_V26j8"
|
|
||||||
|
|
||||||
PDF_PATH = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/originals/מרק קובר-כתב ערר.pdf")
|
|
||||||
OUTPUT_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/extracted")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
client = vision.ImageAnnotatorClient(
|
|
||||||
client_options={"api_key": API_KEY}
|
|
||||||
)
|
|
||||||
|
|
||||||
doc = fitz.open(str(PDF_PATH))
|
|
||||||
page_count = len(doc)
|
|
||||||
print(f"Processing: {PDF_PATH.name} ({page_count} pages)\n")
|
|
||||||
|
|
||||||
pages_text = []
|
|
||||||
total_time = 0.0
|
|
||||||
|
|
||||||
for i in range(page_count):
|
|
||||||
page = doc[i]
|
|
||||||
pix = page.get_pixmap(dpi=300)
|
|
||||||
img_bytes = pix.tobytes("png")
|
|
||||||
|
|
||||||
image = vision.Image(content=img_bytes)
|
|
||||||
|
|
||||||
print(f" Page {i+1}/{page_count}...", end=" ", flush=True)
|
|
||||||
t0 = time.time()
|
|
||||||
response = client.document_text_detection(
|
|
||||||
image=image,
|
|
||||||
image_context={"language_hints": ["he"]}
|
|
||||||
)
|
|
||||||
elapsed = time.time() - t0
|
|
||||||
total_time += elapsed
|
|
||||||
|
|
||||||
if response.error.message:
|
|
||||||
print(f"ERROR: {response.error.message}")
|
|
||||||
pages_text.append("")
|
|
||||||
continue
|
|
||||||
|
|
||||||
text = response.full_text_annotation.text if response.full_text_annotation else ""
|
|
||||||
pages_text.append(text)
|
|
||||||
print(f"{len(text):,} chars, {elapsed:.1f}s")
|
|
||||||
|
|
||||||
doc.close()
|
|
||||||
|
|
||||||
full_text = "\n\n".join(pages_text)
|
|
||||||
out_file = OUTPUT_DIR / f"{PDF_PATH.stem}.md"
|
|
||||||
out_file.write_text(full_text, encoding="utf-8")
|
|
||||||
|
|
||||||
print(f"\nTotal: {len(full_text):,} chars, {len(full_text.split()):,} words, {total_time:.1f}s")
|
|
||||||
print(f"Saved: {out_file}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Extract text from a single PDF using Google Cloud Vision API."""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import fitz
|
|
||||||
from google.cloud import vision
|
|
||||||
|
|
||||||
API_KEY = "AIzaSyDZgUsxsy_FHkkREU7R_oQLJALU3_V26j8"
|
|
||||||
OUTPUT_DIR = Path("/home/chaim/legal-ai/data/cases/1130-25/documents/extracted")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
pdf_path = Path(sys.argv[1])
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
client = vision.ImageAnnotatorClient(client_options={"api_key": API_KEY})
|
|
||||||
doc = fitz.open(str(pdf_path))
|
|
||||||
page_count = len(doc)
|
|
||||||
print(f"Processing: {pdf_path.name} ({page_count} pages)\n")
|
|
||||||
|
|
||||||
pages_text = []
|
|
||||||
total_time = 0.0
|
|
||||||
|
|
||||||
for i in range(page_count):
|
|
||||||
page = doc[i]
|
|
||||||
pix = page.get_pixmap(dpi=300)
|
|
||||||
img_bytes = pix.tobytes("png")
|
|
||||||
image = vision.Image(content=img_bytes)
|
|
||||||
|
|
||||||
print(f" Page {i+1}/{page_count}...", end=" ", flush=True)
|
|
||||||
t0 = time.time()
|
|
||||||
response = client.document_text_detection(image=image, image_context={"language_hints": ["he"]})
|
|
||||||
elapsed = time.time() - t0
|
|
||||||
total_time += elapsed
|
|
||||||
|
|
||||||
if response.error.message:
|
|
||||||
print(f"ERROR: {response.error.message}")
|
|
||||||
pages_text.append("")
|
|
||||||
continue
|
|
||||||
|
|
||||||
text = response.full_text_annotation.text if response.full_text_annotation else ""
|
|
||||||
pages_text.append(text)
|
|
||||||
print(f"{len(text):,} chars, {elapsed:.1f}s")
|
|
||||||
|
|
||||||
doc.close()
|
|
||||||
full_text = "\n\n".join(pages_text)
|
|
||||||
out_file = OUTPUT_DIR / f"{pdf_path.stem}.md"
|
|
||||||
out_file.write_text(full_text, encoding="utf-8")
|
|
||||||
print(f"\nTotal: {len(full_text):,} chars, {len(full_text.split()):,} words, {total_time:.1f}s")
|
|
||||||
print(f"Saved: {out_file}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Import 6 final signed decisions: extract text, store in DB."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from datetime import date
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "mcp-server" / "src"))
|
|
||||||
|
|
||||||
import fitz # PyMuPDF
|
|
||||||
from docx import Document as DocxDocument
|
|
||||||
|
|
||||||
from legal_mcp.services.db import get_pool, init_schema, close_pool
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
# 6 Final Decisions
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
FINAL_DECISIONS = [
|
|
||||||
{
|
|
||||||
"case_number": "1180-1181",
|
|
||||||
"file_path": "legacy/dafna-tamir/04_Archive/ערר 1180-1181 הכט/החלטה/הכט 1180-1181.pdf",
|
|
||||||
"title": "החלטה סופית — הכט 1180-1181",
|
|
||||||
"outcome": "rejected",
|
|
||||||
"decision_date": date(2026, 2, 5),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"case_number": "8255-25",
|
|
||||||
"file_path": "legacy/dafna-tamir/04_Archive/בל\"מ 8255-25 אפרים אבי נ' הוועדה המקומית לתכנון ובניה/החלטה/אליהו הרנון - להפצה.docx",
|
|
||||||
"title": "החלטה סופית — אפרים אבי 8255-25",
|
|
||||||
"outcome": "rejected",
|
|
||||||
"decision_date": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"case_number": "8007-24",
|
|
||||||
"file_path": "legacy/dafna-tamir/04_Archive/ערר 8007-24-עומר דרוויש-ערר על שומה מכרעת/החלטה/החלטה-סופית.docx",
|
|
||||||
"title": "החלטה סופית — עומר דרוויש 8007-24",
|
|
||||||
"outcome": "",
|
|
||||||
"decision_date": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"case_number": "1113/25",
|
|
||||||
"file_path": "legacy/dafna-tamir/04_Archive/ערר-1113-25-אייל-מבורך/החלטה/החלטה-1113-25-טיוטה-סופית.docx",
|
|
||||||
"title": "החלטה סופית — מבורך 1113-25",
|
|
||||||
"outcome": "",
|
|
||||||
"decision_date": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"case_number": "1126/25+1141/25",
|
|
||||||
"file_path": "legacy/dafna-tamir/04_Archive/ערר-1126-25-תמא-38-בית-הכרם/החלטה/בית הכרם-טיוטת החלטה-9.pdf",
|
|
||||||
"title": "החלטה סופית — בית הכרם 1126/25",
|
|
||||||
"outcome": "partial",
|
|
||||||
"decision_date": date(2026, 3, 1),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"case_number": "1128/25",
|
|
||||||
"file_path": "legacy/dafna-tamir/04_Archive/ערר-1128-25-שטרית/החלטה/1128-25 החלטה להפצה.pdf",
|
|
||||||
"title": "החלטה סופית — שטרית 1128-25",
|
|
||||||
"outcome": "",
|
|
||||||
"decision_date": None,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
def extract_pdf_text(file_path: Path) -> str:
|
|
||||||
"""Extract text from PDF using PyMuPDF."""
|
|
||||||
doc = fitz.open(str(file_path))
|
|
||||||
text_parts = []
|
|
||||||
for page in doc:
|
|
||||||
text_parts.append(page.get_text())
|
|
||||||
doc.close()
|
|
||||||
return "\n".join(text_parts)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_docx_text(file_path: Path) -> str:
|
|
||||||
"""Extract text from DOCX."""
|
|
||||||
doc = DocxDocument(str(file_path))
|
|
||||||
return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def extract_text(file_path: Path) -> str:
|
|
||||||
"""Extract text based on file extension."""
|
|
||||||
suffix = file_path.suffix.lower()
|
|
||||||
if suffix == ".pdf":
|
|
||||||
return extract_pdf_text(file_path)
|
|
||||||
elif suffix == ".docx":
|
|
||||||
return extract_docx_text(file_path)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported format: {suffix}")
|
|
||||||
|
|
||||||
|
|
||||||
def count_words(text: str) -> int:
|
|
||||||
return len(text.split())
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
await init_schema()
|
|
||||||
pool = await get_pool()
|
|
||||||
|
|
||||||
for d in FINAL_DECISIONS:
|
|
||||||
file_path = PROJECT_ROOT / d["file_path"]
|
|
||||||
if not file_path.exists():
|
|
||||||
print(f"❌ קובץ לא נמצא: {file_path}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract text
|
|
||||||
print(f"\nמחלץ טקסט: {d['title']}...")
|
|
||||||
text = extract_text(file_path)
|
|
||||||
word_count = count_words(text)
|
|
||||||
print(f" {word_count} מילים, {len(text)} תווים")
|
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
# Get case_id
|
|
||||||
case_id = await conn.fetchval(
|
|
||||||
"SELECT id FROM cases WHERE case_number = $1", d["case_number"]
|
|
||||||
)
|
|
||||||
if not case_id:
|
|
||||||
print(f" ⚠ תיק {d['case_number']} לא נמצא ב-DB — מדלג")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Register document
|
|
||||||
existing_doc = await conn.fetchval(
|
|
||||||
"SELECT id FROM documents WHERE file_path = $1",
|
|
||||||
str(file_path),
|
|
||||||
)
|
|
||||||
if existing_doc:
|
|
||||||
doc_id = existing_doc
|
|
||||||
print(f" מסמך כבר קיים ב-DB: {doc_id}")
|
|
||||||
# Update text
|
|
||||||
await conn.execute(
|
|
||||||
"""UPDATE documents SET extracted_text = $1, extraction_status = 'completed'
|
|
||||||
WHERE id = $2""",
|
|
||||||
text, doc_id,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
doc_id = await conn.fetchval(
|
|
||||||
"""INSERT INTO documents (case_id, doc_type, title, file_path, extracted_text, extraction_status, page_count)
|
|
||||||
VALUES ($1, 'decision', $2, $3, $4, 'completed', $5)
|
|
||||||
RETURNING id""",
|
|
||||||
case_id, d["title"], str(file_path), text,
|
|
||||||
len(fitz.open(str(file_path))) if file_path.suffix == ".pdf" else None,
|
|
||||||
)
|
|
||||||
print(f" מסמך נרשם: {doc_id}")
|
|
||||||
|
|
||||||
# Create/update decision record
|
|
||||||
existing_decision = await conn.fetchval(
|
|
||||||
"SELECT id FROM decisions WHERE case_id = $1", case_id
|
|
||||||
)
|
|
||||||
if existing_decision:
|
|
||||||
await conn.execute(
|
|
||||||
"""UPDATE decisions SET status = 'final', outcome = $1, total_words = $2,
|
|
||||||
decision_date = $3, updated_at = now() WHERE id = $4""",
|
|
||||||
d["outcome"], word_count, d["decision_date"], existing_decision,
|
|
||||||
)
|
|
||||||
decision_id = existing_decision
|
|
||||||
print(f" החלטה עודכנה: {decision_id}")
|
|
||||||
else:
|
|
||||||
decision_id = await conn.fetchval(
|
|
||||||
"""INSERT INTO decisions (case_id, version, status, outcome, outcome_summary,
|
|
||||||
total_words, decision_date, author)
|
|
||||||
VALUES ($1, 1, 'final', $2, $3, $4, $5, 'דפנה תמיר')
|
|
||||||
RETURNING id""",
|
|
||||||
case_id, d["outcome"], d["title"], word_count, d["decision_date"],
|
|
||||||
)
|
|
||||||
print(f" החלטה נוצרה: {decision_id}")
|
|
||||||
|
|
||||||
# Update case status
|
|
||||||
await conn.execute(
|
|
||||||
"UPDATE cases SET status = 'final', expected_outcome = $1, updated_at = now() WHERE id = $2",
|
|
||||||
d["outcome"], case_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f" ✅ הושלם: {d['case_number']}")
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
doc_count = await conn.fetchval(
|
|
||||||
"SELECT count(*) FROM documents WHERE doc_type = 'decision' AND extraction_status = 'completed'"
|
|
||||||
)
|
|
||||||
dec_count = await conn.fetchval(
|
|
||||||
"SELECT count(*) FROM decisions WHERE status = 'final'"
|
|
||||||
)
|
|
||||||
total_words = await conn.fetchval(
|
|
||||||
"SELECT sum(total_words) FROM decisions WHERE status = 'final'"
|
|
||||||
)
|
|
||||||
|
|
||||||
await close_pool()
|
|
||||||
|
|
||||||
print(f"\n{'='*50}")
|
|
||||||
print(f"✅ סה\"כ מסמכי החלטה: {doc_count}")
|
|
||||||
print(f"✅ סה\"כ החלטות סופיות: {dec_count}")
|
|
||||||
print(f"✅ סה\"כ מילים: {total_words:,}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -15,7 +15,7 @@ SMTP_HOST = "smtp.gmail.com"
|
|||||||
SMTP_PORT = 587
|
SMTP_PORT = 587
|
||||||
FROM_EMAIL = "notify@marcus-law.co.il"
|
FROM_EMAIL = "notify@marcus-law.co.il"
|
||||||
FROM_PASS = "vuva jwed lbuz xjds"
|
FROM_PASS = "vuva jwed lbuz xjds"
|
||||||
TO_EMAIL = "paperclip+chaim@marcus-law.co.il"
|
TO_EMAIL = "chaim+paperclip@marcus-law.co.il"
|
||||||
|
|
||||||
|
|
||||||
def send(subject: str, body: str) -> bool:
|
def send(subject: str, body: str) -> bool:
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Test semantic search functions."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "mcp-server" / "src"))
|
|
||||||
|
|
||||||
from legal_mcp.services.db import search_similar_paragraphs, search_similar_case_law, search_precedents, init_schema
|
|
||||||
from legal_mcp.services.embeddings import embed_query
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
await init_schema()
|
|
||||||
|
|
||||||
queries = [
|
|
||||||
"טענות קנייניות רוב דרוש בעלי דירות רכוש משותף",
|
|
||||||
"חניה תנועה חניות מצוקת חניה",
|
|
||||||
"היטל השבחה שמאי מכריע התערבות",
|
|
||||||
]
|
|
||||||
|
|
||||||
for query in queries:
|
|
||||||
print(f'=== שאילתה: "{query}" ===')
|
|
||||||
emb = await embed_query(query)
|
|
||||||
results = await search_precedents(emb, limit=3)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
print(" אין תוצאות")
|
|
||||||
else:
|
|
||||||
for i, r in enumerate(results):
|
|
||||||
score = r["score"]
|
|
||||||
cn = r["case_number"]
|
|
||||||
rtype = r["type"]
|
|
||||||
content = r["content"][:120].replace("\n", " ")
|
|
||||||
print(f" {i+1}. [{rtype}] {score:.3f} | {cn} | {content}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,574 +0,0 @@
|
|||||||
# Block Schema — ארכיטקטורת מסמך החלטת ועדת ערר
|
|
||||||
|
|
||||||
מסמך זה מגדיר את המבנה הפורמלי של החלטת ועדת ערר לתכנון ובניה. הוא משמש כמקור סמכותי להגדרת בלוקים, משקלות, פרמטרי עיבוד, וכללי ולידציה.
|
|
||||||
|
|
||||||
**הפניה:** SKILL.md סעיפים 11-12 מכילים סיכום מהיר והנחיות תהליך. מסמך זה מכיל את ההגדרות המלאות.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. יסודות תיאורטיים
|
|
||||||
|
|
||||||
ארכיטקטורת המסמך מבוססת על שילוב של ארבעה frameworks מוכרים:
|
|
||||||
|
|
||||||
### CREAC — מתודולוגיית כתיבה משפטית
|
|
||||||
Conclusion → Rule → Explanation → Application → Conclusion.
|
|
||||||
מקור: Columbia Law School, Legal Writing methodology.
|
|
||||||
**מיפוי:** חל על בלוק י (דיון) ובלוק יא (סיכום). בלוק י פותח במסקנה (C), מציג כלל משפטי (R), מסביר באמצעות פסיקה (E), מיישם על העובדות (A), וחוזר למסקנה (C). בלוק יא = C אחרון בלבד.
|
|
||||||
|
|
||||||
### Federal Judicial Center — Judicial Writing Manual
|
|
||||||
מגדיר תפקוד פונקציונלי לכל חלק בהחלטה שיפוטית:
|
|
||||||
- **Orientation** (אוריינטציה) — מי, מה, איפה → בלוקים א-ה
|
|
||||||
- **Framing** (מסגור) — הקשר עובדתי ותכנוני → בלוק ו
|
|
||||||
- **Argumentation** (טיעון) — עמדות הצדדים → בלוק ז
|
|
||||||
- **Procedural record** (תיעוד הליכי) — מה עשינו → בלוק ח
|
|
||||||
- **Deliberation** (דיון) — ניתוח משפטי → בלוקים ט-י
|
|
||||||
- **Disposition** (החלטה) — תוצאה אופרטיבית → בלוק יא
|
|
||||||
|
|
||||||
### DITA — Darwin Information Typing Architecture
|
|
||||||
סטנדרט OASIS להגדרת סוגי תוכן מובנים. מספק:
|
|
||||||
- **Content model** — אילו אלמנטים מותרים בכל בלוק
|
|
||||||
- **Constraints** — מה אסור (חשוב יותר ממה שמותר)
|
|
||||||
- **Specialization** — ירושה מסוג בסיסי עם התאמות
|
|
||||||
- **Relationships** — תלויות בין בלוקים
|
|
||||||
|
|
||||||
### Akoma Ntoso / LegalDocumentML
|
|
||||||
סטנדרט OASIS בינלאומי למסמכים משפטיים מובנים (UN/DESA). מספק:
|
|
||||||
- **Semantic mapping** — כל בלוק ממופה לרכיב מוכר בסטנדרט
|
|
||||||
- **Document class** — "judgment" (פסק דין / החלטה)
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. הגדרות בלוקים
|
|
||||||
|
|
||||||
### Block א: כותרת מוסדית / Institutional Header
|
|
||||||
|
|
||||||
**ID:** `block-alef`
|
|
||||||
**Akoma Ntoso:** `meta > identification`
|
|
||||||
**CREAC role:** none
|
|
||||||
**Functional purpose (JWM):** Orientation — מזהה את המוסד, התיק והגורם המחליט.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: template-field
|
|
||||||
- Elements: טבלה 2 טורים (מוסד | מספרי תיק)
|
|
||||||
- Sources: מערכת ניהול תיקים
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: שם מוסד, מספר תיק, מספר תכנית/בקשה
|
|
||||||
- MUST NOT: תוכן מהותי כלשהו
|
|
||||||
- Dependencies: none
|
|
||||||
|
|
||||||
**Weight:** 1% (קבוע, לא משתנה בין סוגי עררים)
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: template-fill
|
|
||||||
- Temperature: 0 | Thinking: off | Effort: min | Model: script
|
|
||||||
|
|
||||||
|
|
||||||
### Block ב: הרכב הוועדה / Panel Composition
|
|
||||||
|
|
||||||
**ID:** `block-bet`
|
|
||||||
**Akoma Ntoso:** `meta > references > TLCPerson`
|
|
||||||
**CREAC role:** none
|
|
||||||
**Functional purpose (JWM):** Orientation — מזהה את ההרכב המחליט. חשוב לביקורת שיפוטית (הרכב כשיר).
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: template-field
|
|
||||||
- Elements: "בפני:" + יו"ר + חברים
|
|
||||||
- Sources: מערכת ניהול
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: יו"ר + לפחות חבר אחד
|
|
||||||
- MUST NOT: תוכן מהותי
|
|
||||||
- Dependencies: none
|
|
||||||
|
|
||||||
**Weight:** 1% (קבוע)
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: template-fill
|
|
||||||
- Temperature: 0 | Thinking: off | Effort: min | Model: script
|
|
||||||
|
|
||||||
|
|
||||||
### Block ג: צדדים / Parties
|
|
||||||
|
|
||||||
**ID:** `block-gimel`
|
|
||||||
**Akoma Ntoso:** `meta > references > TLCPerson` (appellants, respondents)
|
|
||||||
**CREAC role:** none
|
|
||||||
**Functional purpose (JWM):** Orientation — מזהה את הצדדים וב"כ. מגדיר את מסגרת הדיון.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: template-field
|
|
||||||
- Elements: עוררים + "נגד" + משיבים + ב"כ
|
|
||||||
- Sources: כתב ערר, כתב תשובה
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: שם כל צד, "נגד" כמפריד
|
|
||||||
- MUST NOT: תוכן מהותי, תיאור הערר
|
|
||||||
- Dependencies: none
|
|
||||||
|
|
||||||
**Weight:** 1% (קבוע)
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: template-fill
|
|
||||||
- Temperature: 0 | Thinking: off | Effort: min | Model: script
|
|
||||||
|
|
||||||
|
|
||||||
### Block ד: כותרת "החלטה" / Decision Title
|
|
||||||
|
|
||||||
**ID:** `block-dalet`
|
|
||||||
**Akoma Ntoso:** `body > judgment > header`
|
|
||||||
**CREAC role:** none
|
|
||||||
**Functional purpose (JWM):** Orientation — סימון פורמלי של תחילת ההחלטה.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: template-field
|
|
||||||
- Elements: מילה אחת: "החלטה"
|
|
||||||
- Sources: none
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: David 16pt, bold, מרכז
|
|
||||||
- Dependencies: none
|
|
||||||
|
|
||||||
**Weight:** 0% (שורה אחת)
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: template-fill
|
|
||||||
- Temperature: 0 | Thinking: off | Effort: min | Model: script
|
|
||||||
|
|
||||||
|
|
||||||
### Block ה: פתיחה / Opening
|
|
||||||
|
|
||||||
**ID:** `block-he`
|
|
||||||
**Akoma Ntoso:** `body > judgment > introduction`
|
|
||||||
**CREAC role:** C (מסקנה ראשונית — הצגת מה לפנינו)
|
|
||||||
**Functional purpose (JWM):** Orientation — מכוון את הקורא למהות הערר במשפט אחד. מגדיר "להלן" מרכזיים.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: narrative (1-2 סעיפים)
|
|
||||||
- Elements: numbered-para עם הגדרות "להלן"
|
|
||||||
- Sources: כתב ערר, החלטת ועדה מקומית
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: "לפנינו...", הגדרת הוועדה המקומית, הגדרת התכנית/הבקשה, הגדרת המגרש
|
|
||||||
- MUST NOT: ניתוח, ערכי שיפוט, ציטוטים מצדדים
|
|
||||||
- Dependencies: block-gimel (שמות צדדים להגדרות)
|
|
||||||
|
|
||||||
**Weight:** 1% (קבוע — 1-2 סעיפים)
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: paraphrase
|
|
||||||
- Temperature: 0.2 | Thinking: low | Effort: low | Model: sonnet
|
|
||||||
|
|
||||||
|
|
||||||
### Block ו: רקע עובדתי / Factual Background ("פתח דבר")
|
|
||||||
|
|
||||||
**ID:** `block-vav`
|
|
||||||
**Akoma Ntoso:** `body > judgment > background`
|
|
||||||
**CREAC role:** none (עובדות בלבד, לא ניתוח)
|
|
||||||
**Functional purpose (JWM):** Framing — מספק את התשתית העובדתית שעליה נבנה הדיון. השופט חייב להבין את המציאות בשטח לפני שקורא טענות.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: narrative, citation-block, image-placeholder
|
|
||||||
- Elements: numbered-para, blockquote (ציטוט מפרוטוקול), image-box
|
|
||||||
- Sources: כתבי טענות, תשריטים, פרוטוקולים, החלטות קודמות, GIS
|
|
||||||
|
|
||||||
**סדר תוכן פנימי:**
|
|
||||||
1. מקרקעין — מיקום, שטח, מאפיינים
|
|
||||||
2. סביבת מקרקעין — בנייה סמוכה, אופי
|
|
||||||
3. 📷 תמונה: מיקום GIS
|
|
||||||
4. היסטוריה תכנונית — תכניות, החלטות (עובדות יבשות בלבד)
|
|
||||||
5. מהות הבקשה/תכנית
|
|
||||||
6. 📷 תמונה: תשריט
|
|
||||||
7. ציטוט מפרוטוקול ועדה מקומית
|
|
||||||
8. החלטת הוועדה + תנאים
|
|
||||||
9. 📷 תמונה: צילום אוויר (אופציונלי)
|
|
||||||
10. הגשת הערר
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: מקרקעין, מהות הבקשה, החלטת הוועדה, הגשת הערר
|
|
||||||
- MUST: לפחות 2 תמונות (מיקום + תשריט)
|
|
||||||
- MUST: ציטוט מפרוטוקול הוועדה המקומית
|
|
||||||
- ⚠️ **MUST NOT ("רקע ניטרלי"):** ציטוטים ישירים מצדדים, מילות ערך/שיפוט ("חריג", "חטא", "בעייתי"). החלטות קודמות = עובדה יבשה ("ביום X נדחתה תכנית Y"), ללא נימוקים וציטוטים מהן.
|
|
||||||
- Dependencies: block-he (הגדרות "להלן")
|
|
||||||
|
|
||||||
**Weight:**
|
|
||||||
|
|
||||||
| סוג ערר | משקל | הערות |
|
|
||||||
|---------|------|-------|
|
|
||||||
| רישוי — דחייה | 15-25% | רקע מפורט עם הקשר תכנוני |
|
|
||||||
| רישוי — קבלה | 30-40% | כולל ציטוט מפרוטוקול |
|
|
||||||
| רישוי — קבלה חלקית | 25-35% | כולל ציטוט מפרוטוקול |
|
|
||||||
| היטל השבחה | 6-18% | רקע מצומצם |
|
|
||||||
|
|
||||||
**Weight methodology:**
|
|
||||||
- Communicative weight (40%): גבוה — מספק את "התמונה" לשופט שלא מכיר את התיק
|
|
||||||
- Reader attention (20%): בינוני-גבוה — primacy effect, הקורא קשוב בהתחלה
|
|
||||||
- Judicial review (25%): גבוה — שופט בודק שהעובדות מלאות ומדויקות
|
|
||||||
- Empirical (15%): מבוסס על מדידת החלטות דפנה (3.2 ב-SKILL.md)
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: reproduction (העתקה נאמנה ממקורות)
|
|
||||||
- Cognitive complexity: lookup (ארגון, לא ניתוח)
|
|
||||||
- Accuracy: high-precision
|
|
||||||
- Temperature: 0 | Thinking: off | Effort: low | Model: sonnet
|
|
||||||
|
|
||||||
|
|
||||||
### Block ז: טענות הצדדים / Parties' Claims
|
|
||||||
|
|
||||||
**ID:** `block-zayin`
|
|
||||||
**Akoma Ntoso:** `body > judgment > arguments`
|
|
||||||
**CREAC role:** none (הצגת טענות, לא ניתוח)
|
|
||||||
**Functional purpose (JWM):** Argumentation — מציג את עמדות הצדדים בנאמנות, כך שהקורא יבין את המחלוקת לפני שקורא את ההכרעה.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: narrative
|
|
||||||
- Elements: section-heading ("תמצית טענות הצדדים"), sub-headings (לכל צד), numbered-para
|
|
||||||
- Sources: כתב ערר, כתב תשובה — **כתבי טענות מקוריים בלבד** (לא השלמות טיעון)
|
|
||||||
|
|
||||||
**סדר קבוע:**
|
|
||||||
1. כותרת: "תמצית טענות הצדדים"
|
|
||||||
2. "טענות העוררים" (אם כמה עוררים — תתי-כותרות לכל אחד)
|
|
||||||
3. "עמדת הוועדה המקומית"
|
|
||||||
4. "עמדת מבקשי ההיתר" / "עמדת מגישי התכנית"
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: כל טענה בסעיף נפרד, גוף שלישי ("העורר טוען כי...")
|
|
||||||
- MUST: כל צד בפרק נפרד, סדר קבוע
|
|
||||||
- MUST NOT: ניתוח, מסקנות, הערכת הוועדה ("טענה זו חלשה...")
|
|
||||||
- MUST NOT: תוכן מהשלמות טיעון (→ block-chet)
|
|
||||||
- Dependencies: block-vav (מספור רציף)
|
|
||||||
|
|
||||||
**Weight:**
|
|
||||||
|
|
||||||
| סוג ערר | משקל | הערות |
|
|
||||||
|---------|------|-------|
|
|
||||||
| רישוי — דחייה | 30-40% | טענות מפורטות |
|
|
||||||
| רישוי — קבלה | 20-30% | כולל השלמות |
|
|
||||||
| רישוי — קבלה חלקית | 25-30% | |
|
|
||||||
| היטל השבחה | 13-25% | |
|
|
||||||
|
|
||||||
**Weight methodology:**
|
|
||||||
- Communicative weight (40%): בינוני — הצגה, לא הכרעה
|
|
||||||
- Reader attention (20%): נמוך-בינוני — scanning attention, הקורא מחפש טענות ספציפיות
|
|
||||||
- Judicial review (25%): גבוה — שופט בודק ש"נשמעו כל הצדדים"
|
|
||||||
- Empirical (15%): מבוסס על מדידת החלטות דפנה
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: paraphrase (סיכום נאמן בשפה של דפנה)
|
|
||||||
- Cognitive complexity: medium-synthesis (קיבוץ וסידור טענות)
|
|
||||||
- Accuracy: high-precision (לא לפספס טענה, לא לעוות)
|
|
||||||
- Temperature: 0.1 | Thinking: low | Effort: medium | Model: sonnet
|
|
||||||
|
|
||||||
|
|
||||||
### Block ח: הליכים בפני ועדת הערר / Proceedings
|
|
||||||
|
|
||||||
**ID:** `block-chet`
|
|
||||||
**Akoma Ntoso:** `body > judgment > proceedings` (custom extension)
|
|
||||||
**CREAC role:** none (תיעוד, לא ניתוח)
|
|
||||||
**Functional purpose (JWM):** Procedural record — מתעד שהוועדה פעלה כדין ונתנה מלוא יום בבית דין. קריטי ל"מבחן השופט" — שופט בעתמ"ם בודק שהצדדים קיבלו הזדמנות הוגנת.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: narrative, image-placeholder
|
|
||||||
- Elements: section-heading ("ההליכים בפני ועדת הערר"), numbered-para, image-box
|
|
||||||
- Sources: פרוטוקול דיון, תמונות סיור, החלטות ביניים, השלמות טיעון
|
|
||||||
|
|
||||||
**סדר כרונולוגי:**
|
|
||||||
1. דיון — תאריך, נוכחים
|
|
||||||
2. סיור — תאריך, תיאור
|
|
||||||
3. 📷 תמונה: צילומים מהסיור
|
|
||||||
4. השלמות טיעון — עם תוכן מפורט (כל השלמה = סעיף נפרד)
|
|
||||||
5. החלטות ביניים
|
|
||||||
6. תגובות לתגובות — כרונולוגי
|
|
||||||
7. 📷 תמונה: הדמיות/חתכים (אם צורפו)
|
|
||||||
8. עררים מקבילים (אם יש)
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: תאריכים מדויקים, כרונולוגיה ברורה
|
|
||||||
- MUST: תוכן השלמות טיעון מפורט — כל השלמה בסעיף נפרד עם תמצית תוכן
|
|
||||||
- MUST NOT: ניתוח או הערכה של ההשלמות ("טענה חזקה/חלשה")
|
|
||||||
- Dependencies: block-zayin (מספור רציף)
|
|
||||||
- References: block-zayin (הפניה לטענות מקוריות כשיש חפיפה)
|
|
||||||
|
|
||||||
**Weight:**
|
|
||||||
|
|
||||||
| סוג ערר | משקל | הערות |
|
|
||||||
|---------|------|-------|
|
|
||||||
| ערר פשוט (ללא השלמות) | 3-5% | דיון + סיור בלבד |
|
|
||||||
| ערר מורכב (השלמות רבות) | 8-15% | כמו אריאלי: 31 סעיפים |
|
|
||||||
| היטל השבחה | 2-4% | בדרך כלל מינימלי |
|
|
||||||
|
|
||||||
**Weight methodology:**
|
|
||||||
- Communicative weight (40%): נמוך-בינוני — תיעוד, לא הכרעה
|
|
||||||
- Reader attention (20%): נמוך — scanning, אלא אם יש ממצאים חדשים מסיור/השלמות
|
|
||||||
- Judicial review (25%): **גבוה מאוד** — שופט בודק שנתנו procedural fairness
|
|
||||||
- Empirical (15%): מגוון רחב — תלוי בכמות ההשלמות
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: reproduction + paraphrase (תאריכים מדויקים + תמצית תוכן)
|
|
||||||
- Cognitive complexity: low (סידור כרונולוגי)
|
|
||||||
- Accuracy: high-precision (תאריכים, שמות מסמכים)
|
|
||||||
- Temperature: 0 | Thinking: off | Effort: low | Model: sonnet
|
|
||||||
|
|
||||||
|
|
||||||
### Block ט: תכניות חלות / Applicable Plans (אופציונלי)
|
|
||||||
|
|
||||||
**ID:** `block-tet`
|
|
||||||
**Akoma Ntoso:** `body > judgment > motivation > background` (extended)
|
|
||||||
**CREAC role:** R (Rule — הצגת הכללים המשפטיים/תכנוניים)
|
|
||||||
**Functional purpose (JWM):** Deliberation (preliminary) — מציג את המסגרת הנורמטיבית שלאורה ייבחנו הטענות. בלוק גשר בין עובדות לניתוח.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: narrative, citation-block
|
|
||||||
- Elements: section-heading, numbered-para, blockquote (ציטוט מהוראות תכנית)
|
|
||||||
- Sources: הוראות תכנית (PDF), נספחי בינוי, החלטות מרכזות
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: ציטוט ישיר מהוראות תכנית עם הדגשת (bold) מילים מכריעות
|
|
||||||
- MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות
|
|
||||||
- Dependencies: block-chet (מספור), block-vav (הגדרות תכניות)
|
|
||||||
- Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות)
|
|
||||||
|
|
||||||
**Weight:**
|
|
||||||
|
|
||||||
| מתי קיים | משקל |
|
|
||||||
|----------|------|
|
|
||||||
| תמ"א 38 + שימור | 8-12% |
|
|
||||||
| פרשנות תכנית | 5-10% |
|
|
||||||
| לא קיים | 0% |
|
|
||||||
|
|
||||||
**Weight methodology:**
|
|
||||||
- Communicative weight (40%): בינוני — הנחת תשתית נורמטיבית
|
|
||||||
- Reader attention (20%): נמוך — טכני, אלא אם פרשנות שנויה במחלוקת
|
|
||||||
- Judicial review (25%): בינוני — שופט בודק שהוועדה הבינה את הדין
|
|
||||||
- Empirical (15%): אריאלי — 14 סעיפים; בית הכרם — משולב בדיון
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: guided-synthesis (ציטוט + ניתוח ראשוני)
|
|
||||||
- Cognitive complexity: medium (פרשנות טקסט משפטי)
|
|
||||||
- Accuracy: precision + interpretation
|
|
||||||
- Temperature: 0.2 | Thinking: medium | Effort: medium | Model: opus
|
|
||||||
|
|
||||||
|
|
||||||
### Block י: דיון והכרעה / Discussion and Decision
|
|
||||||
|
|
||||||
**ID:** `block-yod`
|
|
||||||
**Akoma Ntoso:** `body > judgment > motivation`
|
|
||||||
**CREAC role:** **full-CREAC** — C (מסקנה בפתיחה) → R (כלל משפטי) → E (ציטוט פסיקה) → A (יישום על העובדות) → C (מסקנת ביניים)
|
|
||||||
**Functional purpose (JWM):** Deliberation — ליבת ההחלטה. כאן הוועדה מנתחת, מאזנת, ומכריעה. זהו ה-ratio decidendi.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: narrative, citation-block, image-placeholder
|
|
||||||
- Elements: numbered-para (אסה רציפה ללא כותרות משנה), blockquote (ציטוטי פסיקה ותכנית), image-box
|
|
||||||
- Sources: **כל** הבלוקים הקודמים + פסיקה + skill
|
|
||||||
|
|
||||||
**מבנה פנימי (לפי סוג ערר — ראה SKILL.md סעיף 7.3):**
|
|
||||||
- דחייה: שכבות הגנה (concentric circles)
|
|
||||||
- קבלה: נימוק-נימוק
|
|
||||||
- קבלה חלקית: מיפוי מתחים + ניתוח נושאי
|
|
||||||
- היטל השבחה: פתיחה ישירה עם מסקנה
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: מסקנה בפתיחת הדיון (לא בסוף)
|
|
||||||
- MUST: מענה לכל טענה שהוצגה בבלוק ז
|
|
||||||
- MUST: ציטוט פסיקה בבלוקים ארוכים (200-600 מילים)
|
|
||||||
- ⚠️ **MUST NOT ("ללא כפילות"):** חזרה על עובדות/טענות מבלוקים קודמים. השתמש בהפניות: "כאמור בסעיף X לעיל", "כפי שפורט", "כפי שציינו"
|
|
||||||
- MUST NOT: כותרות משנה (חריג: נושאים נפרדים לחלוטין)
|
|
||||||
- Dependencies: **ALL** previous blocks (ה-ט)
|
|
||||||
|
|
||||||
**Weight:**
|
|
||||||
|
|
||||||
| סוג ערר | משקל | הערות |
|
|
||||||
|---------|------|-------|
|
|
||||||
| רישוי — דחייה | 37-50% | פתיחה רחבה + שכבות |
|
|
||||||
| רישוי — קבלה | 35-45% | נימוק-נימוק |
|
|
||||||
| רישוי — קבלה חלקית | 40-47% | מיפוי מתחים + ניתוח נושאי |
|
|
||||||
| היטל השבחה | 32-48% | ציטוטי פסיקה מרובים |
|
|
||||||
|
|
||||||
**Weight methodology:**
|
|
||||||
- Communicative weight (40%): **מקסימלי** — זהו ה-ratio decidendi, תכלית ההחלטה
|
|
||||||
- Reader attention (20%): **גבוה** — deep reading, הקורא מחפש את הנימוקים
|
|
||||||
- Judicial review (25%): **מקסימלי** — שופט בוחן סבירות, מידתיות, התייחסות לטענות
|
|
||||||
- Empirical (15%): 35-50% באופן עקבי בכל החלטות דפנה
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: **rhetorical-construction** (בניית טיעון, איזון, רטוריקה)
|
|
||||||
- Cognitive complexity: **high-reasoning** (CREAC מלא, שכבות, חידוד)
|
|
||||||
- Accuracy: **precision + creativity** (ניתוח מדויק + ביטוי אלגנטי)
|
|
||||||
- Temperature: **0.4** | Thinking: **max (budget 16K+)** | Effort: **max** | Model: **opus בלבד**
|
|
||||||
|
|
||||||
|
|
||||||
### Block יא: סיכום / סוף דבר / Summary
|
|
||||||
|
|
||||||
**ID:** `block-yod-alef`
|
|
||||||
**Akoma Ntoso:** `body > judgment > decision`
|
|
||||||
**CREAC role:** C (Conclusion אחרון — תמצית אופרטיבית)
|
|
||||||
**Functional purpose (JWM):** Disposition — ההוראה האופרטיבית שמבצעים. זה מה שהצדדים צריכים לדעת "מה עכשיו."
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: narrative
|
|
||||||
- Elements: section-heading ("סיכום"/"סוף דבר"), numbered-para, sub-items (א. ב. ג.)
|
|
||||||
- Sources: block-yod (מסקנות)
|
|
||||||
|
|
||||||
**מבנה לפי תוצאה (ראה SKILL.md סעיף 8):**
|
|
||||||
- דחייה: "הערר נדחה" + תתי-סעיפים + פסקה חמה (רישוי בלבד)
|
|
||||||
- קבלה: "הערר מתקבל בכפוף ל..." + פרוזה
|
|
||||||
- קבלה חלקית: "הערר מתקבל באופן חלקי" + 2-3 הוראות אופרטיביות
|
|
||||||
- היטל השבחה: יבש
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: תוצאה ברורה (נדחה/מתקבל/מתקבל חלקית)
|
|
||||||
- MUST NOT (בקבלה חלקית): חזרה על נימוקים — ההנמקה כבר בדיון
|
|
||||||
- Dependencies: block-yod (מסקנות)
|
|
||||||
|
|
||||||
**Weight:**
|
|
||||||
|
|
||||||
| סוג ערר | משקל |
|
|
||||||
|---------|------|
|
|
||||||
| דחייה | 2-9% |
|
|
||||||
| קבלה | 3-5% |
|
|
||||||
| קבלה חלקית | 2-3% |
|
|
||||||
| היטל השבחה | 3-4% |
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: paraphrase (עיבוד מסקנות בלוק י)
|
|
||||||
- Cognitive complexity: low
|
|
||||||
- Accuracy: high-precision (הוראות חייבות להיות חד-משמעיות)
|
|
||||||
- Temperature: 0.1 | Thinking: low | Effort: low | Model: sonnet
|
|
||||||
|
|
||||||
|
|
||||||
### Block יב: חתימות / Signatures
|
|
||||||
|
|
||||||
**ID:** `block-yod-bet`
|
|
||||||
**Akoma Ntoso:** `conclusions > signature`
|
|
||||||
**CREAC role:** none
|
|
||||||
**Functional purpose (JWM):** Authentication — אישור פורמלי של ההחלטה.
|
|
||||||
|
|
||||||
**Content model:**
|
|
||||||
- Types: template-field
|
|
||||||
- Elements: "ניתנה פה אחד" + תאריך עברי/לועזי + טבלת חתימות
|
|
||||||
- Sources: none
|
|
||||||
|
|
||||||
**Constraints:**
|
|
||||||
- MUST: "ניתנה פה אחד", תאריך, יו"ר + מזכיר/ה
|
|
||||||
- Dependencies: none
|
|
||||||
|
|
||||||
**Weight:** 1% (קבוע)
|
|
||||||
|
|
||||||
**Processing:**
|
|
||||||
- Generation type: template-fill
|
|
||||||
- Temperature: 0 | Thinking: off | Effort: min | Model: script
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. כללי גזירת פרמטרים
|
|
||||||
|
|
||||||
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית:
|
|
||||||
|
|
||||||
### Temperature — נגזר מסוג הייצור
|
|
||||||
|
|
||||||
| Generation type | Temperature | נימוק |
|
|
||||||
|----------------|-------------|-------|
|
|
||||||
| template-fill | 0 | אין צורך בשפה — מילוי שדות |
|
|
||||||
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
|
|
||||||
| paraphrase | 0.1 | מרווח מינימלי לניסוח בשפה של דפנה |
|
|
||||||
| guided-synthesis | 0.2 | גמישות בארגון וחיבור מקורות, לא בתוכן |
|
|
||||||
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
|
|
||||||
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
|
|
||||||
|
|
||||||
### Thinking budget — נגזר ממורכבות קוגניטיבית
|
|
||||||
|
|
||||||
| Cognitive task | Budget | נימוק |
|
|
||||||
|---------------|--------|-------|
|
|
||||||
| template-fill / lookup | off | אין צורך בחשיבה |
|
|
||||||
| sequential-extraction | low | חילוץ מידע חד-שלבי |
|
|
||||||
| multi-source-integration | medium | צריך להצליב מקורות |
|
|
||||||
| legal-analysis-with-CREAC | max (16K+) | חשיבה רב-שלבית: מסקנה → כלל → הסבר → יישום |
|
|
||||||
|
|
||||||
### Model — נגזר מדרישת דיוק
|
|
||||||
|
|
||||||
| Accuracy profile | Model | נימוק |
|
|
||||||
|-----------------|-------|-------|
|
|
||||||
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
|
|
||||||
| precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה |
|
|
||||||
| precision + creativity | opus | נדרש לניתוח משפטי מורכב ורטוריקה |
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. מתודולוגיית משקלות
|
|
||||||
|
|
||||||
משקל כל בלוק נקבע על ידי שקלול 4 גורמים:
|
|
||||||
|
|
||||||
### 4.1 Communicative Weight (40%)
|
|
||||||
מה חלקו של הבלוק בתכלית ההחלטה? ההחלטה באה לעשות דבר אחד: להכריע במחלוקת ולנמק. בלוק י (דיון) הוא ליבת התכלית. בלוקים א-ד (כותרות) הם עטיפה.
|
|
||||||
|
|
||||||
### 4.2 Reader Attention Distribution (20%)
|
|
||||||
מבוסס על מחקרי F-pattern ו-primacy/recency:
|
|
||||||
- **פתיחה** (בלוקים ה-ו): קשב גבוה (primacy effect)
|
|
||||||
- **אמצע** (בלוקים ז-ח): scanning — הקורא מחפש טענות ספציפיות
|
|
||||||
- **דיון** (בלוק י): deep reading — הקורא מחפש נימוקים
|
|
||||||
- **סיום** (בלוק יא): קשב גבוה (recency effect)
|
|
||||||
|
|
||||||
### 4.3 Judicial Review Requirement (25%)
|
|
||||||
מה שופט בבית משפט לעניינים מנהליים יבדוק ("מבחן השופט"):
|
|
||||||
- **תשתית עובדתית** (בלוק ו): מלאה ומדויקת?
|
|
||||||
- **שמיעת צדדים** (בלוקים ז-ח): נתנו מלוא יום בבית דין?
|
|
||||||
- **סבירות ומידתיות** (בלוק י): ההכרעה מנומקת ומאוזנת?
|
|
||||||
- **התייחסות לטענות** (בלוק י): כל טענה קיבלה מענה?
|
|
||||||
|
|
||||||
### 4.4 Empirical Basis (15%)
|
|
||||||
מבוסס על מדידה מהחלטות שפורסמו:
|
|
||||||
- הכט 1180-1181 (דחייה, 02.2026)
|
|
||||||
- בית הכרם 1126/25 (קבלה חלקית, 03.2026)
|
|
||||||
- אריאלי 1078+1083 (קבלה, 03.2026)
|
|
||||||
|
|
||||||
המשקלות ב-SKILL.md סעיף 3.2 (יחסי הזהב) משמשים כבסיס אמפירי שאומת על ידי שלושת הגורמים האנליטיים.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. כללי ולידציה
|
|
||||||
|
|
||||||
### 5.1 סדר בלוקים
|
|
||||||
- בלוקים חייבים להופיע בסדר א עד יב
|
|
||||||
- בלוקים א-ה ויב נדרשים בכל החלטה
|
|
||||||
- בלוק ט אופציונלי (רק כשיש מורכבות תכנונית)
|
|
||||||
|
|
||||||
### 5.2 Content Constraints
|
|
||||||
- **רקע ניטרלי (בלוק ו):** אם סעיף מכיל ציטוט ישיר מצד או מילת שיפוט → לא שייך כאן
|
|
||||||
- **טענות מקוריות בלבד (בלוק ז):** רק מכתבי ערר/תשובה. השלמות → בלוק ח
|
|
||||||
- **ללא כפילות (בלוק י):** הפניה לבלוקים קודמים, לא חזרה. חריג: "נשוב על כך כי..." (חזרה מכוונת עם שכבה חדשה)
|
|
||||||
- **הליכים ללא הערכה (בלוק ח):** תיעוד מה הוגש, לא הערכה של חוזק הטענות
|
|
||||||
|
|
||||||
### 5.3 Weight Compliance
|
|
||||||
- משקל כל בלוק (ספירת מילים / סה"כ) צריך להיות בטווח המוגדר **±10%**
|
|
||||||
- אם בלוק י < 30% → flag: דיון לא מפותח מספיק
|
|
||||||
- אם בלוק ו > 35% → flag: רקע מנופח, בדוק שאין תוכן טענתי
|
|
||||||
|
|
||||||
### 5.4 Structural Integrity
|
|
||||||
- מספור סעיפים רציף מ-1 עד הסוף, ללא איפוס בין בלוקים
|
|
||||||
- כל הגדרת "להלן" חייבת להופיע לפני השימוש הראשון בה
|
|
||||||
- כל טענה בבלוק ז חייבת לקבל מענה בבלוק י (ישיר או "למעלה מן הצורך")
|
|
||||||
- כותרות פרקים: David 14pt, bold, קו תחתון, מרכז
|
|
||||||
- כותרות משנה: David 12pt, bold, מרכז, ללא קו תחתון
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. גרף תלויות בין בלוקים
|
|
||||||
|
|
||||||
```
|
|
||||||
א (כותרת) → עצמאי
|
|
||||||
ב (הרכב) → עצמאי
|
|
||||||
ג (צדדים) → עצמאי
|
|
||||||
ד (כותרת) → עצמאי
|
|
||||||
ה (פתיחה) → תלוי ב: ג (שמות צדדים להגדרות "להלן")
|
|
||||||
ו (רקע) → תלוי ב: ה (הגדרות). מספור ממשיך מ-ה.
|
|
||||||
ז (טענות) → תלוי ב: ו (מספור). מפנה ל: ה, ו (הגדרות)
|
|
||||||
ח (הליכים) → תלוי ב: ז (מספור). מפנה ל: ז (טענות מקוריות)
|
|
||||||
ט (תכניות) → תלוי ב: ח (מספור). אופציונלי. מפנה ל: ו (הגדרות תכניות)
|
|
||||||
י (דיון) → תלוי ב: **כל** הבלוקים ה-ט. מפנה ל: כולם.
|
|
||||||
יא (סיכום) → תלוי ב: י (מסקנות). מפנה ל: י בלבד.
|
|
||||||
יב (חתימות) → עצמאי
|
|
||||||
```
|
|
||||||
1
skills/decision/references/block-schema.md
Symbolic link
1
skills/decision/references/block-schema.md
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../../docs/block-schema.md
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use } from "react";
|
import { use, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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({
|
export default function ComposePage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -78,24 +163,7 @@ export default function ComposePage({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<AnalysisActions caseNumber={caseNumber} hasAnalysis={!!analysis.data} onUploaded={() => analysis.refetch()} />
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||||
|
|||||||
@@ -13,9 +13,14 @@ import { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
|||||||
import { StatusGuide } from "@/components/cases/status-guide";
|
import { StatusGuide } from "@/components/cases/status-guide";
|
||||||
import { StatusChanger } from "@/components/cases/status-changer";
|
import { StatusChanger } from "@/components/cases/status-changer";
|
||||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||||
|
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||||
|
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||||
|
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
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(
|
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
expectedOutcomes.map((o) => [o.value, o.label]),
|
expectedOutcomes.map((o) => [o.value, o.label]),
|
||||||
@@ -32,6 +37,8 @@ export default function CaseDetailPage({
|
|||||||
}) {
|
}) {
|
||||||
const { caseNumber } = use(params);
|
const { caseNumber } = use(params);
|
||||||
const { data, isPending, error } = useCase(caseNumber);
|
const { data, isPending, error } = useCase(caseNumber);
|
||||||
|
const startWorkflow = useStartWorkflow(caseNumber);
|
||||||
|
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
||||||
const expectedOutcomeLabel = data?.expected_outcome
|
const expectedOutcomeLabel = data?.expected_outcome
|
||||||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||||||
: null;
|
: null;
|
||||||
@@ -78,6 +85,12 @@ export default function CaseDetailPage({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="drafts">
|
||||||
|
טיוטות והערות
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="agents">
|
||||||
|
סוכנים
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<UploadSheet caseNumber={caseNumber} />
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +116,29 @@ export default function CaseDetailPage({
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 flex-wrap pt-2 border-t border-rule">
|
<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">
|
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
|
||||||
<Link href={`/cases/${caseNumber}/compose`}>
|
<Link href={`/cases/${caseNumber}/compose`}>
|
||||||
פתח בעורך ההחלטה
|
פתח בעורך ההחלטה
|
||||||
@@ -115,12 +151,24 @@ export default function CaseDetailPage({
|
|||||||
<TabsContent value="documents" className="mt-5">
|
<TabsContent value="documents" className="mt-5">
|
||||||
<DocumentsPanel data={data} />
|
<DocumentsPanel data={data} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="drafts" className="mt-5">
|
||||||
|
<DraftsPanel
|
||||||
|
caseNumber={caseNumber}
|
||||||
|
status={data?.status}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="agents" className="mt-5">
|
||||||
|
<AgentActivityFeed caseNumber={caseNumber} />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-surface border-rule shadow-sm h-fit">
|
<Card className="bg-surface border-rule shadow-sm h-fit">
|
||||||
<CardContent className="px-6 py-5">
|
<CardContent className="px-6 py-5 space-y-5">
|
||||||
|
<AgentStatusWidget caseNumber={caseNumber} />
|
||||||
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
|
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
|
||||||
<WorkflowTimeline status={data?.status} />
|
<WorkflowTimeline status={data?.status} />
|
||||||
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
|
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
|
||||||
|
|||||||
@@ -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[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ href: "/", label: "בית" },
|
{ href: "/", label: "בית" },
|
||||||
{ href: "/cases/new", label: "תיק חדש" },
|
|
||||||
{ href: "/training", label: "אימון סגנון" },
|
{ href: "/training", label: "אימון סגנון" },
|
||||||
{ href: "/feedback", label: "הערות יו״ר" },
|
|
||||||
{ href: "/skills", label: "מיומנויות" },
|
{ href: "/skills", label: "מיומנויות" },
|
||||||
{ href: "/diagnostics", label: "אבחון" },
|
{ href: "/diagnostics", label: "אבחון" },
|
||||||
|
{ href: "/settings", label: "הגדרות" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function isActive(pathname: string, href: string): boolean {
|
function isActive(pathname: string, href: string): boolean {
|
||||||
|
|||||||
293
web-ui/src/components/cases/agent-activity-feed.tsx
Normal file
293
web-ui/src/components/cases/agent-activity-feed.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Markdown } from "@/components/ui/markdown";
|
||||||
|
import { useAgentActivity, useSendComment } from "@/lib/api/agents";
|
||||||
|
import type { PaperclipComment } from "@/lib/api/agents";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/* ── Role → color mapping ────────────────────────────────────── */
|
||||||
|
|
||||||
|
const ROLE_COLORS: Record<string, string> = {
|
||||||
|
ceo: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
researcher: "bg-purple-100 text-purple-800 border-purple-200",
|
||||||
|
engineer: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
|
qa: "bg-amber-100 text-amber-800 border-amber-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_DOT: Record<string, string> = {
|
||||||
|
ceo: "bg-blue-500",
|
||||||
|
researcher: "bg-purple-500",
|
||||||
|
engineer: "bg-emerald-500",
|
||||||
|
qa: "bg-amber-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
ceo: "מנהל",
|
||||||
|
researcher: "חוקר",
|
||||||
|
engineer: "מהנדס",
|
||||||
|
qa: "בודק איכות",
|
||||||
|
general: "כללי",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ISSUE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
backlog: "ממתין",
|
||||||
|
todo: "לביצוע",
|
||||||
|
in_progress: "בביצוע",
|
||||||
|
in_review: "בבדיקה",
|
||||||
|
done: "הושלם",
|
||||||
|
cancelled: "בוטל",
|
||||||
|
blocked: "חסום",
|
||||||
|
};
|
||||||
|
|
||||||
|
function roleColor(role: string | null) {
|
||||||
|
return ROLE_COLORS[role ?? ""] ?? "bg-gray-100 text-gray-700 border-gray-200";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleDot(role: string | null) {
|
||||||
|
return ROLE_DOT[role ?? ""] ?? "bg-gray-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: string | null) {
|
||||||
|
return ROLE_LABELS[role ?? ""] ?? role ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueStatusLabel(status: string) {
|
||||||
|
return ISSUE_STATUS_LABELS[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Time formatting ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function timeAgo(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
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} ימים`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Issue identifier → find matching identifier ─────────────── */
|
||||||
|
|
||||||
|
function issueIdentifier(
|
||||||
|
comment: PaperclipComment,
|
||||||
|
issueMap: Map<string, string>,
|
||||||
|
): string {
|
||||||
|
return issueMap.get(comment.issue_id) ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Comment card ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function CommentCard({
|
||||||
|
comment,
|
||||||
|
issueMap,
|
||||||
|
}: {
|
||||||
|
comment: PaperclipComment;
|
||||||
|
issueMap: Map<string, string>;
|
||||||
|
}) {
|
||||||
|
const isAgent = !!comment.author_agent_id;
|
||||||
|
const label = isAgent ? comment.agent_name ?? "סוכן" : "חיים";
|
||||||
|
const identifier = issueIdentifier(comment, issueMap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group relative flex gap-3 py-3 px-2 rounded-lg hover:bg-sand-soft/50 transition-colors">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex-shrink-0 pt-0.5">
|
||||||
|
{isAgent ? (
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center ${roleColor(comment.agent_role)}`}
|
||||||
|
>
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-gold-soft text-gold-deep border border-gold">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold text-navy">{label}</span>
|
||||||
|
{isAgent && comment.agent_role && (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-full border ${roleColor(comment.agent_role)}`}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${roleDot(comment.agent_role)}`} />
|
||||||
|
{roleLabel(comment.agent_role)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{identifier && (
|
||||||
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
|
{identifier}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-ink-faint mr-auto flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{timeAgo(comment.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="text-sm">
|
||||||
|
<Markdown content={comment.body} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function AgentActivityFeed({
|
||||||
|
caseNumber,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
||||||
|
const sendComment = useSendComment(caseNumber);
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Build issue_id → identifier map
|
||||||
|
const issueMap = new Map<string, string>();
|
||||||
|
if (data?.issues) {
|
||||||
|
for (const iss of data.issues) {
|
||||||
|
issueMap.set(iss.id, iss.identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll on new comments
|
||||||
|
const commentCount = data?.comments?.length ?? 0;
|
||||||
|
useEffect(() => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [commentCount]);
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!body.trim()) return;
|
||||||
|
sendComment.mutate(
|
||||||
|
{ body: body.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setBody("");
|
||||||
|
toast.success(`נשלח ל-${res.issue_identifier}`);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Empty / loading states ──
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-ink-faint">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin ml-2" />
|
||||||
|
<span>טוען פעילות סוכנים...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-red-500 text-sm">
|
||||||
|
שגיאה בטעינת פעילות סוכנים
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.issues?.length) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 space-y-2">
|
||||||
|
<MessageSquare className="w-10 h-10 mx-auto text-ink-faint/40" />
|
||||||
|
<p className="text-sm text-ink-faint">
|
||||||
|
התהליך טרם הופעל. לחץ "התחל תהליך" בלשונית סקירה.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = data.comments ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Issue summary bar */}
|
||||||
|
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
|
||||||
|
{data.issues.map((iss) => (
|
||||||
|
<Badge
|
||||||
|
key={iss.id}
|
||||||
|
variant={iss.status === "done" ? "secondary" : "default"}
|
||||||
|
className="text-[11px] font-mono"
|
||||||
|
>
|
||||||
|
{iss.identifier} — {issueStatusLabel(iss.status)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments stream */}
|
||||||
|
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-ink-faint text-sm">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
|
הסוכנים התחילו לעבוד, ממתין לדיווח ראשון...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comments.map((c) => (
|
||||||
|
<CommentCard key={c.id} comment={c} issueMap={issueMap} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment input */}
|
||||||
|
<div className="border-t border-rule pt-3 mt-3 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder="כתוב הוראה לסוכנים..."
|
||||||
|
className="min-h-[60px] resize-none text-sm"
|
||||||
|
dir="rtl"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-ink-faint">
|
||||||
|
ההודעה תנותב דרך סוכן ה-CEO · Ctrl+Enter לשליחה
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!body.trim() || sendComment.isPending}
|
||||||
|
>
|
||||||
|
{sendComment.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-4 h-4 ml-1" />
|
||||||
|
)}
|
||||||
|
שלח
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
web-ui/src/components/cases/agent-status-widget.tsx
Normal file
77
web-ui/src/components/cases/agent-status-widget.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAgentActivity } from "@/lib/api/agents";
|
||||||
|
import type { PaperclipAgent } from "@/lib/api/agents";
|
||||||
|
import { Bot } from "lucide-react";
|
||||||
|
|
||||||
|
/* ── Status dot colors ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
const STATUS_DOT: Record<string, string> = {
|
||||||
|
active: "bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.6)]",
|
||||||
|
idle: "bg-gray-300",
|
||||||
|
error: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
active: "פעיל",
|
||||||
|
idle: "ממתין",
|
||||||
|
error: "שגיאה",
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusDot(status: string) {
|
||||||
|
return STATUS_DOT[status] ?? STATUS_DOT.idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Agent row ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function AgentRow({ agent }: { agent: PaperclipAgent }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<span
|
||||||
|
className={`w-2 h-2 rounded-full flex-shrink-0 ${statusDot(agent.status)}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-ink truncate">{agent.name}</span>
|
||||||
|
<span className="text-[10px] text-ink-faint mr-auto">
|
||||||
|
{STATUS_LABEL[agent.status] ?? agent.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Widget ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function AgentStatusWidget({
|
||||||
|
caseNumber,
|
||||||
|
}: {
|
||||||
|
caseNumber: string;
|
||||||
|
}) {
|
||||||
|
const { data } = useAgentActivity(caseNumber);
|
||||||
|
|
||||||
|
// Don't render if no Paperclip project yet
|
||||||
|
if (!data?.issues?.length) return null;
|
||||||
|
|
||||||
|
const agents = data.agents ?? [];
|
||||||
|
const activeCount = agents.filter((a) => a.status === "active").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-navy">
|
||||||
|
<Bot className="w-3.5 h-3.5" />
|
||||||
|
<span>סוכנים</span>
|
||||||
|
</div>
|
||||||
|
{agents.length > 0 && (
|
||||||
|
<span className="text-[10px] text-ink-faint">
|
||||||
|
{activeCount} פעילים מתוך {agents.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<AgentRow key={agent.id} agent={agent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import { SyncIndicator } from "@/components/cases/sync-indicator";
|
||||||
import {
|
import {
|
||||||
PRACTICE_AREA_LABELS,
|
PRACTICE_AREA_LABELS,
|
||||||
APPEAL_SUBTYPE_LABELS,
|
APPEAL_SUBTYPE_LABELS,
|
||||||
@@ -71,6 +72,10 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
|
|||||||
עודכן
|
עודכן
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
<dd className="text-ink-soft tabular-nums">{formatDate(data?.updated_at)}</dd>
|
||||||
|
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
|
||||||
|
סנכרון
|
||||||
|
</dt>
|
||||||
|
<dd><SyncIndicator caseNumber={data?.case_number} /></dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</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[] {
|
function bucketize(cases: Case[] | undefined): Bucket[] {
|
||||||
const c = cases ?? [];
|
const c = cases ?? [];
|
||||||
const inProgress = c.filter((x) =>
|
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;
|
).length;
|
||||||
const drafting = c.filter((x) =>
|
const drafting = c.filter((x) =>
|
||||||
["drafting", "qa_review", "drafted"].includes(x.status),
|
["drafting", "qa_review", "drafted"].includes(x.status),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import {
|
import {
|
||||||
FilePlus2, Upload, Loader2, FileCheck, Target,
|
FilePlus2, Upload, Loader2, FileCheck, Target,
|
||||||
Lightbulb, Compass, PenLine, SearchCheck, FileText,
|
Lightbulb, Compass, PenLine, SearchCheck, FileText,
|
||||||
FileOutput, CheckCircle2, Award,
|
FileOutput, CheckCircle2, Award, ShieldCheck, BookOpen,
|
||||||
|
Microscope, PlayCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CaseStatus } from "@/lib/api/cases";
|
import type { CaseStatus } from "@/lib/api/cases";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
@@ -12,11 +13,15 @@ const STATUS_LABELS: Record<CaseStatus, string> = {
|
|||||||
uploading: "מעלה",
|
uploading: "מעלה",
|
||||||
processing: "בעיבוד",
|
processing: "בעיבוד",
|
||||||
documents_ready: "מסמכים מוכנים",
|
documents_ready: "מסמכים מוכנים",
|
||||||
|
analyst_verified: "ניתוח אומת",
|
||||||
|
research_complete: "מחקר הושלם",
|
||||||
outcome_set: "תוצאה נקבעה",
|
outcome_set: "תוצאה נקבעה",
|
||||||
brainstorming: "סיעור מוחות",
|
brainstorming: "סיעור מוחות",
|
||||||
direction_approved: "כיוון אושר",
|
direction_approved: "כיוון אושר",
|
||||||
|
analysis_enriched: "ניתוח הועמק",
|
||||||
|
ready_for_writing: "מוכן לכתיבה",
|
||||||
drafting: "בכתיבה",
|
drafting: "בכתיבה",
|
||||||
qa_review: "QA",
|
qa_review: "בדיקת איכות",
|
||||||
drafted: "טיוטה",
|
drafted: "טיוטה",
|
||||||
exported: "יוצא",
|
exported: "יוצא",
|
||||||
reviewed: "נבדק",
|
reviewed: "נבדק",
|
||||||
@@ -28,9 +33,13 @@ const STATUS_ICONS: Record<CaseStatus, LucideIcon> = {
|
|||||||
uploading: Upload,
|
uploading: Upload,
|
||||||
processing: Loader2,
|
processing: Loader2,
|
||||||
documents_ready: FileCheck,
|
documents_ready: FileCheck,
|
||||||
|
analyst_verified: ShieldCheck,
|
||||||
|
research_complete: BookOpen,
|
||||||
outcome_set: Target,
|
outcome_set: Target,
|
||||||
brainstorming: Lightbulb,
|
brainstorming: Lightbulb,
|
||||||
direction_approved: Compass,
|
direction_approved: Compass,
|
||||||
|
analysis_enriched: Microscope,
|
||||||
|
ready_for_writing: PlayCircle,
|
||||||
drafting: PenLine,
|
drafting: PenLine,
|
||||||
qa_review: SearchCheck,
|
qa_review: SearchCheck,
|
||||||
drafted: FileText,
|
drafted: FileText,
|
||||||
@@ -44,12 +53,16 @@ const STATUS_DESCRIPTIONS: Record<CaseStatus, string> = {
|
|||||||
uploading: "מסמכים בתהליך העלאה לשרת",
|
uploading: "מסמכים בתהליך העלאה לשרת",
|
||||||
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
processing: "המערכת מעבדת ומנתחת את המסמכים",
|
||||||
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
documents_ready: "כל המסמכים עובדו ומוכנים לעבודה",
|
||||||
|
analyst_verified: "ניתוח ראשוני אומת — ממתין למחקר תקדימים",
|
||||||
|
research_complete: "מחקר תקדימים הושלם — ממתין לבחירת תוצאה",
|
||||||
outcome_set: "נקבעה תוצאה צפויה לערר",
|
outcome_set: "נקבעה תוצאה צפויה לערר",
|
||||||
brainstorming: "ניתוח כיוונים אפשריים להחלטה",
|
brainstorming: "ניתוח כיוונים אפשריים להחלטה",
|
||||||
direction_approved: "כיוון ההחלטה אושר — ניתן להתחיל כתיבה",
|
direction_approved: "כיוון ההחלטה אושר — בהעמקת ניתוח",
|
||||||
|
analysis_enriched: "ניתוח הועמק ופסיקה אומתה — מוכן לכתיבה",
|
||||||
|
ready_for_writing: "הכל מוכן — ממתין לכותב ההחלטה",
|
||||||
drafting: "טיוטת ההחלטה בתהליך כתיבה",
|
drafting: "טיוטת ההחלטה בתהליך כתיבה",
|
||||||
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
qa_review: "הטיוטה בבדיקת איכות אוטומטית",
|
||||||
drafted: "טיוטה ראשונה מוכנה לעיון",
|
drafted: "טיוטה מוכנה לעיון",
|
||||||
exported: "ההחלטה יוצאה לקובץ DOCX",
|
exported: "ההחלטה יוצאה לקובץ DOCX",
|
||||||
reviewed: "ההחלטה נבדקה ע\"י היו\"ר",
|
reviewed: "ההחלטה נבדקה ע\"י היו\"ר",
|
||||||
final: "החלטה סופית — מוכנה להגשה",
|
final: "החלטה סופית — מוכנה להגשה",
|
||||||
@@ -66,9 +79,13 @@ const STATUS_TONE: Record<CaseStatus, string> = {
|
|||||||
uploading: "bg-rule-soft text-ink-muted border-rule",
|
uploading: "bg-rule-soft text-ink-muted border-rule",
|
||||||
processing: "bg-info-bg text-info border-info/30",
|
processing: "bg-info-bg text-info border-info/30",
|
||||||
documents_ready: "bg-info-bg text-info border-info/40",
|
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",
|
outcome_set: "bg-info-bg text-info border-info/40",
|
||||||
brainstorming: "bg-gold-wash text-gold-deep border-gold/40",
|
brainstorming: "bg-gold-wash text-gold-deep border-gold/40",
|
||||||
direction_approved:"bg-gold-wash text-gold-deep border-gold/50",
|
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",
|
drafting: "bg-warn-bg text-warn border-warn/40",
|
||||||
qa_review: "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",
|
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[] = [
|
const ALL_STATUSES: CaseStatus[] = [
|
||||||
"new", "uploading", "processing",
|
"new", "uploading", "processing",
|
||||||
"documents_ready", "outcome_set",
|
"documents_ready", "analyst_verified", "research_complete", "outcome_set",
|
||||||
"brainstorming", "direction_approved",
|
"brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing",
|
||||||
"drafting", "qa_review", "drafted",
|
"drafting", "qa_review", "drafted",
|
||||||
"exported", "reviewed", "final",
|
"exported", "reviewed", "final",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ type GroupKey = "intake" | "prep" | "thinking" | "writing" | "done";
|
|||||||
|
|
||||||
const GROUP_OF: Record<CaseStatus, GroupKey> = {
|
const GROUP_OF: Record<CaseStatus, GroupKey> = {
|
||||||
new: "intake", uploading: "intake", processing: "intake",
|
new: "intake", uploading: "intake", processing: "intake",
|
||||||
documents_ready: "prep", outcome_set: "prep",
|
documents_ready: "prep", analyst_verified: "prep", research_complete: "prep", outcome_set: "prep",
|
||||||
brainstorming: "thinking", direction_approved: "thinking",
|
brainstorming: "thinking", direction_approved: "thinking", analysis_enriched: "thinking", ready_for_writing: "thinking",
|
||||||
drafting: "writing", qa_review: "writing", drafted: "writing",
|
drafting: "writing", qa_review: "writing", drafted: "writing",
|
||||||
exported: "done", reviewed: "done", final: "done",
|
exported: "done", reviewed: "done", final: "done",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ type PhaseGroup = {
|
|||||||
|
|
||||||
const PHASE_GROUPS: PhaseGroup[] = [
|
const PHASE_GROUPS: PhaseGroup[] = [
|
||||||
{ label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] },
|
{ label: "קליטה ועיבוד", statuses: ["new", "uploading", "processing"] },
|
||||||
{ label: "הכנת תיק", statuses: ["documents_ready", "outcome_set"] },
|
{ label: "הכנת תיק", statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
|
||||||
{ label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved"] },
|
{ label: "ניתוח וכיוון", statuses: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] },
|
||||||
{ label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] },
|
{ label: "כתיבת טיוטה", statuses: ["drafting", "qa_review", "drafted"] },
|
||||||
{ label: "סגירה", statuses: ["exported", "reviewed", "final"] },
|
{ 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[] = [
|
const PHASES: Phase[] = [
|
||||||
{ key: "intake", label: "קליטה ועיבוד", icon: FolderInput, statuses: ["new", "uploading", "processing"] },
|
{ key: "intake", label: "קליטה ועיבוד", icon: FolderInput, statuses: ["new", "uploading", "processing"] },
|
||||||
{ key: "prep", label: "הכנת תיק", icon: ClipboardList, statuses: ["documents_ready", "outcome_set"] },
|
{ key: "prep", label: "הכנת תיק", icon: ClipboardList, statuses: ["documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
|
||||||
{ key: "thinking", label: "ניתוח וכיוון", icon: Brain, statuses: ["brainstorming", "direction_approved"] },
|
{ 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: "writing", label: "כתיבת טיוטה", icon: PenLine, statuses: ["drafting", "qa_review", "drafted"] },
|
||||||
{ key: "done", label: "סגירה", icon: CheckCircle2, statuses: ["exported", "reviewed", "final"] },
|
{ key: "done", label: "סגירה", icon: CheckCircle2, statuses: ["exported", "reviewed", "final"] },
|
||||||
];
|
];
|
||||||
|
|||||||
87
web-ui/src/lib/api/agents.ts
Normal file
87
web-ui/src/lib/api/agents.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Paperclip agent activity hooks — mirror agent work into Legal-AI UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PaperclipIssue = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
identifier: string;
|
||||||
|
priority: string;
|
||||||
|
assignee_name: string | null;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
company_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaperclipComment = {
|
||||||
|
id: string;
|
||||||
|
issue_id: string;
|
||||||
|
body: string;
|
||||||
|
created_at: string | null;
|
||||||
|
author_agent_id: string | null;
|
||||||
|
author_user_id: string | null;
|
||||||
|
agent_name: string | null;
|
||||||
|
agent_role: string | null;
|
||||||
|
agent_icon: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaperclipAgent = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
title: string | null;
|
||||||
|
status: string;
|
||||||
|
icon: string | null;
|
||||||
|
last_heartbeat_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentActivityResponse = {
|
||||||
|
issues: PaperclipIssue[];
|
||||||
|
comments: PaperclipComment[];
|
||||||
|
agents: PaperclipAgent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Query Keys ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const agentKeys = {
|
||||||
|
activity: (caseNumber: string) =>
|
||||||
|
["agents", "activity", caseNumber] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Hooks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useAgentActivity(caseNumber: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: agentKeys.activity(caseNumber ?? ""),
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<AgentActivityResponse>(
|
||||||
|
`/api/cases/${caseNumber}/agents`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: !!caseNumber,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSendComment(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
||||||
|
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
||||||
|
`/api/cases/${caseNumber}/agents/comment`,
|
||||||
|
{ method: "POST", body: vars },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,9 +18,13 @@ export type CaseStatus =
|
|||||||
| "uploading"
|
| "uploading"
|
||||||
| "processing"
|
| "processing"
|
||||||
| "documents_ready"
|
| "documents_ready"
|
||||||
|
| "analyst_verified"
|
||||||
|
| "research_complete"
|
||||||
| "outcome_set"
|
| "outcome_set"
|
||||||
| "brainstorming"
|
| "brainstorming"
|
||||||
| "direction_approved"
|
| "direction_approved"
|
||||||
|
| "analysis_enriched"
|
||||||
|
| "ready_for_writing"
|
||||||
| "drafting"
|
| "drafting"
|
||||||
| "qa_review"
|
| "qa_review"
|
||||||
| "drafted"
|
| "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) {
|
export function useWorkflowStatus(caseNumber: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,
|
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() {
|
export function useCreateFeedback() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -100,6 +113,16 @@ export const CATEGORY_LABELS: Record<FeedbackCategory, string> = {
|
|||||||
other: "אחר",
|
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 */
|
/** Block ID labels */
|
||||||
export const BLOCK_LABELS: Record<string, string> = {
|
export const BLOCK_LABELS: Record<string, string> = {
|
||||||
"block-he": "ה — פתיחה",
|
"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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
469
web/app.py
469
web/app.py
@@ -22,7 +22,6 @@ import zipfile
|
|||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
from fastapi.responses import FileResponse, StreamingResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -35,7 +34,16 @@ from legal_mcp.tools import cases as cases_tools, search as search_tools, workfl
|
|||||||
_web_dir = Path(__file__).resolve().parent
|
_web_dir = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(_web_dir.parent))
|
sys.path.insert(0, str(_web_dir.parent))
|
||||||
from web.gitea_client import create_repo, setup_remote_and_push
|
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_agents_for_company as pc_get_agents,
|
||||||
|
get_case_issues as pc_get_case_issues,
|
||||||
|
get_issue_comments as pc_get_issue_comments,
|
||||||
|
get_project_url,
|
||||||
|
post_comment as pc_post_comment,
|
||||||
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -58,20 +66,12 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="העלאת מסמכים משפטיים", lifespan=lifespan)
|
app = FastAPI(title="העלאת מסמכים משפטיים", lifespan=lifespan)
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
|
||||||
|
|
||||||
|
|
||||||
# ── API Endpoints ──────────────────────────────────────────────────
|
# ── API Endpoints ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index():
|
async def index():
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
return {"status": "ok", "frontend": "https://legal-ai-next.nautilus.marcusgroup.org"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/design-system.css")
|
|
||||||
async def design_system_css():
|
|
||||||
return FileResponse(STATIC_DIR / "design-system.css", media_type="text/css")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/upload")
|
@app.post("/api/upload")
|
||||||
@@ -1102,7 +1102,91 @@ async def api_case_create(req: CaseCreateRequest):
|
|||||||
practice_area=req.practice_area,
|
practice_area=req.practice_area,
|
||||||
appeal_subtype=req.appeal_subtype,
|
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")
|
@app.get("/api/cases/{case_number}/details")
|
||||||
@@ -1633,6 +1717,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):
|
class ChairPositionRequest(BaseModel):
|
||||||
section_id: str
|
section_id: str
|
||||||
position: str = ""
|
position: str = ""
|
||||||
@@ -1806,6 +1975,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")
|
@app.post("/api/cases/{case_number}/exports/upload")
|
||||||
async def api_upload_export(case_number: str, file: UploadFile = File(...)):
|
async def api_upload_export(case_number: str, file: UploadFile = File(...)):
|
||||||
"""Upload a revised version of a draft."""
|
"""Upload a revised version of a draft."""
|
||||||
@@ -1989,16 +2169,178 @@ async def api_paperclip_create_project(req: PaperclipProjectRequest):
|
|||||||
return project
|
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, issue.get("company_id", ""))
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Agent Activity Mirror ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cases/{case_number}/agents")
|
||||||
|
async def api_case_agents(case_number: str):
|
||||||
|
"""Get all Paperclip agent activity for a case: issues, comments, agent status."""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not issues:
|
||||||
|
return {"issues": [], "comments": [], "agents": []}
|
||||||
|
|
||||||
|
issue_ids = [i["id"] for i in issues]
|
||||||
|
company_id = issues[0]["company_id"]
|
||||||
|
|
||||||
|
comments, agents = await pc_get_issue_comments(issue_ids), await pc_get_agents(company_id)
|
||||||
|
|
||||||
|
return {"issues": issues, "comments": comments, "agents": agents}
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCommentRequest(BaseModel):
|
||||||
|
body: str
|
||||||
|
issue_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/agents/comment")
|
||||||
|
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||||
|
"""Post a comment on a Paperclip issue linked to a case.
|
||||||
|
|
||||||
|
If issue_id is omitted, the most recent non-done issue is used.
|
||||||
|
"""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not issues:
|
||||||
|
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
||||||
|
|
||||||
|
if req.issue_id:
|
||||||
|
target = next((i for i in issues if i["id"] == req.issue_id), None)
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
|
else:
|
||||||
|
# Pick the most recent non-done issue, or the last one
|
||||||
|
active = [i for i in issues if i["status"] != "done"]
|
||||||
|
target = active[-1] if active else issues[-1]
|
||||||
|
|
||||||
|
result = await pc_post_comment(target["id"], target["company_id"], req.body)
|
||||||
|
|
||||||
|
# Find the identifier for the response
|
||||||
|
result["issue_identifier"] = target.get("identifier", "")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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 ───────────────────────────────────────────
|
# ── Skill Management API ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
PAPERCLIP_DB_URL = os.environ.get(
|
PAPERCLIP_DB_URL = os.environ.get(
|
||||||
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
|
"PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip"
|
||||||
)
|
)
|
||||||
# In Docker: mounted at /paperclip-skills; locally: ~/.paperclip/instances/default/skills
|
# Paperclip runs locally via pm2; skills are in ~/.paperclip
|
||||||
_docker_skills = Path("/paperclip-skills")
|
|
||||||
_local_skills = Path.home() / ".paperclip" / "instances" / "default" / "skills"
|
_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
|
# Default company ID for skills
|
||||||
SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-ac97-7424f65df294")
|
SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-ac97-7424f65df294")
|
||||||
|
|
||||||
@@ -2006,15 +2348,20 @@ SKILLS_COMPANY_ID = os.environ.get("PAPERCLIP_COMPANY_ID", "42a7acd0-30c5-4cbd-a
|
|||||||
@app.get("/api/admin/skills")
|
@app.get("/api/admin/skills")
|
||||||
async def api_list_skills():
|
async def api_list_skills():
|
||||||
"""List installed Paperclip skills with DB sync status."""
|
"""List installed Paperclip skills with DB sync status."""
|
||||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
rows = []
|
||||||
try:
|
try:
|
||||||
rows = await conn.fetch(
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL, timeout=5)
|
||||||
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
|
try:
|
||||||
"FROM company_skills WHERE company_id = $1::uuid ORDER BY slug",
|
rows = await conn.fetch(
|
||||||
SKILLS_COMPANY_ID,
|
"SELECT slug, name, length(markdown) as md_chars, file_inventory, updated_at "
|
||||||
)
|
"FROM company_skills WHERE company_id = $1::uuid ORDER BY slug",
|
||||||
finally:
|
SKILLS_COMPANY_ID,
|
||||||
await conn.close()
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
except (OSError, asyncpg.PostgresError, asyncpg.InterfaceError, TimeoutError):
|
||||||
|
# Paperclip DB unreachable — continue with disk-only skills
|
||||||
|
pass
|
||||||
|
|
||||||
skills = []
|
skills = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -2317,7 +2664,7 @@ async def api_restart_paperclip():
|
|||||||
"message": "Restart requested — the host watcher will restart Paperclip shortly.",
|
"message": "Restart requested — the host watcher will restart Paperclip shortly.",
|
||||||
}
|
}
|
||||||
except Exception:
|
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")
|
@app.post("/api/cases/{case_number}/documents/upload-tagged")
|
||||||
@@ -2394,25 +2741,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"}
|
_progress[task_id] = {"status": "processing", "filename": display_name, "step": "extracting"}
|
||||||
result = await processor.process_document(doc_id, case_id)
|
result = await processor.process_document(doc_id, case_id)
|
||||||
|
|
||||||
# Git commit + push
|
# Git commit + push (best-effort — don't fail upload on git errors)
|
||||||
repo_dir = config.find_case_dir(case_number)
|
try:
|
||||||
if repo_dir.exists():
|
repo_dir = config.find_case_dir(case_number)
|
||||||
env = {
|
if repo_dir.exists():
|
||||||
"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
env = {
|
||||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||||
"PATH": "/usr/bin:/bin",
|
"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)
|
doc_type_hebrew = DOC_TYPE_NAMES.get(doc_type, doc_type)
|
||||||
subprocess.run(
|
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {display_name}"],
|
subprocess.run(
|
||||||
cwd=repo_dir, capture_output=True, env=env,
|
["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={
|
# Try to push to Gitea (non-blocking)
|
||||||
**env,
|
subprocess.run(["git", "push"], cwd=repo_dir, capture_output=True, env={
|
||||||
"GIT_TERMINAL_PROMPT": "0",
|
**env,
|
||||||
})
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Git commit/push failed for %s (non-critical)", display_name)
|
||||||
|
|
||||||
_progress[task_id] = {
|
_progress[task_id] = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -2650,21 +3000,24 @@ async def _process_case_document(task_id: str, source: Path, req: ClassifyReques
|
|||||||
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "extracting"}
|
_progress[task_id] = {"status": "processing", "filename": req.filename, "step": "extracting"}
|
||||||
result = await processor.process_document(UUID(doc["id"]), case_id)
|
result = await processor.process_document(UUID(doc["id"]), case_id)
|
||||||
|
|
||||||
# Git commit
|
# Git commit (best-effort)
|
||||||
repo_dir = config.find_case_dir(req.case_number)
|
try:
|
||||||
if repo_dir.exists():
|
repo_dir = config.find_case_dir(req.case_number)
|
||||||
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
if repo_dir.exists():
|
||||||
doc_type_hebrew = {
|
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True)
|
||||||
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
|
doc_type_hebrew = {
|
||||||
"reference": "מסמך עזר", "exhibit": "נספח",
|
"appeal": "כתב ערר", "response": "תשובה", "decision": "החלטה",
|
||||||
}.get(req.doc_type, req.doc_type)
|
"reference": "מסמך עזר", "exhibit": "נספח",
|
||||||
subprocess.run(
|
}.get(req.doc_type, req.doc_type)
|
||||||
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
|
subprocess.run(
|
||||||
cwd=repo_dir, capture_output=True,
|
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
|
||||||
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
cwd=repo_dir, capture_output=True,
|
||||||
"GIT_COMMITTER_NAME": "Ezer Mishpati", "GIT_COMMITTER_EMAIL": "legal@local",
|
env={"GIT_AUTHOR_NAME": "Ezer Mishpati", "GIT_AUTHOR_EMAIL": "legal@local",
|
||||||
"PATH": "/usr/bin:/bin"},
|
"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
|
# Remove from uploads
|
||||||
source.unlink(missing_ok=True)
|
source.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ PAPERCLIP_DB_URL = os.environ.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
PLUGIN_ID = "53461b5a-7f58-411a-9952-72f9c8d4a328" # marcusgroup.legal-ai
|
||||||
|
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
|
# Company IDs from Paperclip DB
|
||||||
COMPANIES = {
|
COMPANIES = {
|
||||||
@@ -27,19 +30,49 @@ COMPANIES = {
|
|||||||
"betterment": "8639e837-4c9d-47fa-a76b-95788d651896", # CMPA — היטלי השבחה
|
"betterment": "8639e837-4c9d-47fa-a76b-95788d651896", # CMPA — היטלי השבחה
|
||||||
}
|
}
|
||||||
|
|
||||||
APPEAL_TYPE_TO_COMPANY = {
|
# CEO agent per company — used for wakeup routing
|
||||||
"רישוי": "licensing",
|
CEO_AGENTS = {
|
||||||
"licensing": "licensing",
|
COMPANIES["licensing"]: "752cebdd-6748-4a04-aacd-c7ab0294ef33", # CMP CEO
|
||||||
"היטל השבחה": "betterment",
|
COMPANIES["betterment"]: "cdbfa8bc-3d61-41a4-a2e7-677ec7d34562", # CMPA CEO
|
||||||
"betterment_levy": "betterment",
|
}
|
||||||
"פיצויים": "betterment",
|
# Default for backwards compat
|
||||||
"compensation": "betterment",
|
CEO_AGENT_ID = CEO_AGENTS[COMPANIES["licensing"]]
|
||||||
|
|
||||||
|
# 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")
|
async def _get_company_id(appeal_type: str) -> str:
|
||||||
return COMPANIES[key]
|
"""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(
|
async def create_project(
|
||||||
@@ -50,11 +83,16 @@ async def create_project(
|
|||||||
color: str = "#6366f1",
|
color: str = "#6366f1",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a project in the Paperclip embedded DB, or return existing one."""
|
"""Create a project in the Paperclip embedded DB, or return existing one."""
|
||||||
company_id = _get_company_id(appeal_type)
|
company_id = await _get_company_id(appeal_type)
|
||||||
prefix = "CMP" if _get_company_id(appeal_type) == COMPANIES["licensing"] else "CMPA"
|
|
||||||
|
|
||||||
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
try:
|
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
|
# Check for existing project with this case number
|
||||||
existing = await conn.fetchrow(
|
existing = await conn.fetchrow(
|
||||||
"SELECT id, name FROM projects WHERE name LIKE $1 AND company_id = $2::uuid",
|
"SELECT id, name FROM projects WHERE name LIKE $1 AND company_id = $2::uuid",
|
||||||
@@ -216,8 +254,245 @@ async def get_project_url(case_number: str) -> str | None:
|
|||||||
f"%{case_number}%",
|
f"%{case_number}%",
|
||||||
)
|
)
|
||||||
if row:
|
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 f"https://pc.nautilus.marcusgroup.org/{prefix}/projects/{row['id']}/issues"
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
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,
|
||||||
|
"company_id": company_id,
|
||||||
|
"project_url": project_url,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_case_issues(case_number: str) -> list[dict]:
|
||||||
|
"""Get all Paperclip issues linked to a legal-ai case number."""
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT i.id, i.title, i.status, i.identifier, i.priority,
|
||||||
|
i.assignee_agent_id, a.name AS assignee_name,
|
||||||
|
i.started_at, i.completed_at, i.created_at, i.company_id
|
||||||
|
FROM issues i
|
||||||
|
JOIN plugin_state ps ON ps.scope_id = i.id::text
|
||||||
|
LEFT JOIN agents a ON i.assignee_agent_id = a.id
|
||||||
|
WHERE ps.plugin_id = $1::uuid
|
||||||
|
AND ps.state_key = 'legal-case-number'
|
||||||
|
AND ps.value_json = $2::jsonb
|
||||||
|
ORDER BY i.created_at""",
|
||||||
|
PLUGIN_ID, json.dumps(case_number),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"title": r["title"],
|
||||||
|
"status": r["status"],
|
||||||
|
"identifier": r["identifier"],
|
||||||
|
"priority": r["priority"],
|
||||||
|
"assignee_name": r["assignee_name"],
|
||||||
|
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
|
||||||
|
"completed_at": r["completed_at"].isoformat() if r["completed_at"] else None,
|
||||||
|
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||||
|
"company_id": str(r["company_id"]),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_issue_comments(issue_ids: list[str]) -> list[dict]:
|
||||||
|
"""Get all comments on a list of Paperclip issues, with agent metadata."""
|
||||||
|
if not issue_ids:
|
||||||
|
return []
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT ic.id, ic.issue_id, ic.body, ic.created_at,
|
||||||
|
ic.author_agent_id, ic.author_user_id,
|
||||||
|
a.name AS agent_name, a.role AS agent_role, a.icon AS agent_icon
|
||||||
|
FROM issue_comments ic
|
||||||
|
LEFT JOIN agents a ON ic.author_agent_id = a.id
|
||||||
|
WHERE ic.issue_id = ANY($1::uuid[])
|
||||||
|
ORDER BY ic.created_at""",
|
||||||
|
issue_ids,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"issue_id": str(r["issue_id"]),
|
||||||
|
"body": r["body"],
|
||||||
|
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||||
|
"author_agent_id": str(r["author_agent_id"]) if r["author_agent_id"] else None,
|
||||||
|
"author_user_id": r["author_user_id"],
|
||||||
|
"agent_name": r["agent_name"],
|
||||||
|
"agent_role": r["agent_role"],
|
||||||
|
"agent_icon": r["agent_icon"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_agents_for_company(company_id: str) -> list[dict]:
|
||||||
|
"""Get all agents belonging to a Paperclip company."""
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT id, name, role, title, status, icon, last_heartbeat_at
|
||||||
|
FROM agents
|
||||||
|
WHERE company_id = $1::uuid
|
||||||
|
ORDER BY role, name""",
|
||||||
|
company_id,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"name": r["name"],
|
||||||
|
"role": r["role"],
|
||||||
|
"title": r["title"],
|
||||||
|
"status": r["status"],
|
||||||
|
"icon": r["icon"],
|
||||||
|
"last_heartbeat_at": r["last_heartbeat_at"].isoformat() if r["last_heartbeat_at"] else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def post_comment(issue_id: str, company_id: str, body: str) -> dict:
|
||||||
|
"""Post a comment on a Paperclip issue.
|
||||||
|
|
||||||
|
Tries the Board API first (triggers plugin events for CEO routing).
|
||||||
|
Falls back to direct DB insert + CEO wakeup if API fails.
|
||||||
|
"""
|
||||||
|
# Try Board API first — this triggers the event bus
|
||||||
|
if PAPERCLIP_BOARD_API_KEY:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{PAPERCLIP_API_URL}/api/board/issues/{issue_id}/comments",
|
||||||
|
json={"body": body},
|
||||||
|
headers={"Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}"},
|
||||||
|
)
|
||||||
|
if resp.status_code < 400:
|
||||||
|
result = resp.json()
|
||||||
|
logger.info("Posted comment via Board API on issue %s", issue_id)
|
||||||
|
return {"comment_id": result.get("id", ""), "issue_id": issue_id, "method": "api"}
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Board API comment failed for issue %s, falling back to DB", issue_id)
|
||||||
|
|
||||||
|
# Fallback: direct DB insert + explicit CEO wakeup
|
||||||
|
comment_id = str(uuid.uuid4())
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO issue_comments (id, company_id, issue_id, author_user_id, body)
|
||||||
|
VALUES ($1, $2::uuid, $3::uuid, 'chaim', $4)""",
|
||||||
|
comment_id, company_id, issue_id, body,
|
||||||
|
)
|
||||||
|
logger.info("Posted comment via DB fallback on issue %s", issue_id)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
# Wake the correct CEO for this company
|
||||||
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
|
try:
|
||||||
|
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
||||||
|
payload = {
|
||||||
|
"source": "on_demand",
|
||||||
|
"triggerDetail": "manual",
|
||||||
|
"reason": f"user_comment_{issue_id}",
|
||||||
|
"payload": {"issueId": issue_id, "mutation": "comment"},
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to wake CEO after DB comment on issue %s", issue_id)
|
||||||
|
|
||||||
|
return {"comment_id": comment_id, "issue_id": issue_id, "method": "db_fallback"}
|
||||||
|
|
||||||
|
|
||||||
|
async def wake_ceo_agent(issue_id: str, case_number: str, company_id: 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).
|
||||||
|
Routes to the correct CEO based on company_id.
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
raise RuntimeError("PAPERCLIP_BOARD_API_KEY not set — cannot wake CEO agent")
|
||||||
|
|
||||||
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
|
url = f"{PAPERCLIP_API_URL}/api/agents/{ceo_id}/wakeup"
|
||||||
|
payload = {
|
||||||
|
"source": "on_demand",
|
||||||
|
"triggerDetail": "manual",
|
||||||
|
"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
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
/* ════════════════════════════════════════════════════════════
|
|
||||||
* Ezer Mishpati — Design System
|
|
||||||
* Editorial/Judicial aesthetic for a Hebrew RTL judicial tool.
|
|
||||||
*
|
|
||||||
* Typography: Frank Ruhl Libre (display) + Assistant (body)
|
|
||||||
* Palette: Navy #0f172a + Cream #f5f1e8 + Gold #a97d3a
|
|
||||||
* ════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Heebo:wght@300;400;500;600;700;800;900&display=swap');
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* ── Colors ─────────────────────────────────────────── */
|
|
||||||
--color-navy: #0f172a;
|
|
||||||
--color-navy-soft: #1e293b;
|
|
||||||
--color-navy-dim: #334155;
|
|
||||||
|
|
||||||
--color-cream: #f5f1e8;
|
|
||||||
--color-cream-deep: #ede8d8;
|
|
||||||
--color-parchment: #fbf8f0;
|
|
||||||
|
|
||||||
--color-gold: #a97d3a;
|
|
||||||
--color-gold-deep: #8b6428;
|
|
||||||
--color-gold-soft: #c89a56;
|
|
||||||
--color-gold-wash: #fdf6e8;
|
|
||||||
|
|
||||||
--color-ink: #1a1a2e;
|
|
||||||
--color-ink-soft: #3a3a52;
|
|
||||||
--color-ink-muted: #6b7280;
|
|
||||||
--color-ink-light: #9ca3af;
|
|
||||||
|
|
||||||
--color-rule: #e5dfd0; /* cream-toned hairline */
|
|
||||||
--color-rule-soft: #f0ead8;
|
|
||||||
|
|
||||||
--color-surface: #ffffff;
|
|
||||||
--color-surface-raised: #fbf8f0;
|
|
||||||
--color-bg: var(--color-cream);
|
|
||||||
|
|
||||||
/* Status colors — tuned to the palette */
|
|
||||||
--color-success: #4a7c59;
|
|
||||||
--color-success-bg: #e8efe7;
|
|
||||||
--color-warn: #b8894a;
|
|
||||||
--color-warn-bg: #faf0dc;
|
|
||||||
--color-danger: #a54242;
|
|
||||||
--color-danger-bg: #f5e6e6;
|
|
||||||
--color-info: #4e6a8c;
|
|
||||||
--color-info-bg: #e6ecf3;
|
|
||||||
|
|
||||||
/* ── Typography — Heebo (Google's primary Hebrew font) ─── */
|
|
||||||
--font-display: 'Heebo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
--font-body: 'Heebo', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
--font-mono: ui-monospace, 'Cascadia Code', 'SF Mono', Menlo, monospace;
|
|
||||||
|
|
||||||
--text-xs: 0.75rem;
|
|
||||||
--text-sm: 0.85rem;
|
|
||||||
--text-base: 0.95rem;
|
|
||||||
--text-md: 1.05rem;
|
|
||||||
--text-lg: 1.2rem;
|
|
||||||
--text-xl: 1.45rem;
|
|
||||||
--text-2xl: 1.8rem;
|
|
||||||
--text-3xl: 2.3rem;
|
|
||||||
--text-4xl: 2.9rem;
|
|
||||||
|
|
||||||
--leading-tight: 1.25;
|
|
||||||
--leading-snug: 1.4;
|
|
||||||
--leading-body: 1.65;
|
|
||||||
--leading-prose: 1.8;
|
|
||||||
|
|
||||||
--weight-light: 300;
|
|
||||||
--weight-normal: 400;
|
|
||||||
--weight-medium: 500;
|
|
||||||
--weight-semi: 600;
|
|
||||||
--weight-bold: 700;
|
|
||||||
--weight-display: 900;
|
|
||||||
|
|
||||||
/* ── Spacing scale (8px grid) ───────────────────────── */
|
|
||||||
--space-1: 4px;
|
|
||||||
--space-2: 8px;
|
|
||||||
--space-3: 12px;
|
|
||||||
--space-4: 16px;
|
|
||||||
--space-5: 20px;
|
|
||||||
--space-6: 24px;
|
|
||||||
--space-7: 32px;
|
|
||||||
--space-8: 40px;
|
|
||||||
--space-9: 56px;
|
|
||||||
--space-10: 72px;
|
|
||||||
|
|
||||||
/* ── Radii ──────────────────────────────────────────── */
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--radius: 6px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--radius-xl: 16px;
|
|
||||||
--radius-pill: 999px;
|
|
||||||
|
|
||||||
/* ── Shadows — soft, editorial ──────────────────────── */
|
|
||||||
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05);
|
|
||||||
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
||||||
--shadow: 0 2px 6px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
||||||
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.08), 0 2px 4px rgba(15, 23, 42, 0.04);
|
|
||||||
--shadow-lg: 0 10px 30px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.05);
|
|
||||||
--shadow-gold: 0 0 0 3px var(--color-gold-wash);
|
|
||||||
|
|
||||||
/* ── Transitions ────────────────────────────────────── */
|
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
|
||||||
--t-fast: 120ms var(--ease-out);
|
|
||||||
--t: 180ms var(--ease-out);
|
|
||||||
--t-slow: 280ms var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Dark theme overrides ────────────────────────────── */
|
|
||||||
body.dark {
|
|
||||||
--color-navy: #f5f1e8;
|
|
||||||
--color-navy-soft: #e8e0c8;
|
|
||||||
--color-navy-dim: #c7bc9a;
|
|
||||||
|
|
||||||
--color-cream: #0a0f1c;
|
|
||||||
--color-cream-deep: #121a2e;
|
|
||||||
--color-parchment: #161f36;
|
|
||||||
|
|
||||||
--color-gold: #d4a55a;
|
|
||||||
--color-gold-deep: #e8bc6f;
|
|
||||||
--color-gold-soft: #c89a56;
|
|
||||||
--color-gold-wash: rgba(212, 165, 90, 0.08);
|
|
||||||
|
|
||||||
--color-ink: #f5f1e8;
|
|
||||||
--color-ink-soft: #d8d2c0;
|
|
||||||
--color-ink-muted: #9a9380;
|
|
||||||
--color-ink-light: #6a6458;
|
|
||||||
|
|
||||||
--color-rule: #2a3352;
|
|
||||||
--color-rule-soft: #1e2a45;
|
|
||||||
|
|
||||||
--color-surface: #141b2f;
|
|
||||||
--color-surface-raised: #1a2238;
|
|
||||||
--color-bg: #0a0f1c;
|
|
||||||
|
|
||||||
--color-success: #5a9a6a;
|
|
||||||
--color-success-bg: rgba(90, 154, 106, 0.12);
|
|
||||||
--color-warn: #c79956;
|
|
||||||
--color-warn-bg: rgba(199, 153, 86, 0.12);
|
|
||||||
--color-danger: #c16565;
|
|
||||||
--color-danger-bg: rgba(193, 101, 101, 0.12);
|
|
||||||
--color-info: #6d8bab;
|
|
||||||
--color-info-bg: rgba(109, 139, 171, 0.12);
|
|
||||||
|
|
||||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
|
|
||||||
--shadow: 0 2px 6px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.25);
|
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45), 0 2px 4px rgba(0, 0, 0, 0.25);
|
|
||||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark header {
|
|
||||||
background: #060a18;
|
|
||||||
border-bottom-color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Base overrides ──────────────────────────────────── */
|
|
||||||
|
|
||||||
html { font-size: 16px; }
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: var(--weight-normal);
|
|
||||||
font-size: var(--text-base);
|
|
||||||
line-height: var(--leading-body);
|
|
||||||
color: var(--color-ink);
|
|
||||||
background: var(--color-bg);
|
|
||||||
direction: rtl;
|
|
||||||
text-align: right;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
font-feature-settings: "kern", "liga", "clig", "calt";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Display typography — serif for headings */
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-weight: var(--weight-bold);
|
|
||||||
line-height: var(--leading-tight);
|
|
||||||
color: var(--color-navy);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: var(--text-3xl); font-weight: var(--weight-display); }
|
|
||||||
h2 { font-size: var(--text-2xl); }
|
|
||||||
h3 { font-size: var(--text-xl); }
|
|
||||||
h4 { font-size: var(--text-lg); }
|
|
||||||
h5 { font-size: var(--text-md); }
|
|
||||||
h6 { font-size: var(--text-base); }
|
|
||||||
|
|
||||||
/* Prose paragraphs — justify both sides for Hebrew legal text */
|
|
||||||
p,
|
|
||||||
.prose {
|
|
||||||
text-align: justify;
|
|
||||||
text-justify: inter-word;
|
|
||||||
hyphens: auto;
|
|
||||||
line-height: var(--leading-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text that should NOT justify (short labels, meta) */
|
|
||||||
.no-justify, .meta, .label, .caption,
|
|
||||||
th, td, button, input, select, label, nav {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Links */
|
|
||||||
a {
|
|
||||||
color: var(--color-gold-deep);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color var(--t-fast);
|
|
||||||
}
|
|
||||||
a:hover { color: var(--color-gold); }
|
|
||||||
|
|
||||||
/* Focus rings — gold, subtle */
|
|
||||||
*:focus-visible {
|
|
||||||
outline: 2px solid var(--color-gold);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selection */
|
|
||||||
::selection {
|
|
||||||
background: var(--color-gold-wash);
|
|
||||||
color: var(--color-navy);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Utility classes ─────────────────────────────────── */
|
|
||||||
|
|
||||||
.text-display { font-family: var(--font-display); }
|
|
||||||
.text-body { font-family: var(--font-body); }
|
|
||||||
.text-mono { font-family: var(--font-mono); }
|
|
||||||
|
|
||||||
.text-xs { font-size: var(--text-xs); }
|
|
||||||
.text-sm { font-size: var(--text-sm); }
|
|
||||||
.text-base { font-size: var(--text-base); }
|
|
||||||
.text-md { font-size: var(--text-md); }
|
|
||||||
.text-lg { font-size: var(--text-lg); }
|
|
||||||
.text-xl { font-size: var(--text-xl); }
|
|
||||||
.text-2xl { font-size: var(--text-2xl); }
|
|
||||||
.text-3xl { font-size: var(--text-3xl); }
|
|
||||||
|
|
||||||
.text-muted { color: var(--color-ink-muted); }
|
|
||||||
.text-light { color: var(--color-ink-light); }
|
|
||||||
.text-gold { color: var(--color-gold-deep); }
|
|
||||||
.text-navy { color: var(--color-navy); }
|
|
||||||
|
|
||||||
.weight-light { font-weight: var(--weight-light); }
|
|
||||||
.weight-normal { font-weight: var(--weight-normal); }
|
|
||||||
.weight-medium { font-weight: var(--weight-medium); }
|
|
||||||
.weight-bold { font-weight: var(--weight-bold); }
|
|
||||||
|
|
||||||
.justify { text-align: justify; text-justify: inter-word; }
|
|
||||||
.start { text-align: right; } /* RTL start */
|
|
||||||
.end { text-align: left; } /* RTL end */
|
|
||||||
.center { text-align: center; }
|
|
||||||
|
|
||||||
.ornament {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-gold);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.3em;
|
|
||||||
margin: var(--space-6) 0;
|
|
||||||
}
|
|
||||||
.ornament::before { content: "❦"; font-size: 1.3em; }
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
border: 0;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(
|
|
||||||
to left,
|
|
||||||
transparent 0%,
|
|
||||||
var(--color-rule) 20%,
|
|
||||||
var(--color-rule) 80%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
margin: var(--space-6) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider-gold {
|
|
||||||
border: 0;
|
|
||||||
height: 2px;
|
|
||||||
background: linear-gradient(
|
|
||||||
to left,
|
|
||||||
transparent 0%,
|
|
||||||
var(--color-gold) 50%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
margin: var(--space-6) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Loading skeleton ───────────────────────────────── */
|
|
||||||
.skeleton {
|
|
||||||
background: linear-gradient(
|
|
||||||
100deg,
|
|
||||||
var(--color-cream-deep) 30%,
|
|
||||||
var(--color-parchment) 50%,
|
|
||||||
var(--color-cream-deep) 70%
|
|
||||||
);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: skeleton-shimmer 1.4s linear infinite;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
color: transparent;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
@keyframes skeleton-shimmer {
|
|
||||||
from { background-position: 100% 0; }
|
|
||||||
to { background-position: -100% 0; }
|
|
||||||
}
|
|
||||||
.skeleton-line {
|
|
||||||
height: 0.9em;
|
|
||||||
margin: 4px 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
.skeleton-line.short { width: 40%; }
|
|
||||||
.skeleton-line.medium { width: 70%; }
|
|
||||||
|
|
||||||
/* ── Print — optimized for Dafna printing the portrait ─ */
|
|
||||||
@media print {
|
|
||||||
:root {
|
|
||||||
--color-bg: #fff;
|
|
||||||
--color-surface: #fff;
|
|
||||||
--color-navy: #000;
|
|
||||||
--color-ink: #000;
|
|
||||||
--color-ink-muted: #444;
|
|
||||||
}
|
|
||||||
body { background: #fff; color: #000; font-size: 11pt; }
|
|
||||||
header, .status-bar, .process-panel, .toast, .btn, nav,
|
|
||||||
#navDiagnostics, .home-sidebar, .home-hero-actions,
|
|
||||||
#processPanel, #trainingAnalysisCard, #trainingTasksCard {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.main { max-width: 100% !important; padding: 0 !important; }
|
|
||||||
.page { display: none !important; }
|
|
||||||
.page.active { display: block !important; }
|
|
||||||
.portrait-card, .card {
|
|
||||||
box-shadow: none !important;
|
|
||||||
border: 1px solid #ccc !important;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
margin-bottom: 16px !important;
|
|
||||||
}
|
|
||||||
.portrait-headline {
|
|
||||||
background: #fafafa !important;
|
|
||||||
border-right: 3px solid #000 !important;
|
|
||||||
color: #000 !important;
|
|
||||||
}
|
|
||||||
h1, h2, h3 { color: #000 !important; page-break-after: avoid; }
|
|
||||||
.growth-curve, .donut, .hero-timeline { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
||||||
.phrase-filters, .btn, button { display: none !important; }
|
|
||||||
/* Force expand all details */
|
|
||||||
details { display: block !important; }
|
|
||||||
summary::marker, summary::-webkit-details-marker { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive (desktop-first, minimal mobile) ────── */
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.main { padding: var(--space-5) var(--space-4); }
|
|
||||||
header { padding: 14px 20px; flex-wrap: wrap; gap: 10px; }
|
|
||||||
header nav { gap: 2px; }
|
|
||||||
header nav a { padding: 6px 10px; font-size: 0.82em; }
|
|
||||||
.home-hero-title { font-size: 2em; }
|
|
||||||
.style-report-header h1 { font-size: 2em; }
|
|
||||||
.portrait-card { padding: var(--space-6) var(--space-5); }
|
|
||||||
.portrait-hero .hero-body { grid-template-columns: 1fr; }
|
|
||||||
.hero-donut-wrap { justify-content: center; }
|
|
||||||
.process-panel { width: 280px; }
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user