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 { 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 { expectedOutcomes } from "@/lib/schemas/case";
|
||||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||
@@ -86,6 +88,9 @@ export default function CaseDetailPage({
|
||||
<TabsTrigger value="drafts">
|
||||
טיוטות והערות
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents">
|
||||
סוכנים
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<UploadSheet caseNumber={caseNumber} />
|
||||
</div>
|
||||
@@ -153,12 +158,17 @@ export default function CaseDetailPage({
|
||||
status={data?.status}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-5">
|
||||
<AgentActivityFeed caseNumber={caseNumber} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<WorkflowTimeline status={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 (
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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 ──────────────────────────────
|
||||
|
||||
@app.get("/api/settings/paperclip-companies")
|
||||
|
||||
@@ -303,6 +303,159 @@ async def create_workflow_issue(case_number: str, title: str) -> dict:
|
||||
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:
|
||||
"""Wake the CEO agent via Paperclip's wakeup API.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user