Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 289a9cf27f | |||
| 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 - עוזר חכם
|
# SmartAssistant - עוזר חכם
|
||||||
|
|
||||||
**גרסה:** 1.1.7 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
**גרסה:** 2.7.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||||
|
|
||||||
## תיאור
|
## תיאור
|
||||||
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
עוזר 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'])")
|
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']))")
|
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
||||||
ZIPNAME="${MODULE}-${VERSION}.zip"
|
ZIPNAME="${MODULE}-${VERSION}.zip"
|
||||||
|
BUILDDIR="build"
|
||||||
|
|
||||||
# Update version in README.md if it exists
|
# Update version in README.md if it exists
|
||||||
if [[ -f README.md ]]; then
|
if [[ -f README.md ]]; then
|
||||||
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BUILDDIR"
|
||||||
echo "Building $ZIPNAME..."
|
echo "Building $ZIPNAME..."
|
||||||
rm -f "$ZIPNAME"
|
rm -f "$BUILDDIR/$ZIPNAME"
|
||||||
zip -r "$ZIPNAME" manifest.json files/ \
|
zip -r "$BUILDDIR/$ZIPNAME" manifest.json files/ \
|
||||||
-x "*.DS_Store" "*__MACOSX*" "*.zip"
|
-x "*.DS_Store" "*__MACOSX*"
|
||||||
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
|
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;
|
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;
|
self.conversationId = response.conversationId;
|
||||||
$loading.remove();
|
$loading.remove();
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,23 @@ use Espo\Core\Exceptions\Forbidden;
|
|||||||
use Espo\Core\InjectableFactory;
|
use Espo\Core\InjectableFactory;
|
||||||
use Espo\Core\Acl;
|
use Espo\Core\Acl;
|
||||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
||||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
|
||||||
class SmartAssistant
|
class SmartAssistant
|
||||||
{
|
{
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
private Acl $acl;
|
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->injectableFactory = $injectableFactory;
|
||||||
$this->acl = $acl;
|
$this->acl = $acl;
|
||||||
|
$this->user = $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getService(): SmartAssistantService
|
private function getService(): SmartAssistantService
|
||||||
@@ -169,4 +175,130 @@ class SmartAssistant
|
|||||||
'id' => $entry->get('id'),
|
'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 = json_decode(json_encode($data->params ?? []), true) ?? [];
|
||||||
|
$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": {
|
"upcomingHearingDays": {
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"default": 7
|
"default": 7
|
||||||
|
},
|
||||||
|
"maxDocumentChars": {
|
||||||
|
"type": "int",
|
||||||
|
"default": 100000
|
||||||
|
},
|
||||||
|
"agenticLoopEnabled": {
|
||||||
|
"type": "bool",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"maxAgenticLoops": {
|
||||||
|
"type": "int",
|
||||||
|
"default": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowUserAccess": false,
|
"allowUserAccess": false,
|
||||||
|
|||||||
@@ -86,5 +86,13 @@
|
|||||||
"controller": "SmartAssistant",
|
"controller": "SmartAssistant",
|
||||||
"action": "saveMemory"
|
"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),
|
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
|
||||||
'list_documents' => $this->listDocuments($caseId),
|
'list_documents' => $this->listDocuments($caseId),
|
||||||
'analyze_document' => $this->analyzeDocument($params, $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),
|
'rename_document' => $this->renameDocument($params),
|
||||||
|
'batch_rename_documents' => $this->batchRenameDocuments($params),
|
||||||
'upload_document' => $this->uploadDocument($params, $caseId),
|
'upload_document' => $this->uploadDocument($params, $caseId),
|
||||||
'generate_document' => $this->generateDocument($params, $caseId),
|
'generate_document' => $this->generateDocument($params, $caseId),
|
||||||
'merge_documents' => $this->mergeDocuments($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]];
|
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
|
private function renameDocument(array $params): array
|
||||||
{
|
{
|
||||||
$sourcePath = $params['sourcePath'] ?? null;
|
$sourcePath = $params['sourcePath'] ?? null;
|
||||||
@@ -395,6 +457,37 @@ class ActionExecutor
|
|||||||
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
|
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
|
private function uploadDocument(array $params, ?string $caseId): array
|
||||||
{
|
{
|
||||||
if (!$caseId) {
|
if (!$caseId) {
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
|
|||||||
|
|
||||||
class DocumentAnalyzer
|
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 MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
private const TMP_DIR = 'data/tmp/assistant-docs';
|
private const TMP_DIR = 'data/tmp/assistant-docs';
|
||||||
|
|
||||||
private EntityManager $entityManager;
|
private EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
private Log $log;
|
private Log $log;
|
||||||
|
private ?int $maxTextLength = null;
|
||||||
|
|
||||||
private const CATEGORY_PATTERNS = [
|
private const CATEGORY_PATTERNS = [
|
||||||
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
||||||
@@ -73,6 +74,13 @@ class DocumentAnalyzer
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function extractTextContent(string $filePath): ?string
|
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();
|
$client = $this->getStorageClient();
|
||||||
$filename = basename($filePath);
|
$filename = basename($filePath);
|
||||||
@@ -98,8 +106,41 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
if (file_exists($tmpFile)) unlink($tmpFile);
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
||||||
if ($text === null) return null;
|
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
|
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
|
private function getStorageClient(): StorageClientInterface
|
||||||
{
|
{
|
||||||
return $this->getNetworkDocumentService()->getClient();
|
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';
|
public const NOTE_TYPE_ACTION = 'SmartAction';
|
||||||
|
|
||||||
// All tools execute inline — Shira has full permissions
|
// 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 EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
@@ -105,68 +115,28 @@ class SmartAssistantService
|
|||||||
// Ignore
|
// 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
|
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
|
||||||
$effectiveCaseId = $caseId;
|
$effectiveCaseId = $caseId;
|
||||||
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
|
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
|
||||||
$effectiveCaseId = $context['drillDown']['case']['id'];
|
$effectiveCaseId = $context['drillDown']['case']['id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
// Execute tools with agentic loop — read tools feed results back to AI
|
||||||
|
try {
|
||||||
foreach ($actions as $action) {
|
$loopResult = $this->executeActionsWithLoop(
|
||||||
$tool = $action['tool'] ?? '';
|
$message, $context, $conversationId, $conversationHistory,
|
||||||
$actionCaseId = $effectiveCaseId;
|
$mode, $effectiveCaseId, $userId
|
||||||
|
);
|
||||||
// Resolve case from action params if not available
|
} catch (\Exception $e) {
|
||||||
if (!$actionCaseId && !empty($action['params']['caseId'])) {
|
$this->log->error("SmartAssistant: Chat failed: " . $e->getMessage());
|
||||||
$actionCaseId = $action['params']['caseId'];
|
$loopResult = [
|
||||||
}
|
'responseText' => "⚠️ אירעה שגיאה בתקשורת עם המערכת. אנא נסה שוב בעוד מספר דקות.\n\nפרטי השגיאה: " . $e->getMessage(),
|
||||||
|
'executedActions' => [],
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($inlineResults)) {
|
$responseText = $loopResult['responseText'];
|
||||||
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
$executedActions = $loopResult['executedActions'];
|
||||||
}
|
|
||||||
|
|
||||||
// Persist response
|
// Persist response
|
||||||
try {
|
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
|
public function executeAction(string $conversationId, string $actionId, bool $approved): array
|
||||||
{
|
{
|
||||||
$conversation = $this->loadConversation($conversationId);
|
$conversation = $this->loadConversation($conversationId);
|
||||||
@@ -406,7 +518,7 @@ class SmartAssistantService
|
|||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_HTTPHEADER => $headers,
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
CURLOPT_TIMEOUT => 120,
|
CURLOPT_TIMEOUT => 180,
|
||||||
CURLOPT_CONNECTTIMEOUT => 10,
|
CURLOPT_CONNECTTIMEOUT => 10,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 750 KiB |
+2
-2
@@ -3,11 +3,11 @@
|
|||||||
"module": "SmartAssistant",
|
"module": "SmartAssistant",
|
||||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||||
"author": "klear",
|
"author": "klear",
|
||||||
"version": "2.5.2",
|
"version": "2.7.2",
|
||||||
"acceptableVersions": [
|
"acceptableVersions": [
|
||||||
">=8.0.0"
|
">=8.0.0"
|
||||||
],
|
],
|
||||||
"releaseDate": "2026-04-06",
|
"releaseDate": "2026-04-09",
|
||||||
"php": [
|
"php": [
|
||||||
">=8.1"
|
">=8.1"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user