Add Paperclip agent activity mirror to case detail page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m16s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m16s
New "Agents" tab in case detail shows all Paperclip agent comments,
issue status, and agent status for each case — eliminating the need
to switch between Legal-AI and Paperclip UIs.
Backend: 4 new DB query functions in paperclip_client.py (issues,
comments, agents, post_comment) + 2 new API endpoints (GET/POST
/api/cases/{case_number}/agents). Comment posting uses Board API
with DB+wakeup fallback to ensure CEO routing.
Frontend: agents.ts hooks (10s polling), AgentActivityFeed component
(markdown timeline + comment input), AgentStatusWidget (sidebar),
4th tab in case detail page.
Also includes new-company-setup-guide.md documenting the process
for setting up the betterment levy (CMPA) company.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
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` הנכון
|
||||||
@@ -14,6 +14,8 @@ 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 { 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, useStartWorkflow } from "@/lib/api/cases";
|
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||||
@@ -86,6 +88,9 @@ export default function CaseDetailPage({
|
|||||||
<TabsTrigger value="drafts">
|
<TabsTrigger value="drafts">
|
||||||
טיוטות והערות
|
טיוטות והערות
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="agents">
|
||||||
|
סוכנים
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<UploadSheet caseNumber={caseNumber} />
|
<UploadSheet caseNumber={caseNumber} />
|
||||||
</div>
|
</div>
|
||||||
@@ -153,12 +158,17 @@ export default function CaseDetailPage({
|
|||||||
status={data?.status}
|
status={data?.status}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</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} />
|
||||||
|
|||||||
267
web-ui/src/components/cases/agent-activity-feed.tsx
Normal file
267
web-ui/src/components/cases/agent-activity-feed.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"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",
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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)}`} />
|
||||||
|
{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} — {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
53
web/app.py
53
web/app.py
@@ -37,7 +37,11 @@ from web.gitea_client import create_repo, setup_remote_and_push
|
|||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
create_project as pc_create_project,
|
create_project as pc_create_project,
|
||||||
create_workflow_issue as pc_create_workflow_issue,
|
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,
|
get_project_url,
|
||||||
|
post_comment as pc_post_comment,
|
||||||
wake_ceo_agent as pc_wake_ceo,
|
wake_ceo_agent as pc_wake_ceo,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2214,6 +2218,55 @@ async def api_start_workflow(case_number: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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 ──────────────────────────────
|
# ── Settings: Tag → Company Mappings ──────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/settings/paperclip-companies")
|
@app.get("/api/settings/paperclip-companies")
|
||||||
|
|||||||
@@ -303,6 +303,159 @@ async def create_workflow_issue(case_number: str, title: str) -> dict:
|
|||||||
await conn.close()
|
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 CEO so it processes the comment
|
||||||
|
try:
|
||||||
|
url = f"{PAPERCLIP_API_URL}/api/agents/{CEO_AGENT_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) -> dict:
|
async def wake_ceo_agent(issue_id: str, case_number: str) -> dict:
|
||||||
"""Wake the CEO agent via Paperclip's wakeup API.
|
"""Wake the CEO agent via Paperclip's wakeup API.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user