Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bb0c06345 | |||
| 59fc8acdc4 | |||
| 586b543a4c | |||
| c3212e71fe | |||
| f84b2f1cef | |||
| 2a8046b737 | |||
| f319f981f9 | |||
| bf98495198 | |||
| c541069cb5 | |||
| 7480e7db35 | |||
| 3c6e54d233 |
@@ -0,0 +1,2 @@
|
||||
*.zip
|
||||
build/
|
||||
@@ -0,0 +1,454 @@
|
||||
# SmartAssistant v1.0.3 — סיכום מנהלים לפריסה בפיתוח
|
||||
|
||||
**תאריך:** 2026-03-31
|
||||
**גרסה:** 1.0.3 | **תאריך שחרור:** 2026-03-29
|
||||
**סביבת יעד:** EspoCRM Development (https://espocrm.dev.marcus-law.co.il)
|
||||
**מחבר:** klear | **דרישות:** EspoCRM >= 8.0.0, PHP >= 8.1
|
||||
|
||||
---
|
||||
|
||||
## 1. תיאור כללי
|
||||
|
||||
SmartAssistant הוא מודול עוזר AI מאוחד למשרד עורכי דין.
|
||||
|
||||
המודול מספק:
|
||||
- **ממשק צ'אט צף** (Floating Action Button) בכל דפי המערכת
|
||||
- **מצב משרד** — סקירה כללית, התראות, סטטיסטיקות עומס עבודה
|
||||
- **מצב תיק** — סיוע מעמיק בתיק ספציפי עם הקשר מלא
|
||||
- **מערכת זיכרון תיק** (CaseMemory) — מאגר ידע מובנה לכל תיק ב-7 קטגוריות
|
||||
- **ביצוע פעולות באישור** — יצירת משימות, פתקים, דיונים, שינוי סטטוס
|
||||
- **אינטגרציה עם Stream** — כל אינטראקציה מופיעה בציר הזמן של התיק
|
||||
- **התראות בסרגל ניווט** — תיקים לא פעילים, משימות באיחור, דיונים קרובים
|
||||
|
||||
---
|
||||
|
||||
## 2. מה בוצע
|
||||
|
||||
### 2.1 Backend (PHP) — 16 קבצים
|
||||
|
||||
| רכיב | קובץ | תפקיד |
|
||||
|-------|------|--------|
|
||||
| Controller | `Controllers/SmartAssistant.php` | 10 נקודות קצה API (chat, execute, summary, alerts, history, conversations, conversationMessages, searchHistory, caseMemory, saveMemory) |
|
||||
| שירות ראשי | `Services/SmartAssistantService.php` | תזמור מלא: בניית הקשר → קריאת webhook → טיפול בפעולות → שמירת שיחה |
|
||||
| בונה הקשר תיק | `Services/CaseContextBuilder.php` | אוסף: נתוני תיק, אנשי קשר, משימות פתוחות, דיונים קרובים, פתקים אחרונים, זיכרון |
|
||||
| בונה הקשר משרד | `Services/OfficeContextBuilder.php` | סטטיסטיקות כלליות, התראות, עומס עבודה לפי משתמש, drill-down לתיקים |
|
||||
| מחשבון התראות | `Services/AlertCalculator.php` | 6 סוגי התראות: תיקים לא פעילים (14/30 יום), משימות באיחור, דיונים קרובים, תיקים ללא שיוך, משימות בעדיפות גבוהה |
|
||||
| מבצע פעולות | `Services/ActionExecutor.php` | 8 כלים: create_task, add_note, change_status, create_meeting, schedule_hearing, list_documents, analyze_document, save_memory, rename_document |
|
||||
| שירות זיכרון | `Services/CaseMemoryService.php` | CRUD מלא לאנטיטי CaseMemory |
|
||||
| ספק הקשר זיכרון | `Services/CaseMemoryContextProvider.php` | הזרקת זיכרון מוצמד/חשוב להקשר AI |
|
||||
| מאגר שיחות | `Services/ConversationRepository.php` | שמירה ואחזור שיחות ב-AssistantConversation |
|
||||
| מנתח מסמכים | `Services/DocumentAnalyzer.php` | סריקת מסמכים דרך FileStorage |
|
||||
| Hook - שינוי תיק | `Hooks/Case/CaseFieldChangeMemory.php` | זיכרון אוטומטי בשינוי: סטטוס, שופט, בית משפט, מועד דיון |
|
||||
| Hook - דיון | `Hooks/Meeting/HearingScheduledMemory.php` | זיכרון אוטומטי בקביעת דיון חדש |
|
||||
| FileStorage | `Classes/FileStorage/*` | 4 קבצים: Interface, Factory, WebDav, NextCloud |
|
||||
|
||||
### 2.2 Frontend (JavaScript) — 9 קבצים
|
||||
|
||||
| רכיב | תפקיד |
|
||||
|-------|--------|
|
||||
| `floating-chat.js` | ממשק צ'אט צף (~1500 שורות): שליחת הודעות, היסטוריה, זיכרון, אישור/דחיית פעולות, עיצוב RTL, Material Design |
|
||||
| `site/navbar.js` | אייקון התראות בסרגל ניווט עם badge מספרי |
|
||||
| `dashlets/smart-assistant.js` | דשלט לדשבורד: סיכום מדדים + התראות אחרונות |
|
||||
| `dashlets/options/smart-assistant.js` | הגדרות דשלט |
|
||||
| `case/modals/add-memory.js` | מודל להוספת זיכרון ידנית לתיק |
|
||||
| `stream/notes/smart-request.js` | תצוגת הודעת משתמש ב-Stream |
|
||||
| `stream/notes/smart-response.js` | תצוגת תשובת עוזר ב-Stream |
|
||||
| `stream/notes/smart-action.js` | תצוגת פעולה שבוצעה ב-Stream |
|
||||
|
||||
### 2.3 מטא-נתונים וקונפיגורציה — 17 קבצי JSON
|
||||
|
||||
- **routes.json** — 10 נתיבי API
|
||||
- **module.json** — רישום מודול עם clientModule
|
||||
- **entityDefs/** — CaseMemory (אנטיטי חדש, 15 שדות, 4 אינדקסים), Case (שדה cNextHearing), Note (סוגי stream חדשים)
|
||||
- **scopes/CaseMemory.json** — הגדרות scope
|
||||
- **layouts/CaseMemory/** — 4 layouts (list, detail, listSmall, detailSmall)
|
||||
- **dashlets/SmartAssistant.json** — הגדרת דשלט
|
||||
- **integrations/SmartAssistant.json** — 6 שדות הגדרה (webhookUrl, apiKey, maxMessagesPerHour, inactivityWarningDays, inactivityCriticalDays, upcomingHearingDays)
|
||||
- **clientDefs/App.json** — רישום navbar
|
||||
|
||||
### 2.4 תרגומים — 10 קבצים
|
||||
|
||||
- **en_US** — 5 קבצי תרגום (SmartAssistant, CaseMemory, Case, Global, Integration)
|
||||
- **fa_IR** — 5 קבצי תרגום מקבילים (עברית/פרסית)
|
||||
|
||||
### 2.5 בנייה והפצה
|
||||
|
||||
- **build.sh** — סקריפט בנייה: קורא גרסה מ-manifest.json, מעדכן README, יוצר ZIP
|
||||
- **composer.json** — הגדרת חבילה ל-Composer Registry
|
||||
- **4 גרסאות ZIP** — 1.0.0, 1.0.1, 1.0.2, 1.0.3
|
||||
|
||||
---
|
||||
|
||||
## 3. היסטוריית גרסאות ותיקוני באגים
|
||||
|
||||
### v1.0.0 — `a148e0f`
|
||||
- **פיצ'ר ראשוני** — מודול SmartAssistant מלא עם case memory
|
||||
|
||||
### v1.0.0 → v1.0.1 — תיקונים
|
||||
| Commit | תקלה | תיקון |
|
||||
|--------|-------|--------|
|
||||
| `0cdbf6d` | דשלט ירש מ-DashletView שלא קיים בגרסאות חדשות | שונה ל-base View + הוספת getTitle() |
|
||||
| `ffd966a` | לא היה composer.json לפרסום אוטומטי | נוסף composer.json |
|
||||
| `bd1cd17` | גרסה ב-README לא מתעדכנת אוטומטית | build.sh מעדכן README |
|
||||
| `95ca8a0` | חסר תיעוד מקיף | נוסף README מלא בעברית |
|
||||
| `fbe4f82` | נתיבי client לא תואמים לפורמט מודולים של EspoCRM | מיגרציה לנתיבי מודול תקניים |
|
||||
|
||||
### v1.0.1 → v1.0.2 — `f380542`
|
||||
| תקלה | תיקון |
|
||||
|-------|--------|
|
||||
| נתיבי CSS ו-script שגויים ב-app/client.json metadata | תיקון הנתיבים ל-format הנכון |
|
||||
|
||||
### v1.0.2 → v1.0.3 — `d5c8a60`
|
||||
| תקלה | תיקון |
|
||||
|-------|--------|
|
||||
| EspoCRM לא מצליח לרזולב templates בצד לקוח כי חסר clientModule | הוספת `clientModule` ל-module.json |
|
||||
|
||||
### סיכום תקלות שנמצאו ותוקנו
|
||||
|
||||
| # | תקלה | חומרה | סטטוס |
|
||||
|---|-------|--------|--------|
|
||||
| 1 | דשלט ירש מ-class לא קיים | גבוהה | תוקן v1.0.1 |
|
||||
| 2 | נתיבי client לא תקניים לפורמט מודולים | גבוהה | תוקן v1.0.1 |
|
||||
| 3 | נתיבי CSS/script שגויים ב-metadata | בינונית | תוקן v1.0.2 |
|
||||
| 4 | חסר clientModule לרזולוציית templates | גבוהה | תוקן v1.0.3 |
|
||||
|
||||
---
|
||||
|
||||
## 4. מה נבדק
|
||||
|
||||
### נבדק ידנית (על סמך תיקוני הבאגים)
|
||||
- רישום מודול וטעינת client-side assets
|
||||
- תצוגת דשלט בדשבורד
|
||||
- רזולוציית templates בצד לקוח
|
||||
- נתיבי metadata (CSS, scripts)
|
||||
|
||||
### לא נבדק (אין קבצי טסט בפרויקט)
|
||||
- **אין בדיקות יחידה (unit tests)** לקוד PHP
|
||||
- **אין בדיקות אינטגרציה** ל-API endpoints
|
||||
- **אין בדיקות E2E** לממשק הצ'אט
|
||||
- **אין CI/CD pipeline** (אין GitHub Actions / Gitea Actions)
|
||||
|
||||
---
|
||||
|
||||
## 5. דרישות קדם לפריסה בפיתוח
|
||||
|
||||
| דרישה | סטטוס | פירוט |
|
||||
|--------|--------|--------|
|
||||
| EspoCRM >= 8.0.0 | לאמת | לוודא גרסה בסביבת dev |
|
||||
| PHP >= 8.1 | לאמת | לוודא גרסה בשרת |
|
||||
| Webhook URL | **נדרש הגדרה** | יש להגדיר n8n workflow שיקבל את הפניות |
|
||||
| API Key | אופציונלי | לאימות webhook |
|
||||
| AssistantConversation entity | לאמת | נדרש שהאנטיטי קיים |
|
||||
| WebDAV/NextCloud | אופציונלי | רק לפיצ'ר ניתוח מסמכים |
|
||||
|
||||
---
|
||||
|
||||
## 6. מה נשאר לעשות
|
||||
|
||||
### קריטי לפני פריסה
|
||||
| # | משימה | עדיפות |
|
||||
|---|--------|---------|
|
||||
| 1 | **הגדרת n8n workflow** לקבלת webhook מהעוזר (chat endpoint) | קריטי |
|
||||
| 2 | **בדיקת תאימות** עם גרסת EspoCRM בסביבת dev | קריטי |
|
||||
| 3 | **לוודא שאין שאריות** של מודולים ישנים בסביבת dev | קריטי |
|
||||
| 4 | **הגדרת Integration** — webhookUrl + apiKey ב-Admin > Integrations | קריטי |
|
||||
| 5 | **בדיקה שאנטיטי AssistantConversation קיים** או שצריך ליצור אותו | קריטי |
|
||||
|
||||
### חשוב אחרי פריסה
|
||||
| # | משימה | עדיפות |
|
||||
|---|--------|---------|
|
||||
| 6 | **בדיקות ידניות מקיפות** — צ'אט במצב משרד ומצב תיק | גבוהה |
|
||||
| 7 | **בדיקת פעולות** — create_task, add_note, schedule_hearing, change_status | גבוהה |
|
||||
| 8 | **בדיקת hooks** — שינוי סטטוס תיק → זיכרון אוטומטי | גבוהה |
|
||||
| 9 | **בדיקת התראות** — navbar badge + דשלט | בינונית |
|
||||
| 10 | **בדיקת היסטוריה** — שמירה ואחזור שיחות, חיפוש | בינונית |
|
||||
|
||||
### שיפורים עתידיים
|
||||
| # | משימה | עדיפות |
|
||||
|---|--------|---------|
|
||||
| 11 | כתיבת בדיקות יחידה ל-PHP | בינונית |
|
||||
| 12 | הוספת CI/CD pipeline (Gitea Actions) | בינונית |
|
||||
| 13 | הוספת תרגום עברי (he_IL) — כרגע en_US + fa_IR בלבד | בינונית |
|
||||
| 14 | Rate limiting בצד client (כרגע רק בצד server) | נמוכה |
|
||||
| 15 | הצפנת שיחות | נמוכה |
|
||||
|
||||
---
|
||||
|
||||
## 7. תיעוד מפורט של הקוד והקבצים
|
||||
|
||||
### 7.1 ארכיטקטורה כללית
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Frontend (JS) │
|
||||
│ floating-chat.js ←→ navbar.js ←→ dashlet.js │
|
||||
│ │ │ │ │
|
||||
│ [צ'אט צף] [התראות navbar] [דשלט דשבורד] │
|
||||
└───────────────────────┬─────────────────────────┘
|
||||
│ REST API (10 endpoints)
|
||||
┌───────────────────────┴─────────────────────────┐
|
||||
│ Controller/SmartAssistant.php │
|
||||
│ chat | execute | summary | alerts | history ... │
|
||||
└───────────────────────┬─────────────────────────┘
|
||||
│
|
||||
┌───────────────────────┴─────────────────────────┐
|
||||
│ SmartAssistantService.php │
|
||||
│ תזמור: הקשר → webhook → פעולות → שמירה │
|
||||
├──────────┬──────────┬──────────┬─────────────────┤
|
||||
│ Context │ Action │ Memory │ Conversation │
|
||||
│ Builders │ Executor │ Service │ Repository │
|
||||
├──────────┴──────────┴──────────┴─────────────────┤
|
||||
│ CaseContext │ OfficeContext │ AlertCalculator │
|
||||
│ Builder │ Builder │ │
|
||||
└───────────────┴──────────────┴───────────────────┘
|
||||
│
|
||||
┌─────────┴──────────┐
|
||||
│ External n8n │
|
||||
│ Webhook (AI) │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
### 7.2 זרימת צ'אט (Chat Flow)
|
||||
|
||||
```
|
||||
1. משתמש שולח הודעה ← floating-chat.js
|
||||
2. POST /SmartAssistant/action/chat { message, caseId?, conversationId }
|
||||
3. Controller → SmartAssistantService::chat()
|
||||
4. בניית הקשר:
|
||||
├─ מצב תיק → CaseContextBuilder (נתוני תיק + אנשי קשר + משימות + דיונים + זיכרון)
|
||||
└─ מצב משרד → OfficeContextBuilder (סטטיסטיקות + התראות)
|
||||
5. קריאת webhook חיצוני (n8n) עם: message + context + history
|
||||
6. קבלת תשובה: { text, actions[] }
|
||||
7. פעולות מיידיות (list_documents, save_memory) → ביצוע אוטומטי
|
||||
8. פעולות הדורשות אישור → שמירה + החזרה למשתמש
|
||||
9. שמירת שיחה ב-ConversationRepository
|
||||
10. שמירת הודעות ב-Stream (smart-request + smart-response notes)
|
||||
```
|
||||
|
||||
### 7.3 פירוט קבצי Backend
|
||||
|
||||
#### `Controllers/SmartAssistant.php` (168 שורות)
|
||||
- **actionChat()** — מקבל message, caseId, conversationId. בודק ACL ל-Case. קורא ל-SmartAssistantService::chat()
|
||||
- **actionExecute()** — מקבל actionId, conversationId. מבצע פעולה שאושרה
|
||||
- **actionSummary()** — מחזיר סיכום משרדי + התראות
|
||||
- **actionAlerts()** — רשימת התראות מלאה
|
||||
- **actionHistory()** — היסטוריית שיחות (לפי caseId או אחרונה)
|
||||
- **actionConversations()** — רשימת שיחות אחרונות
|
||||
- **actionConversationMessages()** — הודעות שיחה ספציפית
|
||||
- **actionSearchHistory()** — חיפוש חופשי בשיחות
|
||||
- **actionCaseMemory()** — זיכרון תיק לפי קטגוריה
|
||||
- **actionSaveMemory()** — שמירת זיכרון ידנית
|
||||
|
||||
#### `Services/SmartAssistantService.php` (~500 שורות)
|
||||
הקובץ המרכזי ביותר. אחראי על:
|
||||
- **chat()** — הזרימה המלאה: בניית הקשר, קריאת webhook, עיבוד פעולות, שמירת שיחה
|
||||
- **callWebhook()** — קריאת CURL ל-n8n עם timeout, headers (X-Api-Key), JSON payload
|
||||
- **processActions()** — מיון פעולות: מיידיות vs. דורשות אישור
|
||||
- **executeAction()** — ביצוע פעולה שאושרה דרך ActionExecutor
|
||||
- **getSummary()** / **getAlerts()** — מצב משרד
|
||||
- **getConversations()** / **getHistory()** / **searchHistory()** — ניהול שיחות
|
||||
|
||||
#### `Services/CaseContextBuilder.php` (~150 שורות)
|
||||
בונה הקשר עשיר לתיק:
|
||||
```php
|
||||
[
|
||||
'case' => [...], // שדות תיק מרכזיים
|
||||
'contacts' => [...], // אנשי קשר מקושרים
|
||||
'openTasks' => [...], // משימות פתוחות (עד 10)
|
||||
'upcomingMeetings' => [...], // דיונים קרובים (עד 5)
|
||||
'recentNotes' => [...], // פתקים אחרונים (עד 10)
|
||||
'memories' => [...], // זיכרון מוצמד + חשוב
|
||||
'stats' => [...] // מספרי סיכום
|
||||
]
|
||||
```
|
||||
|
||||
#### `Services/OfficeContextBuilder.php` (~80 שורות)
|
||||
בונה סיכום משרדי:
|
||||
```php
|
||||
[
|
||||
'totalActiveCases' => int,
|
||||
'casesByStatus' => [...],
|
||||
'overdueTasks' => int,
|
||||
'upcomingHearings' => int,
|
||||
'userWorkload' => [...], // עומס לפי משתמש
|
||||
'alerts' => [...]
|
||||
]
|
||||
```
|
||||
|
||||
#### `Services/ActionExecutor.php` (~250 שורות)
|
||||
מבצע 8 סוגי פעולות:
|
||||
|
||||
| כלי | פרמטרים | פעולה |
|
||||
|------|----------|--------|
|
||||
| `create_task` | name, dateEnd, priority, description | יצירת Task מקושר לתיק |
|
||||
| `add_note` | text | הוספת Post ב-Stream של התיק |
|
||||
| `change_status` | status | שינוי סטטוס (עם validation מול enum) |
|
||||
| `create_meeting` | name, dateStart, dateEnd, description | יצירת Meeting |
|
||||
| `schedule_hearing` | date, description | עדכון cNextHearing + יצירת Meeting |
|
||||
| `list_documents` | — | רשימת מסמכים (read-only, ללא אישור) |
|
||||
| `analyze_document` | documentId | ניתוח מסמך דרך DocumentAnalyzer |
|
||||
| `save_memory` | name, content, category, importance | שמירת CaseMemory (ללא אישור) |
|
||||
| `rename_document` | documentId, newName | שינוי שם קובץ |
|
||||
|
||||
#### `Services/AlertCalculator.php` (~200 שורות)
|
||||
6 סוגי התראות:
|
||||
|
||||
| סוג | לוגיקה | חומרה |
|
||||
|------|--------|--------|
|
||||
| תיק לא פעיל (אזהרה) | modifiedAt < now - 14 יום | warning |
|
||||
| תיק לא פעיל (קריטי) | modifiedAt < now - 30 יום | critical |
|
||||
| משימה באיחור | dateEnd < now, status != Completed | critical |
|
||||
| דיון קרוב | dateStart < now + 7 ימים | warning |
|
||||
| תיק ללא שיוך | status = New, assignedUserId = null | warning |
|
||||
| משימה בעדיפות גבוהה | priority = Urgent, dateEnd < now + 5 ימים | warning |
|
||||
|
||||
#### `Services/CaseMemoryService.php` (106 שורות)
|
||||
CRUD פשוט:
|
||||
- **getMemories()** — לפי caseId, עם סינון אופציונלי לפי category
|
||||
- **saveMemory()** — יצירת רשומת CaseMemory
|
||||
- **updateMemory()** — עדכון
|
||||
- **deleteMemory()** — מחיקה רכה
|
||||
|
||||
#### `Services/CaseMemoryContextProvider.php`
|
||||
מזריק זיכרון להקשר AI:
|
||||
- זיכרון מוצמד (isPinned = true)
|
||||
- זיכרון בחשיבות high/critical
|
||||
- ממוין לפי importance DESC, modifiedAt DESC
|
||||
|
||||
#### `Services/ConversationRepository.php` (~120 שורות)
|
||||
- **save()** — שמירת שיחה באנטיטי AssistantConversation
|
||||
- **getHistory()** — אחזור לפי caseId או אחרון
|
||||
- **getConversations()** — רשימה ממוינת (עד limit)
|
||||
- **getMessages()** — הודעות שיחה ספציפית
|
||||
- **search()** — חיפוש חופשי בתוכן
|
||||
|
||||
#### Hooks
|
||||
- **CaseFieldChangeMemory** — afterSave hook על Case. בודק אם שדות key השתנו (status, cJudge, cCourt, cNextHearing). אם כן, יוצר CaseMemory אוטומטי מסוג `auto` בקטגוריה המתאימה.
|
||||
- **HearingScheduledMemory** — afterSave hook על Meeting. אם סוג Meeting = "Hearing", יוצר CaseMemory בקטגוריה timeline.
|
||||
|
||||
### 7.4 פירוט קבצי Frontend
|
||||
|
||||
#### `floating-chat.js` (~1500 שורות)
|
||||
הקובץ הגדול ביותר. View יחיד שמנהל:
|
||||
- **תצוגת צ'אט** — קלט הודעה, בועות הודעות (משתמש/עוזר), פעולות בהמתנה
|
||||
- **תצוגת היסטוריה** — רשימת שיחות קודמות, טעינת שיחה
|
||||
- **תצוגת זיכרון** — tabs לפי קטגוריה, הוספה/מחיקה/הצמדה
|
||||
- **RTL layout** — כל ה-CSS מותאם לעברית
|
||||
- **עיצוב Material Design** — צבע ראשי #5c6bc0 (אינדיגו), אנימציות slide-up
|
||||
- **רספונסיבי** — breakpoint ב-480px למובייל
|
||||
- **Inline CSS** — כל הסגנונות מוטמעים בקובץ (לא CSS חיצוני)
|
||||
|
||||
#### `site/navbar.js`
|
||||
- מרחיב את navbar הרגיל של EspoCRM
|
||||
- מוסיף אייקון התראות עם badge מספרי
|
||||
- קורא ל-GET /SmartAssistant/action/alerts ומציג ספירה
|
||||
- רענון אוטומטי כל X דקות
|
||||
|
||||
#### `dashlets/smart-assistant.js`
|
||||
- דשלט לדשבורד הראשי
|
||||
- מציג: סה"כ תיקים פעילים, משימות באיחור, דיונים קרובים
|
||||
- רשימת 5 התראות אחרונות
|
||||
- ACL scope = Case
|
||||
|
||||
#### Stream Notes (3 קבצים)
|
||||
- **smart-request.js** — מציג הודעת משתמש עם אייקון שאלה
|
||||
- **smart-response.js** — מציג תשובת עוזר עם markdown rendering
|
||||
- **smart-action.js** — מציג פעולה שבוצעה עם סטטוס (approved/rejected/executed)
|
||||
|
||||
#### `case/modals/add-memory.js`
|
||||
- מודל Espo.Views.Modal סטנדרטי
|
||||
- שדות: name, category (dropdown), content (textarea), importance (dropdown)
|
||||
- POST ל-/SmartAssistant/action/saveMemory
|
||||
|
||||
### 7.5 אנטיטי CaseMemory — מבנה מלא
|
||||
|
||||
```
|
||||
שדות:
|
||||
├── name (varchar, required) — כותרת הזיכרון
|
||||
├── case (link → Case, required) — תיק משויך
|
||||
├── category (enum) — key_facts | strategy | decisions | contacts_notes | timeline | documents_notes | billing_notes
|
||||
├── content (text, required) — תוכן מלא
|
||||
├── source (enum, readOnly) — manual | assistant | auto
|
||||
├── importance (enum) — low | normal | high | critical
|
||||
├── isPinned (bool) — הצמדה לגישה מהירה
|
||||
├── isArchived (bool) — הסתרת רשומות ישנות
|
||||
├── sourceEntityType (varchar, readOnly) — סוג מקור (למעקב)
|
||||
├── sourceEntityId (varchar, readOnly) — ID מקור
|
||||
├── assignedUser (link → User)
|
||||
├── teams (linkMultiple → Team)
|
||||
├── createdAt, modifiedAt (datetime)
|
||||
└── createdBy, modifiedBy (link → User)
|
||||
|
||||
אינדקסים:
|
||||
├── caseId + deleted
|
||||
├── caseId + category + deleted
|
||||
├── caseId + isPinned + deleted
|
||||
└── importance + deleted
|
||||
```
|
||||
|
||||
### 7.6 הגדרות אינטגרציה
|
||||
|
||||
**Admin > Integrations > Smart Assistant:**
|
||||
|
||||
| שדה | סוג | ברירת מחדל | תיאור |
|
||||
|------|------|------------|--------|
|
||||
| webhookUrl | url | — | כתובת webhook (n8n) |
|
||||
| apiKey | varchar(255) | — | מפתח אימות, נשלח כ-X-Api-Key header |
|
||||
| maxMessagesPerHour | int | 30 | הגבלת קצב |
|
||||
| inactivityWarningDays | int | 14 | סף אזהרה לחוסר פעילות |
|
||||
| inactivityCriticalDays | int | 30 | סף קריטי |
|
||||
| upcomingHearingDays | int | 7 | חלון דיונים קרובים |
|
||||
|
||||
### 7.7 פורמט Webhook
|
||||
|
||||
**Request (נשלח ל-n8n):**
|
||||
```json
|
||||
{
|
||||
"message": "מה המצב של תיק כהן?",
|
||||
"context": {
|
||||
"case": { "name": "כהן נ' לוי", "status": "Active", ... },
|
||||
"contacts": [...],
|
||||
"openTasks": [...],
|
||||
"memories": [...]
|
||||
},
|
||||
"conversationId": "conv_abc123",
|
||||
"conversationHistory": [
|
||||
{ "role": "user", "content": "..." },
|
||||
{ "role": "assistant", "content": "..." }
|
||||
],
|
||||
"mode": "case"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (נדרש מ-n8n):**
|
||||
```json
|
||||
{
|
||||
"text": "תיק כהן פעיל. יש 3 משימות פתוחות ודיון ב-15 באפריל.",
|
||||
"actions": [
|
||||
{
|
||||
"tool": "create_task",
|
||||
"params": { "name": "הכנת סיכומים לדיון", "dateEnd": "2026-04-14" },
|
||||
"actionId": "action_xyz789",
|
||||
"displayText": "יצירת משימה: הכנת סיכומים לדיון"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. סיכום מוכנות לפריסה
|
||||
|
||||
| קריטריון | סטטוס | הערות |
|
||||
|-----------|--------|--------|
|
||||
| קוד backend | מוכן | 16 קבצי PHP, ארכיטקטורה נקייה |
|
||||
| קוד frontend | מוכן | 9 קבצי JS, עיצוב RTL |
|
||||
| מטא-נתונים | מוכן | תוקן ב-v1.0.1-1.0.3 |
|
||||
| תרגומים | חלקי | en_US + fa_IR, חסר he_IL ייעודי |
|
||||
| קובץ התקנה | מוכן | SmartAssistant-1.0.3.zip |
|
||||
| n8n workflow | **חסר** | צריך לבנות workflow שיטפל ב-chat |
|
||||
| בדיקות אוטומטיות | **אין** | אין unit/integration/E2E tests |
|
||||
| CI/CD | **אין** | אין pipeline |
|
||||
| תיעוד | מוכן | README מקיף בעברית |
|
||||
|
||||
**המלצה:** המודול מוכן טכנית לפריסה בסביבת פיתוח. יש להגדיר webhook URL (n8n workflow) לפני שהצ'אט יהיה פונקציונלי. מומלץ לבצע בדיקות ידניות מקיפות לאחר ההתקנה.
|
||||
@@ -1,6 +1,6 @@
|
||||
# SmartAssistant - עוזר חכם
|
||||
|
||||
**גרסה:** 1.1.7 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
**גרסה:** 2.7.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
|
||||
## תיאור
|
||||
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,13 +4,16 @@ set -euo pipefail
|
||||
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
|
||||
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
||||
ZIPNAME="${MODULE}-${VERSION}.zip"
|
||||
BUILDDIR="build"
|
||||
|
||||
# Update version in README.md if it exists
|
||||
if [[ -f README.md ]]; then
|
||||
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILDDIR"
|
||||
echo "Building $ZIPNAME..."
|
||||
rm -f "$ZIPNAME"
|
||||
zip -r "$ZIPNAME" manifest.json files/ \
|
||||
-x "*.DS_Store" "*__MACOSX*" "*.zip"
|
||||
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
|
||||
rm -f "$BUILDDIR/$ZIPNAME"
|
||||
zip -r "$BUILDDIR/$ZIPNAME" manifest.json files/ \
|
||||
-x "*.DS_Store" "*__MACOSX*"
|
||||
echo "✓ Built: $BUILDDIR/$ZIPNAME ($(du -h "$BUILDDIR/$ZIPNAME" | cut -f1))"
|
||||
|
||||
@@ -474,7 +474,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
payload.caseId = this.currentCaseId;
|
||||
}
|
||||
|
||||
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload).then(function (response) {
|
||||
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 180000}).then(function (response) {
|
||||
self.conversationId = response.conversationId;
|
||||
$loading.remove();
|
||||
|
||||
|
||||
@@ -9,17 +9,23 @@ use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
||||
use Espo\Entities\User;
|
||||
|
||||
class SmartAssistant
|
||||
{
|
||||
private InjectableFactory $injectableFactory;
|
||||
private Acl $acl;
|
||||
private User $user;
|
||||
|
||||
public function __construct(InjectableFactory $injectableFactory, Acl $acl)
|
||||
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
|
||||
{
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
$this->acl = $acl;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
private function getService(): SmartAssistantService
|
||||
@@ -169,4 +175,130 @@ class SmartAssistant
|
||||
'id' => $entry->get('id'),
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionExecuteTool(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
if (empty($data->tool)) {
|
||||
throw new BadRequest('tool is required.');
|
||||
}
|
||||
|
||||
$tool = $data->tool;
|
||||
$params = (array) ($data->params ?? []);
|
||||
$caseId = $data->caseId ?? null;
|
||||
|
||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||
|
||||
return $executor->execute($tool, $params, $caseId, $this->user->getId());
|
||||
}
|
||||
|
||||
public function postActionReadDocument(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$filePath = $data->filePath ?? null;
|
||||
if (empty($filePath)) {
|
||||
throw new BadRequest('filePath is required.');
|
||||
}
|
||||
|
||||
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$text = $analyzer->extractFullText($filePath, $maxLength);
|
||||
|
||||
return [
|
||||
'success' => $text !== null,
|
||||
'text' => $text,
|
||||
'fileName' => basename($filePath),
|
||||
'charCount' => $text !== null ? mb_strlen($text) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionReadMultipleDocuments(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$filePaths = $data->filePaths ?? [];
|
||||
if (empty($filePaths)) {
|
||||
throw new BadRequest('filePaths is required (array of file paths).');
|
||||
}
|
||||
|
||||
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'documents' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionBatchRename(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$renames = $data->renames ?? [];
|
||||
if (empty($renames)) {
|
||||
throw new BadRequest('renames is required (array of {sourcePath, newName}).');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$results = [];
|
||||
$successCount = 0;
|
||||
|
||||
foreach ($renames as $rename) {
|
||||
$rename = (array) $rename;
|
||||
$sourcePath = $rename['sourcePath'] ?? null;
|
||||
$newName = $rename['newName'] ?? null;
|
||||
|
||||
if (!$sourcePath || !$newName) {
|
||||
$results[] = ['success' => false, 'error' => 'Missing sourcePath or newName'];
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $analyzer->renameDocument($sourcePath, $newName);
|
||||
$results[] = ['success' => true, 'oldName' => $result['oldName'], 'newName' => $result['newName']];
|
||||
$successCount++;
|
||||
} catch (\Exception $e) {
|
||||
$results[] = ['success' => false, 'oldName' => basename($sourcePath), 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'totalCount' => count($renames),
|
||||
'successCount' => $successCount,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionGenerateFromTemplate(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$templateId = $data->templateId ?? null;
|
||||
$caseId = $data->caseId ?? null;
|
||||
|
||||
if (empty($templateId)) {
|
||||
throw new BadRequest('templateId is required.');
|
||||
}
|
||||
if (empty($caseId)) {
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
|
||||
$documentSubject = $data->documentSubject ?? null;
|
||||
$customPlaceholders = (array) ($data->customPlaceholders ?? []);
|
||||
|
||||
$generator = $this->injectableFactory->create(GenericTemplateGenerator::class);
|
||||
|
||||
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -27,6 +27,18 @@
|
||||
"upcomingHearingDays": {
|
||||
"type": "int",
|
||||
"default": 7
|
||||
},
|
||||
"maxDocumentChars": {
|
||||
"type": "int",
|
||||
"default": 100000
|
||||
},
|
||||
"agenticLoopEnabled": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"maxAgenticLoops": {
|
||||
"type": "int",
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"allowUserAccess": false,
|
||||
|
||||
@@ -86,5 +86,13 @@
|
||||
"controller": "SmartAssistant",
|
||||
"action": "saveMemory"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/SmartAssistant/action/executeTool",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "SmartAssistant",
|
||||
"action": "executeTool"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -49,7 +49,10 @@ class ActionExecutor
|
||||
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
|
||||
'list_documents' => $this->listDocuments($caseId),
|
||||
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
||||
'read_document' => $this->readDocument($params, $caseId),
|
||||
'read_multiple_documents' => $this->readMultipleDocuments($params, $caseId),
|
||||
'rename_document' => $this->renameDocument($params),
|
||||
'batch_rename_documents' => $this->batchRenameDocuments($params),
|
||||
'upload_document' => $this->uploadDocument($params, $caseId),
|
||||
'generate_document' => $this->generateDocument($params, $caseId),
|
||||
'merge_documents' => $this->mergeDocuments($params, $caseId),
|
||||
@@ -383,6 +386,65 @@ class ActionExecutor
|
||||
return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]];
|
||||
}
|
||||
|
||||
private function readDocument(array $params, ?string $caseId): array
|
||||
{
|
||||
$filePath = $params['filePath'] ?? null;
|
||||
if (!$filePath) throw new Error('filePath parameter is required.');
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$text = $analyzer->extractFullText($filePath);
|
||||
$fileName = basename($filePath);
|
||||
|
||||
if ($text === null) {
|
||||
return ['success' => false, 'message' => "לא ניתן לחלץ טקסט מ-\"{$fileName}\""];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => $text,
|
||||
'data' => [
|
||||
'filePath' => $filePath,
|
||||
'fileName' => $fileName,
|
||||
'charCount' => mb_strlen($text),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function readMultipleDocuments(array $params, ?string $caseId): array
|
||||
{
|
||||
$filePaths = $params['filePaths'] ?? [];
|
||||
if (empty($filePaths)) throw new Error('filePaths parameter is required (array of file paths).');
|
||||
|
||||
$maxPerFile = $params['maxCharsPerFile'] ?? 50000;
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$results = $analyzer->extractMultipleDocuments($filePaths, $maxPerFile);
|
||||
|
||||
$lines = [];
|
||||
$successCount = 0;
|
||||
|
||||
foreach ($results as $r) {
|
||||
$lines[] = "=== {$r['fileName']} ===";
|
||||
if (!empty($r['error'])) {
|
||||
$lines[] = "שגיאה: {$r['error']}";
|
||||
} elseif ($r['text'] === null) {
|
||||
$lines[] = "לא ניתן לחלץ טקסט";
|
||||
} else {
|
||||
$lines[] = $r['text'];
|
||||
$successCount++;
|
||||
}
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => implode("\n", $lines),
|
||||
'data' => [
|
||||
'totalFiles' => count($filePaths),
|
||||
'successCount' => $successCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function renameDocument(array $params): array
|
||||
{
|
||||
$sourcePath = $params['sourcePath'] ?? null;
|
||||
@@ -395,6 +457,37 @@ class ActionExecutor
|
||||
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
|
||||
}
|
||||
|
||||
private function batchRenameDocuments(array $params): array
|
||||
{
|
||||
$renames = $params['renames'] ?? [];
|
||||
if (empty($renames)) throw new Error('renames parameter is required (array of {sourcePath, newName}).');
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$results = [];
|
||||
$successCount = 0;
|
||||
|
||||
foreach ($renames as $rename) {
|
||||
$sourcePath = $rename['sourcePath'] ?? null;
|
||||
$newName = $rename['newName'] ?? null;
|
||||
|
||||
if (!$sourcePath || !$newName) {
|
||||
$results[] = "שגיאה: חסר sourcePath או newName";
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $analyzer->renameDocument($sourcePath, $newName);
|
||||
$results[] = "\"{$result['oldName']}\" → \"{$result['newName']}\"";
|
||||
$successCount++;
|
||||
} catch (\Exception $e) {
|
||||
$results[] = "שגיאה בשינוי שם \"" . basename($sourcePath) . "\": " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$msg = "שינוי שמות: {$successCount}/" . count($renames) . " הצליחו\n" . implode("\n", $results);
|
||||
return ['success' => true, 'message' => $msg];
|
||||
}
|
||||
|
||||
private function uploadDocument(array $params, ?string $caseId): array
|
||||
{
|
||||
if (!$caseId) {
|
||||
|
||||
@@ -11,13 +11,14 @@ use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
|
||||
|
||||
class DocumentAnalyzer
|
||||
{
|
||||
private const MAX_TEXT_LENGTH = 2000;
|
||||
private const DEFAULT_MAX_TEXT_LENGTH = 100000;
|
||||
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
private const TMP_DIR = 'data/tmp/assistant-docs';
|
||||
|
||||
private EntityManager $entityManager;
|
||||
private InjectableFactory $injectableFactory;
|
||||
private Log $log;
|
||||
private ?int $maxTextLength = null;
|
||||
|
||||
private const CATEGORY_PATTERNS = [
|
||||
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
||||
@@ -73,6 +74,13 @@ class DocumentAnalyzer
|
||||
}
|
||||
|
||||
public function extractTextContent(string $filePath): ?string
|
||||
{
|
||||
$maxLen = $this->getMaxTextLength();
|
||||
$text = $this->extractFullText($filePath, $maxLen);
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function extractFullText(string $filePath, ?int $maxLength = null): ?string
|
||||
{
|
||||
$client = $this->getStorageClient();
|
||||
$filename = basename($filePath);
|
||||
@@ -98,8 +106,41 @@ class DocumentAnalyzer
|
||||
|
||||
if (file_exists($tmpFile)) unlink($tmpFile);
|
||||
if ($text === null) return null;
|
||||
if (mb_strlen($text) > self::MAX_TEXT_LENGTH) $text = mb_substr($text, 0, self::MAX_TEXT_LENGTH) . '...';
|
||||
return trim($text);
|
||||
|
||||
$text = trim($text);
|
||||
if ($maxLength !== null && mb_strlen($text) > $maxLength) {
|
||||
$text = mb_substr($text, 0, $maxLength) . '...';
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($filePaths as $filePath) {
|
||||
$fileName = basename($filePath);
|
||||
try {
|
||||
$text = $this->extractFullText($filePath, $maxPerFile);
|
||||
$results[] = [
|
||||
'path' => $filePath,
|
||||
'fileName' => $fileName,
|
||||
'text' => $text,
|
||||
'charCount' => $text !== null ? mb_strlen($text) : 0,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results[] = [
|
||||
'path' => $filePath,
|
||||
'fileName' => $fileName,
|
||||
'text' => null,
|
||||
'charCount' => 0,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function renameDocument(string $sourcePath, string $newName): array
|
||||
@@ -169,6 +210,27 @@ class DocumentAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
private function getMaxTextLength(): int
|
||||
{
|
||||
if ($this->maxTextLength !== null) {
|
||||
return $this->maxTextLength;
|
||||
}
|
||||
|
||||
try {
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = (array) ($integration->get('data') ?? []);
|
||||
$this->maxTextLength = (int) ($data['maxDocumentChars'] ?? self::DEFAULT_MAX_TEXT_LENGTH);
|
||||
return $this->maxTextLength;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
$this->maxTextLength = self::DEFAULT_MAX_TEXT_LENGTH;
|
||||
return $this->maxTextLength;
|
||||
}
|
||||
|
||||
private function getStorageClient(): StorageClientInterface
|
||||
{
|
||||
return $this->getNetworkDocumentService()->getClient();
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||
use PhpOffice\PhpWord\TemplateProcessor;
|
||||
|
||||
class GenericTemplateGenerator
|
||||
{
|
||||
private const TEMP_DIR = 'data/tmp/';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Log $log,
|
||||
private User $user
|
||||
) {}
|
||||
|
||||
public function generate(string $templateId, string $caseId, ?string $documentSubject = null, array $customPlaceholders = []): array
|
||||
{
|
||||
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
|
||||
if (!$template) {
|
||||
throw new NotFound("Template {$templateId} not found.");
|
||||
}
|
||||
|
||||
if (!$template->get('isActive')) {
|
||||
throw new Error("Template is not active.");
|
||||
}
|
||||
|
||||
$templatePath = $template->get('templatePath');
|
||||
if (!$templatePath) {
|
||||
throw new Error("Template path is not configured.");
|
||||
}
|
||||
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) {
|
||||
throw new NotFound("Case {$caseId} not found.");
|
||||
}
|
||||
|
||||
$templateName = $template->get('name') ?? 'document';
|
||||
|
||||
// Step 1: Download template from WebDAV
|
||||
$localTemplatePath = $this->downloadTemplate($templatePath);
|
||||
|
||||
try {
|
||||
// Step 2: Build placeholder data from entity fields
|
||||
$templateService = $this->injectableFactory->create(DocumentTemplateService::class);
|
||||
$placeholderData = $templateService->buildPlaceholderData($case);
|
||||
|
||||
// Step 3: Merge custom placeholders (override entity data)
|
||||
foreach ($customPlaceholders as $key => $value) {
|
||||
$placeholderData[$key] = $value;
|
||||
}
|
||||
|
||||
// Step 4: Process template
|
||||
$outputPath = self::TEMP_DIR . uniqid('gen_') . '.docx';
|
||||
if (!is_dir(self::TEMP_DIR)) {
|
||||
mkdir(self::TEMP_DIR, 0775, true);
|
||||
}
|
||||
|
||||
$this->processTemplate($localTemplatePath, $outputPath, $placeholderData);
|
||||
|
||||
// Step 5: Read generated content
|
||||
$content = file_get_contents($outputPath);
|
||||
@unlink($outputPath);
|
||||
@unlink($localTemplatePath);
|
||||
|
||||
// Step 6: Build document name
|
||||
$contactName = $this->getContactName($case);
|
||||
$docName = $documentSubject ?? "{$templateName} — {$contactName}";
|
||||
|
||||
// Step 7: Create Attachment + Document
|
||||
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||
$attachment->set([
|
||||
'name' => $docName . '.docx',
|
||||
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'role' => 'Attachment',
|
||||
'size' => strlen($content),
|
||||
'relatedType' => 'Document',
|
||||
]);
|
||||
$this->entityManager->saveEntity($attachment);
|
||||
file_put_contents('data/upload/' . $attachment->getId(), $content);
|
||||
|
||||
$document = $this->entityManager->getNewEntity('Document');
|
||||
$document->set([
|
||||
'name' => $docName,
|
||||
'fileId' => $attachment->getId(),
|
||||
'fileName' => $docName . '.docx',
|
||||
'assignedUserId' => $this->user->getId(),
|
||||
]);
|
||||
$this->entityManager->saveEntity($document);
|
||||
|
||||
// Link to Case
|
||||
$this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'documents')
|
||||
->relate($document);
|
||||
|
||||
$this->log->info("GenericTemplateGenerator: Created '{$docName}' from template '{$templateName}' for Case {$caseId}");
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ מסמך \"{$docName}\" נוצר בהצלחה מתבנית \"{$templateName}\"\n📄 המסמך צורף לתיק.",
|
||||
'entityType' => 'Document',
|
||||
'entityId' => $document->getId(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
if (isset($localTemplatePath) && file_exists($localTemplatePath)) {
|
||||
@unlink($localTemplatePath);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function downloadTemplate(string $templatePath): string
|
||||
{
|
||||
// Try local paths first
|
||||
if (file_exists($templatePath)) {
|
||||
return $templatePath;
|
||||
}
|
||||
|
||||
$localPath = 'data/document-templates/' . ltrim($templatePath, '/');
|
||||
if (file_exists($localPath)) {
|
||||
return $localPath;
|
||||
}
|
||||
|
||||
// Download from NetworkStorage (WebDAV)
|
||||
try {
|
||||
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
$client = $nds->getClient();
|
||||
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'NetworkStorage');
|
||||
$templatesFolderPath = 'Templates';
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = (array) ($integration->get('data') ?? []);
|
||||
$templatesFolderPath = $data['templatesFolderPath'] ?? 'Templates';
|
||||
}
|
||||
|
||||
$storagePath = $templatesFolderPath . '/' . $templatePath;
|
||||
|
||||
if (!$client->exists($storagePath)) {
|
||||
throw new Error("Template file not found on storage: {$storagePath}");
|
||||
}
|
||||
|
||||
$content = $client->downloadFile($storagePath);
|
||||
|
||||
if (!is_dir(self::TEMP_DIR)) {
|
||||
mkdir(self::TEMP_DIR, 0775, true);
|
||||
}
|
||||
$tmpPath = self::TEMP_DIR . 'tpl_' . uniqid() . '.docx';
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
return $tmpPath;
|
||||
} catch (Error $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
throw new Error("Failed to download template: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function processTemplate(string $inputPath, string $outputPath, array $data): void
|
||||
{
|
||||
if (!class_exists(TemplateProcessor::class, false)) {
|
||||
$autoloader = 'vendor/phpoffice/phpword/src/PhpWord/Autoloader.php';
|
||||
if (file_exists($autoloader)) {
|
||||
require_once $autoloader;
|
||||
\PhpOffice\PhpWord\Autoloader::register();
|
||||
}
|
||||
}
|
||||
|
||||
$processor = new TemplateProcessor($inputPath);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if ($value === null) {
|
||||
$value = '';
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$value = implode(', ', $value);
|
||||
} elseif (is_bool($value)) {
|
||||
$value = $value ? 'כן' : 'לא';
|
||||
}
|
||||
$processor->setValue($key, (string) $value);
|
||||
}
|
||||
|
||||
$processor->saveAs($outputPath);
|
||||
}
|
||||
|
||||
private function getContactName($case): string
|
||||
{
|
||||
$contactId = $case->get('contactId');
|
||||
if (!$contactId) {
|
||||
$ids = $case->getLinkMultipleIdList('contacts');
|
||||
if (!empty($ids)) {
|
||||
$contactId = $ids[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ($contactId) {
|
||||
$contact = $this->entityManager->getEntityById('Contact', $contactId);
|
||||
if ($contact) {
|
||||
return trim($contact->get('firstName') . ' ' . $contact->get('lastName'));
|
||||
}
|
||||
}
|
||||
|
||||
return $case->get('name') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,17 @@ class SmartAssistantService
|
||||
public const NOTE_TYPE_ACTION = 'SmartAction';
|
||||
|
||||
// All tools execute inline — Shira has full permissions
|
||||
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
|
||||
private const TOOLS_REQUIRING_CASE = [
|
||||
'change_status', 'schedule_hearing', 'list_documents', 'analyze_document',
|
||||
'read_document', 'read_multiple_documents', 'upload_document',
|
||||
'generate_document', 'merge_documents', 'save_memory',
|
||||
'batch_rename_documents',
|
||||
];
|
||||
|
||||
// Tools whose output is fed back to the AI for a follow-up webhook call
|
||||
private const AGENTIC_TOOLS = ['analyze_document', 'read_document', 'read_multiple_documents', 'list_documents'];
|
||||
|
||||
private const DEFAULT_MAX_AGENTIC_LOOPS = 3;
|
||||
|
||||
private EntityManager $entityManager;
|
||||
private InjectableFactory $injectableFactory;
|
||||
@@ -105,68 +115,28 @@ class SmartAssistantService
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Call webhook
|
||||
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
|
||||
|
||||
// Process actions — execute all inline (Shira has full permissions)
|
||||
$actions = $response['actions'] ?? [];
|
||||
$responseText = $response['text'] ?? '';
|
||||
$inlineResults = [];
|
||||
$executedActions = [];
|
||||
|
||||
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
|
||||
$effectiveCaseId = $caseId;
|
||||
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
|
||||
$effectiveCaseId = $context['drillDown']['case']['id'];
|
||||
}
|
||||
|
||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||
|
||||
foreach ($actions as $action) {
|
||||
$tool = $action['tool'] ?? '';
|
||||
$actionCaseId = $effectiveCaseId;
|
||||
|
||||
// Resolve case from action params if not available
|
||||
if (!$actionCaseId && !empty($action['params']['caseId'])) {
|
||||
$actionCaseId = $action['params']['caseId'];
|
||||
}
|
||||
|
||||
// Skip tools that require a case if no case context available
|
||||
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
|
||||
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
|
||||
if (!empty($result['message'])) {
|
||||
$inlineResults[] = $result['message'];
|
||||
}
|
||||
$executedActions[] = [
|
||||
'tool' => $tool,
|
||||
'status' => 'executed',
|
||||
'entityType' => $result['entityType'] ?? null,
|
||||
'entityId' => $result['entityId'] ?? null,
|
||||
];
|
||||
|
||||
// Create stream note for executed action in case context
|
||||
if ($actionCaseId) {
|
||||
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
|
||||
'conversationId' => $conversationId,
|
||||
'status' => 'executed',
|
||||
'entityType' => $result['entityType'] ?? null,
|
||||
'entityId' => $result['entityId'] ?? null,
|
||||
]);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
|
||||
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
||||
}
|
||||
// Execute tools with agentic loop — read tools feed results back to AI
|
||||
try {
|
||||
$loopResult = $this->executeActionsWithLoop(
|
||||
$message, $context, $conversationId, $conversationHistory,
|
||||
$mode, $effectiveCaseId, $userId
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->log->error("SmartAssistant: Chat failed: " . $e->getMessage());
|
||||
$loopResult = [
|
||||
'responseText' => "⚠️ אירעה שגיאה בתקשורת עם המערכת. אנא נסה שוב בעוד מספר דקות.\n\nפרטי השגיאה: " . $e->getMessage(),
|
||||
'executedActions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($inlineResults)) {
|
||||
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
||||
}
|
||||
$responseText = $loopResult['responseText'];
|
||||
$executedActions = $loopResult['executedActions'];
|
||||
|
||||
// Persist response
|
||||
try {
|
||||
@@ -191,6 +161,148 @@ class SmartAssistantService
|
||||
];
|
||||
}
|
||||
|
||||
private function executeActionsWithLoop(
|
||||
string $message,
|
||||
array $context,
|
||||
string $conversationId,
|
||||
array $conversationHistory,
|
||||
string $mode,
|
||||
?string $effectiveCaseId,
|
||||
string $userId
|
||||
): array {
|
||||
$agenticEnabled = $this->getIntegrationSetting('agenticLoopEnabled', true);
|
||||
$maxLoops = (int) $this->getIntegrationSetting('maxAgenticLoops', self::DEFAULT_MAX_AGENTIC_LOOPS);
|
||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||
|
||||
$loopCount = 0;
|
||||
$allExecutedActions = [];
|
||||
$currentHistory = $conversationHistory;
|
||||
$finalResponseText = '';
|
||||
|
||||
while ($loopCount <= $maxLoops) {
|
||||
// Call webhook (first call uses original message, subsequent calls use empty message)
|
||||
$currentMessage = $loopCount === 0 ? $message : '';
|
||||
$response = $this->callWebhook($currentMessage, $context, $conversationId, $currentHistory, $mode);
|
||||
|
||||
$actions = $response['actions'] ?? [];
|
||||
$responseText = $response['text'] ?? '';
|
||||
|
||||
// If no actions, we're done
|
||||
if (empty($actions)) {
|
||||
$finalResponseText = $responseText;
|
||||
break;
|
||||
}
|
||||
|
||||
$agenticResults = [];
|
||||
$inlineResults = [];
|
||||
|
||||
foreach ($actions as $action) {
|
||||
$tool = $action['tool'] ?? '';
|
||||
$actionCaseId = $effectiveCaseId;
|
||||
|
||||
if (!$actionCaseId && !empty($action['params']['caseId'])) {
|
||||
$actionCaseId = $action['params']['caseId'];
|
||||
}
|
||||
|
||||
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
|
||||
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
|
||||
|
||||
$allExecutedActions[] = [
|
||||
'tool' => $tool,
|
||||
'status' => 'executed',
|
||||
'entityType' => $result['entityType'] ?? null,
|
||||
'entityId' => $result['entityId'] ?? null,
|
||||
];
|
||||
|
||||
if ($actionCaseId) {
|
||||
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
|
||||
'conversationId' => $conversationId,
|
||||
'status' => 'executed',
|
||||
'entityType' => $result['entityType'] ?? null,
|
||||
'entityId' => $result['entityId'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Separate agentic tools (need re-invocation) from inline tools
|
||||
if ($agenticEnabled && in_array($tool, self::AGENTIC_TOOLS, true)) {
|
||||
$agenticResults[] = [
|
||||
'tool' => $tool,
|
||||
'params' => $action['params'] ?? [],
|
||||
'result' => $result['message'] ?? '',
|
||||
];
|
||||
} else {
|
||||
if (!empty($result['message'])) {
|
||||
$inlineResults[] = $result['message'];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
|
||||
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// If no agentic results, we're done — return response with inline results
|
||||
if (empty($agenticResults)) {
|
||||
$finalResponseText = $responseText;
|
||||
if (!empty($inlineResults)) {
|
||||
$finalResponseText .= "\n\n" . implode("\n\n", $inlineResults);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Feed agentic results back to AI for next iteration
|
||||
$this->log->debug("SmartAssistant: Agentic loop iteration " . ($loopCount + 1) . " — " . count($agenticResults) . " tool results to feed back");
|
||||
|
||||
// Add the AI response to history
|
||||
$currentHistory[] = ['role' => 'assistant', 'content' => $responseText];
|
||||
|
||||
// Add each tool result to history
|
||||
foreach ($agenticResults as $ar) {
|
||||
$currentHistory[] = [
|
||||
'role' => 'tool_result',
|
||||
'toolName' => $ar['tool'],
|
||||
'content' => $ar['result'],
|
||||
];
|
||||
}
|
||||
|
||||
// Also append any inline results
|
||||
if (!empty($inlineResults)) {
|
||||
$currentHistory[] = [
|
||||
'role' => 'tool_result',
|
||||
'toolName' => '_inline_actions',
|
||||
'content' => implode("\n\n", $inlineResults),
|
||||
];
|
||||
}
|
||||
|
||||
$loopCount++;
|
||||
}
|
||||
|
||||
// Safety: if we exhausted the loop, use the last response
|
||||
if ($loopCount > $maxLoops && empty($finalResponseText)) {
|
||||
$finalResponseText = $response['text'] ?? 'הגעתי למגבלת הסיבובים. אנא נסה שוב.';
|
||||
}
|
||||
|
||||
return [
|
||||
'responseText' => $finalResponseText,
|
||||
'executedActions' => $allExecutedActions,
|
||||
];
|
||||
}
|
||||
|
||||
private function getIntegrationSetting(string $key, $default = null)
|
||||
{
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = (array) ($integration->get('data') ?? []);
|
||||
return $data[$key] ?? $default;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
public function executeAction(string $conversationId, string $actionId, bool $approved): array
|
||||
{
|
||||
$conversation = $this->loadConversation($conversationId);
|
||||
@@ -406,7 +518,7 @@ class SmartAssistantService
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_TIMEOUT => 180,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
]);
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 750 KiB |
+2
-2
@@ -3,11 +3,11 @@
|
||||
"module": "SmartAssistant",
|
||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||
"author": "klear",
|
||||
"version": "2.5.2",
|
||||
"version": "2.7.1",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"releaseDate": "2026-04-06",
|
||||
"releaseDate": "2026-04-09",
|
||||
"php": [
|
||||
">=8.1"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user