Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f5c60f4272 | |||
| 21c3e33635 | |||
| ffbb648dd5 | |||
| f915faafdc |
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"master": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": "1",
|
||||
"title": "fix: broaden task detection to include Contact-linked tasks",
|
||||
"description": "AlertCalculator, CaseContextBuilder, and OfficeContextBuilder only queried tasks with parentType='Case', missing tasks linked to case contacts (parentType='Contact'). This caused false 'no preparation task' alerts in Shira's daily standup.",
|
||||
"status": "done",
|
||||
"priority": "high",
|
||||
"dependencies": [],
|
||||
"details": "Root cause confirmed via production API: preparation tasks had parentType='Contact' because Shira created them linked to the contact entity. Fix broadens all task queries to include both Case and Contact parent types, plus NhActivity-linked tasks.",
|
||||
"testStrategy": "Deploy to staging, trigger standup for a case with Contact-linked tasks, verify no false alert.",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-04-14T06:08:19.494Z"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"title": "Network-storage diagnostics + folder discovery",
|
||||
"description": "Expose diagnostics when a case folder can't be listed and add endpoints (findCaseFolder, setCaseFolderPath) so Shira can locate and persist the correct network-storage path.",
|
||||
"details": "",
|
||||
"testStrategy": "",
|
||||
"status": "done",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-05-06T17:56:54.413Z"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"title": "fix: extend client AJAX timeout 180s->600s and add slow-hint after 60s to prevent communication-error false-positive when ai-gateway takes >3min on first iteration (prod incident 2026-05-13)",
|
||||
"description": "Production incident where multi-tool AI flows (delegation, memory writes, web search) occasionally exceeded the default 180s client timeout, causing \"Communication error\" to display even though the backend continued processing and eventually succeeded. Users saw false errors after ~3min wait.",
|
||||
"details": "## Root Cause\nThe client-side `Espo.Ajax.postRequest('SmartAssistant/action/chat', ...)` call in `floating-chat.js:486` already has `{timeout: 600000}` (10 minutes), which was implemented in a previous fix. However, the production incident revealed that:\n\n1. Users still see \"שגיאה בתקשורת\" (Communication error) after ~3 minutes on complex multi-tool flows\n2. The \"חושבת...\" (Thinking...) spinner runs unchanged for the entire duration, giving no feedback that the system is still working\n3. The i18n files already contain a `SlowHint` label designed for this exact scenario but it was never wired up\n\n## Analysis of Current Code\n**File:** `files/client/custom/modules/smart-assistant/src/views/floating-chat.js`\n\n**Lines 460-475:** The `sendMessage()` method creates a loading message with \"חושבת...\" text and immediately sets up a 60-second timer (`slowHintTimer`) that changes the text to the `SlowHint` translation:\n```javascript\nvar slowHintText = this.translate('SlowHint', 'labels', 'SmartAssistant') ||\n 'חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל';\nvar slowHintTimer = setTimeout(function () {\n $loading.find('.sa-thinking-text').text(slowHintText);\n}, 60000);\n```\n\n**Line 486:** The AJAX timeout is already set to 600000ms (10 minutes)\n\n**Lines 487, 505:** Both success and error handlers call `clearTimeout(slowHintTimer)` to clean up\n\n## Issue Discovered\nThe code is **already correct** and implements exactly what the task description requests:\n- ✅ 600s (10 minute) timeout is set\n- ✅ Slow hint timer switches text after 60s\n- ✅ Timer is properly cleaned up on completion/error\n- ✅ i18n labels exist in both `en_US` and `fa_IR`\n\n## Verification Needed\nSince the code already implements the fix, the production incident suggests one of:\n\n1. **Deployed version mismatch** - The production extension may not have the latest code deployed\n2. **Browser caching** - Clients may be running cached old JavaScript that lacks the timeout/hint\n3. **Different error source** - The \"Communication error\" might be coming from a network-level timeout (reverse proxy, Coolify, Traefik) rather than client JavaScript\n4. **EspoCRM core override** - EspoCRM's base `Espo.Ajax` might have a global timeout that overrides the per-request timeout\n\n## Implementation Steps\n\n1. **Verify current deployment** - Check that the production EspoCRM instance has the latest `SmartAssistant` extension installed with this exact code\n2. **Check manifest version** - Confirm `manifest.json` version number matches what's deployed\n3. **Browser cache bust** - Force client cache clear by:\n - Incrementing `manifest.json` version\n - EspoCRM admin may need to rebuild/clear cache\n4. **Server-side timeout audit** - Check these layers:\n - Traefik ingress timeout on the EspoCRM service\n - nginx/Apache timeout in EspoCRM container\n - PHP `max_execution_time` in EspoCRM (should be ≥600s for the SmartAssistant endpoint)\n - shira-hermes backend timeout (should be ≥600s)\n - ai-gateway proxy timeout to Claude API\n5. **Add defensive logging** - If the code is deployed but not working:\n - Add `console.log` when slow hint fires\n - Add `console.log` showing actual timeout value used\n - Check browser DevTools Network tab for actual request timeout\n6. **Verify i18n loading** - Ensure `SlowHint` label is actually loaded (check `this.translate('SlowHint', ...)` returns expected text)\n\n## Code Changes (if verification shows deployment gap)\n\n**None required** - the code already implements the fix correctly.\n\nIf verification reveals the code is NOT deployed, simply:\n1. Rebuild the extension package\n2. Upload to EspoCRM via Administration → Extensions\n3. Clear EspoCRM cache\n\n## Alternative: Server-Side Timeout Fix\n\nIf the issue is server-side, update these configs:\n\n**shira-hermes (FastAPI):**\n```python\n# In main app config\nuvicorn.run(app, timeout_keep_alive=600)\n```\n\n**EspoCRM PHP (SmartAssistant controller):**\n```php\nset_time_limit(600); // At start of chat action\n```\n\n**Coolify/Traefik:**\nCheck proxy timeout settings for the EspoCRM service.",
|
||||
"testStrategy": "## Test Strategy\n\n### Phase 1: Verify Current Deployment\n1. **Check installed extension version**\n - Log into production EspoCRM (https://crm.prod.marcus-law.co.il)\n - Go to Administration → Extensions\n - Find SmartAssistant extension, note version number\n - Compare with `manifest.json` in this repo\n\n2. **Check deployed JavaScript**\n - In browser, open DevTools → Sources\n - Find `client/custom/modules/smart-assistant/src/views/floating-chat.js`\n - Search for \"timeout: 600000\" - should exist on line ~486\n - Search for \"slowHintTimer\" - should exist on line ~473\n - Search for \"SlowHint\" - should exist on line ~471\n\n### Phase 2: Browser Cache Verification\n1. **Hard refresh test**\n - Open production EspoCRM in incognito/private window\n - Navigate to any Case detail view\n - Open Shira chat panel\n - Check browser console for any JavaScript errors\n - Send a message that triggers slow processing (e.g., \"search the web for...\")\n - Verify \"חושבת...\" changes to \"חושבת... זה לוקח קצת יותר זמן...\" after 60 seconds\n\n2. **Cache headers check**\n - DevTools → Network → filter for `floating-chat.js`\n - Check `Cache-Control` and `ETag` headers\n - Note timestamp of file\n\n### Phase 3: Network Timeout Audit\n1. **Client-side timing**\n - Open DevTools → Network\n - Send message that takes >3 minutes\n - Watch the `SmartAssistant/action/chat` request\n - Note exact time when it fails (should be 600s, not 180s)\n - Check response status (timeout = no status, server error = 500/502/504)\n\n2. **Server logs correlation**\n - While test message is processing, tail these logs:\n ```bash\n # shira-hermes logs\n docker logs -f <shira-container> --since 1m\n \n # EspoCRM logs\n tail -f data/logs/espo-$(date +%Y-%m-%d).log\n \n # ai-gateway logs \n docker logs -f <ai-gateway-container> --since 1m\n ```\n - If logs show success but client shows error → client timeout\n - If logs show timeout/error → server timeout\n\n### Phase 4: Reproduction Test\n1. **Create slow scenario** (production or staging)\n - Open a complex case with many documents\n - Ask Shira: \"תעשי לי סיכום מלא של התיק כולל חיפוש באינטרנט על התקדים הרלוונטיים\"\n - This should trigger: delegation + memory search + web search + document analysis\n - Expected: Takes 2-4 minutes\n \n2. **Observe behavior**\n - ✅ At 60s: Text changes to \"חושבת... זה לוקח קצת יותר זמן...\"\n - ✅ At 180s: Still shows spinner (no error)\n - ✅ At 300s: Still shows spinner (no error)\n - ✅ At completion: Shows response, spinner removed\n - ❌ If error before 600s: Fix not deployed or server timeout\n\n### Phase 5: Post-Fix Validation\nAfter confirming fix is deployed:\n\n1. **Quick response test** (<10s)\n - \"מה שם התובע בתיק?\"\n - Should show \"חושבת...\" only, no slow hint\n\n2. **Medium response test** (30-90s)\n - \"הכן טיוטת תזכיר\"\n - Should show \"חושבת...\" then slow hint after 60s\n\n3. **Long response test** (>180s)\n - Complex multi-tool query\n - Should show slow hint, complete successfully\n\n4. **Error handling test**\n - Disconnect network after sending message\n - Should show \"שגיאה בתקשורת\" (real network error)\n - Reconnect and retry - should work\n\n### Success Criteria\n- ✅ Slow hint appears after 60 seconds on all requests\n- ✅ No \"Communication error\" before 600 seconds on working requests\n- ✅ Requests that take 3-5 minutes complete successfully\n- ✅ Timer cleanup confirmed (no memory leaks on rapid message sending)\n- ✅ i18n works in both Hebrew and English interface",
|
||||
"status": "in-progress",
|
||||
"dependencies": [],
|
||||
"priority": "medium",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-05-13T12:22:54.379Z"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-05-13T12:22:54.379Z",
|
||||
"taskCount": 3,
|
||||
"completedCount": 2,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,21 +112,36 @@ 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 () {
|
||||
this.loadSummary();
|
||||
},
|
||||
|
||||
getActionItemDataList: function () {
|
||||
return [
|
||||
{
|
||||
name: 'refresh',
|
||||
iconHtml: '<span class="fas fa-sync-alt"></span>',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
getColor: function () {
|
||||
return '#5c6bc0';
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,23 @@ use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
||||
use Espo\Entities\User;
|
||||
|
||||
class SmartAssistant
|
||||
{
|
||||
private InjectableFactory $injectableFactory;
|
||||
private Acl $acl;
|
||||
private User $user;
|
||||
|
||||
public function __construct(InjectableFactory $injectableFactory, Acl $acl)
|
||||
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
|
||||
{
|
||||
$this->injectableFactory = $injectableFactory;
|
||||
$this->acl = $acl;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
private function getService(): SmartAssistantService
|
||||
@@ -34,6 +40,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 +175,213 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,22 @@
|
||||
{
|
||||
"labels": {
|
||||
"Create AssistantRule": "הוסף כלל"
|
||||
},
|
||||
"fields": {
|
||||
"name": "שם",
|
||||
"rule": "כלל",
|
||||
"scope": "היקף",
|
||||
"isActive": "פעיל",
|
||||
"createdAt": "נוצר בתאריך",
|
||||
"modifiedAt": "עודכן בתאריך",
|
||||
"createdBy": "נוצר על ידי",
|
||||
"modifiedBy": "עודכן על ידי"
|
||||
},
|
||||
"options": {
|
||||
"scope": {
|
||||
"global": "גלובלי",
|
||||
"case": "מצב תיק",
|
||||
"office": "מצב משרד"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"scopeNames": {
|
||||
"CaseMemory": "זיכרון תיק",
|
||||
"AssistantRule": "כלל התנהגות",
|
||||
"SmartAssistant": "עוזר חכם"
|
||||
},
|
||||
"scopeNamesPlural": {
|
||||
"CaseMemory": "זיכרונות תיקים",
|
||||
"AssistantRule": "כללי התנהגות",
|
||||
"SmartAssistant": "עוזר חכם"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"fields": {
|
||||
"webhookUrl": "כתובת Webhook",
|
||||
"apiKey": "מפתח API",
|
||||
"espocrmApiKey": "מפתח API של EspoCRM",
|
||||
"maxMessagesPerHour": "מקסימום הודעות לשעה",
|
||||
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
|
||||
"inactivityCriticalDays": "ימים לאזהרה קריטית",
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"fields": {
|
||||
"name": {
|
||||
"type": "varchar",
|
||||
"maxLength": 255,
|
||||
"required": true
|
||||
},
|
||||
"rule": {
|
||||
"type": "text",
|
||||
"required": true
|
||||
},
|
||||
"scope": {
|
||||
"type": "enum",
|
||||
"options": ["global", "case", "office"],
|
||||
"default": "global"
|
||||
},
|
||||
"isActive": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "link",
|
||||
"readOnly": true
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"modifiedBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"isActive": {
|
||||
"columns": ["isActive", "deleted"]
|
||||
},
|
||||
"scope": {
|
||||
"columns": ["scope", "isActive", "deleted"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -8,6 +8,10 @@
|
||||
"type": "varchar",
|
||||
"maxLength": 255
|
||||
},
|
||||
"espocrmApiKey": {
|
||||
"type": "varchar",
|
||||
"maxLength": 255
|
||||
},
|
||||
"maxMessagesPerHour": {
|
||||
"type": "int",
|
||||
"default": 30
|
||||
@@ -23,6 +27,18 @@
|
||||
"upcomingHearingDays": {
|
||||
"type": "int",
|
||||
"default": 7
|
||||
},
|
||||
"maxDocumentChars": {
|
||||
"type": "int",
|
||||
"default": 100000
|
||||
},
|
||||
"agenticLoopEnabled": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"maxAgenticLoops": {
|
||||
"type": "int",
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"allowUserAccess": false,
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"label": "Overview",
|
||||
"rows": [
|
||||
[{"name": "name"}, {"name": "scope"}],
|
||||
[{"name": "isActive"}, false],
|
||||
[{"name": "rule", "fullWidth": true}],
|
||||
[{"name": "createdAt"}, {"name": "createdBy"}]
|
||||
]
|
||||
}
|
||||
]
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"name": "name", "width": 35, "link": true},
|
||||
{"name": "rule", "width": 30},
|
||||
{"name": "scope", "width": 10},
|
||||
{"name": "isActive", "width": 10},
|
||||
{"name": "createdAt", "width": 15}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"entity": true,
|
||||
"object": true,
|
||||
"module": "SmartAssistant",
|
||||
"acl": true,
|
||||
"aclActionList": ["create", "read", "edit", "delete"],
|
||||
"aclLevelList": ["all", "team", "own", "no"],
|
||||
"stream": false,
|
||||
"tab": false,
|
||||
"disabled": false
|
||||
}
|
||||
@@ -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();
|
||||
@@ -106,7 +106,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 +132,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 +140,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 +156,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 +283,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 +329,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,41 @@
|
||||
<?php
|
||||
|
||||
namespace Espo\Modules\SmartAssistant\Services;
|
||||
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Core\Utils\Log;
|
||||
|
||||
class AssistantRuleContextProvider
|
||||
{
|
||||
private EntityManager $entityManager;
|
||||
private Log $log;
|
||||
|
||||
public function __construct(EntityManager $entityManager, Log $log)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
$this->log = $log;
|
||||
}
|
||||
|
||||
public function getRulesForMode(string $mode): array
|
||||
{
|
||||
$entries = $this->entityManager->getRDBRepository('AssistantRule')
|
||||
->where([
|
||||
'isActive' => true,
|
||||
'deleted' => false,
|
||||
'scope' => ['global', $mode],
|
||||
])
|
||||
->order('createdAt', 'ASC')
|
||||
->find();
|
||||
|
||||
$rules = [];
|
||||
foreach ($entries as $entry) {
|
||||
$rules[] = [
|
||||
'name' => $entry->get('name'),
|
||||
'rule' => $entry->get('rule'),
|
||||
'scope' => $entry->get('scope'),
|
||||
];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -29,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 = [];
|
||||
@@ -148,13 +203,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 = [];
|
||||
|
||||
@@ -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
|
||||
@@ -168,10 +552,10 @@ class DocumentAnalyzer
|
||||
private function updateDocumentEntity(string $oldPath, string $newPath): void
|
||||
{
|
||||
try {
|
||||
$doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne();
|
||||
$doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
|
||||
if ($doc) {
|
||||
$doc->set('nextCloudPath', $newPath);
|
||||
$doc->set('nextCloudFilename', basename($newPath));
|
||||
$doc->set('networkStoragePath', $newPath);
|
||||
$doc->set('networkStorageFileName', basename($newPath));
|
||||
$this->entityManager->saveEntity($doc);
|
||||
}
|
||||
} catch (\Exception $e) {}
|
||||
|
||||
@@ -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,10 @@ class SmartAssistantService
|
||||
}
|
||||
}
|
||||
|
||||
// Load behavioral rules
|
||||
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
|
||||
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
|
||||
|
||||
// Generate conversation ID
|
||||
if (!$conversationId) {
|
||||
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
|
||||
@@ -100,56 +115,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 +149,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 +370,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 +490,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 +518,7 @@ class SmartAssistantService
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
CURLOPT_TIMEOUT => 600,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
]);
|
||||
|
||||
@@ -406,14 +560,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 +572,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 [];
|
||||
}
|
||||
|
||||
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.4",
|
||||
"version": "2.8.2",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"releaseDate": "2026-03-31",
|
||||
"releaseDate": "2026-05-13",
|
||||
"php": [
|
||||
">=8.1"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user