Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd1d484e74 | |||
| d266380e6d | |||
| 1d200fc3f6 | |||
| 65faf99e11 | |||
| e40f3d4534 | |||
| 89b3f5844e | |||
| 37a8b67b95 | |||
| 39d9093bf6 | |||
| b05e1c8488 | |||
| b313c96955 | |||
| 2a03425514 | |||
| f400f2fec7 | |||
| 405ceb7684 | |||
| 2569003f33 | |||
| 289a9cf27f | |||
| 1bb0c06345 | |||
| 59fc8acdc4 | |||
| 586b543a4c | |||
| c3212e71fe | |||
| f84b2f1cef | |||
| 2a8046b737 | |||
| f319f981f9 | |||
| bf98495198 | |||
| c541069cb5 | |||
| 7480e7db35 | |||
| 3c6e54d233 | |||
| 6c97ba2361 | |||
| 94397c809b | |||
| b810c9fcff | |||
| 0dc26f3b15 | |||
| ca04dd72f8 | |||
| 87bc4b6770 | |||
| 2fe807b7f9 | |||
| 026c32a3f7 | |||
| 2e350e33b9 | |||
| 1d7eb19a43 | |||
| 0fc53b54b7 | |||
| 4c0bf3a67c | |||
| 35c57d36ae | |||
| 0f77631f75 | |||
| 81fa5f3a08 | |||
| cf0e9ad2bc | |||
| 07df19b412 | |||
| b9542e3980 | |||
| 4f5b6be884 | |||
| b8e8c01da5 | |||
| 62004360e8 | |||
| f616065585 | |||
| 1eb856b178 | |||
| c9d08aa1b9 | |||
| 233f56eccf | |||
| 0157b31249 | |||
| 49c42e3d20 | |||
| 4dad65f578 | |||
| f116611c73 | |||
| ff2388e7e1 | |||
| 92c3cd2cdc | |||
| 3102bcff77 |
@@ -0,0 +1,2 @@
|
||||
*.zip
|
||||
build/
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"models": {
|
||||
"main": {
|
||||
"provider": "claude-code",
|
||||
"modelId": "sonnet",
|
||||
"maxTokens": 64000,
|
||||
"temperature": 0.2,
|
||||
"id": "sonnet"
|
||||
},
|
||||
"research": {
|
||||
"provider": "claude-code",
|
||||
"modelId": "sonnet",
|
||||
"maxTokens": 8700,
|
||||
"temperature": 0.1,
|
||||
"id": "claude-sonnet-4-20250514"
|
||||
},
|
||||
"fallback": {
|
||||
"provider": "claude-code",
|
||||
"modelId": "sonnet",
|
||||
"maxTokens": 120000,
|
||||
"temperature": 0.2
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"logLevel": "info",
|
||||
"debug": false,
|
||||
"defaultNumTasks": 10,
|
||||
"defaultSubtasks": 5,
|
||||
"defaultPriority": "medium",
|
||||
"projectName": "Task Master",
|
||||
"ollamaBaseURL": "http://localhost:11434/api",
|
||||
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
|
||||
"responseLanguage": "English",
|
||||
"enableCodebaseAnalysis": true,
|
||||
"enableProxy": false,
|
||||
"anonymousTelemetry": true,
|
||||
"userId": "1234567890"
|
||||
},
|
||||
"claudeCode": {},
|
||||
"codexCli": {},
|
||||
"grokCli": {
|
||||
"timeout": 120000,
|
||||
"workingDirectory": null,
|
||||
"defaultModel": "grok-4-latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"currentTag": "master",
|
||||
"lastSwitched": "2026-04-14T06:05:06.044Z",
|
||||
"branchTagMapping": {},
|
||||
"migrationNoticeShown": true
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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,12 +1,10 @@
|
||||
# SmartAssistant - עוזר חכם
|
||||
|
||||
**גרסה:** 1.0.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
**גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
|
||||
## תיאור
|
||||
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
||||
|
||||
מחליף את CrmAssistant + OfficeAssistant (deprecated).
|
||||
|
||||
## תלויות
|
||||
- Webhook חיצוני (n8n או דומה) לעיבוד AI
|
||||
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,13 +4,16 @@ set -euo pipefail
|
||||
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
|
||||
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
||||
ZIPNAME="${MODULE}-${VERSION}.zip"
|
||||
BUILDDIR="build"
|
||||
|
||||
# Update version in README.md if it exists
|
||||
if [[ -f README.md ]]; then
|
||||
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILDDIR"
|
||||
echo "Building $ZIPNAME..."
|
||||
rm -f "$ZIPNAME"
|
||||
zip -r "$ZIPNAME" manifest.json files/ \
|
||||
-x "*.DS_Store" "*__MACOSX*" "*.zip"
|
||||
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
|
||||
rm -f "$BUILDDIR/$ZIPNAME"
|
||||
zip -r "$BUILDDIR/$ZIPNAME" manifest.json files/ \
|
||||
-x "*.DS_Store" "*__MACOSX*"
|
||||
echo "✓ Built: $BUILDDIR/$ZIPNAME ($(du -h "$BUILDDIR/$ZIPNAME" | cut -f1))"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -5,38 +5,68 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
||||
name: 'SmartAssistant',
|
||||
|
||||
getTitle: function () {
|
||||
return this.translate('Smart Assistant', 'labels', 'SmartAssistant') || 'Smart Assistant';
|
||||
return 'שירה — עוזרת אישית';
|
||||
},
|
||||
|
||||
templateContent: '' +
|
||||
'<div style="direction: rtl; text-align: right; padding: 8px;">' +
|
||||
'<div class="sa-dashlet-cards" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;"></div>' +
|
||||
'<div class="sa-dashlet-alerts" style="max-height: 200px; overflow-y: auto;"></div>' +
|
||||
'<div class="sa-dashlet-detail" style="max-height: 250px; overflow-y: auto;"></div>' +
|
||||
'</div>',
|
||||
|
||||
events: {
|
||||
'click .sa-card': function (e) {
|
||||
var type = $(e.currentTarget).data('type');
|
||||
this.showDetail(type);
|
||||
},
|
||||
},
|
||||
|
||||
afterRender: function () {
|
||||
this.loadSummary();
|
||||
this._alerts = [];
|
||||
this._summary = {};
|
||||
this._activeType = 'attention';
|
||||
|
||||
var self = this;
|
||||
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
|
||||
if (!result.enabled) {
|
||||
self.$el.find('.sa-dashlet-cards').html(
|
||||
'<div style="text-align: center; color: #999; padding: 20px; font-size: 13px;">שירה לא מופעלת. הפעל ב-Admin > Integrations > Smart Assistant</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.loadSummary();
|
||||
}).catch(function () {
|
||||
self.loadSummary();
|
||||
});
|
||||
},
|
||||
|
||||
loadSummary: function () {
|
||||
var self = this;
|
||||
Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) {
|
||||
self.renderCards(response.summary || {});
|
||||
self.renderAlerts(response.alerts || []);
|
||||
self._summary = response.summary || {};
|
||||
self._alerts = response.alerts || [];
|
||||
self._openCases = response.openCases || [];
|
||||
self._upcomingHearings = response.upcomingHearings || [];
|
||||
self.renderCards(self._summary);
|
||||
self.showDetail(self._activeType);
|
||||
}).catch(function () {});
|
||||
},
|
||||
|
||||
renderCards: function (summary) {
|
||||
var cards = [
|
||||
{label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
|
||||
{label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'},
|
||||
{label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'},
|
||||
{label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
|
||||
{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: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
|
||||
];
|
||||
|
||||
var self = this;
|
||||
var html = '';
|
||||
cards.forEach(function (c) {
|
||||
html += '<div style="flex: 1; min-width: 80px; background: ' + c.bg + '; padding: 10px; border-radius: 8px; text-align: center;">' +
|
||||
var active = self._activeType === c.type;
|
||||
var border = active ? '2px solid ' + c.color : '2px solid transparent';
|
||||
var cursor = c.value > 0 ? 'pointer' : 'default';
|
||||
html += '<div class="sa-card" data-type="' + c.type + '" style="flex: 1; min-width: 80px; background: ' + c.bg + '; padding: 10px; border-radius: 8px; text-align: center; cursor: ' + cursor + '; border: ' + border + '; transition: border 0.2s;">' +
|
||||
'<div style="font-size: 22px; font-weight: bold; color: ' + c.color + ';">' + c.value + '</div>' +
|
||||
'<div style="font-size: 11px; color: ' + c.color + ';"><span class="fas ' + c.icon + '"></span> ' + c.label + '</div>' +
|
||||
'</div>';
|
||||
@@ -45,10 +75,36 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
||||
this.$el.find('.sa-dashlet-cards').html(html);
|
||||
},
|
||||
|
||||
renderAlerts: function (alerts) {
|
||||
var $alerts = this.$el.find('.sa-dashlet-alerts');
|
||||
if (alerts.length === 0) {
|
||||
$alerts.html('<div style="text-align: center; color: #4caf50; padding: 12px;">אין התראות</div>');
|
||||
showDetail: function (type) {
|
||||
var self = this;
|
||||
var $detail = this.$el.find('.sa-dashlet-detail');
|
||||
|
||||
if (!type || type === this._activeType && $detail.html()) {
|
||||
// Toggle off
|
||||
this._activeType = null;
|
||||
$detail.html('');
|
||||
this.renderCards(this._summary);
|
||||
return;
|
||||
}
|
||||
|
||||
this._activeType = type;
|
||||
this.renderCards(this._summary);
|
||||
|
||||
var alerts = this._alerts;
|
||||
var items = [];
|
||||
|
||||
if (type === 'overdue') {
|
||||
items = alerts.filter(function (a) { return a.type === 'overdue_task'; });
|
||||
} else if (type === 'hearings') {
|
||||
items = this._upcomingHearings || [];
|
||||
} else if (type === 'attention') {
|
||||
items = alerts.filter(function (a) { return a.severity === 'critical' || a.severity === 'warning'; });
|
||||
} else if (type === 'open') {
|
||||
items = this._openCases || [];
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
$detail.html('<div style="text-align: center; color: #999; padding: 12px; font-size: 12px;">אין פריטים להצגה</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,17 +112,19 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
|
||||
var severityIcons = {critical: 'fa-exclamation-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle'};
|
||||
|
||||
var html = '';
|
||||
alerts.slice(0, 10).forEach(function (a) {
|
||||
items.forEach(function (a) {
|
||||
var color = severityColors[a.severity] || '#333';
|
||||
var icon = severityIcons[a.severity] || 'fa-circle';
|
||||
var link = a.caseId ? '<a href="#Case/view/' + a.caseId + '" style="color: ' + color + '; text-decoration: none;">' : '<span>';
|
||||
var linkEnd = a.caseId ? '</a>' : '</span>';
|
||||
|
||||
html += '<div style="padding: 6px 0; border-bottom: 1px solid #f5f5f5; font-size: 12px; color: ' + color + ';">' +
|
||||
html += '<div style="padding: 6px 8px; border-bottom: 1px solid #f0f0f0; font-size: 12px; color: ' + color + ';">' +
|
||||
'<span class="fas ' + icon + '" style="margin-left: 6px;"></span>' +
|
||||
'<span>' + Espo.Utils.escapeString(a.message) + '</span>' +
|
||||
link + self.escapeString(a.message) + linkEnd +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
$alerts.html(html);
|
||||
$detail.html(html);
|
||||
},
|
||||
|
||||
actionRefresh: function () {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Prompt editor field for AssistantPrompt.content, AssistantSkill.content,
|
||||
* UserProfile.content. Inherits from the standard text field — adds:
|
||||
* - monospace font (mixed Hebrew + English + JSON snippets read better)
|
||||
* - dir="auto" so RTL/LTR works without a manual toggle
|
||||
* - taller textarea (min 500px) — these prompts run to thousands of chars
|
||||
*/
|
||||
define('smart-assistant:views/fields/prompt-editor', ['views/fields/text'], function (Dep) {
|
||||
|
||||
return Dep.extend({
|
||||
|
||||
rowsDefault: 20,
|
||||
|
||||
afterRender: function () {
|
||||
Dep.prototype.afterRender.call(this);
|
||||
|
||||
if (this.isEditMode() || this.isDetailMode()) {
|
||||
var $area = this.$element;
|
||||
if ($area && $area.length) {
|
||||
$area.attr('dir', 'auto');
|
||||
$area.css({
|
||||
'font-family': "'Menlo','Consolas','DejaVu Sans Mono',monospace",
|
||||
'font-size': '13px',
|
||||
'line-height': '1.45',
|
||||
'min-height': '500px',
|
||||
'tab-size': '4',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,16 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
return View.extend({
|
||||
|
||||
templateContent: '' +
|
||||
'<div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
|
||||
' <span class="fas fa-robot"></span>' +
|
||||
'<div class="sa-fab-wrapper">' +
|
||||
' <div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
|
||||
' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" class="sa-fab-avatar" alt="שירה">' +
|
||||
' </div>' +
|
||||
' <span class="sa-fab-badge" style="display:none;"></span>' +
|
||||
'</div>' +
|
||||
'<div class="sa-chat-panel" style="display:none;">' +
|
||||
' <div class="sa-chat-header">' +
|
||||
' <span class="sa-chat-title">{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}</span>' +
|
||||
' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" style="width: 26px; height: 26px; border-radius: 50%; margin-left: 8px;">' +
|
||||
' <span class="sa-chat-title">שירה</span>' +
|
||||
' <span class="sa-mode-badge"></span>' +
|
||||
' <div class="sa-chat-header-actions">' +
|
||||
' <button class="btn btn-link sa-memory-btn" type="button" title="{{translate \'Case Memory\' scope=\'SmartAssistant\'}}" style="display:none;">' +
|
||||
@@ -21,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>' +
|
||||
@@ -50,7 +56,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
' <div class="sa-chat-summary" style="display:none;"></div>' +
|
||||
' <div class="sa-chat-messages"></div>' +
|
||||
' <div class="sa-chat-input-row">' +
|
||||
' <input type="text" class="form-control sa-chat-input" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}">' +
|
||||
' <textarea class="form-control sa-chat-input" rows="1" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}"></textarea>' +
|
||||
' <button class="btn btn-primary sa-chat-send" type="button" disabled>' +
|
||||
' <span class="fas fa-paper-plane"></span>' +
|
||||
' </button>' +
|
||||
@@ -59,10 +65,15 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
'</div>' +
|
||||
'<style>' +
|
||||
'.smart-assistant-fab-container { position: fixed; bottom: 24px; left: 24px; z-index: 1050; direction: rtl; font-family: inherit; }' +
|
||||
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: #5c6bc0; color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s, background 0.2s; font-size: 22px; position: relative; }' +
|
||||
'.sa-fab:hover { background: #3f51b5; transform: scale(1.08); }' +
|
||||
'.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; }' +
|
||||
'.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-fab-wrapper { position: relative; display: inline-block; }' +
|
||||
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: transparent; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s; overflow: hidden; }' +
|
||||
'.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; 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; }' +
|
||||
@@ -76,14 +87,20 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
'.sa-chat-messages:empty::before { content: attr(data-empty-text); display: block; text-align: center; color: #999; padding: 30px 10px; font-size: 13px; }' +
|
||||
'.sa-msg { margin-bottom: 10px; padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; max-width: 90%; word-wrap: break-word; }' +
|
||||
'.sa-msg-user { background: #e3f2fd; margin-left: auto; margin-right: 0; text-align: right; }' +
|
||||
'.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; }' +
|
||||
'.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; position: relative; padding-right: 36px; }' +
|
||||
'.sa-msg-assistant::before { content: ""; position: absolute; right: 6px; top: 6px; width: 24px; height: 24px; border-radius: 50%; background: url(client/custom/modules/smart-assistant/img/shira-avatar.png) center/cover; }' +
|
||||
'.sa-msg-error { background: #fce4ec; color: #c62828; }' +
|
||||
'.sa-msg-loading { background: #f5f5f5; color: #999; }' +
|
||||
'.sa-table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; direction: rtl; text-align: right; }' +
|
||||
'.sa-table th, .sa-table td { border: 1px solid #ddd; padding: 5px 8px; white-space: nowrap; unicode-bidi: plaintext; }' +
|
||||
'.sa-table th { background: #f0eef8; font-weight: 600; color: #333; }' +
|
||||
'.sa-table tr:nth-child(even) { background: #fafafa; }' +
|
||||
'.sa-table tr:hover { background: #f5f0ff; }' +
|
||||
'.sa-msg-action { background: #fff8e1; padding: 6px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }' +
|
||||
'.sa-action-btns { display: flex; gap: 4px; }' +
|
||||
'.sa-action-btns .btn { padding: 2px 8px; font-size: 11px; border-radius: 4px; }' +
|
||||
'.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; }' +
|
||||
'.sa-chat-input { flex: 1; border-radius: 20px; padding: 8px 14px; font-size: 13px; direction: rtl; }' +
|
||||
'.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; align-items: flex-end; }' +
|
||||
'.sa-chat-input { flex: 1; border-radius: 16px; padding: 8px 14px; font-size: 13px; direction: rtl; resize: none; overflow-y: auto; max-height: 120px; min-height: 36px; line-height: 1.4; font-family: inherit; }' +
|
||||
'.sa-chat-send { border-radius: 50%; width: 36px; height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; }' +
|
||||
'.sa-history-view, .sa-memory-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }' +
|
||||
'.sa-history-header, .sa-memory-header { padding: 8px 14px; border-bottom: 1px solid #eee; direction: rtl; text-align: right; display: flex; justify-content: space-between; align-items: center; }' +
|
||||
@@ -111,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,
|
||||
@@ -120,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(); },
|
||||
@@ -141,11 +160,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
var cat = $(e.currentTarget).data('category');
|
||||
this.filterMemory(cat);
|
||||
},
|
||||
'keypress .sa-chat-input': function (e) {
|
||||
'keydown .sa-chat-input': function (e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); }
|
||||
},
|
||||
'input .sa-chat-input': function (e) {
|
||||
this.$el.find('.sa-chat-send').prop('disabled', !e.target.value.trim());
|
||||
// Auto-grow textarea
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px';
|
||||
},
|
||||
'click .sa-action-approve': function (e) {
|
||||
var $btn = $(e.currentTarget);
|
||||
@@ -158,10 +180,22 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
},
|
||||
|
||||
afterRender: function () {
|
||||
this.$el.find('.sa-chat-messages').attr('data-empty-text',
|
||||
this.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם העוזר החכם');
|
||||
this.loadAlertCount();
|
||||
this._setupRouteListener();
|
||||
var self = this;
|
||||
|
||||
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
|
||||
if (!result.enabled) {
|
||||
self.$el.find('.sa-fab').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
self.$el.find('.sa-fab').show();
|
||||
self.$el.find('.sa-chat-messages').attr('data-empty-text',
|
||||
self.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם שירה');
|
||||
self.loadAlertCount();
|
||||
self._setupRouteListener();
|
||||
}).catch(function () {
|
||||
self.$el.find('.sa-fab').hide();
|
||||
});
|
||||
},
|
||||
|
||||
_setupRouteListener: function () {
|
||||
@@ -234,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 () {
|
||||
@@ -404,13 +457,22 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
var $btn = this.$el.find('.sa-chat-send');
|
||||
|
||||
$messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
|
||||
var thinkingText = this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...';
|
||||
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
|
||||
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושב...') + '</div>');
|
||||
'<span class="sa-thinking-text">' + this.escapeHtml(thinkingText) + '</span></div>');
|
||||
$messages.append($loading);
|
||||
this.scrollToBottom();
|
||||
|
||||
$btn.prop('disabled', true);
|
||||
$input.prop('disabled', true).val('');
|
||||
$input.prop('disabled', true).val('').css('height', 'auto');
|
||||
|
||||
// After 60s, swap the spinner text to a "still working" hint so users
|
||||
// know the longer requests (multi-tool flows) haven't stalled.
|
||||
var slowHintText = this.translate('SlowHint', 'labels', 'SmartAssistant') ||
|
||||
'חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל';
|
||||
var slowHintTimer = setTimeout(function () {
|
||||
$loading.find('.sa-thinking-text').text(slowHintText);
|
||||
}, 60000);
|
||||
|
||||
var payload = {
|
||||
message: message,
|
||||
@@ -421,31 +483,26 @@ 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: 600000}).then(function (response) {
|
||||
clearTimeout(slowHintTimer);
|
||||
self.conversationId = response.conversationId;
|
||||
$loading.remove();
|
||||
|
||||
$messages.append('<div class="sa-msg sa-msg-assistant">' + self.formatResponse(response.text) + '</div>');
|
||||
|
||||
// Render action items
|
||||
if (response.actions && response.actions.length > 0) {
|
||||
response.actions.forEach(function (action) {
|
||||
var actionHtml = '<div class="sa-msg-action">' +
|
||||
'<span><span class="fas fa-bolt" style="color: #f57c00; margin-left: 4px;"></span>' +
|
||||
self.escapeHtml(action.displayText || action.tool) + '</span>' +
|
||||
'<div class="sa-action-btns">' +
|
||||
'<button class="btn btn-success btn-xs sa-action-approve" data-conversation-id="' + self.escapeHtml(response.conversationId) +
|
||||
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-check"></span></button>' +
|
||||
'<button class="btn btn-danger btn-xs sa-action-reject" data-conversation-id="' + self.escapeHtml(response.conversationId) +
|
||||
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-times"></span></button>' +
|
||||
'</div></div>';
|
||||
$messages.append(actionHtml);
|
||||
// Auto-refresh panels if actions were executed
|
||||
if (response.executedActions && response.executedActions.length > 0) {
|
||||
response.executedActions.forEach(function (action) {
|
||||
if (action.entityType) {
|
||||
self.refreshCurrentView(action.entityType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.scrollToBottom();
|
||||
$input.prop('disabled', false).focus();
|
||||
}).catch(function () {
|
||||
clearTimeout(slowHintTimer);
|
||||
$loading.remove();
|
||||
$messages.append('<div class="sa-msg sa-msg-error">' +
|
||||
self.escapeHtml(self.translate('Error', 'labels', 'SmartAssistant') || 'שגיאה בתקשורת') + '</div>');
|
||||
@@ -468,6 +525,10 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
var cls = approved ? (result.success ? 'sa-msg-assistant' : 'sa-msg-error') : 'sa-msg-loading';
|
||||
$messages.append('<div class="sa-msg ' + cls + '">' + self.escapeHtml(msg) + '</div>');
|
||||
self.scrollToBottom();
|
||||
|
||||
if (approved && result.success) {
|
||||
self.refreshCurrentView(result.entityType);
|
||||
}
|
||||
}).catch(function () {
|
||||
$messages.append('<div class="sa-msg sa-msg-error">שגיאה בביצוע הפעולה</div>');
|
||||
self.scrollToBottom();
|
||||
@@ -595,11 +656,101 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
|
||||
formatResponse: function (text) {
|
||||
if (!text) return '';
|
||||
text = this.escapeHtml(text);
|
||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
text = text.replace(/\n/g, '<br>');
|
||||
text = text.replace(/^- (.+)/gm, '• $1');
|
||||
return text;
|
||||
|
||||
var self = this;
|
||||
var LRM = '\u200E';
|
||||
|
||||
// Split into lines for table detection
|
||||
var lines = text.split('\n');
|
||||
var result = [];
|
||||
var i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
// Detect markdown table: line with |, followed by separator |---|, followed by more | lines
|
||||
if (lines[i].indexOf('|') !== -1 && i + 1 < lines.length && /^\|?[\s\-:|]+\|/.test(lines[i + 1])) {
|
||||
var tableLines = [];
|
||||
var j = i;
|
||||
|
||||
// Collect all contiguous lines that contain |
|
||||
while (j < lines.length && lines[j].indexOf('|') !== -1) {
|
||||
tableLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
if (tableLines.length >= 2) {
|
||||
result.push(self.renderMarkdownTable(tableLines, LRM));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular line — escape and format
|
||||
var line = self.escapeHtml(lines[i]);
|
||||
line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Headings
|
||||
line = line.replace(/^### (.+)/, '<strong style="font-size: 14px;">$1</strong>');
|
||||
line = line.replace(/^## (.+)/, '<strong style="font-size: 15px;">$1</strong>');
|
||||
|
||||
// List items
|
||||
line = line.replace(/^[-*] (.+)/, '• $1');
|
||||
|
||||
// Numbered lists
|
||||
line = line.replace(/^(\d+)\. (.+)/, '$1. $2');
|
||||
|
||||
result.push(line);
|
||||
i++;
|
||||
}
|
||||
|
||||
return result.join('<br>');
|
||||
},
|
||||
|
||||
renderMarkdownTable: function (lines, LRM) {
|
||||
var self = this;
|
||||
|
||||
var parseRow = function (line) {
|
||||
// Remove leading/trailing |, split by |
|
||||
var trimmed = line.replace(/^\|/, '').replace(/\|$/, '');
|
||||
return trimmed.split('|').map(function (cell) { return cell.trim(); });
|
||||
};
|
||||
|
||||
// First line = headers
|
||||
var headers = parseRow(lines[0]);
|
||||
|
||||
// Skip separator line (line[1])
|
||||
// Remaining lines = data rows
|
||||
var rows = [];
|
||||
for (var i = 2; i < lines.length; i++) {
|
||||
// Skip if it looks like another separator
|
||||
if (/^[\s\-:|]+$/.test(lines[i].replace(/\|/g, ''))) continue;
|
||||
rows.push(parseRow(lines[i]));
|
||||
}
|
||||
|
||||
// Build HTML table with LRM for BiDi safety
|
||||
var html = '<table class="sa-table" dir="rtl">';
|
||||
|
||||
// Header
|
||||
html += '<thead><tr>';
|
||||
headers.forEach(function (h) {
|
||||
var escaped = self.escapeHtml(h).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html += '<th>' + LRM + escaped + LRM + '</th>';
|
||||
});
|
||||
html += '</tr></thead>';
|
||||
|
||||
// Body
|
||||
html += '<tbody>';
|
||||
rows.forEach(function (row) {
|
||||
html += '<tr>';
|
||||
for (var c = 0; c < headers.length; c++) {
|
||||
var cell = row[c] !== undefined ? row[c] : '';
|
||||
var escaped = self.escapeHtml(cell).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html += '<td>' + LRM + escaped + LRM + '</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
|
||||
return html;
|
||||
},
|
||||
|
||||
escapeHtml: function (text) {
|
||||
@@ -608,5 +759,37 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
refreshCurrentView: function (entityType) {
|
||||
var mainView = this.getParentView();
|
||||
|
||||
while (mainView && mainView.getParentView()) {
|
||||
mainView = mainView.getParentView();
|
||||
}
|
||||
|
||||
if (!mainView) return;
|
||||
|
||||
var currentView = mainView.getView('main');
|
||||
|
||||
if (!currentView) return;
|
||||
|
||||
var recordView = currentView.getView('record');
|
||||
|
||||
if (recordView && recordView.model) {
|
||||
recordView.model.trigger('update-all');
|
||||
}
|
||||
|
||||
if (entityType === 'Task' || entityType === 'Meeting' || entityType === 'Call') {
|
||||
var sideView = currentView.getView('side');
|
||||
|
||||
if (sideView) {
|
||||
var activities = sideView.getView('activities');
|
||||
var tasks = sideView.getView('tasks');
|
||||
|
||||
if (activities && activities.actionRefresh) activities.actionRefresh();
|
||||
if (tasks && tasks.actionRefresh) tasks.actionRefresh();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
|
||||
templateContent: '' +
|
||||
'<div class="sa-action-note sa-action-{{status}}" style="padding: 6px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
|
||||
'<span class="fas {{icon}}" style="margin-left: 6px;"></span>' +
|
||||
'{{post}}' +
|
||||
'{{{formattedPost}}}' +
|
||||
'</div>',
|
||||
|
||||
data: function () {
|
||||
@@ -13,6 +13,8 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
|
||||
var noteData = this.model.get('data') || {};
|
||||
var status = noteData.status || 'executed';
|
||||
|
||||
data.post = this.model.get('post');
|
||||
data.formattedPost = this.getHelper().escapeString(data.post || '');
|
||||
data.status = status;
|
||||
|
||||
if (status === 'executed') {
|
||||
|
||||
+14
-3
@@ -5,11 +5,22 @@ define('modules/smart-assistant/views/stream/notes/smart-request', ['views/strea
|
||||
templateContent: '' +
|
||||
'<div style="background: #e3f2fd; padding: 8px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
|
||||
'<span class="fas fa-user" style="color: #1565c0; margin-left: 6px;"></span>' +
|
||||
'{{post}}' +
|
||||
'{{{formattedPost}}}' +
|
||||
'</div>',
|
||||
|
||||
setup: function () {
|
||||
NoteView.prototype.setup.call(this);
|
||||
data: function () {
|
||||
var data = NoteView.prototype.data.call(this);
|
||||
|
||||
data.formattedPost = this.formatPost(this.model.get('post'));
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
formatPost: function (text) {
|
||||
if (!text) return '';
|
||||
text = this.getHelper().escapeString(text);
|
||||
text = text.replace(/\n/g, '<br>');
|
||||
return text;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Controllers;
|
||||
|
||||
/**
|
||||
* Standard CRUD over /api/v1/AssistantPrompt. Pattern matches AssistantRule.
|
||||
*/
|
||||
class AssistantPrompt extends \Espo\Core\Controllers\Record
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Controllers;
|
||||
|
||||
/**
|
||||
* Exposes the AssistantRule entity over the standard REST API
|
||||
* (GET/POST/PUT/DELETE /api/v1/AssistantRule).
|
||||
*
|
||||
* Without this controller class EspoCRM responds 404 "Controller does not exist"
|
||||
* even when the entity, object, and acl flags in scopes/AssistantRule.json are
|
||||
* set. Inheriting from Record gives us the full CRUD surface — list/search,
|
||||
* read, create, update, delete — with ACL enforcement.
|
||||
*/
|
||||
class AssistantRule extends \Espo\Core\Controllers\Record
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Controllers;
|
||||
|
||||
/**
|
||||
* Standard CRUD over /api/v1/AssistantSkill. Pattern matches AssistantRule.
|
||||
*/
|
||||
class AssistantSkill extends \Espo\Core\Controllers\Record
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Controllers;
|
||||
|
||||
/**
|
||||
* Standard CRUD over /api/v1/CaseMemory.
|
||||
*
|
||||
* Until SmartAssistant 2.10.0 the CaseMemory entity was created via
|
||||
* CaseMemoryService::createMemory and listed via Case relation panels —
|
||||
* never via the bare REST endpoint. Flipping `tab: true` exposes the
|
||||
* navbar list view, which uses GET /api/v1/CaseMemory and needs this
|
||||
* Controller class to exist (otherwise EspoCRM returns 404 "Controller
|
||||
* does not exist"). Same pattern as the AssistantRule fix shipped in 2.9.2.
|
||||
*/
|
||||
class CaseMemory extends \Espo\Core\Controllers\Record
|
||||
{
|
||||
}
|
||||
@@ -9,17 +9,25 @@ use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
||||
use Espo\Modules\SmartAssistant\Services\FreeDocumentGenerator;
|
||||
use Espo\Modules\SmartAssistant\Services\CaseFolderManager;
|
||||
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
|
||||
@@ -34,6 +42,11 @@ class SmartAssistant
|
||||
}
|
||||
}
|
||||
|
||||
public function getActionStatus(Request $request, Response $response): array
|
||||
{
|
||||
return $this->getService()->getStatus();
|
||||
}
|
||||
|
||||
public function postActionChat(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
@@ -164,4 +177,326 @@ 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 = json_decode(json_encode($data->params ?? []), true) ?? [];
|
||||
$caseId = $data->caseId ?? null;
|
||||
|
||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||
|
||||
return $executor->execute($tool, $params, $caseId, $this->user->getId());
|
||||
}
|
||||
|
||||
public function postActionReadDocument(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$filePath = $data->filePath ?? null;
|
||||
if (empty($filePath)) {
|
||||
throw new BadRequest('filePath is required.');
|
||||
}
|
||||
|
||||
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$text = $analyzer->extractFullText($filePath, $maxLength);
|
||||
|
||||
return [
|
||||
'success' => $text !== null,
|
||||
'text' => $text,
|
||||
'fileName' => basename($filePath),
|
||||
'charCount' => $text !== null ? mb_strlen($text) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionGetDocumentBytes(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$filePath = $data->filePath ?? null;
|
||||
if (empty($filePath)) {
|
||||
throw new BadRequest('filePath is required.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return $analyzer->getDocumentBytes($filePath);
|
||||
}
|
||||
|
||||
public function postActionReadMultipleDocuments(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$filePaths = $data->filePaths ?? [];
|
||||
if (empty($filePaths)) {
|
||||
throw new BadRequest('filePaths is required (array of file paths).');
|
||||
}
|
||||
|
||||
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'documents' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionBatchRename(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$renames = $data->renames ?? [];
|
||||
if (empty($renames)) {
|
||||
throw new BadRequest('renames is required (array of {sourcePath, newName}).');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$results = [];
|
||||
$successCount = 0;
|
||||
|
||||
foreach ($renames as $rename) {
|
||||
$rename = (array) $rename;
|
||||
$sourcePath = $rename['sourcePath'] ?? null;
|
||||
$newName = $rename['newName'] ?? null;
|
||||
|
||||
if (!$sourcePath || !$newName) {
|
||||
$results[] = ['success' => false, 'error' => 'Missing sourcePath or newName'];
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $analyzer->renameDocument($sourcePath, $newName);
|
||||
$results[] = ['success' => true, 'oldName' => $result['oldName'], 'newName' => $result['newName']];
|
||||
$successCount++;
|
||||
} catch (\Exception $e) {
|
||||
$results[] = ['success' => false, 'oldName' => basename($sourcePath), 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'totalCount' => count($renames),
|
||||
'successCount' => $successCount,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionListDocuments(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
if (empty($caseId)) {
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return $analyzer->listDocumentsRecursive($caseId);
|
||||
}
|
||||
|
||||
public function postActionBrowseFolder(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$path = $data->path ?? null;
|
||||
if ($path === null) {
|
||||
throw new BadRequest('path is required.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return [
|
||||
'path' => $path,
|
||||
'entries' => $analyzer->browseFolder((string) $path),
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionFindCaseFolder(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$query = $data->query ?? null;
|
||||
if (empty($query)) {
|
||||
throw new BadRequest('query is required.');
|
||||
}
|
||||
|
||||
$limit = isset($data->limit) ? max(1, min((int) $data->limit, 50)) : 20;
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return [
|
||||
'query' => $query,
|
||||
'matches' => $analyzer->findCaseFolder((string) $query, $limit),
|
||||
];
|
||||
}
|
||||
|
||||
public function postActionSetCaseFolderPath(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
if (!$this->acl->checkScope('Case', 'edit')) {
|
||||
throw new Forbidden('No edit access to Case.');
|
||||
}
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
$caseId = $data->caseId ?? null;
|
||||
$path = $data->path ?? null;
|
||||
|
||||
if (empty($caseId) || empty($path)) {
|
||||
throw new BadRequest('caseId and path are required.');
|
||||
}
|
||||
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
return $analyzer->setCaseFolderPath((string) $caseId, (string) $path);
|
||||
}
|
||||
|
||||
public function postActionGenerateFromTemplate(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$templateId = $data->templateId ?? null;
|
||||
$caseId = $data->caseId ?? null;
|
||||
|
||||
if (empty($templateId)) {
|
||||
throw new BadRequest('templateId is required.');
|
||||
}
|
||||
if (empty($caseId)) {
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
|
||||
$documentSubject = $data->documentSubject ?? null;
|
||||
$customPlaceholders = (array) ($data->customPlaceholders ?? []);
|
||||
|
||||
$generator = $this->injectableFactory->create(GenericTemplateGenerator::class);
|
||||
|
||||
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
|
||||
}
|
||||
|
||||
public function postActionCreateDocument(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
$title = $data->title ?? null;
|
||||
$body = $data->body ?? null;
|
||||
|
||||
if (empty($caseId)) {
|
||||
throw new BadRequest('caseId is required.');
|
||||
}
|
||||
if (empty($title)) {
|
||||
throw new BadRequest('title is required.');
|
||||
}
|
||||
if (empty($body)) {
|
||||
throw new BadRequest('body is required.');
|
||||
}
|
||||
|
||||
$documentType = $data->documentType ?? 'letter';
|
||||
$recipient = $data->recipient ?? null;
|
||||
|
||||
$generator = $this->injectableFactory->create(FreeDocumentGenerator::class);
|
||||
|
||||
return $generator->generate($caseId, $title, $body, $documentType, $recipient);
|
||||
}
|
||||
|
||||
public function postActionWriteToFolder(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
$relPath = $data->relPath ?? null;
|
||||
$contentBase64 = $data->contentBase64 ?? null;
|
||||
|
||||
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||
if (empty($relPath)) throw new BadRequest('relPath is required.');
|
||||
if (!isset($contentBase64)) throw new BadRequest('contentBase64 is required.');
|
||||
|
||||
$content = base64_decode((string) $contentBase64, true);
|
||||
if ($content === false) {
|
||||
throw new BadRequest('contentBase64 is not valid base64.');
|
||||
}
|
||||
|
||||
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||
return $manager->writeFile($caseId, $relPath, $content);
|
||||
}
|
||||
|
||||
public function postActionCreateFolder(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
$relPath = $data->relPath ?? null;
|
||||
|
||||
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||
if (empty($relPath)) throw new BadRequest('relPath is required.');
|
||||
|
||||
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||
return $manager->createFolder($caseId, $relPath);
|
||||
}
|
||||
|
||||
public function postActionRenameItem(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
$currentRelPath = $data->currentRelPath ?? null;
|
||||
$newName = $data->newName ?? null;
|
||||
|
||||
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||
if (empty($currentRelPath)) throw new BadRequest('currentRelPath is required.');
|
||||
if (empty($newName)) throw new BadRequest('newName is required.');
|
||||
|
||||
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||
return $manager->renameItem($caseId, $currentRelPath, $newName);
|
||||
}
|
||||
|
||||
public function postActionMoveItem(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
$sourceRelPath = $data->sourceRelPath ?? null;
|
||||
$targetRelPath = $data->targetRelPath ?? null;
|
||||
|
||||
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||
if (empty($sourceRelPath)) throw new BadRequest('sourceRelPath is required.');
|
||||
if (empty($targetRelPath)) throw new BadRequest('targetRelPath is required.');
|
||||
|
||||
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||
return $manager->moveItem($caseId, $sourceRelPath, $targetRelPath);
|
||||
}
|
||||
|
||||
public function postActionMarkForDeletion(Request $request, Response $response): array
|
||||
{
|
||||
$this->checkAccess();
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$caseId = $data->caseId ?? null;
|
||||
$fileRelPath = $data->fileRelPath ?? null;
|
||||
|
||||
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||
if (empty($fileRelPath)) throw new BadRequest('fileRelPath is required.');
|
||||
|
||||
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||
return $manager->markForDeletion($caseId, $fileRelPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Controllers;
|
||||
|
||||
/**
|
||||
* Standard CRUD over /api/v1/UserProfile.
|
||||
*
|
||||
* Pattern matches AssistantRule.php — without this class EspoCRM returns
|
||||
* 404 "Controller does not exist" even when the entity is fully defined.
|
||||
*/
|
||||
class UserProfile extends \Espo\Core\Controllers\Record
|
||||
{
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"No previous conversations": "No previous conversations",
|
||||
"Ask the assistant...": "Ask the assistant...",
|
||||
"Thinking": "Thinking...",
|
||||
"SlowHint": "Still thinking… this one is taking a bit longer because I'm processing multiple sources",
|
||||
"Error": "Communication error",
|
||||
"Back": "Back",
|
||||
"Chat with the assistant": "Chat with the assistant",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create AssistantPrompt": "צור פרומפט חדש"
|
||||
},
|
||||
"fields": {
|
||||
"name": "שם תיאורי",
|
||||
"key": "מפתח (key)",
|
||||
"description": "תיאור — מה הסעיף הזה עושה",
|
||||
"mode": "מצב",
|
||||
"displayOrder": "סדר הצגה",
|
||||
"content": "תוכן הפרומפט",
|
||||
"isActive": "פעיל",
|
||||
"createdAt": "נוצר",
|
||||
"modifiedAt": "עודכן"
|
||||
},
|
||||
"options": {
|
||||
"mode": {
|
||||
"both": "תיק + משרד",
|
||||
"case": "תיק בלבד",
|
||||
"office": "משרד בלבד"
|
||||
}
|
||||
},
|
||||
"tooltips": {
|
||||
"key": "המפתח שדרכו shira-hermes שולפת את הסעיף הזה. שמות מוכרים: tool_rules, legal_assistance, personality_case, personality_office, writing_style, other_rules_case, other_rules_office. שינוי שם המפתח של סעיף קיים יגרום לכך ש-fallback ל-default יחזור.",
|
||||
"mode": "אם 'both' — מופיע בכל סשן. 'case' — רק כשנפתח תיק. 'office' — רק במסך המשרד הכללי.",
|
||||
"displayOrder": "מספר נמוך = מופיע מוקדם יותר ב-prompt. ברירת מחדל 100.",
|
||||
"content": "הטקסט שיוזרק ל-system prompt. תמיכה ב-RTL אוטומטית. שורה ריקה מסמנת default — קוד ה-Python יחזור לערך המקורי.",
|
||||
"isActive": "כיבוי = הסעיף מתעלם, fallback ל-default מהקוד."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create AssistantRule": "הוסף כלל"
|
||||
},
|
||||
"fields": {
|
||||
"name": "שם",
|
||||
"rule": "כלל",
|
||||
"scope": "היקף",
|
||||
"isActive": "פעיל",
|
||||
"createdAt": "נוצר בתאריך",
|
||||
"modifiedAt": "עודכן בתאריך",
|
||||
"createdBy": "נוצר על ידי",
|
||||
"modifiedBy": "עודכן על ידי"
|
||||
},
|
||||
"options": {
|
||||
"scope": {
|
||||
"global": "גלובלי",
|
||||
"case": "מצב תיק",
|
||||
"office": "מצב משרד"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create AssistantSkill": "צור מיומנות חדשה"
|
||||
},
|
||||
"fields": {
|
||||
"name": "שם",
|
||||
"description": "תיאור קצר",
|
||||
"triggers": "מילות הפעלה",
|
||||
"content": "תוכן המיומנות (Markdown)",
|
||||
"version": "גרסה",
|
||||
"isActive": "פעיל",
|
||||
"createdAt": "נוצר",
|
||||
"modifiedAt": "עודכן"
|
||||
},
|
||||
"tooltips": {
|
||||
"name": "שם ייחודי (snake-case או kebab-case). למשל direct-access-report-flow.",
|
||||
"description": "משפט קצר: מתי כדאי לשירה להפעיל את המיומנות.",
|
||||
"triggers": "מילים/ביטויים שבהם המיומנות צריכה להופיע — מופרדים בפסיק.",
|
||||
"content": "ה-Markdown המלא של המיומנות. שירה תקרא אותו דרך read_skill כשהיא צריכה אותו."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"fields": {
|
||||
"markedForDeletionAt": "סומן למחיקה בתאריך",
|
||||
"markedForDeletionBy": "סומן למחיקה על ידי"
|
||||
},
|
||||
"tooltips": {
|
||||
"markedForDeletionAt": "מסמך זה סומן למחיקה על ידי שירה ומחכה לאישור ידני של עו\"ד למחיקה הפיזית."
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
{
|
||||
"scopeNames": {
|
||||
"CaseMemory": "זיכרון תיק",
|
||||
"AssistantRule": "כלל התנהגות",
|
||||
"UserProfile": "פרופיל משתמש",
|
||||
"AssistantSkill": "מיומנות שירה",
|
||||
"AssistantPrompt": "פרומפט שירה",
|
||||
"SmartAssistant": "עוזר חכם"
|
||||
},
|
||||
"scopeNamesPlural": {
|
||||
"CaseMemory": "זיכרונות תיקים",
|
||||
"AssistantRule": "כללי התנהגות",
|
||||
"UserProfile": "פרופילי משתמשים",
|
||||
"AssistantSkill": "מיומנויות שירה",
|
||||
"AssistantPrompt": "פרומפטים של שירה",
|
||||
"SmartAssistant": "עוזר חכם"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"fields": {
|
||||
"webhookUrl": "כתובת Webhook",
|
||||
"apiKey": "מפתח API",
|
||||
"espocrmApiKey": "מפתח API של EspoCRM",
|
||||
"maxMessagesPerHour": "מקסימום הודעות לשעה",
|
||||
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
|
||||
"inactivityCriticalDays": "ימים לאזהרה קריטית",
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"labels": {
|
||||
"Smart Assistant": "עוזר חכם",
|
||||
"Smart Assistant": "שירה",
|
||||
"Office Mode": "מצב משרד",
|
||||
"Case Mode": "מצב תיק",
|
||||
"Add Memory": "הוסף זיכרון",
|
||||
"Search Memory": "חפש בזיכרון",
|
||||
"Add Memory": "הוספת זיכרון",
|
||||
"Search Memory": "חיפוש בזיכרון",
|
||||
"New Conversation": "שיחה חדשה",
|
||||
"Conversation History": "היסטוריית שיחות",
|
||||
"Previous Conversations": "שיחות קודמות",
|
||||
"No previous conversations": "אין שיחות קודמות",
|
||||
"Ask the assistant...": "שאל/י את העוזר...",
|
||||
"Thinking": "חושב...",
|
||||
"Ask the assistant...": "שאל/י את שירה...",
|
||||
"Thinking": "חושבת...",
|
||||
"SlowHint": "חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל",
|
||||
"Error": "שגיאה בתקשורת",
|
||||
"Back": "חזרה",
|
||||
"Chat with the assistant": "שוחח/י עם העוזר החכם",
|
||||
"Chat with the assistant": "שוחח/י עם שירה",
|
||||
"Case Memory": "זיכרון תיק",
|
||||
"Pin": "הצמד",
|
||||
"Unpin": "הסר הצמדה",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create UserProfile": "צור פרופיל משתמש"
|
||||
},
|
||||
"fields": {
|
||||
"name": "שם",
|
||||
"user": "משתמש",
|
||||
"content": "תוכן הפרופיל",
|
||||
"isActive": "פעיל",
|
||||
"createdAt": "נוצר",
|
||||
"modifiedAt": "עודכן",
|
||||
"createdBy": "נוצר על ידי",
|
||||
"modifiedBy": "עודכן על ידי"
|
||||
},
|
||||
"tooltips": {
|
||||
"content": "טקסט חופשי שמוזרק ל-prompt של שירה תחת '=== ABOUT THIS USER ===' לכל שיחה של המשתמש הזה. שמור עובדות מפתח: תפקיד, תחומי התמחות, העדפות תקשורת, נושאי טיק שמטופלים."
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"rows": [
|
||||
[{"name": "name"}, {"name": "key"}],
|
||||
[{"name": "mode"}, {"name": "displayOrder"}],
|
||||
[{"name": "isActive"}, false],
|
||||
[{"name": "description", "fullWidth": true}]
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Prompt",
|
||||
"rows": [
|
||||
[{"name": "content", "fullWidth": true}]
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"name": "name", "link": true},
|
||||
{"name": "key"},
|
||||
{"name": "mode"},
|
||||
{"name": "displayOrder"},
|
||||
{"name": "isActive"},
|
||||
{"name": "modifiedAt"}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"rows": [
|
||||
[{"name": "name"}, {"name": "scope"}],
|
||||
[{"name": "isActive"}, false],
|
||||
[{"name": "rule", "fullWidth": true}],
|
||||
[{"name": "createdAt"}, {"name": "createdBy"}]
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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,17 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"rows": [
|
||||
[{"name": "name"}, {"name": "version"}],
|
||||
[{"name": "description", "fullWidth": true}],
|
||||
[{"name": "triggers", "fullWidth": true}],
|
||||
[{"name": "isActive"}, false]
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Skill",
|
||||
"rows": [
|
||||
[{"name": "content", "fullWidth": true}]
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"name": "name", "link": true},
|
||||
{"name": "description"},
|
||||
{"name": "isActive"},
|
||||
{"name": "version"},
|
||||
{"name": "modifiedAt"}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"rows": [
|
||||
[{"name": "name"}, {"name": "user"}],
|
||||
[{"name": "isActive"}, false]
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Profile",
|
||||
"rows": [
|
||||
[{"name": "content", "fullWidth": true}]
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"name": "name", "link": true},
|
||||
{"name": "user", "link": true},
|
||||
{"name": "isActive"},
|
||||
{"name": "modifiedAt"}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"shiraManagement": {
|
||||
"label": "ניהול שירה",
|
||||
"itemList": [
|
||||
{
|
||||
"url": "#AssistantPrompt",
|
||||
"label": "פרומפטים",
|
||||
"iconClass": "fas fa-file-code",
|
||||
"description": "שינוי הסעיפים שמוזרקים ל-system prompt של שירה. עריכה כאן מחליפה את ברירות-המחדל בקוד."
|
||||
},
|
||||
{
|
||||
"url": "#AssistantRule",
|
||||
"label": "כללי התנהגות",
|
||||
"iconClass": "fas fa-list-check",
|
||||
"description": "כללים גלובליים/לפי מצב שיופיעו תחת '=== BEHAVIORAL RULES (MUST FOLLOW) ===' של ה-system prompt."
|
||||
},
|
||||
{
|
||||
"url": "#AssistantSkill",
|
||||
"label": "מיומנויות (Skills)",
|
||||
"iconClass": "fas fa-magic",
|
||||
"description": "תהליכי עבודה מוכנים שהמודל יכול להפעיל. כל מיומנות פעילה מופיעה ב-context."
|
||||
},
|
||||
{
|
||||
"url": "#UserProfile",
|
||||
"label": "פרופילי משתמשים",
|
||||
"iconClass": "fas fa-user-cog",
|
||||
"description": "פרופיל אישי לכל עורך/ת דין. מופיע ב-system prompt תחת '=== ABOUT THIS USER ==='."
|
||||
},
|
||||
{
|
||||
"url": "#CaseMemory",
|
||||
"label": "זיכרון תיקים",
|
||||
"iconClass": "fas fa-brain",
|
||||
"description": "כל רשומות הזיכרון מכל התיקים. גישה ישירה לחיפוש/עריכה — לצד כניסה רגילה מתוך כל תיק."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"controller": "controllers/record",
|
||||
"color": "#d99848",
|
||||
"iconClass": "fas fa-file-code"
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"controller": "controllers/record",
|
||||
"color": "#aa72b3",
|
||||
"iconClass": "fas fa-magic"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"itemViews": {
|
||||
"SmartRequest": "modules/smart-assistant/views/stream/notes/smart-request",
|
||||
"SmartResponse": "modules/smart-assistant/views/stream/notes/smart-response",
|
||||
"SmartAction": "modules/smart-assistant/views/stream/notes/smart-action"
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"controller": "controllers/record",
|
||||
"boolFilterList": ["onlyMy"],
|
||||
"filterList": [{"name": "active"}, {"name": "inactive"}],
|
||||
"color": "#7c8caf",
|
||||
"iconClass": "fas fa-user-cog"
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "varchar",
|
||||
"maxLength": 200,
|
||||
"required": true,
|
||||
"trim": true
|
||||
},
|
||||
"key": {
|
||||
"type": "varchar",
|
||||
"maxLength": 100,
|
||||
"required": true,
|
||||
"trim": true,
|
||||
"lowerCase": true
|
||||
},
|
||||
"description": {
|
||||
"type": "varchar",
|
||||
"maxLength": 500
|
||||
},
|
||||
"mode": {
|
||||
"type": "enum",
|
||||
"options": ["both", "case", "office"],
|
||||
"default": "both",
|
||||
"required": true,
|
||||
"style": {
|
||||
"both": "primary",
|
||||
"case": "info",
|
||||
"office": "warning"
|
||||
}
|
||||
},
|
||||
"displayOrder": {
|
||||
"type": "int",
|
||||
"default": 100,
|
||||
"min": 0
|
||||
},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"view": "smart-assistant:views/fields/prompt-editor",
|
||||
"lengthOfMaxDisplay": 600
|
||||
},
|
||||
"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"}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "displayOrder",
|
||||
"order": "asc",
|
||||
"textFilterFields": ["name", "key", "description", "content"]
|
||||
},
|
||||
"indexes": {
|
||||
"key": {"columns": ["key", "deleted"], "unique": true},
|
||||
"modeActive": {"columns": ["mode", "isActive", "deleted"]}
|
||||
}
|
||||
}
|
||||
+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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "varchar",
|
||||
"maxLength": 100,
|
||||
"required": true,
|
||||
"trim": true
|
||||
},
|
||||
"description": {
|
||||
"type": "varchar",
|
||||
"maxLength": 500
|
||||
},
|
||||
"triggers": {
|
||||
"type": "text",
|
||||
"lengthOfMaxDisplay": 200
|
||||
},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"view": "smart-assistant:views/fields/prompt-editor",
|
||||
"lengthOfMaxDisplay": 400
|
||||
},
|
||||
"version": {
|
||||
"type": "varchar",
|
||||
"maxLength": 20,
|
||||
"default": "1.0"
|
||||
},
|
||||
"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"}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "name",
|
||||
"order": "asc",
|
||||
"textFilterFields": ["name", "description", "triggers", "content"]
|
||||
},
|
||||
"indexes": {
|
||||
"name": {"columns": ["name", "deleted"], "unique": true},
|
||||
"isActive": {"columns": ["isActive", "deleted"]}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"fields": {
|
||||
"markedForDeletionAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true,
|
||||
"tooltip": true
|
||||
},
|
||||
"markedForDeletionBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"markedForDeletionBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "varchar",
|
||||
"maxLength": 255,
|
||||
"required": true
|
||||
},
|
||||
"user": {
|
||||
"type": "link",
|
||||
"required": true
|
||||
},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"view": "smart-assistant:views/fields/prompt-editor",
|
||||
"lengthOfMaxDisplay": 400
|
||||
},
|
||||
"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": {
|
||||
"user": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"orderBy": "modifiedAt",
|
||||
"order": "desc",
|
||||
"textFilterFields": ["name", "content"]
|
||||
},
|
||||
"indexes": {
|
||||
"userId": {
|
||||
"columns": ["userId", "deleted"],
|
||||
"unique": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"entity": true,
|
||||
"object": true,
|
||||
"module": "SmartAssistant",
|
||||
"acl": true,
|
||||
"aclActionList": ["create", "read", "edit", "delete"],
|
||||
"aclLevelList": ["all", "no"],
|
||||
"tab": true,
|
||||
"stream": false,
|
||||
"disabled": false,
|
||||
"importable": false,
|
||||
"customizable": true
|
||||
}
|
||||
@@ -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": true,
|
||||
"disabled": false
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"entity": true,
|
||||
"object": true,
|
||||
"module": "SmartAssistant",
|
||||
"acl": true,
|
||||
"aclActionList": ["create", "read", "edit", "delete"],
|
||||
"aclLevelList": ["all", "no"],
|
||||
"tab": true,
|
||||
"stream": false,
|
||||
"disabled": false,
|
||||
"importable": false,
|
||||
"customizable": true
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"acl": true,
|
||||
"aclActionList": ["create", "read", "edit", "delete"],
|
||||
"aclLevelList": ["all", "team", "own", "no"],
|
||||
"tab": false,
|
||||
"tab": true,
|
||||
"stream": false,
|
||||
"disabled": false,
|
||||
"importable": false,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"entity": true,
|
||||
"object": true,
|
||||
"module": "SmartAssistant",
|
||||
"acl": true,
|
||||
"aclActionList": ["create", "read", "edit", "delete"],
|
||||
"aclLevelList": ["all", "own", "no"],
|
||||
"tab": true,
|
||||
"stream": false,
|
||||
"disabled": false,
|
||||
"importable": false,
|
||||
"customizable": true
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"route": "/SmartAssistant/action/status",
|
||||
"method": "get",
|
||||
"params": {
|
||||
"controller": "SmartAssistant",
|
||||
"action": "status"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/SmartAssistant/action/chat",
|
||||
"method": "post",
|
||||
@@ -78,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,28 +89,76 @@ 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
|
||||
{
|
||||
$task = $this->entityManager->getNewEntity('Task');
|
||||
$task->set([
|
||||
$taskData = [
|
||||
'name' => $params['name'] ?? 'משימה חדשה',
|
||||
'status' => 'Not Started',
|
||||
'priority' => $params['priority'] ?? 'Normal',
|
||||
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
|
||||
'description' => $params['description'] ?? null,
|
||||
'parentType' => 'Case', 'parentId' => $caseId,
|
||||
'assignedUserId' => $params['assignedUserId'] ?? $userId,
|
||||
]);
|
||||
];
|
||||
|
||||
if ($caseId) {
|
||||
$taskData['parentType'] = 'Case';
|
||||
$taskData['parentId'] = $caseId;
|
||||
}
|
||||
|
||||
$task = $this->entityManager->getNewEntity('Task');
|
||||
$task->set($taskData);
|
||||
$this->entityManager->saveEntity($task);
|
||||
return ['success' => true, 'message' => "משימה \"{$params['name']}\" נוצרה בהצלחה", 'entityType' => 'Task', 'entityId' => $task->get('id')];
|
||||
|
||||
$msg = "✅ משימה \"{$params['name']}\" נוצרה בהצלחה";
|
||||
if ($caseId) {
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if ($case) {
|
||||
$msg .= " בתיק " . $case->get('name');
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'message' => $msg, 'entityType' => 'Task', 'entityId' => $task->get('id')];
|
||||
}
|
||||
|
||||
private function addNote(array $params, ?string $caseId, string $userId): array
|
||||
{
|
||||
$noteData = ['type' => 'Post', 'post' => $params['post'] ?? '', 'createdById' => $userId];
|
||||
|
||||
if ($caseId) {
|
||||
$noteData['parentType'] = 'Case';
|
||||
$noteData['parentId'] = $caseId;
|
||||
}
|
||||
|
||||
$note = $this->entityManager->getNewEntity('Note');
|
||||
$note->set(['type' => 'Post', 'post' => $params['post'] ?? '', 'parentType' => 'Case', 'parentId' => $caseId, 'createdById' => $userId]);
|
||||
$note->set($noteData);
|
||||
$this->entityManager->saveEntity($note);
|
||||
return ['success' => true, 'message' => 'הערה נוספה לתיק', 'entityType' => 'Note', 'entityId' => $note->get('id')];
|
||||
return ['success' => true, 'message' => '✅ הערה נוספה' . ($caseId ? ' לתיק' : ''), 'entityType' => 'Note', 'entityId' => $note->get('id')];
|
||||
}
|
||||
|
||||
private function changeStatus(array $params, ?string $caseId): array
|
||||
@@ -119,16 +182,137 @@ class ActionExecutor
|
||||
|
||||
private function createMeeting(array $params, ?string $caseId, string $userId): array
|
||||
{
|
||||
$meeting = $this->entityManager->getNewEntity('Meeting');
|
||||
$meeting->set([
|
||||
$meetingData = [
|
||||
'name' => $params['name'] ?? 'פגישה חדשה', 'status' => 'Planned',
|
||||
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
|
||||
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
|
||||
'description' => $params['description'] ?? null,
|
||||
'parentType' => 'Case', 'parentId' => $caseId, 'assignedUserId' => $userId,
|
||||
]);
|
||||
'assignedUserId' => $userId,
|
||||
];
|
||||
|
||||
if ($caseId) {
|
||||
$meetingData['parentType'] = 'Case';
|
||||
$meetingData['parentId'] = $caseId;
|
||||
}
|
||||
|
||||
$meeting = $this->entityManager->getNewEntity('Meeting');
|
||||
$meeting->set($meetingData);
|
||||
$this->entityManager->saveEntity($meeting);
|
||||
return ['success' => true, 'message' => "פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')];
|
||||
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
|
||||
@@ -202,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;
|
||||
@@ -214,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;
|
||||
@@ -228,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class AlertCalculator
|
||||
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
||||
|
||||
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])->count();
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
|
||||
|
||||
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
|
||||
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
||||
@@ -69,11 +69,12 @@ class AlertCalculator
|
||||
{
|
||||
$alerts = [];
|
||||
$now = new \DateTime();
|
||||
$today = $now->format('Y-m-d');
|
||||
$warningThreshold = (new \DateTime())->modify("-{$warningDays} days")->format('Y-m-d H:i:s');
|
||||
$criticalThreshold = (new \DateTime())->modify("-{$criticalDays} days")->format('Y-m-d H:i:s');
|
||||
|
||||
$cases = $this->entityManager->getRDBRepository('Case')
|
||||
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
|
||||
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt', 'cNextHearing'])
|
||||
->where([
|
||||
'status!=' => self::CLOSED_STATUSES, 'deleted' => false,
|
||||
'OR' => [
|
||||
@@ -83,6 +84,17 @@ class AlertCalculator
|
||||
])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find();
|
||||
|
||||
foreach ($cases as $case) {
|
||||
$status = $case->get('status');
|
||||
|
||||
// Suppress inactivity alerts for cases legitimately awaiting court action.
|
||||
// PendingDecision: nothing the lawyer can do until the court rules.
|
||||
// PendingHearing: nothing to do if a future hearing is already scheduled.
|
||||
if ($status === 'PendingDecision') continue;
|
||||
if ($status === 'PendingHearing') {
|
||||
$nextHearing = $case->get('cNextHearing');
|
||||
if ($nextHearing && $nextHearing >= $today) continue;
|
||||
}
|
||||
|
||||
$lastActivity = $case->get('cLastActivityAt') ?? $case->get('createdAt');
|
||||
if (!$lastActivity) continue;
|
||||
$daysSince = $now->diff(new \DateTime($lastActivity))->days;
|
||||
@@ -106,7 +118,7 @@ class AlertCalculator
|
||||
|
||||
$tasks = $this->entityManager->getRDBRepository('Task')
|
||||
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
|
||||
->order('dateEnd', 'ASC')->limit(0, 50)->find();
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
@@ -132,7 +144,7 @@ class AlertCalculator
|
||||
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
|
||||
|
||||
$cases = $this->entityManager->getRDBRepository('Case')
|
||||
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt'])
|
||||
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt', 'contactId'])
|
||||
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
|
||||
->order('cNextHearing', 'ASC')->find();
|
||||
|
||||
@@ -140,13 +152,7 @@ class AlertCalculator
|
||||
$lastActivity = $case->get('cLastActivityAt');
|
||||
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
|
||||
|
||||
$openTaskCount = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false])->count();
|
||||
if ($openTaskCount > 0) continue;
|
||||
|
||||
$recentCompleted = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status' => 'Completed', 'modifiedAt>=' => $recentTaskThreshold, 'deleted' => false])->count();
|
||||
if ($recentCompleted > 0) continue;
|
||||
if ($this->caseHasPreparation($case, $recentTaskThreshold)) continue;
|
||||
|
||||
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
|
||||
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
|
||||
@@ -162,6 +168,104 @@ class AlertCalculator
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a case has any evidence of hearing preparation:
|
||||
* 1. Tasks linked directly to the case (parentType='Case')
|
||||
* 2. Tasks linked to the case's contacts (parentType='Contact')
|
||||
* 3. Tasks linked through NhActivity records for this case
|
||||
*/
|
||||
private function caseHasPreparation(\Espo\ORM\Entity $case, string $recentTaskThreshold): bool
|
||||
{
|
||||
$caseId = $case->get('id');
|
||||
|
||||
// Collect all parentId+parentType pairs that relate to this case
|
||||
$parentConditions = [
|
||||
['parentType' => 'Case', 'parentId' => $caseId],
|
||||
];
|
||||
|
||||
// Also check tasks linked to the case's contacts
|
||||
$contactIds = $this->getCaseContactIds($case);
|
||||
if (!empty($contactIds)) {
|
||||
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||
}
|
||||
|
||||
// Check 1: Any open tasks linked to the case or its contacts
|
||||
$openTaskCount = $this->entityManager->getRDBRepository('Task')
|
||||
->where([
|
||||
'OR' => $parentConditions,
|
||||
'status!=' => ['Completed', 'Canceled', 'Deferred'],
|
||||
'deleted' => false,
|
||||
])->count();
|
||||
if ($openTaskCount > 0) return true;
|
||||
|
||||
// Check 2: Any recently completed tasks linked to the case or its contacts
|
||||
$recentCompleted = $this->entityManager->getRDBRepository('Task')
|
||||
->where([
|
||||
'OR' => $parentConditions,
|
||||
'status' => 'Completed',
|
||||
'modifiedAt>=' => $recentTaskThreshold,
|
||||
'deleted' => false,
|
||||
])->count();
|
||||
if ($recentCompleted > 0) return true;
|
||||
|
||||
// Check 3: Tasks linked through NhActivity records for this case
|
||||
$nhActivityTaskCount = $this->entityManager->getRDBRepository('NhActivity')
|
||||
->where([
|
||||
'caseId' => $caseId,
|
||||
'taskId!=' => null,
|
||||
'deleted' => false,
|
||||
])->count();
|
||||
|
||||
if ($nhActivityTaskCount > 0) {
|
||||
$nhActivities = $this->entityManager->getRDBRepository('NhActivity')
|
||||
->select(['taskId'])
|
||||
->where([
|
||||
'caseId' => $caseId,
|
||||
'taskId!=' => null,
|
||||
'deleted' => false,
|
||||
])->find();
|
||||
|
||||
$taskIds = [];
|
||||
foreach ($nhActivities as $nha) {
|
||||
$taskIds[] = $nha->get('taskId');
|
||||
}
|
||||
|
||||
$activeNhTasks = $this->entityManager->getRDBRepository('Task')
|
||||
->where([
|
||||
'id' => $taskIds,
|
||||
'status!=' => ['Canceled'],
|
||||
'deleted' => false,
|
||||
])->count();
|
||||
if ($activeNhTasks > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getCaseContactIds(\Espo\ORM\Entity $case): array
|
||||
{
|
||||
$contactIds = [];
|
||||
|
||||
$primaryContactId = $case->get('contactId');
|
||||
if ($primaryContactId) {
|
||||
$contactIds[] = $primaryContactId;
|
||||
}
|
||||
|
||||
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'contacts')
|
||||
->select(['id'])
|
||||
->find();
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$id = $contact->get('id');
|
||||
if (!in_array($id, $contactIds)) {
|
||||
$contactIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $contactIds;
|
||||
}
|
||||
|
||||
private function findUnassignedNewCases(): array
|
||||
{
|
||||
$alerts = [];
|
||||
@@ -191,7 +295,7 @@ class AlertCalculator
|
||||
|
||||
$tasks = $this->entityManager->getRDBRepository('Task')
|
||||
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => 'Case'])
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
|
||||
->order('dateEnd', 'ASC')->limit(0, 30)->find();
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
@@ -237,12 +341,84 @@ class AlertCalculator
|
||||
|
||||
foreach (array_keys($userCounts) as $uid) {
|
||||
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => 'Case'])->count();
|
||||
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
|
||||
}
|
||||
|
||||
return array_values($userCounts);
|
||||
}
|
||||
|
||||
public function getOpenCaseItems(int $limit = 30): array
|
||||
{
|
||||
$items = [];
|
||||
$cases = $this->entityManager->getRDBRepository('Case')
|
||||
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
|
||||
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])
|
||||
->order('createdAt', 'DESC')
|
||||
->limit(0, $limit)
|
||||
->find();
|
||||
|
||||
$statusLabels = [
|
||||
'New' => 'חדש', 'Assigned' => 'שובץ', 'Pending' => 'ממתין',
|
||||
'PendingHearing' => 'ממתין לדיון', 'PendingDecision' => 'ממתין להחלטה',
|
||||
'PendingResponse' => 'ממתין לתגובה', 'Negotiation' => 'משא ומתן', 'Suspended' => 'מושהה',
|
||||
];
|
||||
|
||||
foreach ($cases as $case) {
|
||||
$status = $case->get('status');
|
||||
$label = $statusLabels[$status] ?? $status;
|
||||
$assigned = $case->get('assignedUserName');
|
||||
|
||||
$items[] = [
|
||||
'type' => 'open_case',
|
||||
'severity' => 'info',
|
||||
'caseId' => $case->get('id'),
|
||||
'caseName' => $case->get('name'),
|
||||
'message' => $case->get('name') . ' — ' . $label . ($assigned ? ' (' . $assigned . ')' : ''),
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function getUpcomingHearingItems(int $days = 7): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$threshold = date('Y-m-d', strtotime("+{$days} days"));
|
||||
$items = [];
|
||||
|
||||
$cases = $this->entityManager->getRDBRepository('Case')
|
||||
->select(['id', 'name', 'cNextHearing', 'cCourt', 'assignedUserName'])
|
||||
->where([
|
||||
'cNextHearing>=' => $today,
|
||||
'cNextHearing<=' => $threshold,
|
||||
'status!=' => self::CLOSED_STATUSES,
|
||||
'deleted' => false,
|
||||
])
|
||||
->order('cNextHearing', 'ASC')
|
||||
->find();
|
||||
|
||||
foreach ($cases as $case) {
|
||||
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
|
||||
$court = $case->get('cCourt');
|
||||
$severity = ($daysUntil <= 1) ? 'warning' : 'info';
|
||||
|
||||
$msg = $case->get('name') . ' — דיון בעוד ' . $daysUntil . ' ימים';
|
||||
if ($court) {
|
||||
$msg .= ' (' . $court . ')';
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'type' => 'upcoming_hearing',
|
||||
'severity' => $severity,
|
||||
'caseId' => $case->get('id'),
|
||||
'caseName' => $case->get('name'),
|
||||
'hearingDate' => $case->get('cNextHearing'),
|
||||
'daysUntil' => $daysUntil,
|
||||
'message' => $msg,
|
||||
];
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function getUpcomingHearings(int $days = 7): array
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Log;
|
||||
|
||||
/**
|
||||
* Loads active prompt sections keyed by their `key` field, filtered by mode.
|
||||
*
|
||||
* Returned shape: `["tool_rules" => "...", "writing_style" => "...", ...]`.
|
||||
* The Python prompt_builder treats a missing key (or empty string) as
|
||||
* "use the in-code default" — so this provider is purely additive.
|
||||
*/
|
||||
class AssistantPromptContextProvider
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private Log $log;
|
||||
|
||||
public function __construct(EntityManager $entityManager, Log $log)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->log = $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string> key => content
|
||||
*/
|
||||
public function getPromptsForMode(string $mode): array
|
||||
{
|
||||
$entries = $this->entityManager->getRDBRepository('AssistantPrompt')
|
||||
->where([
|
||||
'isActive' => true,
|
||||
'deleted' => false,
|
||||
'mode' => ['both', $mode],
|
||||
])
|
||||
->order('displayOrder', 'ASC')
|
||||
->find();
|
||||
|
||||
$out = [];
|
||||
foreach ($entries as $entry) {
|
||||
$key = (string) $entry->get('key');
|
||||
$content = (string) ($entry->get('content') ?? '');
|
||||
if ($key === '' || $content === '') {
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $content;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Log;
|
||||
|
||||
/**
|
||||
* Lists active skills (metadata only — name/description/triggers) for the
|
||||
* system prompt's "=== SKILLS (learned workflows) ===" hint. The full body
|
||||
* is fetched on demand by shira-hermes' read_skill tool via REST.
|
||||
*/
|
||||
class AssistantSkillContextProvider
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private Log $log;
|
||||
|
||||
public function __construct(EntityManager $entityManager, Log $log)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->log = $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{name: string, description: string, triggers: string}>
|
||||
*/
|
||||
public function listSkills(): array
|
||||
{
|
||||
$entries = $this->entityManager->getRDBRepository('AssistantSkill')
|
||||
->where(['isActive' => true, 'deleted' => false])
|
||||
->order('name', 'ASC')
|
||||
->find();
|
||||
|
||||
$skills = [];
|
||||
foreach ($entries as $entry) {
|
||||
$skills[] = [
|
||||
'name' => (string) $entry->get('name'),
|
||||
'description' => (string) ($entry->get('description') ?? ''),
|
||||
'triggers' => (string) ($entry->get('triggers') ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $skills;
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,11 @@ 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),
|
||||
'signatureRequests' => $this->getSignatureRequests($caseId),
|
||||
'currentUser' => $this->getUserData($userId),
|
||||
'validStatuses' => ActionExecutor::VALID_STATUSES,
|
||||
];
|
||||
@@ -81,9 +83,18 @@ class CaseContextBuilder
|
||||
|
||||
private function getOpenTasks(string $caseId): array
|
||||
{
|
||||
$parentConditions = [
|
||||
['parentType' => 'Case', 'parentId' => $caseId],
|
||||
];
|
||||
|
||||
$contactIds = $this->getCaseContactIds($caseId);
|
||||
if (!empty($contactIds)) {
|
||||
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||
}
|
||||
|
||||
$tasks = [];
|
||||
$collection = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['parentType' => 'Case', 'parentId' => $caseId, 'status!=' => ['Completed', 'Canceled']])
|
||||
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled']])
|
||||
->order('dateEnd', 'ASC')->limit(0, 10)->find();
|
||||
|
||||
foreach ($collection as $t) {
|
||||
@@ -96,6 +107,32 @@ class CaseContextBuilder
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
private function getCaseContactIds(string $caseId): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) return [];
|
||||
|
||||
$contactIds = [];
|
||||
$primaryContactId = $case->get('contactId');
|
||||
if ($primaryContactId) {
|
||||
$contactIds[] = $primaryContactId;
|
||||
}
|
||||
|
||||
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'contacts')
|
||||
->select(['id'])
|
||||
->find();
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$id = $contact->get('id');
|
||||
if (!in_array($id, $contactIds)) {
|
||||
$contactIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $contactIds;
|
||||
}
|
||||
|
||||
private function getUpcomingMeetings(string $caseId): array
|
||||
{
|
||||
$meetings = [];
|
||||
@@ -113,6 +150,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 = [];
|
||||
@@ -133,10 +188,20 @@ class CaseContextBuilder
|
||||
{
|
||||
$templates = [];
|
||||
$collection = $this->entityManager->getRDBRepository('DocumentTemplate')
|
||||
->where(['entityType' => 'Case'])->order('name', 'ASC')->find();
|
||||
->where([
|
||||
'targetEntityType' => 'Case',
|
||||
'isActive' => true,
|
||||
])
|
||||
->order('name', 'ASC')
|
||||
->find();
|
||||
|
||||
foreach ($collection as $t) {
|
||||
$templates[] = ['id' => $t->get('id'), 'name' => $t->get('name')];
|
||||
$templates[] = [
|
||||
'id' => $t->get('id'),
|
||||
'name' => $t->get('name'),
|
||||
'category' => $t->get('category'),
|
||||
'description' => $t->get('description'),
|
||||
];
|
||||
}
|
||||
return $templates;
|
||||
}
|
||||
@@ -148,13 +213,51 @@ class CaseContextBuilder
|
||||
return ['id' => $user->get('id'), 'name' => $user->get('name')];
|
||||
}
|
||||
|
||||
private function getSignatureRequests(string $caseId): array
|
||||
{
|
||||
try {
|
||||
$collection = $this->entityManager->getRDBRepository('SignatureRequest')
|
||||
->where([
|
||||
'caseId' => $caseId,
|
||||
'status!=' => ['Completed', 'Voided'],
|
||||
])
|
||||
->order('createdAt', 'DESC')
|
||||
->limit(0, 10)
|
||||
->find();
|
||||
|
||||
$requests = [];
|
||||
|
||||
foreach ($collection as $sr) {
|
||||
$requests[] = [
|
||||
'id' => $sr->get('id'),
|
||||
'name' => $sr->get('name'),
|
||||
'status' => $sr->get('status'),
|
||||
'sentAt' => $sr->get('sentAt'),
|
||||
];
|
||||
}
|
||||
|
||||
return $requests;
|
||||
} catch (\Exception $e) {
|
||||
// SignatureRequest entity might not exist if DigitalSignature not installed
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function getDocumentListing(string $caseId): array
|
||||
{
|
||||
try {
|
||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||
$result = $analyzer->listDocumentsRecursive($caseId);
|
||||
|
||||
$simplified = ['totalFiles' => $result['totalFiles'], 'folders' => [], 'rootFiles' => []];
|
||||
$simplified = [
|
||||
'totalFiles' => $result['totalFiles'],
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => $result['resolvedPath'] ?? null,
|
||||
'pathSource' => $result['pathSource'] ?? null,
|
||||
'folderExists' => $result['folderExists'] ?? null,
|
||||
'diagnostic' => $result['reason'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($result['folders'] as $folder) {
|
||||
$files = [];
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||
|
||||
/**
|
||||
* Wraps NetworkDocumentService and constrains every operation to the case's
|
||||
* own folder. Never exposes a physical delete — only soft-delete via
|
||||
* markForDeletion().
|
||||
*/
|
||||
class CaseFolderManager
|
||||
{
|
||||
private const DELETION_FOLDER_NAME = 'מסמכים למחיקה';
|
||||
private const DELETION_PREFIX = 'למחיקה - ';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Log $log,
|
||||
private User $user
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, path: string, size: int}
|
||||
*/
|
||||
public function writeFile(string $caseId, string $relPath, string $content): array
|
||||
{
|
||||
$absInsideStorage = $this->resolveSafePath($caseId, $relPath, requireExists: false);
|
||||
$client = $this->getNds()->getClient();
|
||||
|
||||
$parent = $this->dirnameRel($absInsideStorage);
|
||||
if ($parent !== '') {
|
||||
$client->createFolderRecursive($parent);
|
||||
}
|
||||
|
||||
$ok = $client->uploadFile($absInsideStorage, $content);
|
||||
if (!$ok) {
|
||||
throw new Error("Failed to write file: {$absInsideStorage}");
|
||||
}
|
||||
|
||||
$this->log->info("CaseFolderManager: wrote '{$absInsideStorage}' (case {$caseId})");
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'path' => $absInsideStorage,
|
||||
'size' => strlen($content),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, path: string, alreadyExisted: bool}
|
||||
*/
|
||||
public function createFolder(string $caseId, string $relPath): array
|
||||
{
|
||||
$abs = $this->resolveSafePath($caseId, $relPath, requireExists: false);
|
||||
$client = $this->getNds()->getClient();
|
||||
|
||||
$alreadyExisted = $client->exists($abs);
|
||||
if (!$alreadyExisted) {
|
||||
$ok = $client->createFolderRecursive($abs);
|
||||
if (!$ok) {
|
||||
throw new Error("Failed to create folder: {$abs}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->log->info("CaseFolderManager: created folder '{$abs}' (case {$caseId})");
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'path' => $abs,
|
||||
'alreadyExisted' => $alreadyExisted,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, oldPath: string, newPath: string}
|
||||
*/
|
||||
public function renameItem(string $caseId, string $currentRelPath, string $newName): array
|
||||
{
|
||||
$newName = trim($newName);
|
||||
if ($newName === '' || str_contains($newName, '/') || str_contains($newName, '\\')) {
|
||||
throw new BadRequest('newName must be a plain file/folder name without slashes.');
|
||||
}
|
||||
|
||||
$absCurrent = $this->resolveSafePath($caseId, $currentRelPath, requireExists: true);
|
||||
|
||||
$parent = $this->dirnameRel($absCurrent);
|
||||
$absNew = $parent === '' ? $newName : ($parent . '/' . $newName);
|
||||
|
||||
// Confirm the new path is still inside the case folder.
|
||||
$this->assertWithinCaseRoot($caseId, $absNew);
|
||||
|
||||
$client = $this->getNds()->getClient();
|
||||
if ($client->exists($absNew)) {
|
||||
throw new Error("Target already exists: {$absNew}");
|
||||
}
|
||||
|
||||
$ok = $client->move($absCurrent, $absNew);
|
||||
if (!$ok) {
|
||||
throw new Error("Failed to rename: {$absCurrent} -> {$absNew}");
|
||||
}
|
||||
|
||||
$this->log->info("CaseFolderManager: renamed '{$absCurrent}' -> '{$absNew}' (case {$caseId})");
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'oldPath' => $absCurrent,
|
||||
'newPath' => $absNew,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, oldPath: string, newPath: string}
|
||||
*/
|
||||
public function moveItem(string $caseId, string $sourceRelPath, string $targetRelPath): array
|
||||
{
|
||||
$absSource = $this->resolveSafePath($caseId, $sourceRelPath, requireExists: true);
|
||||
$absTarget = $this->resolveSafePath($caseId, $targetRelPath, requireExists: false);
|
||||
|
||||
$client = $this->getNds()->getClient();
|
||||
|
||||
$parent = $this->dirnameRel($absTarget);
|
||||
if ($parent !== '' && !$client->exists($parent)) {
|
||||
$client->createFolderRecursive($parent);
|
||||
}
|
||||
|
||||
if ($client->exists($absTarget)) {
|
||||
throw new Error("Target already exists: {$absTarget}");
|
||||
}
|
||||
|
||||
$ok = $client->move($absSource, $absTarget);
|
||||
if (!$ok) {
|
||||
throw new Error("Failed to move: {$absSource} -> {$absTarget}");
|
||||
}
|
||||
|
||||
$this->log->info("CaseFolderManager: moved '{$absSource}' -> '{$absTarget}' (case {$caseId})");
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'oldPath' => $absSource,
|
||||
'newPath' => $absTarget,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: move file into the case's "מסמכים למחיקה" folder with a
|
||||
* "למחיקה - " prefix. Never physically deletes. Also updates the linked
|
||||
* Document entity (if any) so admins can audit pending deletions.
|
||||
*
|
||||
* @return array{success: bool, originalPath: string, newPath: string, deletionFolder: string}
|
||||
*/
|
||||
public function markForDeletion(string $caseId, string $fileRelPath): array
|
||||
{
|
||||
$absSource = $this->resolveSafePath($caseId, $fileRelPath, requireExists: true);
|
||||
$caseRoot = $this->getCaseRoot($caseId);
|
||||
|
||||
$deletionFolder = $caseRoot . '/' . self::DELETION_FOLDER_NAME;
|
||||
$client = $this->getNds()->getClient();
|
||||
|
||||
if (!$client->exists($deletionFolder)) {
|
||||
$client->createFolderRecursive($deletionFolder);
|
||||
}
|
||||
|
||||
$baseName = basename($absSource);
|
||||
$targetName = self::DELETION_PREFIX . $baseName;
|
||||
$absTarget = $deletionFolder . '/' . $targetName;
|
||||
|
||||
// Avoid name collisions with already-marked files.
|
||||
if ($client->exists($absTarget)) {
|
||||
$stamp = date('Y-m-d_H-i-s');
|
||||
$targetName = self::DELETION_PREFIX . $stamp . ' - ' . $baseName;
|
||||
$absTarget = $deletionFolder . '/' . $targetName;
|
||||
}
|
||||
|
||||
$ok = $client->move($absSource, $absTarget);
|
||||
if (!$ok) {
|
||||
throw new Error("Failed to mark for deletion: {$absSource}");
|
||||
}
|
||||
|
||||
// Best-effort: update Document entity if we can find one pointing to this file.
|
||||
$this->touchDocumentForDeletion($absSource, $absTarget);
|
||||
|
||||
$this->log->info(
|
||||
"CaseFolderManager: marked for deletion '{$absSource}' -> '{$absTarget}' " .
|
||||
"(case {$caseId}, by user {$this->user->getId()})"
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'originalPath' => $absSource,
|
||||
'newPath' => $absTarget,
|
||||
'deletionFolder' => $deletionFolder,
|
||||
];
|
||||
}
|
||||
|
||||
// --- internals ---------------------------------------------------------
|
||||
|
||||
private function getNds(): NetworkDocumentService
|
||||
{
|
||||
return $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the storage-relative root folder of the case.
|
||||
*/
|
||||
private function getCaseRoot(string $caseId): string
|
||||
{
|
||||
$root = $this->getNds()->getEntityFolderPath('Case', $caseId);
|
||||
if (!$root) {
|
||||
throw new NotFound("No folder is configured for Case {$caseId}.");
|
||||
}
|
||||
$root = $this->normalize($root);
|
||||
if ($root === '') {
|
||||
throw new Error("Case {$caseId} resolved to an empty folder path; refusing.");
|
||||
}
|
||||
return $root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize + reject traversal, return storage-relative path inside the case folder.
|
||||
* If $requireExists, ensures the resolved path exists in storage.
|
||||
*/
|
||||
private function resolveSafePath(string $caseId, string $relPath, bool $requireExists): string
|
||||
{
|
||||
$relPath = trim((string) $relPath);
|
||||
if ($relPath === '') {
|
||||
throw new BadRequest('Path is required.');
|
||||
}
|
||||
if (str_starts_with($relPath, '/') || str_starts_with($relPath, '\\')) {
|
||||
throw new BadRequest('Absolute paths are not allowed.');
|
||||
}
|
||||
|
||||
$caseRoot = $this->getCaseRoot($caseId);
|
||||
|
||||
// Allow the caller to either pass a path relative to the case root, OR a
|
||||
// full storage-relative path that already includes the case root.
|
||||
$normalizedRel = $this->normalize($relPath);
|
||||
$candidate = str_starts_with($normalizedRel, $caseRoot . '/') || $normalizedRel === $caseRoot
|
||||
? $normalizedRel
|
||||
: $this->normalize($caseRoot . '/' . $normalizedRel);
|
||||
|
||||
$this->assertWithinCaseRoot($caseId, $candidate);
|
||||
|
||||
if ($requireExists) {
|
||||
$client = $this->getNds()->getClient();
|
||||
if (!$client->exists($candidate)) {
|
||||
throw new NotFound("Path not found: {$candidate}");
|
||||
}
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function assertWithinCaseRoot(string $caseId, string $absInStorage): void
|
||||
{
|
||||
$caseRoot = $this->getCaseRoot($caseId);
|
||||
if ($absInStorage !== $caseRoot && !str_starts_with($absInStorage, $caseRoot . '/')) {
|
||||
throw new Forbidden("Path '{$absInStorage}' is outside the case folder '{$caseRoot}'.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a storage-relative path:
|
||||
* - convert '\' to '/'
|
||||
* - collapse repeated '/'
|
||||
* - resolve '.' segments
|
||||
* - reject '..' segments (refuses traversal)
|
||||
* - strip trailing '/'
|
||||
*/
|
||||
private function normalize(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', $path);
|
||||
$segments = explode('/', $path);
|
||||
$out = [];
|
||||
foreach ($segments as $seg) {
|
||||
if ($seg === '' || $seg === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($seg === '..') {
|
||||
throw new Forbidden('Parent-directory traversal is not allowed.');
|
||||
}
|
||||
// Block null bytes and other suspicious characters.
|
||||
if (preg_match('/[\x00-\x1F]/', $seg)) {
|
||||
throw new BadRequest('Path contains invalid control characters.');
|
||||
}
|
||||
$out[] = $seg;
|
||||
}
|
||||
return implode('/', $out);
|
||||
}
|
||||
|
||||
private function dirnameRel(string $path): string
|
||||
{
|
||||
$pos = strrpos($path, '/');
|
||||
if ($pos === false) {
|
||||
return '';
|
||||
}
|
||||
return substr($path, 0, $pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the Document entity (if any) that pointed to this file, so the
|
||||
* CRM reflects the soft-delete state.
|
||||
*/
|
||||
private function touchDocumentForDeletion(string $oldPath, string $newPath): void
|
||||
{
|
||||
try {
|
||||
$doc = $this->entityManager->getRDBRepository('Document')
|
||||
->where(['networkStoragePath' => $oldPath])
|
||||
->findOne();
|
||||
if (!$doc) {
|
||||
return;
|
||||
}
|
||||
$doc->set([
|
||||
'networkStoragePath' => $newPath,
|
||||
'markedForDeletionAt' => date('Y-m-d H:i:s'),
|
||||
'markedForDeletionById' => $this->user->getId(),
|
||||
]);
|
||||
$this->entityManager->saveEntity($doc);
|
||||
} catch (\Throwable $e) {
|
||||
$this->log->warning(
|
||||
"CaseFolderManager: could not update Document entity for '{$oldPath}': " . $e->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,7 @@ class ConversationRepository
|
||||
if (!$entity) return;
|
||||
}
|
||||
|
||||
$messages = $entity->get('messagesData') ?? [];
|
||||
if (is_object($messages)) $messages = (array) $messages;
|
||||
$messages = json_decode(json_encode($entity->get('messagesData') ?? []), true);
|
||||
|
||||
$newMessage = ['role' => $role, 'content' => $content, 'timestamp' => date('Y-m-d H:i:s')];
|
||||
if (!empty($actions)) $newMessage['actions'] = $actions;
|
||||
@@ -81,8 +80,7 @@ class ConversationRepository
|
||||
$entity = $this->entityManager->getRDBRepository('AssistantConversation')
|
||||
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
|
||||
if (!$entity) return [];
|
||||
$messages = $entity->get('messagesData') ?? [];
|
||||
return is_object($messages) ? (array) $messages : $messages;
|
||||
return json_decode(json_encode($entity->get('messagesData') ?? []), true);
|
||||
}
|
||||
|
||||
public function getRecentConversations(?string $type = null, ?string $caseId = null, int $limit = 20): array
|
||||
@@ -137,8 +135,7 @@ class ConversationRepository
|
||||
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
|
||||
if (!$entity) return;
|
||||
|
||||
$data = $entity->get('messagesData') ?? [];
|
||||
if (is_object($data)) $data = (array) $data;
|
||||
$data = json_decode(json_encode($entity->get('messagesData') ?? []), true);
|
||||
for ($i = count($data) - 1; $i >= 0; $i--) {
|
||||
if (($data[$i]['role'] ?? '') === 'assistant') { $data[$i]['pendingActions'] = $actions; break; }
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
||||
@@ -40,20 +41,78 @@ class DocumentAnalyzer
|
||||
|
||||
public function listDocumentsRecursive(string $caseId): array
|
||||
{
|
||||
$casePath = $this->getCaseFolderPath($caseId);
|
||||
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
|
||||
$description = $this->describeCaseFolderPath($caseId);
|
||||
$casePath = $description['path'];
|
||||
|
||||
if (!$casePath) {
|
||||
return [
|
||||
'casePath' => null,
|
||||
'totalFiles' => 0,
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => null,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => false,
|
||||
'reason' => $description['error'] ?? 'Case has no networkStorageFolderPath and no default could be computed.',
|
||||
];
|
||||
}
|
||||
|
||||
$client = $this->getStorageClient();
|
||||
|
||||
try {
|
||||
$folderExists = $client->exists($casePath);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'casePath' => $casePath,
|
||||
'totalFiles' => 0,
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => $casePath,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => false,
|
||||
'reason' => "Storage client failed while checking '{$casePath}': " . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$folderExists) {
|
||||
return [
|
||||
'casePath' => $casePath,
|
||||
'totalFiles' => 0,
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => $casePath,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => false,
|
||||
'reason' => "Folder '{$casePath}' (source: {$description['source']}) does not exist on storage.",
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$topLevel = $client->listFolder($casePath);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'casePath' => $casePath,
|
||||
'totalFiles' => 0,
|
||||
'folders' => [],
|
||||
'rootFiles' => [],
|
||||
'resolvedPath' => $casePath,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => true,
|
||||
'reason' => "Failed to list folder '{$casePath}': " . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$storage = $this->getStorage();
|
||||
$topLevel = $storage->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,27 +121,38 @@ 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++;
|
||||
}
|
||||
}
|
||||
|
||||
return ['casePath' => $casePath, 'totalFiles' => $totalFiles, 'folders' => $folders, 'rootFiles' => $rootFiles];
|
||||
return [
|
||||
'casePath' => $casePath,
|
||||
'totalFiles' => $totalFiles,
|
||||
'folders' => $folders,
|
||||
'rootFiles' => $rootFiles,
|
||||
'resolvedPath' => $casePath,
|
||||
'pathSource' => $description['source'],
|
||||
'folderExists' => true,
|
||||
'reason' => $totalFiles === 0 ? "Folder '{$casePath}' exists but is empty." : null,
|
||||
];
|
||||
}
|
||||
|
||||
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 +171,125 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw file bytes + metadata for OCR/vision fallback.
|
||||
*
|
||||
* @return array{success: bool, fileName: string, mimeType: string, sizeBytes: int, base64: ?string, error: ?string}
|
||||
*/
|
||||
public function getDocumentBytes(string $filePath): array
|
||||
{
|
||||
$fileName = basename($filePath);
|
||||
$client = $this->getStorageClient();
|
||||
|
||||
try {
|
||||
$content = $client->downloadFile($filePath);
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'success' => false,
|
||||
'fileName' => $fileName,
|
||||
'mimeType' => '',
|
||||
'sizeBytes' => 0,
|
||||
'base64' => null,
|
||||
'error' => 'Failed to download: ' . $e->getMessage(),
|
||||
];
|
||||
}
|
||||
|
||||
$sizeBytes = strlen($content);
|
||||
if ($sizeBytes > self::MAX_FILE_SIZE) {
|
||||
return [
|
||||
'success' => false,
|
||||
'fileName' => $fileName,
|
||||
'mimeType' => '',
|
||||
'sizeBytes' => $sizeBytes,
|
||||
'base64' => null,
|
||||
'error' => 'File too large (max 10MB).',
|
||||
];
|
||||
}
|
||||
|
||||
$mimeType = $this->guessMimeType($fileName, $content);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'fileName' => $fileName,
|
||||
'mimeType' => $mimeType,
|
||||
'sizeBytes' => $sizeBytes,
|
||||
'base64' => base64_encode($content),
|
||||
'error' => null,
|
||||
];
|
||||
}
|
||||
|
||||
private function guessMimeType(string $fileName, string $content): string
|
||||
{
|
||||
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||
$map = [
|
||||
'pdf' => 'application/pdf',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'doc' => 'application/msword',
|
||||
'txt' => 'text/plain',
|
||||
'csv' => 'text/csv',
|
||||
'log' => 'text/plain',
|
||||
'png' => 'image/png',
|
||||
'jpg' => 'image/jpeg',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'bmp' => 'image/bmp',
|
||||
'tiff' => 'image/tiff',
|
||||
'tif' => 'image/tiff',
|
||||
'webp' => 'image/webp',
|
||||
];
|
||||
if (isset($map[$ext])) {
|
||||
return $map[$ext];
|
||||
}
|
||||
// Fallback: finfo
|
||||
if (function_exists('finfo_buffer')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_buffer($finfo, $content);
|
||||
finfo_close($finfo);
|
||||
if ($mime) return $mime;
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
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 +302,223 @@ 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).');
|
||||
}
|
||||
|
||||
$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,
|
||||
];
|
||||
}
|
||||
|
||||
private function getStorage(): FileStorageInterface
|
||||
public function getCaseFolderPath(string $caseId): ?string
|
||||
{
|
||||
return $this->injectableFactory->create(FileStorageFactory::class)->create();
|
||||
return $this->describeCaseFolderPath($caseId)['path'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the case folder path with diagnostic metadata.
|
||||
*
|
||||
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
|
||||
* source: 'stored' | 'computed' | 'none'
|
||||
*/
|
||||
public function describeCaseFolderPath(string $caseId): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) {
|
||||
return ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false];
|
||||
}
|
||||
|
||||
$storedPath = $case->get('networkStorageFolderPath') ?: null;
|
||||
if ($storedPath) {
|
||||
return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true];
|
||||
}
|
||||
|
||||
try {
|
||||
$nds = $this->getNetworkDocumentService();
|
||||
$computed = $nds->getEntityFolderPath('Case', $caseId);
|
||||
if ($computed) {
|
||||
return ['path' => $computed, 'source' => 'computed', 'error' => null, 'caseExists' => true];
|
||||
}
|
||||
return ['path' => null, 'source' => 'none', 'error' => 'No stored path, and default path computation returned null.', 'caseExists' => true];
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning("SmartAssistant: Failed to compute case folder path: " . $e->getMessage());
|
||||
return ['path' => null, 'source' => 'none', 'error' => $e->getMessage(), 'caseExists' => true];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the immediate contents of an arbitrary folder path on network storage.
|
||||
* Manual override for when auto-resolution of a case folder fails.
|
||||
*
|
||||
* @return array<int, array{name: string, path: string, type: string, size: ?int, mimeType: ?string, modified: ?string}>
|
||||
*/
|
||||
public function browseFolder(string $path): array
|
||||
{
|
||||
$client = $this->getStorageClient();
|
||||
$entries = [];
|
||||
foreach ($client->listFolder($path) as $item) {
|
||||
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||
$entries[] = [
|
||||
'name' => $item['name'] ?? '',
|
||||
'path' => $item['path'] ?? '',
|
||||
'type' => $isFolder ? 'folder' : 'file',
|
||||
'size' => $item['size'] ?? null,
|
||||
'mimeType' => $item['mimeType'] ?? null,
|
||||
'modified' => $item['modified'] ?? null,
|
||||
];
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the storage for folders matching a query (case-insensitive substring).
|
||||
* Scans root and one level deep. Returns sorted matches.
|
||||
*
|
||||
* @return array<int, array{path: string, name: string, level: int, score: int}>
|
||||
*/
|
||||
public function findCaseFolder(string $query, int $limit = 20): array
|
||||
{
|
||||
$normalized = trim($query);
|
||||
if ($normalized === '') return [];
|
||||
|
||||
$client = $this->getStorageClient();
|
||||
$needle = mb_strtolower($normalized);
|
||||
$matches = [];
|
||||
|
||||
try {
|
||||
$roots = $client->listFolder('');
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning("SmartAssistant: findCaseFolder root listing failed: " . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($roots as $item) {
|
||||
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||
if (!$isFolder) continue;
|
||||
|
||||
$name = (string) ($item['name'] ?? '');
|
||||
$path = (string) ($item['path'] ?? $name);
|
||||
|
||||
$score = $this->fuzzyScore($name, $needle);
|
||||
if ($score > 0) {
|
||||
$matches[] = ['path' => $path, 'name' => $name, 'level' => 0, 'score' => $score];
|
||||
}
|
||||
|
||||
// One level deeper, but only inspect folder names — don't list files to keep it cheap.
|
||||
try {
|
||||
foreach ($client->listFolder($path) as $sub) {
|
||||
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
|
||||
if (!$subIsFolder) continue;
|
||||
$subName = (string) ($sub['name'] ?? '');
|
||||
$subPath = (string) ($sub['path'] ?? ($path . '/' . $subName));
|
||||
$subScore = $this->fuzzyScore($subName, $needle);
|
||||
if ($subScore > 0) {
|
||||
$matches[] = ['path' => $subPath, 'name' => $subName, 'level' => 1, 'score' => $subScore];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Non-fatal: skip unreadable subfolders silently.
|
||||
}
|
||||
}
|
||||
|
||||
usort($matches, fn($a, $b) => $b['score'] <=> $a['score']);
|
||||
return array_slice($matches, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the resolved folder path on the Case so future calls skip discovery.
|
||||
*/
|
||||
public function setCaseFolderPath(string $caseId, string $path): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) {
|
||||
throw new Error("Case '{$caseId}' not found.");
|
||||
}
|
||||
|
||||
$client = $this->getStorageClient();
|
||||
if (!$client->exists($path)) {
|
||||
throw new Error("Path '{$path}' does not exist on storage.");
|
||||
}
|
||||
|
||||
$case->set('networkStorageFolderPath', $path);
|
||||
$this->entityManager->saveEntity($case);
|
||||
|
||||
return ['success' => true, 'caseId' => $caseId, 'path' => $path];
|
||||
}
|
||||
|
||||
private function fuzzyScore(string $haystack, string $needleLower): int
|
||||
{
|
||||
$hay = mb_strtolower($haystack);
|
||||
if ($hay === $needleLower) return 100;
|
||||
if (mb_strpos($hay, $needleLower) !== false) return 80;
|
||||
|
||||
// Token overlap (whitespace/dash/underscore).
|
||||
$hayTokens = preg_split('/[\s\-_]+/u', $hay) ?: [];
|
||||
$needleTokens = preg_split('/[\s\-_]+/u', $needleLower) ?: [];
|
||||
$hits = 0;
|
||||
foreach ($needleTokens as $nt) {
|
||||
if ($nt === '') continue;
|
||||
foreach ($hayTokens as $ht) {
|
||||
if ($ht !== '' && mb_strpos($ht, $nt) !== false) { $hits++; break; }
|
||||
}
|
||||
}
|
||||
return $hits > 0 ? 40 + $hits * 5 : 0;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -149,29 +533,46 @@ class DocumentAnalyzer
|
||||
|
||||
private function extractFromDocx(string $tmpFile): ?string
|
||||
{
|
||||
if (!class_exists(\PhpOffice\PhpWord\IOFactory::class)) return null;
|
||||
$phpWord = \PhpOffice\PhpWord\IOFactory::load($tmpFile);
|
||||
$text = '';
|
||||
foreach ($phpWord->getSections() as $section) {
|
||||
foreach ($section->getElements() as $element) {
|
||||
if (method_exists($element, 'getText')) $text .= $element->getText() . "\n";
|
||||
elseif (method_exists($element, 'getElements')) {
|
||||
foreach ($element->getElements() as $child) {
|
||||
if (method_exists($child, 'getText')) $text .= $child->getText() . "\n";
|
||||
}
|
||||
// PHPWord (vendor/phpoffice/phpword) ships with the EspoCRM image but
|
||||
// is NOT in the composer PSR-4 autoload map — class_exists silently
|
||||
// returns false. Rather than fix the autoloader (rebuild of the image)
|
||||
// we parse word/document.xml directly. This is also more robust:
|
||||
// PHPWord's element walker only goes one level deep, missing text in
|
||||
// tables, hyperlinks, and footnotes.
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($tmpFile) !== true) {
|
||||
$this->log->warning("SmartAssistant: extractFromDocx: not a valid zip: $tmpFile");
|
||||
return null;
|
||||
}
|
||||
$xml = $zip->getFromName('word/document.xml');
|
||||
$zip->close();
|
||||
if ($xml === false || $xml === '') {
|
||||
$this->log->warning("SmartAssistant: extractFromDocx: word/document.xml missing in $tmpFile");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Each <w:p> is a paragraph; concatenate <w:t> runs inside it.
|
||||
// `(?:\s[^>]*)?` keeps us from also matching <w:tab> / <w:tbl>.
|
||||
$paragraphs = [];
|
||||
if (preg_match_all('#<w:p(?:\s[^>]*)?>(.*?)</w:p>#s', $xml, $pMatches)) {
|
||||
foreach ($pMatches[1] as $block) {
|
||||
if (preg_match_all('#<w:t(?:\s[^>]*)?>(.*?)</w:t>#s', $block, $tMatches)) {
|
||||
$line = trim(implode('', $tMatches[1]));
|
||||
if ($line !== '') $paragraphs[] = html_entity_decode($line, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
return $text ?: null;
|
||||
$text = trim(implode("\n", $paragraphs));
|
||||
return $text !== '' ? $text : null;
|
||||
}
|
||||
|
||||
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) {}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||
use PhpOffice\PhpWord\PhpWord;
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
|
||||
class FreeDocumentGenerator
|
||||
{
|
||||
private const TEMP_DIR = 'data/tmp/';
|
||||
private const FONT_NAME = 'David';
|
||||
private const FONT_SIZE = 12;
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Log $log,
|
||||
private User $user
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, documentId: string, documentName: string, fileName: string, networkPath: ?string, message: string}
|
||||
*/
|
||||
public function generate(
|
||||
string $caseId,
|
||||
string $title,
|
||||
string $body,
|
||||
string $documentType = 'letter',
|
||||
?string $recipient = null
|
||||
): array {
|
||||
$title = trim($title);
|
||||
if ($title === '') {
|
||||
throw new BadRequest('title is required.');
|
||||
}
|
||||
if (trim($body) === '') {
|
||||
throw new BadRequest('body is required.');
|
||||
}
|
||||
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) {
|
||||
throw new NotFound("Case {$caseId} not found.");
|
||||
}
|
||||
|
||||
$docxBinary = $this->buildDocx($title, $body, $recipient);
|
||||
|
||||
$sanitizedTitle = $this->sanitizeFileName($title);
|
||||
$today = date('Y-m-d');
|
||||
$fileName = "{$today} - {$sanitizedTitle}.docx";
|
||||
|
||||
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||
$attachment->set([
|
||||
'name' => $fileName,
|
||||
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'role' => 'Attachment',
|
||||
'size' => strlen($docxBinary),
|
||||
'relatedType' => 'Document',
|
||||
]);
|
||||
$this->entityManager->saveEntity($attachment);
|
||||
file_put_contents('data/upload/' . $attachment->getId(), $docxBinary);
|
||||
|
||||
$document = $this->entityManager->getNewEntity('Document');
|
||||
$document->set([
|
||||
'name' => $title,
|
||||
'fileId' => $attachment->getId(),
|
||||
'fileName' => $fileName,
|
||||
'assignedUserId' => $this->user->getId(),
|
||||
]);
|
||||
$this->entityManager->saveEntity($document);
|
||||
|
||||
$this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'documents')
|
||||
->relate($document);
|
||||
|
||||
$networkPath = null;
|
||||
try {
|
||||
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
if ($nds->isEnabled()) {
|
||||
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
|
||||
$networkPath = $result['path'] ?? null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Document is safely persisted in Espo; the network-storage copy is best-effort.
|
||||
$this->log->warning(
|
||||
"FreeDocumentGenerator: failed to copy to network storage for Case {$caseId}: " .
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
$this->log->info(
|
||||
"FreeDocumentGenerator: Created '{$title}' (type={$documentType}) for Case {$caseId}"
|
||||
);
|
||||
|
||||
$message = "✅ המסמך \"{$title}\" נוצר בהצלחה וצורף לתיק";
|
||||
if ($networkPath) {
|
||||
$message .= " ולתיקיית הרשת";
|
||||
}
|
||||
$message .= ".";
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'documentId' => $document->getId(),
|
||||
'documentName' => $title,
|
||||
'fileName' => $fileName,
|
||||
'networkPath' => $networkPath,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildDocx(string $title, string $body, ?string $recipient): string
|
||||
{
|
||||
$phpWord = new PhpWord();
|
||||
$phpWord->setDefaultFontName(self::FONT_NAME);
|
||||
$phpWord->setDefaultFontSize(self::FONT_SIZE);
|
||||
|
||||
$section = $phpWord->addSection([
|
||||
'marginLeft' => 1440,
|
||||
'marginRight' => 1440,
|
||||
'marginTop' => 1440,
|
||||
'marginBottom' => 1440,
|
||||
]);
|
||||
|
||||
$rtlPara = ['bidi' => true, 'alignment' => 'right'];
|
||||
$centerPara = ['bidi' => true, 'alignment' => 'center'];
|
||||
$titleFont = ['name' => self::FONT_NAME, 'size' => 16, 'bold' => true, 'rtl' => true];
|
||||
$boldFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'bold' => true, 'rtl' => true];
|
||||
$normalFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
|
||||
|
||||
$section->addText($this->formatHebrewDate(new \DateTime()), $normalFont, $rtlPara);
|
||||
$section->addTextBreak(1);
|
||||
|
||||
if ($recipient !== null && trim($recipient) !== '') {
|
||||
$section->addText('אל: ' . trim($recipient), $boldFont, $rtlPara);
|
||||
$section->addTextBreak(1);
|
||||
}
|
||||
|
||||
$section->addText($title, $titleFont, $centerPara);
|
||||
$section->addTextBreak(1);
|
||||
|
||||
$this->renderMarkdown($section, $body);
|
||||
|
||||
if (!is_dir(self::TEMP_DIR)) {
|
||||
mkdir(self::TEMP_DIR, 0775, true);
|
||||
}
|
||||
$tmpPath = self::TEMP_DIR . 'free_' . uniqid() . '.docx';
|
||||
$writer = IOFactory::createWriter($phpWord, 'Word2007');
|
||||
$writer->save($tmpPath);
|
||||
$content = file_get_contents($tmpPath);
|
||||
@unlink($tmpPath);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function renderMarkdown($section, string $body): void
|
||||
{
|
||||
$rtlPara = ['bidi' => true, 'alignment' => 'right'];
|
||||
$bulletStyle = ['listType' => \PhpOffice\PhpWord\Style\ListItem::TYPE_BULLET_FILLED];
|
||||
$bulletFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
|
||||
$h1Size = 16; $h2Size = 14; $h3Size = 13;
|
||||
|
||||
$body = str_replace(["\r\n", "\r"], "\n", $body);
|
||||
$lines = explode("\n", $body);
|
||||
|
||||
$i = 0;
|
||||
$n = count($lines);
|
||||
while ($i < $n) {
|
||||
$line = $lines[$i];
|
||||
$trimmed = ltrim($line);
|
||||
|
||||
if (trim($line) === '') {
|
||||
$section->addTextBreak(1);
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^(#{1,3})\s+(.+)$/u', $trimmed, $m)) {
|
||||
$level = strlen($m[1]);
|
||||
$size = match ($level) { 1 => $h1Size, 2 => $h2Size, default => $h3Size };
|
||||
$section->addText(
|
||||
$m[2],
|
||||
['name' => self::FONT_NAME, 'size' => $size, 'bold' => true, 'rtl' => true],
|
||||
$rtlPara
|
||||
);
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^[\-\*]\s+(.+)$/u', $trimmed)) {
|
||||
while ($i < $n && preg_match('/^[\-\*]\s+(.+)$/u', ltrim($lines[$i]), $m2)) {
|
||||
$plain = preg_replace('/(\*\*|\*)/u', '', $m2[1]);
|
||||
$section->addListItem($plain, 0, $bulletFont, $bulletStyle, $rtlPara);
|
||||
$i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$textRun = $section->addTextRun($rtlPara);
|
||||
$this->addInlineRuns($textRun, $trimmed);
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse **bold** and *italic* into multiple PhpWord runs.
|
||||
*/
|
||||
private function addInlineRuns($textRun, string $text): void
|
||||
{
|
||||
$base = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
|
||||
$i = 0;
|
||||
$len = mb_strlen($text);
|
||||
$buffer = '';
|
||||
|
||||
$flush = function () use (&$buffer, $textRun, $base) {
|
||||
if ($buffer !== '') {
|
||||
$textRun->addText($buffer, $base);
|
||||
$buffer = '';
|
||||
}
|
||||
};
|
||||
|
||||
while ($i < $len) {
|
||||
$two = mb_substr($text, $i, 2);
|
||||
$one = mb_substr($text, $i, 1);
|
||||
|
||||
if ($two === '**') {
|
||||
$end = mb_strpos($text, '**', $i + 2);
|
||||
if ($end !== false) {
|
||||
$flush();
|
||||
$inner = mb_substr($text, $i + 2, $end - $i - 2);
|
||||
$textRun->addText($inner, array_merge($base, ['bold' => true]));
|
||||
$i = $end + 2;
|
||||
continue;
|
||||
}
|
||||
} elseif ($one === '*') {
|
||||
$end = mb_strpos($text, '*', $i + 1);
|
||||
if ($end !== false) {
|
||||
$flush();
|
||||
$inner = mb_substr($text, $i + 1, $end - $i - 1);
|
||||
$textRun->addText($inner, array_merge($base, ['italic' => true]));
|
||||
$i = $end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$buffer .= $one;
|
||||
$i++;
|
||||
}
|
||||
$flush();
|
||||
}
|
||||
|
||||
private function formatHebrewDate(\DateTime $dt): string
|
||||
{
|
||||
$months = [
|
||||
1 => 'ינואר', 2 => 'פברואר', 3 => 'מרץ', 4 => 'אפריל',
|
||||
5 => 'מאי', 6 => 'יוני', 7 => 'יולי', 8 => 'אוגוסט',
|
||||
9 => 'ספטמבר', 10 => 'אוקטובר', 11 => 'נובמבר', 12 => 'דצמבר',
|
||||
];
|
||||
$day = (int) $dt->format('j');
|
||||
$month = $months[(int) $dt->format('n')];
|
||||
$year = $dt->format('Y');
|
||||
return "{$day} ב{$month} {$year}";
|
||||
}
|
||||
|
||||
private function sanitizeFileName(string $name): string
|
||||
{
|
||||
$name = preg_replace('/[\\/:*?"<>|]/', '', $name);
|
||||
$name = preg_replace('/\s+/', ' ', $name);
|
||||
return trim(mb_substr($name, 0, 100));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\Core\Exceptions\Error;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Utils\Log;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
|
||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||
use PhpOffice\PhpWord\TemplateProcessor;
|
||||
|
||||
class GenericTemplateGenerator
|
||||
{
|
||||
private const TEMP_DIR = 'data/tmp/';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private InjectableFactory $injectableFactory,
|
||||
private Log $log,
|
||||
private User $user
|
||||
) {}
|
||||
|
||||
public function generate(string $templateId, string $caseId, ?string $documentSubject = null, array $customPlaceholders = []): array
|
||||
{
|
||||
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
|
||||
if (!$template) {
|
||||
throw new NotFound("Template {$templateId} not found.");
|
||||
}
|
||||
|
||||
if (!$template->get('isActive')) {
|
||||
throw new Error("Template is not active.");
|
||||
}
|
||||
|
||||
$templatePath = $template->get('templatePath');
|
||||
if (!$templatePath) {
|
||||
throw new Error("Template path is not configured.");
|
||||
}
|
||||
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) {
|
||||
throw new NotFound("Case {$caseId} not found.");
|
||||
}
|
||||
|
||||
$templateName = $template->get('name') ?? 'document';
|
||||
|
||||
// Step 1: Download template from WebDAV
|
||||
$localTemplatePath = $this->downloadTemplate($templatePath);
|
||||
|
||||
try {
|
||||
// Step 2: Build placeholder data from entity fields
|
||||
$templateService = $this->injectableFactory->create(DocumentTemplateService::class);
|
||||
$placeholderData = $templateService->buildPlaceholderData($case);
|
||||
|
||||
// Step 3: Merge custom placeholders (override entity data)
|
||||
foreach ($customPlaceholders as $key => $value) {
|
||||
$placeholderData[$key] = $value;
|
||||
}
|
||||
|
||||
// Step 4: Process template
|
||||
$outputPath = self::TEMP_DIR . uniqid('gen_') . '.docx';
|
||||
if (!is_dir(self::TEMP_DIR)) {
|
||||
mkdir(self::TEMP_DIR, 0775, true);
|
||||
}
|
||||
|
||||
$this->processTemplate($localTemplatePath, $outputPath, $placeholderData);
|
||||
|
||||
// Step 5: Read generated content
|
||||
$content = file_get_contents($outputPath);
|
||||
@unlink($outputPath);
|
||||
@unlink($localTemplatePath);
|
||||
|
||||
// Step 6: Build document name
|
||||
$contactName = $this->getContactName($case);
|
||||
$docName = $documentSubject ?? "{$templateName} — {$contactName}";
|
||||
|
||||
// Step 7: Create Attachment + Document
|
||||
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||
$attachment->set([
|
||||
'name' => $docName . '.docx',
|
||||
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'role' => 'Attachment',
|
||||
'size' => strlen($content),
|
||||
'relatedType' => 'Document',
|
||||
]);
|
||||
$this->entityManager->saveEntity($attachment);
|
||||
file_put_contents('data/upload/' . $attachment->getId(), $content);
|
||||
|
||||
$document = $this->entityManager->getNewEntity('Document');
|
||||
$document->set([
|
||||
'name' => $docName,
|
||||
'fileId' => $attachment->getId(),
|
||||
'fileName' => $docName . '.docx',
|
||||
'assignedUserId' => $this->user->getId(),
|
||||
]);
|
||||
$this->entityManager->saveEntity($document);
|
||||
|
||||
// Link to Case
|
||||
$this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'documents')
|
||||
->relate($document);
|
||||
|
||||
$this->log->info("GenericTemplateGenerator: Created '{$docName}' from template '{$templateName}' for Case {$caseId}");
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => "✅ מסמך \"{$docName}\" נוצר בהצלחה מתבנית \"{$templateName}\"\n📄 המסמך צורף לתיק.",
|
||||
'entityType' => 'Document',
|
||||
'entityId' => $document->getId(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
if (isset($localTemplatePath) && file_exists($localTemplatePath)) {
|
||||
@unlink($localTemplatePath);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function downloadTemplate(string $templatePath): string
|
||||
{
|
||||
// Try local paths first
|
||||
if (file_exists($templatePath)) {
|
||||
return $templatePath;
|
||||
}
|
||||
|
||||
$localPath = 'data/document-templates/' . ltrim($templatePath, '/');
|
||||
if (file_exists($localPath)) {
|
||||
return $localPath;
|
||||
}
|
||||
|
||||
// Download from NetworkStorage (WebDAV)
|
||||
try {
|
||||
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||
$client = $nds->getClient();
|
||||
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'NetworkStorage');
|
||||
$templatesFolderPath = 'Templates';
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = (array) ($integration->get('data') ?? []);
|
||||
$templatesFolderPath = $data['templatesFolderPath'] ?? 'Templates';
|
||||
}
|
||||
|
||||
$storagePath = $templatesFolderPath . '/' . $templatePath;
|
||||
|
||||
if (!$client->exists($storagePath)) {
|
||||
throw new Error("Template file not found on storage: {$storagePath}");
|
||||
}
|
||||
|
||||
$content = $client->downloadFile($storagePath);
|
||||
|
||||
if (!is_dir(self::TEMP_DIR)) {
|
||||
mkdir(self::TEMP_DIR, 0775, true);
|
||||
}
|
||||
$tmpPath = self::TEMP_DIR . 'tpl_' . uniqid() . '.docx';
|
||||
file_put_contents($tmpPath, $content);
|
||||
|
||||
return $tmpPath;
|
||||
} catch (Error $e) {
|
||||
throw $e;
|
||||
} catch (\Exception $e) {
|
||||
throw new Error("Failed to download template: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function processTemplate(string $inputPath, string $outputPath, array $data): void
|
||||
{
|
||||
if (!class_exists(TemplateProcessor::class, false)) {
|
||||
$autoloader = 'vendor/phpoffice/phpword/src/PhpWord/Autoloader.php';
|
||||
if (file_exists($autoloader)) {
|
||||
require_once $autoloader;
|
||||
\PhpOffice\PhpWord\Autoloader::register();
|
||||
}
|
||||
}
|
||||
|
||||
$processor = new TemplateProcessor($inputPath);
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
if ($value === null) {
|
||||
$value = '';
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$value = implode(', ', $value);
|
||||
} elseif (is_bool($value)) {
|
||||
$value = $value ? 'כן' : 'לא';
|
||||
}
|
||||
$processor->setValue($key, (string) $value);
|
||||
}
|
||||
|
||||
$processor->saveAs($outputPath);
|
||||
}
|
||||
|
||||
private function getContactName($case): string
|
||||
{
|
||||
$contactId = $case->get('contactId');
|
||||
if (!$contactId) {
|
||||
$ids = $case->getLinkMultipleIdList('contacts');
|
||||
if (!empty($ids)) {
|
||||
$contactId = $ids[0];
|
||||
}
|
||||
}
|
||||
|
||||
if ($contactId) {
|
||||
$contact = $this->entityManager->getEntityById('Contact', $contactId);
|
||||
if ($contact) {
|
||||
return trim($contact->get('firstName') . ' ' . $contact->get('lastName'));
|
||||
}
|
||||
}
|
||||
|
||||
return $case->get('name') ?? '';
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,18 @@ class OfficeContextBuilder
|
||||
$contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')];
|
||||
}
|
||||
|
||||
$parentConditions = [
|
||||
['parentType' => 'Case', 'parentId' => $caseId],
|
||||
];
|
||||
$contactIds = $this->getCaseContactIds($case);
|
||||
if (!empty($contactIds)) {
|
||||
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||
}
|
||||
|
||||
$tasks = [];
|
||||
foreach ($this->entityManager->getRDBRepository('Task')
|
||||
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
|
||||
->where(['parentId' => $caseId, 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
|
||||
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
|
||||
->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) {
|
||||
$tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')];
|
||||
}
|
||||
@@ -76,4 +84,27 @@ class OfficeContextBuilder
|
||||
|
||||
return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes];
|
||||
}
|
||||
|
||||
private function getCaseContactIds(\Espo\ORM\Entity $case): array
|
||||
{
|
||||
$contactIds = [];
|
||||
$primaryContactId = $case->get('contactId');
|
||||
if ($primaryContactId) {
|
||||
$contactIds[] = $primaryContactId;
|
||||
}
|
||||
|
||||
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'contacts')
|
||||
->select(['id'])
|
||||
->find();
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$id = $contact->get('id');
|
||||
if (!in_array($id, $contactIds)) {
|
||||
$contactIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $contactIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,18 @@ class SmartAssistantService
|
||||
public const NOTE_TYPE_RESPONSE = 'SmartResponse';
|
||||
public const NOTE_TYPE_ACTION = 'SmartAction';
|
||||
|
||||
private const READ_ONLY_TOOLS = ['list_documents', 'save_memory'];
|
||||
// All tools execute inline — Shira has full permissions
|
||||
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;
|
||||
@@ -70,6 +81,22 @@ class SmartAssistantService
|
||||
}
|
||||
}
|
||||
|
||||
// Load behavioral rules
|
||||
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
|
||||
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
|
||||
|
||||
// Load editable prompt sections (DB overrides for the Python defaults).
|
||||
$promptProvider = $this->injectableFactory->create(AssistantPromptContextProvider::class);
|
||||
$context['assistantPrompts'] = $promptProvider->getPromptsForMode($mode === 'case' ? 'case' : 'office');
|
||||
|
||||
// Load per-user profile (replaces /opt/data/profiles/{uid}.md).
|
||||
$profileProvider = $this->injectableFactory->create(UserProfileContextProvider::class);
|
||||
$context['userProfile'] = $profileProvider->getProfileForUser($userId);
|
||||
|
||||
// Load active skills (metadata only; full body fetched via REST when needed).
|
||||
$skillProvider = $this->injectableFactory->create(AssistantSkillContextProvider::class);
|
||||
$context['availableSkills'] = $skillProvider->listSkills();
|
||||
|
||||
// Generate conversation ID
|
||||
if (!$conversationId) {
|
||||
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
|
||||
@@ -100,56 +127,32 @@ class SmartAssistantService
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Call webhook
|
||||
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
|
||||
|
||||
// Process actions
|
||||
$actions = $response['actions'] ?? [];
|
||||
$responseText = $response['text'] ?? '';
|
||||
$inlineResults = [];
|
||||
$remainingActions = [];
|
||||
|
||||
foreach ($actions as $action) {
|
||||
$tool = $action['tool'] ?? '';
|
||||
if (in_array($tool, self::READ_ONLY_TOOLS, true)) {
|
||||
try {
|
||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||
$result = $executor->execute($tool, $action['params'] ?? [], $caseId, $userId);
|
||||
if (!empty($result['message'])) {
|
||||
$inlineResults[] = $result['message'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning("SmartAssistant: Inline execution of {$tool} failed: " . $e->getMessage());
|
||||
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$remainingActions[] = $action;
|
||||
}
|
||||
// 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'];
|
||||
}
|
||||
|
||||
if (!empty($inlineResults)) {
|
||||
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
||||
}
|
||||
|
||||
// Store pending actions
|
||||
if (!empty($remainingActions)) {
|
||||
self::$conversations[$conversationId] = [
|
||||
'caseId' => $caseId,
|
||||
'userId' => $userId,
|
||||
'actions' => $remainingActions,
|
||||
// Execute tools with agentic loop — read tools feed results back to AI
|
||||
try {
|
||||
$loopResult = $this->executeActionsWithLoop(
|
||||
$message, $context, $conversationId, $conversationHistory,
|
||||
$mode, $effectiveCaseId, $userId
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->log->error("SmartAssistant: Chat failed: " . $e->getMessage());
|
||||
$loopResult = [
|
||||
'responseText' => "⚠️ אירעה שגיאה בתקשורת עם המערכת. אנא נסה שוב בעוד מספר דקות.\n\nפרטי השגיאה: " . $e->getMessage(),
|
||||
'executedActions' => [],
|
||||
];
|
||||
|
||||
try {
|
||||
$this->getConversationRepo()->storePendingActions($conversationId, $caseId ?? '', $userId, $remainingActions);
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning('SmartAssistant: Failed to persist actions: ' . $e->getMessage());
|
||||
$this->saveConversationFile($conversationId, $caseId, $userId, $remainingActions);
|
||||
}
|
||||
}
|
||||
|
||||
$responseText = $loopResult['responseText'];
|
||||
$executedActions = $loopResult['executedActions'];
|
||||
|
||||
// Persist response
|
||||
try {
|
||||
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $remainingActions);
|
||||
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $executedActions);
|
||||
} catch (\Exception $e) {
|
||||
$this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage());
|
||||
}
|
||||
@@ -158,17 +161,160 @@ class SmartAssistantService
|
||||
if ($mode === 'case' && $caseId) {
|
||||
$this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [
|
||||
'conversationId' => $conversationId,
|
||||
'actions' => $remainingActions,
|
||||
'actions' => $executedActions,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'conversationId' => $conversationId,
|
||||
'text' => $responseText,
|
||||
'actions' => $remainingActions,
|
||||
'actions' => [],
|
||||
'executedActions' => $executedActions,
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -236,14 +382,27 @@ class SmartAssistantService
|
||||
}
|
||||
}
|
||||
|
||||
public function getStatus(): array
|
||||
{
|
||||
$webhookUrl = $this->getWebhookUrl();
|
||||
|
||||
return [
|
||||
'enabled' => $webhookUrl !== null,
|
||||
'webhookConfigured' => !empty($webhookUrl),
|
||||
];
|
||||
}
|
||||
|
||||
public function getSummary(): array
|
||||
{
|
||||
$alertCalc = $this->injectableFactory->create(AlertCalculator::class);
|
||||
$thresholds = $this->getThresholds();
|
||||
$hearingDays = $thresholds['upcomingHearingDays'] ?? 7;
|
||||
|
||||
return [
|
||||
'summary' => $alertCalc->getSummary(),
|
||||
'alerts' => $alertCalc->calculateAlerts($thresholds),
|
||||
'openCases' => $alertCalc->getOpenCaseItems(),
|
||||
'upcomingHearings' => $alertCalc->getUpcomingHearingItems($hearingDays),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -343,12 +502,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'];
|
||||
@@ -364,7 +530,7 @@ class SmartAssistantService
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_TIMEOUT => 600,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
]);
|
||||
|
||||
@@ -406,14 +572,11 @@ class SmartAssistantService
|
||||
|
||||
private function getWebhookUrl(): ?string
|
||||
{
|
||||
// Try SmartAssistant first, then fallback to old integrations
|
||||
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
|
||||
$integration = $this->entityManager->getEntityById('Integration', $name);
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = $integration->get('data') ?? (object) [];
|
||||
if (!empty($data->webhookUrl)) {
|
||||
return $data->webhookUrl;
|
||||
}
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = $integration->get('data') ?? (object) [];
|
||||
if (!empty($data->webhookUrl)) {
|
||||
return $data->webhookUrl;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -421,30 +584,58 @@ class SmartAssistantService
|
||||
|
||||
private function getApiKey(): ?string
|
||||
{
|
||||
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
|
||||
$integration = $this->entityManager->getEntityById('Integration', $name);
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = $integration->get('data') ?? (object) [];
|
||||
if (!empty($data->apiKey)) {
|
||||
return $data->apiKey;
|
||||
}
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = $integration->get('data') ?? (object) [];
|
||||
if (!empty($data->apiKey)) {
|
||||
return $data->apiKey;
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
foreach (['SmartAssistant', 'OfficeAssistant'] as $name) {
|
||||
$integration = $this->entityManager->getEntityById('Integration', $name);
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = (array) ($integration->get('data') ?? []);
|
||||
return [
|
||||
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
|
||||
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
|
||||
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
|
||||
];
|
||||
}
|
||||
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||
if ($integration && $integration->get('enabled')) {
|
||||
$data = (array) ($integration->get('data') ?? []);
|
||||
return [
|
||||
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
|
||||
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
|
||||
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Log;
|
||||
|
||||
/**
|
||||
* Loads the active UserProfile row for a given user into Shira's context.
|
||||
*
|
||||
* Returns the markdown body that becomes the "=== ABOUT THIS USER ===" block
|
||||
* in the system prompt. Returns "" when no profile exists; the Python prompt
|
||||
* builder treats empty string as "skip this section".
|
||||
*/
|
||||
class UserProfileContextProvider
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private Log $log;
|
||||
|
||||
public function __construct(EntityManager $entityManager, Log $log)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->log = $log;
|
||||
}
|
||||
|
||||
public function getProfileForUser(string $userId): string
|
||||
{
|
||||
if (!$userId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getRDBRepository('UserProfile')
|
||||
->where([
|
||||
'userId' => $userId,
|
||||
'isActive' => true,
|
||||
'deleted' => false,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if (!$entity) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (string) ($entity->get('content') ?? '');
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 750 KiB |
+4
-4
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "Smart Assistant",
|
||||
"name": "SmartAssistant",
|
||||
"module": "SmartAssistant",
|
||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and n8n integration",
|
||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||
"author": "klear",
|
||||
"version": "1.0.6",
|
||||
"version": "2.10.2",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"releaseDate": "2026-03-31",
|
||||
"releaseDate": "2026-05-27",
|
||||
"php": [
|
||||
">=8.1"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* AfterInstall script for SmartAssistant extension 2.10.x.
|
||||
*
|
||||
* Idempotently seeds the AssistantPrompt entity with 7 default prompt
|
||||
* sections (tool_rules, writing_style, legal_assistance, personality_case,
|
||||
* personality_office, other_rules_case, other_rules_office). If a row with
|
||||
* the same `key` already exists it is left alone — admin edits win.
|
||||
*
|
||||
* Class must be named `AfterInstall` with NO namespace — that's what the
|
||||
* EspoCRM extension installer looks for in scripts/AfterInstall.php.
|
||||
* See application/Espo/Core/Upgrades/ExtensionManager.php scriptNames map.
|
||||
*/
|
||||
|
||||
use Espo\Core\Container;
|
||||
|
||||
class AfterInstall
|
||||
{
|
||||
public function run(Container $container, $params = null): void
|
||||
{
|
||||
$entityManager = $container->get('entityManager');
|
||||
$log = $container->get('log');
|
||||
|
||||
// The JSON file lives inside the installed module resources at runtime.
|
||||
$resourceFile = 'custom/Espo/Modules/SmartAssistant/Resources/data/default-prompts.json';
|
||||
|
||||
if (!is_file($resourceFile)) {
|
||||
$log->warning("[SmartAssistant] AfterInstall: $resourceFile not found, skipping seed");
|
||||
return;
|
||||
}
|
||||
|
||||
$json = file_get_contents($resourceFile);
|
||||
$defaults = json_decode($json, true);
|
||||
|
||||
if (!is_array($defaults)) {
|
||||
$log->warning("[SmartAssistant] AfterInstall: invalid JSON in $resourceFile");
|
||||
return;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($defaults as $row) {
|
||||
$key = $row['key'] ?? null;
|
||||
if (!$key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $entityManager->getRDBRepository('AssistantPrompt')
|
||||
->where(['key' => $key, 'deleted' => false])
|
||||
->findOne();
|
||||
|
||||
if ($existing) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$entity = $entityManager->getNewEntity('AssistantPrompt');
|
||||
$entity->set([
|
||||
'key' => $key,
|
||||
'name' => $row['name'] ?? $key,
|
||||
'description' => $row['description'] ?? null,
|
||||
'mode' => $row['mode'] ?? 'both',
|
||||
'displayOrder' => $row['displayOrder'] ?? 100,
|
||||
'content' => $row['content'] ?? '',
|
||||
'isActive' => true,
|
||||
]);
|
||||
// skipAll bypasses beforeSave hooks that require a logged-in user
|
||||
// service (installer runs without an HTTP session) and ACL checks
|
||||
// (we're seeding firm-wide defaults; no user is implicated).
|
||||
$entityManager->saveEntity($entity, ['skipAll' => true]);
|
||||
$created++;
|
||||
}
|
||||
|
||||
$log->info("[SmartAssistant] AfterInstall: seeded $created AssistantPrompt rows, $skipped already existed");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user