Compare commits

...

9 Commits

Author SHA1 Message Date
chaim 6c97ba2361 feat: add delete_task tool + expose delete tools to AI gateway
Shira can now delete tasks, meetings, and calls when they're no longer needed.
delete_meeting and delete_call handlers already existed but weren't exposed to the AI.

SmartAssistant v2.5.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:14:19 +00:00
chaim 94397c809b fix: reorder dashlet tabs — attention first, open cases last
Move "needs attention" tab to the right (first in RTL), make it active
by default, and move "open cases" to the left (last).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:09:50 +00:00
chaim b810c9fcff feat: SmartAssistant v2.5.0 — delete tools + expandable chat panel
Add delete_meeting, delete_call, delete_note tools for Shira.
Add expand/collapse toggle button to the floating chat window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:59:06 +00:00
chaim 0dc26f3b15 feat: SmartAssistant v2.4.0 — create_call tool + behavioral rules system
- Add create_call tool: records phone calls as Call entity (not Meeting)
- Add AssistantRule entity: persistent behavioral rules the AI follows
- Add save_rule tool: AI can save new rules when user requests them
- Add systemInstructions to webhook: call vs meeting guidance, open question tracking
- Add recentCalls to case context for AI awareness
- Add AssistantRuleContextProvider: injects active rules per mode into context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:20:23 +00:00
chaim ca04dd72f8 feat: add plugin tool handler support via metadata lookup
ActionExecutor now checks app.smartAssistant.tools.{name}.handler
metadata for unknown tools before throwing. This allows extensions
to register their own tool handlers without modifying SmartAssistant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:08:39 +00:00
chaim 87bc4b6770 feat: SmartAssistant v2.3.0 — document tools + NetworkStorage integration
- Add upload_document, generate_document, merge_documents tools
- Rewrite DocumentAnalyzer to use NetworkStorageIntegration instead of custom WebDAV client
- Delete FileStorage classes (FileStorageInterface, FileStorageFactory, NextCloudFileStorage, WebDavFileStorage)
- Use networkStorageFolderPath instead of nextCloudFolderPath

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:53:27 +00:00
chaim 2fe807b7f9 feat: SmartAssistant v2.2.0 — EspoCRM sends its own credentials to AI Gateway
- EspoCRM sends url + apiKey in every request to the gateway
- New Integration field: espocrmApiKey
- Gateway no longer needs ESPOCRM_URL/ESPOCRM_API_KEY env vars

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 06:46:13 +00:00
chaim 026c32a3f7 chore: bump version to 2.1.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:17:07 +00:00
chaim 2e350e33b9 fix: avatar tight face crop — no gray corners
Inset crop to avoid icon's rounded corners, pure blue background fills entire square.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:16:24 +00:00
25 changed files with 734 additions and 360 deletions
+120
View File
@@ -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 (שיפור עתידי)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 27 KiB

