Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f84b2f1cef | |||
| 2a8046b737 | |||
| f319f981f9 | |||
| bf98495198 | |||
| c541069cb5 | |||
| 7480e7db35 | |||
| 3c6e54d233 | |||
| 6c97ba2361 | |||
| 94397c809b | |||
| b810c9fcff | |||
| 0dc26f3b15 | |||
| ca04dd72f8 | |||
| 87bc4b6770 | |||
| 2fe807b7f9 |
@@ -0,0 +1,120 @@
|
||||
# SmartAssistant — פרומפט פריסה לסביבת פיתוח
|
||||
|
||||
> **העתק את הפרומפט הבא כהודעה פותחת בשיחה חדשה של Claude Code, מתוך תיקיית הפרויקט.**
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
## הקשר
|
||||
|
||||
אני עובד על הרחבת SmartAssistant ל-EspoCRM — עוזר AI צף למשרד עורכי דין.
|
||||
הקוד מוכן (v1.0.3), וצריך להשלים את הפריסה בסביבת הפיתוח.
|
||||
|
||||
קרא את DEPLOYMENT_REVIEW.md לסיכום מלא של מה בוצע, מה נבדק, ומה נשאר.
|
||||
|
||||
## סביבת הפיתוח
|
||||
|
||||
- EspoCRM Dev: https://espocrm.dev.marcus-law.co.il (Coolify UUID: c591uqmu49qa5kh7t2z7a2mp)
|
||||
- n8n: https://n8n.dev.marcus-law.co.il
|
||||
- Gitea: https://gitea.dev.marcus-law.co.il (repo: espocrm-extensions/SmartAssistant)
|
||||
- קרדנשיאלים ב-Infisical (תיקיות /espocrm, /n8n)
|
||||
|
||||
## המשימות — שלב 1: הכנה לפריסה
|
||||
|
||||
בצע את המשימות הבאות בסדר. לפני כל שלב — עדכן אותי מה אתה הולך לעשות וחכה לאישור.
|
||||
|
||||
### 1. בדיקת תאימות סביבה
|
||||
- בדוק גרסת EspoCRM בסביבת dev (צריך >= 8.0.0)
|
||||
- בדוק גרסת PHP (צריך >= 8.1)
|
||||
- בדוק שאין שאריות של מודולים ישנים (CrmAssistant / OfficeAssistant הוסרו מהפרויקט)
|
||||
|
||||
### 2. בדיקת אנטיטי AssistantConversation
|
||||
- המודול משתמש באנטיטי AssistantConversation לשמירת שיחות (ראה ConversationRepository.php)
|
||||
- בדוק אם האנטיטי הזה קיים ב-EspoCRM Dev
|
||||
- אם לא קיים — צריך ליצור אותו. בדוק את הקוד ב-ConversationRepository.php כדי להבין אילו שדות נדרשים, והכן entityDefs JSON
|
||||
|
||||
### 3. בניית n8n Workflow לטיפול בצ'אט
|
||||
זה הרכיב הקריטי החסר. המודול שולח webhook ל-n8n ומצפה לתשובה.
|
||||
|
||||
**Webhook Payload (נשלח מ-EspoCRM):**
|
||||
```json
|
||||
{
|
||||
"message": "הודעת המשתמש",
|
||||
"context": { ... הקשר תיק/משרד מלא ... },
|
||||
"conversationId": "conv_xxx",
|
||||
"conversationHistory": [{ "role": "user/assistant", "content": "..." }],
|
||||
"mode": "office|case"
|
||||
}
|
||||
```
|
||||
|
||||
**Response נדרש (חזרה ל-EspoCRM):**
|
||||
```json
|
||||
{
|
||||
"text": "תשובת העוזר",
|
||||
"actions": [
|
||||
{
|
||||
"tool": "create_task|add_note|change_status|create_meeting|schedule_hearing|list_documents|analyze_document|save_memory|rename_document",
|
||||
"params": { ... },
|
||||
"actionId": "action_xxx",
|
||||
"displayText": "תיאור הפעולה למשתמש"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**הפעולות שהעוזר יכול לבקש:**
|
||||
|
||||
| כלי | פרמטרים | דורש אישור |
|
||||
|------|----------|------------|
|
||||
| create_task | name, dateEnd, priority, description | כן |
|
||||
| add_note | text | כן |
|
||||
| change_status | status | כן |
|
||||
| create_meeting | name, dateStart, dateEnd, description | כן |
|
||||
| create_call | name, dateStart, dateEnd, description, direction, status | כן |
|
||||
| schedule_hearing | date, description | כן |
|
||||
| list_documents | — | לא (מיידי) |
|
||||
| analyze_document | documentId | כן |
|
||||
| save_memory | name, content, category, importance | לא (מיידי) |
|
||||
| save_rule | name, rule, scope (global/case/office) | לא (מיידי) |
|
||||
| rename_document | documentId, newName | כן |
|
||||
|
||||
**דרישות ל-Workflow:**
|
||||
- Webhook trigger שמקבל POST
|
||||
- שליחת ההודעה + הקשר ל-LLM (Claude/GPT)
|
||||
- System prompt שמכיר את הכלים הזמינים ואת פורמט התשובה
|
||||
- החזרת JSON תקין בפורמט למעלה
|
||||
- שמירת היסטוריית שיחה (conversationId)
|
||||
- טיפול בשני המצבים (office/case) עם system prompts מתאימים
|
||||
|
||||
### 4. הגדרת Integration ב-EspoCRM
|
||||
אחרי שה-workflow מוכן:
|
||||
- Admin > Integrations > Smart Assistant
|
||||
- webhookUrl = כתובת ה-webhook מ-n8n
|
||||
- apiKey = מפתח אימות (אם הגדרנו)
|
||||
- שאר הערכים: ברירת מחדל
|
||||
|
||||
### 5. בדיקות ידניות ראשונות
|
||||
אחרי הפריסה:
|
||||
- [ ] האם ה-FAB (כפתור צ'אט) מופיע?
|
||||
- [ ] האם ניתן לשלוח הודעה במצב משרד ולקבל תשובה?
|
||||
- [ ] האם ניתן לנווט לתיק ולשלוח הודעה במצב תיק?
|
||||
- [ ] האם פעולות מוצגות לאישור?
|
||||
- [ ] האם ההתראות מופיעות ב-navbar?
|
||||
- [ ] האם הדשלט מציג נתונים?
|
||||
|
||||
## הנחיות
|
||||
|
||||
- השתמש ב-MCP tools (Coolify, Gitea, n8n, Infisical) לכל פעולה
|
||||
- אל תבצע פעולות הרסניות בלי אישור שלי
|
||||
- אם נתקלת בבעיה — תאר מה קרה ומה ניסית, ותשאל מה לעשות
|
||||
- עדכן את DEPLOYMENT_REVIEW.md עם כל ממצא חדש
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## הערות שימוש
|
||||
|
||||
- **היכן להריץ:** בתיקיית `/home/chaim/espocrm-extensions/SmartAssistant`
|
||||
- **MCP נדרשים:** coolify-dev, gitea-dev, n8n-dev, infisical
|
||||
- **משך משוער:** השלב הקריטי הוא בניית ה-n8n workflow (#3) — זה ידרוש עיצוב system prompts ובדיקות
|
||||
- **מה הפרומפט לא כולל:** הסרת מודולים ישנים (תלוי בממצאי שלב 1), הוספת תרגום he_IL (שיפור עתידי)
|
||||
@@ -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.0.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
|
||||
## תיאור
|
||||
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
|
||||
יצירת טבלה מעוצבת עם תמיכה מלאה בעברית ואנגלית מעורבת.
|
||||
|
||||
כאשר המשתמש מבקש טבלה בכל הקשר — תכנית עבודה, סיכום, השוואה, רשימה — השתמש בפונקציה `bidi_table()` מ-`scripts/bidi_table.py`.
|
||||
|
||||
## הוראות
|
||||
|
||||
1. **תמיד** השתמש ב-Bash כדי להריץ את הסקריפט — אל תנסה לייצר טבלת box-drawing ידנית כי ה-BiDi ישבור אותה.
|
||||
|
||||
2. הרץ כך:
|
||||
```bash
|
||||
python3 -c "
|
||||
import sys; sys.path.insert(0, '/home/chaim/legal-ai')
|
||||
from scripts.bidi_table import bidi_table
|
||||
print(bidi_table(
|
||||
['Header1', 'Header2', 'Header3'],
|
||||
[
|
||||
['value1', 'ערך בעברית', 'mixed ערבוב'],
|
||||
['value2', 'ערך נוסף', 'עוד שורה'],
|
||||
],
|
||||
))
|
||||
"
|
||||
```
|
||||
|
||||
3. כותרות עמודות — עדיף באנגלית (כי שורת הכותרת הכי רגישה ל-BiDi).
|
||||
|
||||
4. תוכן בעברית, באנגלית, או מעורב — הכל עובד בגוף הטבלה.
|
||||
|
||||
5. אם המשתמש מבקש טבלה כחלק ממסמך MD שנכתב לקובץ (לא לטרמינל) — אפשר להשתמש ב-markdown רגיל כי קוראי MD מטפלים ב-RTL בעצמם.
|
||||
|
||||
## $ARGUMENTS
|
||||
תוכן הטבלה — כותרות ושורות. אם לא צוין, שאל את המשתמש מה להציג.
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BiDi-safe box-drawing table renderer for mixed Hebrew/English terminal output.
|
||||
|
||||
Uses LRM (Left-to-Right Mark, U+200E) before box-drawing characters to prevent
|
||||
the BiDi algorithm from breaking table alignment when Hebrew text is present.
|
||||
|
||||
Usage as module:
|
||||
from scripts.bidi_table import bidi_table
|
||||
print(bidi_table(['Col1', 'Col2'], [['val1', 'ערך2']]))
|
||||
|
||||
Usage from CLI:
|
||||
python3 scripts/bidi_table.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LRM = "\u200E" # Left-to-Right Mark — invisible, prevents BiDi reordering
|
||||
|
||||
|
||||
def bidi_table(headers: list[str], rows: list[list[str]]) -> str:
|
||||
"""Render a box-drawing table safe for mixed RTL/LTR terminal display."""
|
||||
ncols = len(headers)
|
||||
|
||||
# Calculate column widths
|
||||
col_widths = [len(h) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row[:ncols]):
|
||||
col_widths[i] = max(col_widths[i], len(cell))
|
||||
|
||||
def hline(left: str, mid: str, right: str) -> str:
|
||||
return left + mid.join("─" * (w + 2) for w in col_widths) + right
|
||||
|
||||
def dataline(cells: list[str]) -> str:
|
||||
parts = []
|
||||
for i in range(ncols):
|
||||
cell = cells[i] if i < len(cells) else ""
|
||||
padded = cell + " " * max(0, col_widths[i] - len(cell))
|
||||
parts.append(" " + padded + " ")
|
||||
return LRM + "│" + (LRM + "│").join(parts) + LRM + "│"
|
||||
|
||||
lines = [hline("┌", "┬", "┐")]
|
||||
lines.append(dataline(headers))
|
||||
lines.append(hline("├", "┼", "┤"))
|
||||
for row in rows:
|
||||
lines.append(dataline(row))
|
||||
lines.append(hline("└", "┴", "┘"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
table = bidi_table(
|
||||
["File", "Description", "Model", "Step"],
|
||||
[
|
||||
["claims_extractor.py", "חילוץ טענות מכתבי טענות", "Sonnet", "שלב 3 — הבא בתור"],
|
||||
["brainstorm.py", "סיעור מוחות — כיווני נימוק", "Sonnet", "שלב 4"],
|
||||
["block_writer.py", "כתיבת בלוקים של החלטה", "Sonnet/Opus", "שלב 5"],
|
||||
["qa_validator.py", "בדיקת איכות QA", "Sonnet", "שלב 6"],
|
||||
["style_analyzer.py", "ניתוח סגנון דפנה", "Opus", "חד-פעמי"],
|
||||
["learning_loop.py", "למידה מהחלטה סופית", "Sonnet", "סוף תהליך"],
|
||||
],
|
||||
)
|
||||
print(table)
|
||||
@@ -24,7 +24,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
||||
afterRender: function () {
|
||||
this._alerts = [];
|
||||
this._summary = {};
|
||||
this._activeType = null;
|
||||
this._activeType = 'attention';
|
||||
|
||||
var self = this;
|
||||
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
|
||||
@@ -54,10 +54,10 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
||||
|
||||
renderCards: function (summary) {
|
||||
var cards = [
|
||||
{type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
|
||||
{type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
|
||||
{type: 'overdue', label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'},
|
||||
{type: 'hearings', label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'},
|
||||
{type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
|
||||
{type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
|
||||
];
|
||||
|
||||
var self = this;
|
||||
|
||||
@@ -24,6 +24,9 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
' <button class="btn btn-link sa-new-btn" type="button" title="{{translate \'New Conversation\' scope=\'SmartAssistant\'}}">' +
|
||||
' <span class="fas fa-plus"></span>' +
|
||||
' </button>' +
|
||||
' <button class="btn btn-link sa-expand-btn" type="button" title="{{translate \'Expand\' scope=\'SmartAssistant\'}}">' +
|
||||
' <span class="fas fa-expand-alt"></span>' +
|
||||
' </button>' +
|
||||
' <button class="btn btn-link sa-close-btn" type="button">' +
|
||||
' <span class="fas fa-times"></span>' +
|
||||
' </button>' +
|
||||
@@ -67,7 +70,10 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
'.sa-fab:hover { transform: scale(1.08); }' +
|
||||
'.sa-fab-avatar { width: 100%; height: 100%; object-fit: cover; }' +
|
||||
'.sa-fab-badge { position: absolute; top: -4px; right: -4px; background: #c62828; color: #fff; border-radius: 10px; min-width: 20px; height: 20px; font-size: 11px; font-weight: bold; display: flex; align-items: center; justify-content: center; padding: 0 5px; z-index: 1; }' +
|
||||
'.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; }' +
|
||||
'.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; transition: width 0.3s ease, max-height 0.3s ease; }' +
|
||||
'.sa-chat-panel.sa-expanded { width: 700px; max-height: 85vh; }' +
|
||||
'.sa-chat-panel.sa-expanded .sa-chat-messages { max-height: calc(85vh - 180px); }' +
|
||||
'.sa-chat-panel.sa-expanded .sa-history-list, .sa-chat-panel.sa-expanded .sa-memory-list { max-height: calc(85vh - 200px); }' +
|
||||
'@keyframes sa-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }' +
|
||||
'.sa-chat-header { display: flex; align-items: center; padding: 10px 16px; background: #5c6bc0; color: #fff; gap: 8px; }' +
|
||||
'.sa-chat-title { font-size: 15px; font-weight: 600; white-space: nowrap; }' +
|
||||
@@ -122,7 +128,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
'.sa-badge-source { background: #f3e5f5; color: #7b1fa2; }' +
|
||||
'.sa-memory-entry-content { font-size: 12px; color: #555; line-height: 1.4; max-height: 60px; overflow: hidden; }' +
|
||||
'.sa-memory-entry-meta { font-size: 10px; color: #999; margin-top: 4px; }' +
|
||||
'@media (max-width: 480px) { .sa-chat-panel { width: calc(100vw - 48px); left: 0; } .smart-assistant-fab-container { bottom: 16px; left: 16px; } }' +
|
||||
'@media (max-width: 480px) { .sa-chat-panel { width: calc(100vw - 48px); left: 0; } .sa-chat-panel.sa-expanded { width: calc(100vw - 48px); } .smart-assistant-fab-container { bottom: 16px; left: 16px; } }' +
|
||||
'</style>',
|
||||
|
||||
conversationId: null,
|
||||
@@ -131,12 +137,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
currentCaseId: null,
|
||||
currentCaseName: null,
|
||||
currentView: 'chat',
|
||||
isExpanded: false,
|
||||
currentMemoryCategory: null,
|
||||
memoryData: null,
|
||||
|
||||
events: {
|
||||
'click .sa-fab': function () { this.togglePanel(); },
|
||||
'click .sa-close-btn': function () { this.closePanel(); },
|
||||
'click .sa-expand-btn': function () { this.toggleExpand(); },
|
||||
'click .sa-chat-send': function () { this.sendMessage(); },
|
||||
'click .sa-history-btn': function () { this.showHistory(); },
|
||||
'click .sa-new-btn': function () { this.startNewConversation(); },
|
||||
@@ -260,7 +268,26 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
|
||||
closePanel: function () {
|
||||
this.isOpen = false;
|
||||
this.$el.find('.sa-chat-panel').css('display', 'none');
|
||||
this.isExpanded = false;
|
||||
var $panel = this.$el.find('.sa-chat-panel');
|
||||
$panel.removeClass('sa-expanded').css('display', 'none');
|
||||
this.$el.find('.sa-expand-btn .fas').removeClass('fa-compress-alt').addClass('fa-expand-alt');
|
||||
},
|
||||
|
||||
toggleExpand: function () {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
var $panel = this.$el.find('.sa-chat-panel');
|
||||
var $icon = this.$el.find('.sa-expand-btn .fas');
|
||||
|
||||
if (this.isExpanded) {
|
||||
$panel.addClass('sa-expanded');
|
||||
$icon.removeClass('fa-expand-alt').addClass('fa-compress-alt');
|
||||
} else {
|
||||
$panel.removeClass('sa-expanded');
|
||||
$icon.removeClass('fa-compress-alt').addClass('fa-expand-alt');
|
||||
}
|
||||
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
showChat: function () {
|
||||
@@ -447,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();
|
||||
|
||||
@@ -741,7 +768,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
recordView.model.trigger('update-all');
|
||||
}
|
||||
|
||||
if (entityType === 'Task' || entityType === 'Meeting') {
|
||||
if (entityType === 'Task' || entityType === 'Meeting' || entityType === 'Call') {
|
||||
var sideView = currentView.getView('side');
|
||||
|
||||
if (sideView) {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Modules\NextCloudIntegration\Services\NextCloudService;
|
||||
|
||||
class FileStorageFactory
|
||||
{
|
||||
private Config $config;
|
||||
private InjectableFactory $injectableFactory;
|
||||
private Log $log;
|
||||
|
||||
public function __construct(Config $config, InjectableFactory $injectableFactory, Log $log)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
$this->log = $log;
|
||||
}
|
||||
|
||||
public function create(?string $userId = null): FileStorageInterface
|
||||
{
|
||||
$storageType = $this->config->get('assistantFileStorage', 'nextcloud');
|
||||
|
||||
return match ($storageType) {
|
||||
'nextcloud' => $this->createNextCloud($userId),
|
||||
'webdav' => $this->createWebDav(),
|
||||
default => throw new Error("Unknown file storage type: {$storageType}"),
|
||||
};
|
||||
}
|
||||
|
||||
private function createNextCloud(?string $userId): NextCloudFileStorage
|
||||
{
|
||||
$nextCloudService = $this->injectableFactory->create(NextCloudService::class);
|
||||
return new NextCloudFileStorage($nextCloudService->getClientForUser($userId));
|
||||
}
|
||||
|
||||
private function createWebDav(): WebDavFileStorage
|
||||
{
|
||||
$url = $this->config->get('assistantWebDavUrl');
|
||||
$user = $this->config->get('assistantWebDavUser');
|
||||
$password = $this->config->get('assistantWebDavPassword');
|
||||
$basePath = $this->config->get('assistantWebDavBasePath', '/webdav');
|
||||
|
||||
if (empty($url) || empty($user) || empty($password)) {
|
||||
throw new Error('WebDAV file storage is not configured.');
|
||||
}
|
||||
|
||||
return new WebDavFileStorage($url, $user, $password, $basePath);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
|
||||
|
||||
interface FileStorageInterface
|
||||
{
|
||||
public function listFolder(string $path): array;
|
||||
public function downloadFile(string $path): string;
|
||||
public function move(string $source, string $destination): bool;
|
||||
public function createFolder(string $path): bool;
|
||||
public function exists(string $path): bool;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
|
||||
|
||||
use Espo\Modules\NextCloudIntegration\Classes\NextCloud\Client;
|
||||
|
||||
class NextCloudFileStorage implements FileStorageInterface
|
||||
{
|
||||
private Client $client;
|
||||
|
||||
public function __construct(Client $client)
|
||||
{
|
||||
$this->client = $client;
|
||||
}
|
||||
|
||||
public function listFolder(string $path): array { return $this->client->listFolder($path); }
|
||||
public function downloadFile(string $path): string { return $this->client->downloadFile($path); }
|
||||
public function move(string $source, string $destination): bool { return $this->client->move($source, $destination); }
|
||||
public function createFolder(string $path): bool { return $this->client->createFolderRecursive($path); }
|
||||
public function exists(string $path): bool { return $this->client->exists($path); }
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
|
||||
/**
|
||||
* Generic WebDAV file storage adapter.
|
||||
* Works with Synology NAS, ownCloud, and any standard WebDAV server.
|
||||
* Uses only DAV-standard operations (no NextCloud/OCS-specific extensions).
|
||||
*/
|
||||
class WebDavFileStorage implements FileStorageInterface
|
||||
{
|
||||
private string $baseUrl;
|
||||
private string $username;
|
||||
private string $password;
|
||||
private string $webdavPath;
|
||||
|
||||
public function __construct(
|
||||
string $baseUrl,
|
||||
string $username,
|
||||
string $password,
|
||||
string $webdavPath = '/webdav'
|
||||
) {
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
$this->username = $username;
|
||||
$this->password = $password;
|
||||
$this->webdavPath = $webdavPath;
|
||||
}
|
||||
|
||||
public function listFolder(string $path): array
|
||||
{
|
||||
$propfindXml = '<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
<d:getcontenttype/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>';
|
||||
|
||||
$response = $this->request('PROPFIND', $path, $propfindXml, [
|
||||
'Content-Type: application/xml',
|
||||
'Depth: 1',
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 207) {
|
||||
throw new Error("Failed to list folder: HTTP " . $response['status']);
|
||||
}
|
||||
|
||||
return $this->parsePropfindResponse($response['body'], $path);
|
||||
}
|
||||
|
||||
public function downloadFile(string $path): string
|
||||
{
|
||||
$response = $this->request('GET', $path);
|
||||
|
||||
if ($response['status'] !== 200) {
|
||||
throw new Error("Failed to download file: HTTP " . $response['status']);
|
||||
}
|
||||
|
||||
return $response['body'];
|
||||
}
|
||||
|
||||
public function move(string $source, string $destination): bool
|
||||
{
|
||||
$destUrl = $this->buildUrl($destination);
|
||||
|
||||
$response = $this->request('MOVE', $source, null, [
|
||||
'Destination: ' . $destUrl,
|
||||
'Overwrite: F',
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 201 && $response['status'] !== 204) {
|
||||
throw new Error("Failed to move: HTTP " . $response['status']);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function createFolder(string $path): bool
|
||||
{
|
||||
$parts = array_filter(explode('/', $path));
|
||||
$currentPath = '';
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$currentPath .= '/' . $part;
|
||||
$response = $this->request('MKCOL', $currentPath);
|
||||
|
||||
// 201 = created, 405 = already exists
|
||||
if ($response['status'] !== 201 && $response['status'] !== 405) {
|
||||
throw new Error("Failed to create folder: HTTP " . $response['status']);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function exists(string $path): bool
|
||||
{
|
||||
try {
|
||||
$response = $this->request('PROPFIND', $path, null, ['Depth: 0']);
|
||||
return $response['status'] === 207;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function buildUrl(string $path): string
|
||||
{
|
||||
$pathParts = explode('/', ltrim($path, '/'));
|
||||
$encodedParts = array_map('rawurlencode', $pathParts);
|
||||
|
||||
return $this->baseUrl . $this->webdavPath . '/' . implode('/', $encodedParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: int, body: string}
|
||||
*/
|
||||
private function request(
|
||||
string $method,
|
||||
string $path,
|
||||
?string $body = null,
|
||||
array $headers = []
|
||||
): array {
|
||||
$url = $this->buildUrl($path);
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
$allHeaders = array_merge([
|
||||
'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password),
|
||||
], $headers);
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $allHeaders,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlError) {
|
||||
throw new Error("WebDAV connection error: $curlError");
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $httpCode,
|
||||
'body' => $responseBody,
|
||||
];
|
||||
}
|
||||
|
||||
private function parsePropfindResponse(string $xml, string $basePath): array
|
||||
{
|
||||
$items = [];
|
||||
$normalizedBasePath = '/' . trim($basePath, '/');
|
||||
|
||||
$doc = new \DOMDocument();
|
||||
$doc->loadXML($xml);
|
||||
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$xpath->registerNamespace('d', 'DAV:');
|
||||
|
||||
$responses = $xpath->query('//d:response');
|
||||
|
||||
foreach ($responses as $response) {
|
||||
$hrefNodes = $xpath->query('d:href', $response);
|
||||
if ($hrefNodes->length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$href = urldecode($hrefNodes->item(0)->textContent);
|
||||
|
||||
// Extract relative path by removing webdav prefix
|
||||
$relativePath = $href;
|
||||
$webdavPrefix = $this->webdavPath . '/';
|
||||
if (($pos = strpos($href, $webdavPrefix)) !== false) {
|
||||
$relativePath = substr($href, $pos + strlen($webdavPrefix));
|
||||
}
|
||||
$relativePath = '/' . trim($relativePath, '/');
|
||||
|
||||
// Skip the queried folder itself
|
||||
if ($relativePath === $normalizedBasePath || $relativePath === $normalizedBasePath . '/') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$propstat = $xpath->query('d:propstat/d:prop', $response)->item(0);
|
||||
if (!$propstat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resourceType = $xpath->query('d:resourcetype/d:collection', $propstat);
|
||||
$isFolder = $resourceType->length > 0;
|
||||
|
||||
$contentLength = $xpath->query('d:getcontentlength', $propstat);
|
||||
$size = $contentLength->length > 0 ? (int) $contentLength->item(0)->textContent : null;
|
||||
|
||||
$lastModified = $xpath->query('d:getlastmodified', $propstat);
|
||||
$modified = $lastModified->length > 0 ? $lastModified->item(0)->textContent : null;
|
||||
|
||||
$contentType = $xpath->query('d:getcontenttype', $propstat);
|
||||
$mimeType = $contentType->length > 0 ? $contentType->item(0)->textContent : null;
|
||||
|
||||
$items[] = [
|
||||
'name' => basename($relativePath),
|
||||
'path' => $relativePath,
|
||||
'isFolder' => $isFolder,
|
||||
'size' => $size,
|
||||
'mimeType' => $isFolder ? 'folder' : $mimeType,
|
||||
'modified' => $modified,
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, function ($a, $b) {
|
||||
if ($a['isFolder'] !== $b['isFolder']) {
|
||||
return $b['isFolder'] <=> $a['isFolder'];
|
||||
}
|
||||
return strcasecmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,22 @@ 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\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 +174,107 @@ 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create AssistantRule": "Create Rule"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"rule": "Rule",
|
||||
"scope": "Scope",
|
||||
"isActive": "Active",
|
||||
"createdAt": "Created At",
|
||||
"modifiedAt": "Modified At",
|
||||
"createdBy": "Created By",
|
||||
"modifiedBy": "Modified By"
|
||||
},
|
||||
"options": {
|
||||
"scope": {
|
||||
"global": "Global",
|
||||
"case": "Case Mode",
|
||||
"office": "Office Mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"scopeNames": {
|
||||
"CaseMemory": "Case Memory",
|
||||
"AssistantRule": "Assistant Rule",
|
||||
"SmartAssistant": "Smart Assistant"
|
||||
},
|
||||
"scopeNamesPlural": {
|
||||
"CaseMemory": "Case Memories",
|
||||
"AssistantRule": "Assistant Rules",
|
||||
"SmartAssistant": "Smart Assistant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"fields": {
|
||||
"webhookUrl": "Webhook URL",
|
||||
"apiKey": "API Key",
|
||||
"espocrmApiKey": "EspoCRM API Key",
|
||||
"maxMessagesPerHour": "Max Messages Per Hour",
|
||||
"inactivityWarningDays": "Inactivity Warning Days",
|
||||
"inactivityCriticalDays": "Inactivity Critical Days",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create AssistantRule": "הוסף כלל"
|
||||
},
|
||||
"fields": {
|
||||
"name": "שם",
|
||||
"rule": "כלל",
|
||||
"scope": "היקף",
|
||||
"isActive": "פעיל",
|
||||
"createdAt": "נוצר בתאריך",
|
||||
"modifiedAt": "עודכן בתאריך",
|
||||
"createdBy": "נוצר על ידי",
|
||||
"modifiedBy": "עודכן על ידי"
|
||||
},
|
||||
"options": {
|
||||
"scope": {
|
||||
"global": "גלובלי",
|
||||
"case": "מצב תיק",
|
||||
"office": "מצב משרד"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"scopeNames": {
|
||||
"CaseMemory": "זיכרון תיק",
|
||||
"AssistantRule": "כלל התנהגות",
|
||||
"SmartAssistant": "עוזר חכם"
|
||||
},
|
||||
"scopeNamesPlural": {
|
||||
"CaseMemory": "זיכרונות תיקים",
|
||||
"AssistantRule": "כללי התנהגות",
|
||||
"SmartAssistant": "עוזר חכם"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"fields": {
|
||||
"webhookUrl": "כתובת Webhook",
|
||||
"apiKey": "מפתח API",
|
||||
"espocrmApiKey": "מפתח API של EspoCRM",
|
||||
"maxMessagesPerHour": "מקסימום הודעות לשעה",
|
||||
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
|
||||
"inactivityCriticalDays": "ימים לאזהרה קריטית",
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "varchar",
|
||||
"maxLength": 255,
|
||||
"required": true
|
||||
},
|
||||
"rule": {
|
||||
"type": "text",
|
||||
"required": true
|
||||
},
|
||||
"scope": {
|
||||
"type": "enum",
|
||||
"options": ["global", "case", "office"],
|
||||
"default": "global"
|
||||
},
|
||||
"isActive": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"isActive": {
|
||||
"columns": ["isActive", "deleted"]
|
||||
},
|
||||
"scope": {
|
||||
"columns": ["scope", "isActive", "deleted"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -8,6 +8,10 @@
|
||||
"type": "varchar",
|
||||
"maxLength": 255
|
||||
},
|
||||
"espocrmApiKey": {
|
||||
"type": "varchar",
|
||||
"maxLength": 255
|
||||
},
|
||||
"maxMessagesPerHour": {
|
||||
"type": "int",
|
||||
"default": 30
|
||||
@@ -23,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,
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"rows": [
|
||||
[{"name": "name"}, {"name": "scope"}],
|
||||
[{"name": "isActive"}, false],
|
||||
[{"name": "rule", "fullWidth": true}],
|
||||
[{"name": "createdAt"}, {"name": "createdBy"}]
|
||||
]
|
||||
}
|
||||
]
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"name": "name", "width": 35, "link": true},
|
||||
{"name": "rule", "width": 30},
|
||||
{"name": "scope", "width": 10},
|
||||
{"name": "isActive", "width": 10},
|
||||
{"name": "createdAt", "width": 15}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"entity": true,
|
||||
"object": true,
|
||||
"module": "SmartAssistant",
|
||||
"acl": true,
|
||||
"aclActionList": ["create", "read", "edit", "delete"],
|
||||
"aclLevelList": ["all", "team", "own", "no"],
|
||||
"stream": false,
|
||||
"tab": false,
|
||||
"disabled": false
|
||||
}
|
||||
@@ -86,5 +86,13 @@
|
||||
"controller": "SmartAssistant",
|
||||
"action": "saveMemory"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/SmartAssistant/action/executeTool",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "SmartAssistant",
|
||||
"action": "executeTool"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,18 +7,21 @@ use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
|
||||
class ActionExecutor
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private InjectableFactory $injectableFactory;
|
||||
private Log $log;
|
||||
private Metadata $metadata;
|
||||
|
||||
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log)
|
||||
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log, Metadata $metadata)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
$this->log = $log;
|
||||
$this->metadata = $metadata;
|
||||
}
|
||||
|
||||
public const VALID_STATUSES = [
|
||||
@@ -38,12 +41,24 @@ class ActionExecutor
|
||||
'add_note' => $this->addNote($params, $caseId, $userId),
|
||||
'change_status' => $this->changeStatus($params, $caseId),
|
||||
'create_meeting' => $this->createMeeting($params, $caseId, $userId),
|
||||
'create_call' => $this->createCall($params, $caseId, $userId),
|
||||
'delete_meeting' => $this->deleteMeeting($params),
|
||||
'delete_call' => $this->deleteCall($params),
|
||||
'delete_task' => $this->deleteTask($params),
|
||||
'delete_note' => $this->deleteNote($params),
|
||||
'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),
|
||||
'save_memory' => $this->saveMemory($params, $caseId, $userId),
|
||||
default => throw new Error("Unknown tool: {$tool}"),
|
||||
'save_rule' => $this->saveRule($params, $userId),
|
||||
default => $this->executePluginTool($tool, $params, $caseId, $userId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,6 +89,32 @@ class ActionExecutor
|
||||
];
|
||||
}
|
||||
|
||||
private function saveRule(array $params, string $userId): array
|
||||
{
|
||||
$name = $params['name'] ?? '';
|
||||
$rule = $params['rule'] ?? '';
|
||||
if (!$name || !$rule) {
|
||||
throw new Error('save_rule requires name and rule parameters.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getNewEntity('AssistantRule');
|
||||
$entity->set([
|
||||
'name' => $name,
|
||||
'rule' => $rule,
|
||||
'scope' => $params['scope'] ?? 'global',
|
||||
'isActive' => true,
|
||||
'createdById' => $userId,
|
||||
]);
|
||||
$this->entityManager->saveEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ כלל חדש נשמר: \"{$name}\"",
|
||||
'entityType' => 'AssistantRule',
|
||||
'entityId' => $entity->get('id'),
|
||||
];
|
||||
}
|
||||
|
||||
private function createTask(array $params, ?string $caseId, string $userId): array
|
||||
{
|
||||
$taskData = [
|
||||
@@ -160,6 +201,120 @@ class ActionExecutor
|
||||
return ['success' => true, 'message' => "✅ פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')];
|
||||
}
|
||||
|
||||
private function createCall(array $params, ?string $caseId, string $userId): array
|
||||
{
|
||||
$callData = [
|
||||
'name' => $params['name'] ?? 'שיחה חדשה',
|
||||
'status' => $params['status'] ?? 'Held',
|
||||
'direction' => $params['direction'] ?? 'Outbound',
|
||||
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
|
||||
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
|
||||
'description' => $params['description'] ?? null,
|
||||
'assignedUserId' => $userId,
|
||||
];
|
||||
|
||||
if ($caseId) {
|
||||
$callData['parentType'] = 'Case';
|
||||
$callData['parentId'] = $caseId;
|
||||
}
|
||||
|
||||
$call = $this->entityManager->getNewEntity('Call');
|
||||
$call->set($callData);
|
||||
$this->entityManager->saveEntity($call);
|
||||
return ['success' => true, 'message' => "✅ שיחה \"{$params['name']}\" תועדה בהצלחה", 'entityType' => 'Call', 'entityId' => $call->get('id')];
|
||||
}
|
||||
|
||||
private function deleteMeeting(array $params): array
|
||||
{
|
||||
$entityId = $params['entityId'] ?? null;
|
||||
if (!$entityId) {
|
||||
throw new Error('entityId parameter is required.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById('Meeting', $entityId);
|
||||
if (!$entity) {
|
||||
throw new NotFound("Meeting {$entityId} not found.");
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ פגישה \"{$name}\" נמחקה בהצלחה",
|
||||
'entityType' => 'Meeting',
|
||||
'entityId' => $entityId,
|
||||
];
|
||||
}
|
||||
|
||||
private function deleteCall(array $params): array
|
||||
{
|
||||
$entityId = $params['entityId'] ?? null;
|
||||
if (!$entityId) {
|
||||
throw new Error('entityId parameter is required.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById('Call', $entityId);
|
||||
if (!$entity) {
|
||||
throw new NotFound("Call {$entityId} not found.");
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ שיחה \"{$name}\" נמחקה בהצלחה",
|
||||
'entityType' => 'Call',
|
||||
'entityId' => $entityId,
|
||||
];
|
||||
}
|
||||
|
||||
private function deleteNote(array $params): array
|
||||
{
|
||||
$entityId = $params['entityId'] ?? null;
|
||||
if (!$entityId) {
|
||||
throw new Error('entityId parameter is required.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById('Note', $entityId);
|
||||
if (!$entity) {
|
||||
throw new NotFound("Note {$entityId} not found.");
|
||||
}
|
||||
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => '✅ הערה נמחקה בהצלחה',
|
||||
'entityType' => 'Note',
|
||||
'entityId' => $entityId,
|
||||
];
|
||||
}
|
||||
|
||||
private function deleteTask(array $params): array
|
||||
{
|
||||
$entityId = $params['entityId'] ?? null;
|
||||
if (!$entityId) {
|
||||
throw new Error('entityId parameter is required.');
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById('Task', $entityId);
|
||||
if (!$entity) {
|
||||
throw new NotFound("Task {$entityId} not found.");
|
||||
}
|
||||
|
||||
$name = $entity->get('name') ?? '';
|
||||
$this->entityManager->removeEntity($entity);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ משימה \"{$name}\" נמחקה בהצלחה",
|
||||
'entityType' => 'Task',
|
||||
'entityId' => $entityId,
|
||||
];
|
||||
}
|
||||
|
||||
private function scheduleHearing(array $params, ?string $caseId, string $userId): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
@@ -231,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;
|
||||
@@ -243,6 +457,130 @@ 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) {
|
||||
throw new Error('upload_document requires a case context.');
|
||||
}
|
||||
|
||||
$fileName = $params['fileName'] ?? null;
|
||||
$content = $params['content'] ?? null;
|
||||
if (!$fileName) throw new Error('fileName parameter is required.');
|
||||
if (!$content) throw new Error('content parameter is required (base64-encoded file data).');
|
||||
|
||||
$decoded = base64_decode($content, true);
|
||||
if ($decoded === false) {
|
||||
throw new Error('content is not valid base64.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$result = $analyzer->uploadDocument($caseId, $fileName, $decoded, $params['folder'] ?? null);
|
||||
|
||||
$msg = "✅ מסמך \"{$fileName}\" הועלה בהצלחה";
|
||||
if (!empty($params['folder'])) {
|
||||
$msg .= " לתיקייה \"{$params['folder']}\"";
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => $msg, 'data' => $result];
|
||||
}
|
||||
|
||||
private function generateDocument(array $params, ?string $caseId): array
|
||||
{
|
||||
if (!$caseId) {
|
||||
throw new Error('generate_document requires a case context.');
|
||||
}
|
||||
|
||||
$templateId = $params['templateId'] ?? null;
|
||||
if (!$templateId) throw new Error('templateId parameter is required.');
|
||||
|
||||
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
|
||||
if (!$template) throw new NotFound("Template {$templateId} not found.");
|
||||
|
||||
$documentSubject = $params['documentSubject'] ?? null;
|
||||
|
||||
$templateService = $this->injectableFactory->create(
|
||||
\Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService::class
|
||||
);
|
||||
|
||||
$result = $templateService->createFromTemplate($templateId, 'Case', $caseId, $documentSubject);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
throw new Error('Failed to generate document from template.');
|
||||
}
|
||||
|
||||
$templateName = $template->get('name') ?? 'document';
|
||||
$fileName = $result['fileName'] ?? "{$templateName}.docx";
|
||||
|
||||
$msg = "✅ מסמך \"{$fileName}\" נוצר בהצלחה מתבנית \"{$templateName}\"";
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => $msg,
|
||||
'data' => ['fileName' => $fileName],
|
||||
];
|
||||
}
|
||||
|
||||
private function mergeDocuments(array $params, ?string $caseId): array
|
||||
{
|
||||
if (!$caseId) {
|
||||
throw new Error('merge_documents requires a case context.');
|
||||
}
|
||||
|
||||
$documentIds = $params['documentIds'] ?? [];
|
||||
if (empty($documentIds)) throw new Error('documentIds parameter is required (array of document IDs).');
|
||||
|
||||
$documents = [];
|
||||
foreach ($documentIds as $docId) {
|
||||
$doc = $this->entityManager->getEntityById('Document', $docId);
|
||||
if (!$doc) throw new NotFound("Document {$docId} not found.");
|
||||
$documents[] = $doc;
|
||||
}
|
||||
|
||||
$mergerService = $this->injectableFactory->create(
|
||||
\Espo\Modules\NetworkStorageIntegration\Services\DocumentMergerService::class
|
||||
);
|
||||
|
||||
$options = $params['options'] ?? [];
|
||||
$mergedPath = $mergerService->mergeWithLabels($documents, $options);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ " . count($documents) . " מסמכים מוזגו בהצלחה",
|
||||
'data' => ['path' => $mergedPath, 'documentCount' => count($documents)],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeDateTime(?string $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') return null;
|
||||
@@ -257,4 +595,23 @@ class ActionExecutor
|
||||
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
|
||||
return $bytes . ' B';
|
||||
}
|
||||
|
||||
private function executePluginTool(string $tool, array $params, ?string $caseId, string $userId): array
|
||||
{
|
||||
$handlerClass = $this->metadata->get(['app', 'smartAssistant', 'tools', $tool, 'handler']);
|
||||
|
||||
if (!$handlerClass) {
|
||||
throw new Error("Unknown tool: {$tool}");
|
||||
}
|
||||
|
||||
$this->log->debug("SmartAssistant: Executing plugin tool={$tool} handler={$handlerClass}");
|
||||
|
||||
$handler = $this->injectableFactory->create($handlerClass);
|
||||
|
||||
if (!method_exists($handler, 'execute')) {
|
||||
throw new Error("Plugin tool handler {$handlerClass} must have an execute() method.");
|
||||
}
|
||||
|
||||
return $handler->execute($params, $caseId, $userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Log;
|
||||
|
||||
class AssistantRuleContextProvider
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private Log $log;
|
||||
|
||||
public function __construct(EntityManager $entityManager, Log $log)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->log = $log;
|
||||
}
|
||||
|
||||
public function getRulesForMode(string $mode): array
|
||||
{
|
||||
$entries = $this->entityManager->getRDBRepository('AssistantRule')
|
||||
->where([
|
||||
'isActive' => true,
|
||||
'deleted' => false,
|
||||
'scope' => ['global', $mode],
|
||||
])
|
||||
->order('createdAt', 'ASC')
|
||||
->find();
|
||||
|
||||
$rules = [];
|
||||
foreach ($entries as $entry) {
|
||||
$rules[] = [
|
||||
'name' => $entry->get('name'),
|
||||
'rule' => $entry->get('rule'),
|
||||
'scope' => $entry->get('scope'),
|
||||
];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ class CaseContextBuilder
|
||||
'contacts' => $this->getCaseContacts($case),
|
||||
'openTasks' => $this->getOpenTasks($caseId),
|
||||
'upcomingMeetings' => $this->getUpcomingMeetings($caseId),
|
||||
'recentCalls' => $this->getRecentCalls($caseId),
|
||||
'recentNotes' => $this->getRecentNotes($caseId),
|
||||
'availableTemplates' => $this->getAvailableTemplates(),
|
||||
'documents' => $this->getDocumentListing($caseId),
|
||||
@@ -113,6 +114,24 @@ class CaseContextBuilder
|
||||
return $meetings;
|
||||
}
|
||||
|
||||
private function getRecentCalls(string $caseId): array
|
||||
{
|
||||
$calls = [];
|
||||
$collection = $this->entityManager->getRDBRepository('Call')
|
||||
->where(['parentType' => 'Case', 'parentId' => $caseId])
|
||||
->order('dateStart', 'DESC')->limit(0, 10)->find();
|
||||
|
||||
foreach ($collection as $c) {
|
||||
$calls[] = [
|
||||
'id' => $c->get('id'), 'name' => $c->get('name'),
|
||||
'status' => $c->get('status'), 'direction' => $c->get('direction'),
|
||||
'dateStart' => $c->get('dateStart'), 'dateEnd' => $c->get('dateEnd'),
|
||||
'description' => $c->get('description'),
|
||||
];
|
||||
}
|
||||
return $calls;
|
||||
}
|
||||
|
||||
private function getRecentNotes(string $caseId): array
|
||||
{
|
||||
$notes = [];
|
||||
|
||||
@@ -6,18 +6,19 @@ use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageFactory;
|
||||
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageInterface;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||
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 = [
|
||||
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
||||
@@ -43,17 +44,19 @@ class DocumentAnalyzer
|
||||
$casePath = $this->getCaseFolderPath($caseId);
|
||||
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
|
||||
|
||||
$storage = $this->getStorage();
|
||||
$topLevel = $storage->listFolder($casePath);
|
||||
$client = $this->getStorageClient();
|
||||
$topLevel = $client->listFolder($casePath);
|
||||
$folders = []; $rootFiles = []; $totalFiles = 0;
|
||||
|
||||
foreach ($topLevel as $item) {
|
||||
if ($item['isFolder']) {
|
||||
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||
if ($isFolder) {
|
||||
$subFiles = [];
|
||||
try {
|
||||
foreach ($storage->listFolder($item['path']) as $sub) {
|
||||
if (!$sub['isFolder']) {
|
||||
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'], 'mimeType' => $sub['mimeType'], 'modified' => $sub['modified'] ?? null];
|
||||
foreach ($client->listFolder($item['path']) as $sub) {
|
||||
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
|
||||
if (!$subIsFolder) {
|
||||
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null];
|
||||
$totalFiles++;
|
||||
}
|
||||
}
|
||||
@@ -62,7 +65,7 @@ class DocumentAnalyzer
|
||||
}
|
||||
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
|
||||
} else {
|
||||
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'], 'mimeType' => $item['mimeType'], 'modified' => $item['modified'] ?? null];
|
||||
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'] ?? null, 'mimeType' => $item['mimeType'] ?? null, 'modified' => $item['modified'] ?? null];
|
||||
$totalFiles++;
|
||||
}
|
||||
}
|
||||
@@ -72,17 +75,19 @@ class DocumentAnalyzer
|
||||
|
||||
public function extractTextContent(string $filePath): ?string
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
$parentPath = dirname($filePath);
|
||||
$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);
|
||||
|
||||
try { $items = $storage->listFolder($parentPath); } catch (\Exception $e) { return null; }
|
||||
try { $content = $client->downloadFile($filePath); } catch (\Exception $e) { return null; }
|
||||
|
||||
$fileSize = null;
|
||||
foreach ($items as $item) { if ($item['name'] === $filename) { $fileSize = $item['size']; break; } }
|
||||
if ($fileSize !== null && $fileSize > self::MAX_FILE_SIZE) return null;
|
||||
|
||||
try { $content = $storage->downloadFile($filePath); } catch (\Exception $e) { return null; }
|
||||
if (strlen($content) > self::MAX_FILE_SIZE) return null;
|
||||
|
||||
if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
|
||||
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
|
||||
@@ -101,13 +106,46 @@ 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
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
$client = $this->getStorageClient();
|
||||
$parentPath = dirname($sourcePath);
|
||||
$oldName = basename($sourcePath);
|
||||
|
||||
@@ -120,21 +158,87 @@ class DocumentAnalyzer
|
||||
$destination = $parentPath . '/' . $newName;
|
||||
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
||||
|
||||
$storage->move($sourcePath, $destination);
|
||||
$client->move($sourcePath, $destination);
|
||||
$this->updateDocumentEntity($sourcePath, $destination);
|
||||
|
||||
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
|
||||
}
|
||||
|
||||
private function getCaseFolderPath(string $caseId): ?string
|
||||
public function uploadDocument(string $caseId, string $fileName, string $content, ?string $subfolder = null): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
return $case ? ($case->get('nextCloudFolderPath') ?: null) : null;
|
||||
$casePath = $this->getCaseFolderPath($caseId);
|
||||
if (!$casePath) {
|
||||
throw new Error('Case does not have a document folder configured (networkStorageFolderPath).');
|
||||
}
|
||||
|
||||
private function getStorage(): FileStorageInterface
|
||||
$client = $this->getStorageClient();
|
||||
|
||||
$targetDir = $casePath;
|
||||
if ($subfolder) {
|
||||
$targetDir = $casePath . '/' . trim($subfolder, '/');
|
||||
if (!$client->exists($targetDir)) {
|
||||
$client->createFolderRecursive($targetDir);
|
||||
}
|
||||
}
|
||||
|
||||
$targetPath = $targetDir . '/' . $fileName;
|
||||
$client->uploadFile($targetPath, $content);
|
||||
|
||||
return [
|
||||
'path' => $targetPath,
|
||||
'fileName' => $fileName,
|
||||
'folder' => $subfolder,
|
||||
];
|
||||
}
|
||||
|
||||
public function getCaseFolderPath(string $caseId): ?string
|
||||
{
|
||||
return $this->injectableFactory->create(FileStorageFactory::class)->create();
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) return null;
|
||||
|
||||
// Try stored path first
|
||||
$storedPath = $case->get('networkStorageFolderPath') ?: null;
|
||||
if ($storedPath) return $storedPath;
|
||||
|
||||
// Fall back to computed path from NetworkDocumentService
|
||||
try {
|
||||
$nds = $this->getNetworkDocumentService();
|
||||
return $nds->getEntityFolderPath('Case', $caseId);
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private function getNetworkDocumentService(): NetworkDocumentService
|
||||
{
|
||||
return $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
}
|
||||
|
||||
private function extractFromPdf(string $tmpFile): ?string
|
||||
@@ -168,10 +272,10 @@ class DocumentAnalyzer
|
||||
private function updateDocumentEntity(string $oldPath, string $newPath): void
|
||||
{
|
||||
try {
|
||||
$doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne();
|
||||
$doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
|
||||
if ($doc) {
|
||||
$doc->set('nextCloudPath', $newPath);
|
||||
$doc->set('nextCloudFilename', basename($newPath));
|
||||
$doc->set('networkStoragePath', $newPath);
|
||||
$doc->set('networkStorageFileName', basename($newPath));
|
||||
$this->entityManager->saveEntity($doc);
|
||||
}
|
||||
} catch (\Exception $e) {}
|
||||
|
||||
@@ -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', '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;
|
||||
@@ -71,6 +81,10 @@ class SmartAssistantService
|
||||
}
|
||||
}
|
||||
|
||||
// Load behavioral rules
|
||||
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
|
||||
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
|
||||
|
||||
// Generate conversation ID
|
||||
if (!$conversationId) {
|
||||
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
|
||||
@@ -101,68 +115,20 @@ 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);
|
||||
// Execute tools with agentic loop — read tools feed results back to AI
|
||||
$loopResult = $this->executeActionsWithLoop(
|
||||
$message, $context, $conversationId, $conversationHistory,
|
||||
$mode, $effectiveCaseId, $userId
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($inlineResults)) {
|
||||
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
||||
}
|
||||
$responseText = $loopResult['responseText'];
|
||||
$executedActions = $loopResult['executedActions'];
|
||||
|
||||
// Persist response
|
||||
try {
|
||||
@@ -187,6 +153,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);
|
||||
@@ -374,12 +482,19 @@ class SmartAssistantService
|
||||
throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.');
|
||||
}
|
||||
|
||||
$siteUrl = $this->config->get('siteUrl') ?? '';
|
||||
|
||||
$payload = json_encode([
|
||||
'message' => $message,
|
||||
'context' => $context,
|
||||
'conversationId' => $conversationId,
|
||||
'conversationHistory' => $history,
|
||||
'mode' => $mode,
|
||||
'systemInstructions' => $this->getSystemInstructions(),
|
||||
'espocrm' => [
|
||||
'url' => rtrim($siteUrl, '/'),
|
||||
'apiKey' => $this->getEspocrmApiKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$headers = ['Content-Type: application/json', 'Accept: application/json'];
|
||||
@@ -395,7 +510,7 @@ class SmartAssistantService
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_TIMEOUT => 180,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
]);
|
||||
|
||||
@@ -459,6 +574,38 @@ class SmartAssistantService
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getEspocrmApiKey(): ?string
|
||||
{
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = $integration->get('data') ?? (object) [];
|
||||
if (!empty($data->espocrmApiKey)) {
|
||||
return $data->espocrmApiKey;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getSystemInstructions(): array
|
||||
{
|
||||
return [
|
||||
'call_vs_meeting' => implode(' ', [
|
||||
'כשהמשתמש מדווח ששוחח, דיבר, התקשר, או שוחחה עם לקוח/ה — השתמש בכלי create_call (לא create_meeting).',
|
||||
'כשהמשתמש מדווח שנפגש, קבע פגישה, או תיאם מפגש — השתמש בכלי create_meeting.',
|
||||
'שיחת טלפון = create_call. פגישה פיזית/זום = create_meeting.',
|
||||
]),
|
||||
'open_questions' => implode(' ', [
|
||||
'אם שאלת את המשתמש שאלה והוא לא ענה עליה בתגובה הבאה — חזור על השאלה בתחילת התשובה הבאה.',
|
||||
'עקוב אחרי שאלות פתוחות ואל תוותר עליהן עד שהמשתמש עונה או אומר לך לוותר.',
|
||||
]),
|
||||
'behavioral_rules' => implode(' ', [
|
||||
'אם context.assistantRules מכיל כללים — יש ליישם אותם בכל שיחה.',
|
||||
'כללים גלובליים תקפים תמיד. כללי "case" תקפים רק במצב תיק. כללי "office" תקפים רק במצב משרד.',
|
||||
'אם המשתמש מבקש להגדיר כלל חדש (למשל "תמיד תעשה X כש-Y") — השתמש בכלי save_rule כדי לשמור אותו.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private function getThresholds(): array
|
||||
{
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||
|
||||
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.1.3",
|
||||
"version": "2.6.1",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"releaseDate": "2026-04-04",
|
||||
"releaseDate": "2026-04-09",
|
||||
"php": [
|
||||
">=8.1"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user