Add Paperclip agent activity mirror to case detail page
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:
2026-04-15 10:44:42 +00:00
parent 2e2d2d42b6
commit 1e4c5c1518
7 changed files with 1051 additions and 1 deletions

View 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` הנכון

View File

@@ -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} />

View 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">
התהליך טרם הופעל. לחץ &quot;התחל תהליך&quot; בלשונית סקירה.
</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 &middot; 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>
);
}

View 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>
);
}

View 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) });
}
},
});
}

View File

@@ -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")

View File

@@ -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.