@@ -24,7 +24,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
afterRender: function () { afterRender: function () {
this._alerts = []; this._alerts = [];
this._summary = {}; this._summary = {};
this._activeType = null; this._activeType = 'attention';
var self = this; var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) { Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
@@ -54,10 +54,10 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
renderCards: function (summary) { renderCards: function (summary) {
var cards = [ var cards = [
{type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'}, {type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
{type: 'overdue', label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'}, {type: '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: 'hearings', label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'},
{type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'}, {type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
]; ];
var self = this; var self = this;
@@ -24,6 +24,9 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
' <button class="btn btn-link sa-new-btn" type="button" title="{{translate \'New Conversation\' scope=\'SmartAssistant\'}}">' + ' <button class="btn btn-link sa-new-btn" type="button" title="{{translate \'New Conversation\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-plus"></span>' + ' <span class="fas fa-plus"></span>' +
' </button>' + ' </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">' + ' <button class="btn btn-link sa-close-btn" type="button">' +
' <span class="fas fa-times"></span>' + ' <span class="fas fa-times"></span>' +
' </button>' + ' </button>' +
@@ -67,7 +70,10 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
'.sa-fab:hover { transform: scale(1.08); }' + '.sa-fab:hover { transform: scale(1.08); }' +
'.sa-fab-avatar { width: 100%; height: 100%; object-fit: cover; }' + '.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-fab-badge { position: absolute; top: -4px; right: -4px; background: #c62828; color: #fff; border-radius: 10px; min-width: 20px; height: 20px; font-size: 11px; font-weight: bold; display: flex; align-items: center; justify-content: center; padding: 0 5px; z-index: 1; }' +
'.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; }' + '.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; transition: width 0.3s ease, max-height 0.3s ease; }' +
'.sa-chat-panel.sa-expanded { width: 700px; max-height: 85vh; }' +
'.sa-chat-panel.sa-expanded .sa-chat-messages { max-height: calc(85vh - 180px); }' +
'.sa-chat-panel.sa-expanded .sa-history-list, .sa-chat-panel.sa-expanded .sa-memory-list { max-height: calc(85vh - 200px); }' +
'@keyframes sa-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }' + '@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-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; }' + '.sa-chat-title { font-size: 15px; font-weight: 600; white-space: nowrap; }' +
@@ -122,7 +128,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
'.sa-badge-source { background: #f3e5f5; color: #7b1fa2; }' + '.sa-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-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; }' + '.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>', '</style>',
conversationId: null, conversationId: null,
@@ -131,12 +137,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
currentCaseId: null, currentCaseId: null,
currentCaseName: null, currentCaseName: null,
currentView: 'chat', currentView: 'chat',
isExpanded: false,
currentMemoryCategory: null, currentMemoryCategory: null,
memoryData: null, memoryData: null,
events: { events: {
'click .sa-fab': function () { this.togglePanel(); }, 'click .sa-fab': function () { this.togglePanel(); },
'click .sa-close-btn': function () { this.closePanel(); }, 'click .sa-close-btn': function () { this.closePanel(); },
'click .sa-expand-btn': function () { this.toggleExpand(); },
'click .sa-chat-send': function () { this.sendMessage(); }, 'click .sa-chat-send': function () { this.sendMessage(); },
'click .sa-history-btn': function () { this.showHistory(); }, 'click .sa-history-btn': function () { this.showHistory(); },
'click .sa-new-btn': function () { this.startNewConversation(); }, 'click .sa-new-btn': function () { this.startNewConversation(); },
@@ -260,7 +268,26 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
closePanel: function () { closePanel: function () {
this.isOpen = false; 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 () { showChat: function () {
@@ -741,7 +768,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
recordView.model.trigger('update-all'); recordView.model.trigger('update-all');
} }
if (entityType === 'Task' || entityType === 'Meeting') { if (entityType === 'Task' || entityType === 'Meeting' || entityType === 'Call') {
var sideView = currentView.getView('side'); var sideView = currentView.getView('side');
if (sideView) { if (sideView) {
@@ -1,54 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\Modules\NextCloudIntegration\Services\NextCloudService;
class FileStorageFactory
{
private Config $config;
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(Config $config, InjectableFactory $injectableFactory, Log $log)
{
$this->config = $config;
$this->injectableFactory = $injectableFactory;
$this->log = $log;
}
public function create(?string $userId = null): FileStorageInterface
{
$storageType = $this->config->get('assistantFileStorage', 'nextcloud');
return match ($storageType) {
'nextcloud' => $this->createNextCloud($userId),
'webdav' => $this->createWebDav(),
default => throw new Error("Unknown file storage type: {$storageType}"),
};
}
private function createNextCloud(?string $userId): NextCloudFileStorage
{
$nextCloudService = $this->injectableFactory->create(NextCloudService::class);
return new NextCloudFileStorage($nextCloudService->getClientForUser($userId));
}
private function createWebDav(): WebDavFileStorage
{
$url = $this->config->get('assistantWebDavUrl');
$user = $this->config->get('assistantWebDavUser');
$password = $this->config->get('assistantWebDavPassword');
$basePath = $this->config->get('assistantWebDavBasePath', '/webdav');
if (empty($url) || empty($user) || empty($password)) {
throw new Error('WebDAV file storage is not configured.');
}
return new WebDavFileStorage($url, $user, $password, $basePath);
}
}
@@ -1,12 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
interface FileStorageInterface
{
public function listFolder(string $path): array;
public function downloadFile(string $path): string;
public function move(string $source, string $destination): bool;
public function createFolder(string $path): bool;
public function exists(string $path): bool;
}
@@ -1,21 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Modules\NextCloudIntegration\Classes\NextCloud\Client;
class NextCloudFileStorage implements FileStorageInterface
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function listFolder(string $path): array { return $this->client->listFolder($path); }
public function downloadFile(string $path): string { return $this->client->downloadFile($path); }
public function move(string $source, string $destination): bool { return $this->client->move($source, $destination); }
public function createFolder(string $path): bool { return $this->client->createFolderRecursive($path); }
public function exists(string $path): bool { return $this->client->exists($path); }
}
@@ -1,234 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
/**
* Generic WebDAV file storage adapter.
* Works with Synology NAS, ownCloud, and any standard WebDAV server.
* Uses only DAV-standard operations (no NextCloud/OCS-specific extensions).
*/
class WebDavFileStorage implements FileStorageInterface
{
private string $baseUrl;
private string $username;
private string $password;
private string $webdavPath;
public function __construct(
string $baseUrl,
string $username,
string $password,
string $webdavPath = '/webdav'
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->username = $username;
$this->password = $password;
$this->webdavPath = $webdavPath;
}
public function listFolder(string $path): array
{
$propfindXml = '<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>';
$response = $this->request('PROPFIND', $path, $propfindXml, [
'Content-Type: application/xml',
'Depth: 1',
]);
if ($response['status'] !== 207) {
throw new Error("Failed to list folder: HTTP " . $response['status']);
}
return $this->parsePropfindResponse($response['body'], $path);
}
public function downloadFile(string $path): string
{
$response = $this->request('GET', $path);
if ($response['status'] !== 200) {
throw new Error("Failed to download file: HTTP " . $response['status']);
}
return $response['body'];
}
public function move(string $source, string $destination): bool
{
$destUrl = $this->buildUrl($destination);
$response = $this->request('MOVE', $source, null, [
'Destination: ' . $destUrl,
'Overwrite: F',
]);
if ($response['status'] !== 201 && $response['status'] !== 204) {
throw new Error("Failed to move: HTTP " . $response['status']);
}
return true;
}
public function createFolder(string $path): bool
{
$parts = array_filter(explode('/', $path));
$currentPath = '';
foreach ($parts as $part) {
$currentPath .= '/' . $part;
$response = $this->request('MKCOL', $currentPath);
// 201 = created, 405 = already exists
if ($response['status'] !== 201 && $response['status'] !== 405) {
throw new Error("Failed to create folder: HTTP " . $response['status']);
}
}
return true;
}
public function exists(string $path): bool
{
try {
$response = $this->request('PROPFIND', $path, null, ['Depth: 0']);
return $response['status'] === 207;
} catch (\Exception $e) {
return false;
}
}
private function buildUrl(string $path): string
{
$pathParts = explode('/', ltrim($path, '/'));
$encodedParts = array_map('rawurlencode', $pathParts);
return $this->baseUrl . $this->webdavPath . '/' . implode('/', $encodedParts);
}
/**
* @return array{status: int, body: string}
*/
private function request(
string $method,
string $path,
?string $body = null,
array $headers = []
): array {
$url = $this->buildUrl($path);
$ch = curl_init();
$allHeaders = array_merge([
'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password),
], $headers);
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $allHeaders,
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Error("WebDAV connection error: $curlError");
}
return [
'status' => $httpCode,
'body' => $responseBody,
];
}
private function parsePropfindResponse(string $xml, string $basePath): array
{
$items = [];
$normalizedBasePath = '/' . trim($basePath, '/');
$doc = new \DOMDocument();
$doc->loadXML($xml);
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('d', 'DAV:');
$responses = $xpath->query('//d:response');
foreach ($responses as $response) {
$hrefNodes = $xpath->query('d:href', $response);
if ($hrefNodes->length === 0) {
continue;
}
$href = urldecode($hrefNodes->item(0)->textContent);
// Extract relative path by removing webdav prefix
$relativePath = $href;
$webdavPrefix = $this->webdavPath . '/';
if (($pos = strpos($href, $webdavPrefix)) !== false) {
$relativePath = substr($href, $pos + strlen($webdavPrefix));
}
$relativePath = '/' . trim($relativePath, '/');
// Skip the queried folder itself
if ($relativePath === $normalizedBasePath || $relativePath === $normalizedBasePath . '/') {
continue;
}
$propstat = $xpath->query('d:propstat/d:prop', $response)->item(0);
if (!$propstat) {
continue;
}
$resourceType = $xpath->query('d:resourcetype/d:collection', $propstat);
$isFolder = $resourceType->length > 0;
$contentLength = $xpath->query('d:getcontentlength', $propstat);
$size = $contentLength->length > 0 ? (int) $contentLength->item(0)->textContent : null;
$lastModified = $xpath->query('d:getlastmodified', $propstat);
$modified = $lastModified->length > 0 ? $lastModified->item(0)->textContent : null;
$contentType = $xpath->query('d:getcontenttype', $propstat);
$mimeType = $contentType->length > 0 ? $contentType->item(0)->textContent : null;
$items[] = [
'name' => basename($relativePath),
'path' => $relativePath,
'isFolder' => $isFolder,
'size' => $size,
'mimeType' => $isFolder ? 'folder' : $mimeType,
'modified' => $modified,
];
}
usort($items, function ($a, $b) {
if ($a['isFolder'] !== $b['isFolder']) {
return $b['isFolder'] <=> $a['isFolder'];
}
return strcasecmp($a['name'], $b['name']);
});
return $items;
}
}
@@ -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": { "scopeNames": {
"CaseMemory": "Case Memory", "CaseMemory": "Case Memory",
"AssistantRule": "Assistant Rule",
"SmartAssistant": "Smart Assistant" "SmartAssistant": "Smart Assistant"
}, },
"scopeNamesPlural": { "scopeNamesPlural": {
"CaseMemory": "Case Memories", "CaseMemory": "Case Memories",
"AssistantRule": "Assistant Rules",
"SmartAssistant": "Smart Assistant" "SmartAssistant": "Smart Assistant"
} }
} }
@@ -3,6 +3,7 @@
"fields": { "fields": {
"webhookUrl": "Webhook URL", "webhookUrl": "Webhook URL",
"apiKey": "API Key", "apiKey": "API Key",
"espocrmApiKey": "EspoCRM API Key",
"maxMessagesPerHour": "Max Messages Per Hour", "maxMessagesPerHour": "Max Messages Per Hour",
"inactivityWarningDays": "Inactivity Warning Days", "inactivityWarningDays": "Inactivity Warning Days",
"inactivityCriticalDays": "Inactivity Critical Days", "inactivityCriticalDays": "Inactivity Critical Days",
@@ -0,0 +1,22 @@
{
"labels": {
"Create AssistantRule": "הוסף כלל"
},
"fields": {
"name": "שם",
"rule": "כלל",
"scope": "היקף",
"isActive": "פעיל",
"createdAt": "נוצר בתאריך",
"modifiedAt": "עודכן בתאריך",
"createdBy": "נוצר על ידי",
"modifiedBy": "עודכן על ידי"
},
"options": {
"scope": {
"global": "גלובלי",
"case": "מצב תיק",
"office": "מצב משרד"
}
}
}
@@ -1,10 +1,12 @@
{ {
"scopeNames": { "scopeNames": {
"CaseMemory": "זיכרון תיק", "CaseMemory": "זיכרון תיק",
"AssistantRule": "כלל התנהגות",
"SmartAssistant": "עוזר חכם" "SmartAssistant": "עוזר חכם"
}, },
"scopeNamesPlural": { "scopeNamesPlural": {
"CaseMemory": "זיכרונות תיקים", "CaseMemory": "זיכרונות תיקים",
"AssistantRule": "כללי התנהגות",
"SmartAssistant": "עוזר חכם" "SmartAssistant": "עוזר חכם"
} }
} }
@@ -3,6 +3,7 @@
"fields": { "fields": {
"webhookUrl": "כתובת Webhook", "webhookUrl": "כתובת Webhook",
"apiKey": "מפתח API", "apiKey": "מפתח API",
"espocrmApiKey": "מפתח API של EspoCRM",
"maxMessagesPerHour": "מקסימום הודעות לשעה", "maxMessagesPerHour": "מקסימום הודעות לשעה",
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות", "inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
"inactivityCriticalDays": "ימים לאזהרה קריטית", "inactivityCriticalDays": "ימים לאזהרה קריטית",
@@ -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"]
}
}
}
@@ -8,6 +8,10 @@
"type": "varchar", "type": "varchar",
"maxLength": 255 "maxLength": 255
}, },
"espocrmApiKey": {
"type": "varchar",
"maxLength": 255
},
"maxMessagesPerHour": { "maxMessagesPerHour": {
"type": "int", "type": "int",
"default": 30 "default": 30
@@ -0,0 +1,11 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "scope"}],
[{"name": "isActive"}, false],
[{"name": "rule", "fullWidth": true}],
[{"name": "createdAt"}, {"name": "createdBy"}]
]
}
]
@@ -0,0 +1,7 @@
[
{"name": "name", "width": 35, "link": true},
{"name": "rule", "width": 30},
{"name": "scope", "width": 10},
{"name": "isActive", "width": 10},
{"name": "createdAt", "width": 15}
]
@@ -0,0 +1,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
}
@@ -7,18 +7,21 @@ use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory; use Espo\Core\InjectableFactory;
use Espo\ORM\EntityManager; use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log; use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
class ActionExecutor class ActionExecutor
{ {
private EntityManager $entityManager; private EntityManager $entityManager;
private InjectableFactory $injectableFactory; private InjectableFactory $injectableFactory;
private Log $log; 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->entityManager = $entityManager;
$this->injectableFactory = $injectableFactory; $this->injectableFactory = $injectableFactory;
$this->log = $log; $this->log = $log;
$this->metadata = $metadata;
} }
public const VALID_STATUSES = [ public const VALID_STATUSES = [
@@ -38,12 +41,21 @@ class ActionExecutor
'add_note' => $this->addNote($params, $caseId, $userId), 'add_note' => $this->addNote($params, $caseId, $userId),
'change_status' => $this->changeStatus($params, $caseId), 'change_status' => $this->changeStatus($params, $caseId),
'create_meeting' => $this->createMeeting($params, $caseId, $userId), '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), 'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
'list_documents' => $this->listDocuments($caseId), 'list_documents' => $this->listDocuments($caseId),
'analyze_document' => $this->analyzeDocument($params, $caseId), 'analyze_document' => $this->analyzeDocument($params, $caseId),
'rename_document' => $this->renameDocument($params), 'rename_document' => $this->renameDocument($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), 'save_memory' => $this->saveMemory($params, $caseId, $userId),
default => throw new Error("Unknown tool: {$tool}"), 'save_rule' => $this->saveRule($params, $userId),
default => $this->executePluginTool($tool, $params, $caseId, $userId),
}; };
} }
@@ -74,6 +86,32 @@ class ActionExecutor
]; ];
} }
private function saveRule(array $params, string $userId): array
{
$name = $params['name'] ?? '';
$rule = $params['rule'] ?? '';
if (!$name || !$rule) {
throw new Error('save_rule requires name and rule parameters.');
}
$entity = $this->entityManager->getNewEntity('AssistantRule');
$entity->set([
'name' => $name,
'rule' => $rule,
'scope' => $params['scope'] ?? 'global',
'isActive' => true,
'createdById' => $userId,
]);
$this->entityManager->saveEntity($entity);
return [
'success' => true,
'message' => "✅ כלל חדש נשמר: \"{$name}\"",
'entityType' => 'AssistantRule',
'entityId' => $entity->get('id'),
];
}
private function createTask(array $params, ?string $caseId, string $userId): array private function createTask(array $params, ?string $caseId, string $userId): array
{ {
$taskData = [ $taskData = [
@@ -160,6 +198,120 @@ class ActionExecutor
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 private function scheduleHearing(array $params, ?string $caseId, string $userId): array
{ {
$case = $this->entityManager->getEntityById('Case', $caseId); $case = $this->entityManager->getEntityById('Case', $caseId);
@@ -243,6 +395,99 @@ class ActionExecutor
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result]; return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
} }
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 private function normalizeDateTime(?string $value): ?string
{ {
if ($value === null || $value === '') return null; if ($value === null || $value === '') return null;
@@ -257,4 +502,23 @@ class ActionExecutor
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB'; if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
return $bytes . ' B'; return $bytes . ' B';
} }
private function executePluginTool(string $tool, array $params, ?string $caseId, string $userId): array
{
$handlerClass = $this->metadata->get(['app', 'smartAssistant', 'tools', $tool, 'handler']);
if (!$handlerClass) {
throw new Error("Unknown tool: {$tool}");
}
$this->log->debug("SmartAssistant: Executing plugin tool={$tool} handler={$handlerClass}");
$handler = $this->injectableFactory->create($handlerClass);
if (!method_exists($handler, 'execute')) {
throw new Error("Plugin tool handler {$handlerClass} must have an execute() method.");
}
return $handler->execute($params, $caseId, $userId);
}
} }
@@ -0,0 +1,41 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
class AssistantRuleContextProvider
{
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->entityManager = $entityManager;
$this->log = $log;
}
public function getRulesForMode(string $mode): array
{
$entries = $this->entityManager->getRDBRepository('AssistantRule')
->where([
'isActive' => true,
'deleted' => false,
'scope' => ['global', $mode],
])
->order('createdAt', 'ASC')
->find();
$rules = [];
foreach ($entries as $entry) {
$rules[] = [
'name' => $entry->get('name'),
'rule' => $entry->get('rule'),
'scope' => $entry->get('scope'),
];
}
return $rules;
}
}
@@ -29,6 +29,7 @@ class CaseContextBuilder
'contacts' => $this->getCaseContacts($case), 'contacts' => $this->getCaseContacts($case),
'openTasks' => $this->getOpenTasks($caseId), 'openTasks' => $this->getOpenTasks($caseId),
'upcomingMeetings' => $this->getUpcomingMeetings($caseId), 'upcomingMeetings' => $this->getUpcomingMeetings($caseId),
'recentCalls' => $this->getRecentCalls($caseId),
'recentNotes' => $this->getRecentNotes($caseId), 'recentNotes' => $this->getRecentNotes($caseId),
'availableTemplates' => $this->getAvailableTemplates(), 'availableTemplates' => $this->getAvailableTemplates(),
'documents' => $this->getDocumentListing($caseId), 'documents' => $this->getDocumentListing($caseId),
@@ -113,6 +114,24 @@ class CaseContextBuilder
return $meetings; 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 private function getRecentNotes(string $caseId): array
{ {
$notes = []; $notes = [];
@@ -6,8 +6,8 @@ use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory; use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Log; use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager; use Espo\ORM\EntityManager;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageFactory; use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageInterface; use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
class DocumentAnalyzer class DocumentAnalyzer
{ {
@@ -43,17 +43,19 @@ class DocumentAnalyzer
$casePath = $this->getCaseFolderPath($caseId); $casePath = $this->getCaseFolderPath($caseId);
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []]; if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
$storage = $this->getStorage(); $client = $this->getStorageClient();
$topLevel = $storage->listFolder($casePath); $topLevel = $client->listFolder($casePath);
$folders = []; $rootFiles = []; $totalFiles = 0; $folders = []; $rootFiles = []; $totalFiles = 0;
foreach ($topLevel as $item) { foreach ($topLevel as $item) {
if ($item['isFolder']) { $isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
if ($isFolder) {
$subFiles = []; $subFiles = [];
try { try {
foreach ($storage->listFolder($item['path']) as $sub) { foreach ($client->listFolder($item['path']) as $sub) {
if (!$sub['isFolder']) { $subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'], 'mimeType' => $sub['mimeType'], 'modified' => $sub['modified'] ?? null]; if (!$subIsFolder) {
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null];
$totalFiles++; $totalFiles++;
} }
} }
@@ -62,7 +64,7 @@ class DocumentAnalyzer
} }
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles]; $folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
} else { } 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++; $totalFiles++;
} }
} }
@@ -72,17 +74,12 @@ class DocumentAnalyzer
public function extractTextContent(string $filePath): ?string public function extractTextContent(string $filePath): ?string
{ {
$storage = $this->getStorage(); $client = $this->getStorageClient();
$parentPath = dirname($filePath);
$filename = basename($filePath); $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; if (strlen($content) > self::MAX_FILE_SIZE) return 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 (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true); if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename; $tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
@@ -107,7 +104,7 @@ class DocumentAnalyzer
public function renameDocument(string $sourcePath, string $newName): array public function renameDocument(string $sourcePath, string $newName): array
{ {
$storage = $this->getStorage(); $client = $this->getStorageClient();
$parentPath = dirname($sourcePath); $parentPath = dirname($sourcePath);
$oldName = basename($sourcePath); $oldName = basename($sourcePath);
@@ -120,21 +117,66 @@ class DocumentAnalyzer
$destination = $parentPath . '/' . $newName; $destination = $parentPath . '/' . $newName;
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $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); $this->updateDocumentEntity($sourcePath, $destination);
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName]; 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); $casePath = $this->getCaseFolderPath($caseId);
return $case ? ($case->get('nextCloudFolderPath') ?: null) : null; 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(); $case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return null;
// Try stored path first
$storedPath = $case->get('networkStorageFolderPath') ?: null;
if ($storedPath) return $storedPath;
// Fall back to computed path from NetworkDocumentService
try {
$nds = $this->getNetworkDocumentService();
return $nds->getEntityFolderPath('Case', $caseId);
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
return null;
}
}
private function getStorageClient(): StorageClientInterface
{
return $this->getNetworkDocumentService()->getClient();
}
private function getNetworkDocumentService(): NetworkDocumentService
{
return $this->injectableFactory->create(NetworkDocumentService::class);
} }
private function extractFromPdf(string $tmpFile): ?string private function extractFromPdf(string $tmpFile): ?string
@@ -168,10 +210,10 @@ class DocumentAnalyzer
private function updateDocumentEntity(string $oldPath, string $newPath): void private function updateDocumentEntity(string $oldPath, string $newPath): void
{ {
try { try {
$doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne(); $doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
if ($doc) { if ($doc) {
$doc->set('nextCloudPath', $newPath); $doc->set('networkStoragePath', $newPath);
$doc->set('nextCloudFilename', basename($newPath)); $doc->set('networkStorageFileName', basename($newPath));
$this->entityManager->saveEntity($doc); $this->entityManager->saveEntity($doc);
} }
} catch (\Exception $e) {} } catch (\Exception $e) {}
@@ -18,7 +18,7 @@ class SmartAssistantService
public const NOTE_TYPE_ACTION = 'SmartAction'; public const NOTE_TYPE_ACTION = 'SmartAction';
// All tools execute inline — Shira has full permissions // All tools execute inline — Shira has full permissions
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'save_memory']; private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
private EntityManager $entityManager; private EntityManager $entityManager;
private InjectableFactory $injectableFactory; private InjectableFactory $injectableFactory;
@@ -71,6 +71,10 @@ class SmartAssistantService
} }
} }
// Load behavioral rules
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
// Generate conversation ID // Generate conversation ID
if (!$conversationId) { if (!$conversationId) {
$prefix = $mode === 'case' ? 'conv_' : 'oconv_'; $prefix = $mode === 'case' ? 'conv_' : 'oconv_';
@@ -374,12 +378,19 @@ class SmartAssistantService
throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.'); throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.');
} }
$siteUrl = $this->config->get('siteUrl') ?? '';
$payload = json_encode([ $payload = json_encode([
'message' => $message, 'message' => $message,
'context' => $context, 'context' => $context,
'conversationId' => $conversationId, 'conversationId' => $conversationId,
'conversationHistory' => $history, 'conversationHistory' => $history,
'mode' => $mode, 'mode' => $mode,
'systemInstructions' => $this->getSystemInstructions(),
'espocrm' => [
'url' => rtrim($siteUrl, '/'),
'apiKey' => $this->getEspocrmApiKey(),
],
]); ]);
$headers = ['Content-Type: application/json', 'Accept: application/json']; $headers = ['Content-Type: application/json', 'Accept: application/json'];
@@ -459,6 +470,38 @@ class SmartAssistantService
return null; 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 private function getThresholds(): array
{ {
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant'); $integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant", "module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration", "description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear", "author": "klear",
"version": "2.1.2", "version": "2.5.2",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"releaseDate": "2026-04-04", "releaseDate": "2026-04-06",
"php": [ "php": [
">=8.1" ">=8.1"
] ]