From a148e0f99ea5699d13124040ebbbeaf98497c139 Mon Sep 17 00:00:00 2001 From: Chaim Marcus Date: Mon, 23 Mar 2026 14:38:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20SmartAssistant=20module=20?= =?UTF-8?q?=E2=80=94=20unified=20AI=20assistant=20with=20case=20memory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges CrmAssistant + OfficeAssistant into a single module with: - Floating chat (FAB) with auto office/case mode detection - CaseMemory entity for persistent per-case knowledge - save_memory AI tool for proactive memory saving - Auto-extraction hooks (status changes, hearings) - AlertCalculator for office-level alerts - Full conversation persistence via ConversationRepository - RTL/Hebrew support throughout (fa_IR) 47 files, 0 dependencies on old modules. --- build.sh | 11 + .../views/case/modals/add-memory.js | 96 +++ .../views/dashlets/options/smart-assistant.js | 20 + .../views/dashlets/smart-assistant.js | 72 +++ .../smart-assistant/views/floating-chat.js | 612 ++++++++++++++++++ .../smart-assistant/views/site/navbar.js | 32 + .../views/stream/notes/smart-action.js | 45 ++ .../views/stream/notes/smart-request.js | 15 + .../views/stream/notes/smart-response.js | 32 + .../FileStorage/FileStorageFactory.php | 54 ++ .../FileStorage/FileStorageInterface.php | 12 + .../FileStorage/NextCloudFileStorage.php | 21 + .../Classes/FileStorage/WebDavFileStorage.php | 234 +++++++ .../Controllers/SmartAssistant.php | 167 +++++ .../Hooks/Case/CaseFieldChangeMemory.php | 74 +++ .../Hooks/Meeting/HearingScheduledMemory.php | 44 ++ .../Resources/i18n/en_US/Case.json | 5 + .../Resources/i18n/en_US/CaseMemory.json | 49 ++ .../Resources/i18n/en_US/Global.json | 10 + .../Resources/i18n/en_US/Integration.json | 11 + .../Resources/i18n/en_US/SmartAssistant.json | 30 + .../Resources/i18n/fa_IR/Case.json | 5 + .../Resources/i18n/fa_IR/CaseMemory.json | 49 ++ .../Resources/i18n/fa_IR/Global.json | 10 + .../Resources/i18n/fa_IR/Integration.json | 11 + .../Resources/i18n/fa_IR/SmartAssistant.json | 30 + .../Resources/metadata/clientDefs/App.json | 3 + .../metadata/dashlets/SmartAssistant.json | 5 + .../Resources/metadata/entityDefs/Case.json | 10 + .../metadata/entityDefs/CaseMemory.json | 143 ++++ .../Resources/metadata/entityDefs/Note.json | 12 + .../metadata/integrations/SmartAssistant.json | 30 + .../metadata/layouts/CaseMemory/detail.json | 13 + .../layouts/CaseMemory/detailSmall.json | 10 + .../metadata/layouts/CaseMemory/list.json | 9 + .../layouts/CaseMemory/listSmall.json | 6 + .../Resources/metadata/scopes/CaseMemory.json | 13 + .../SmartAssistant/Resources/module.json | 9 + .../SmartAssistant/Resources/routes.json | 82 +++ .../Services/ActionExecutor.php | 231 +++++++ .../Services/AlertCalculator.php | 266 ++++++++ .../Services/CaseContextBuilder.php | 177 +++++ .../Services/CaseMemoryContextProvider.php | 87 +++ .../Services/CaseMemoryService.php | 105 +++ .../Services/ConversationRepository.php | 154 +++++ .../Services/DocumentAnalyzer.php | 179 +++++ .../Services/OfficeContextBuilder.php | 79 +++ .../Services/SmartAssistantService.php | 482 ++++++++++++++ manifest.json | 10 + 49 files changed, 3866 insertions(+) create mode 100755 build.sh create mode 100644 files/client/custom/src/modules/smart-assistant/views/case/modals/add-memory.js create mode 100644 files/client/custom/src/modules/smart-assistant/views/dashlets/options/smart-assistant.js create mode 100644 files/client/custom/src/modules/smart-assistant/views/dashlets/smart-assistant.js create mode 100644 files/client/custom/src/modules/smart-assistant/views/floating-chat.js create mode 100644 files/client/custom/src/modules/smart-assistant/views/site/navbar.js create mode 100644 files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-action.js create mode 100644 files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-request.js create mode 100644 files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-response.js create mode 100644 files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageFactory.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageInterface.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/NextCloudFileStorage.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/WebDavFileStorage.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Hooks/Case/CaseFieldChangeMemory.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Hooks/Meeting/HearingScheduledMemory.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Case.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/CaseMemory.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Global.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Integration.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/SmartAssistant.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Case.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/CaseMemory.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Global.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Integration.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/SmartAssistant.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/App.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/dashlets/SmartAssistant.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Case.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/CaseMemory.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Note.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/integrations/SmartAssistant.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/detail.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/detailSmall.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/list.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/listSmall.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/CaseMemory.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/module.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/routes.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/CaseMemoryContextProvider.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/CaseMemoryService.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/ConversationRepository.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/OfficeContextBuilder.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php create mode 100644 manifest.json diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6c1e690 --- /dev/null +++ b/build.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Build extension ZIP package +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" +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))" diff --git a/files/client/custom/src/modules/smart-assistant/views/case/modals/add-memory.js b/files/client/custom/src/modules/smart-assistant/views/case/modals/add-memory.js new file mode 100644 index 0000000..329401c --- /dev/null +++ b/files/client/custom/src/modules/smart-assistant/views/case/modals/add-memory.js @@ -0,0 +1,96 @@ +define('custom:modules/smart-assistant/views/case/modals/add-memory', ['views/modal'], function (ModalView) { + + return ModalView.extend({ + + backdrop: true, + cssName: 'add-memory', + + header: false, + + templateContent: '' + + '
' + + '

{{translate \'Add Memory\' scope=\'SmartAssistant\'}}

' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
', + + buttonList: [ + { + name: 'save', + label: 'Save', + style: 'primary', + }, + { + name: 'cancel', + label: 'Cancel', + }, + ], + + setup: function () { + this.caseId = this.options.caseId; + this.headerHtml = ' ' + + this.translate('Add Memory', 'labels', 'SmartAssistant'); + }, + + actionSave: function () { + var name = this.$el.find('.field-name').val().trim(); + var content = this.$el.find('.field-content').val().trim(); + var category = this.$el.find('.field-category').val(); + var importance = this.$el.find('.field-importance').val(); + + if (!content) { + Espo.Ui.error('Content is required'); + return; + } + + if (!name) { + name = content.substring(0, 100); + } + + var self = this; + + this.disableButton('save'); + + Espo.Ajax.postRequest('SmartAssistant/action/saveMemory', { + caseId: this.caseId, + name: name, + content: content, + category: category, + importance: importance, + }).then(function () { + Espo.Ui.success(self.translate('Memory saved', 'labels', 'SmartAssistant')); + self.trigger('saved'); + }).catch(function () { + Espo.Ui.error('Error saving memory'); + self.enableButton('save'); + }); + }, + }); +}); diff --git a/files/client/custom/src/modules/smart-assistant/views/dashlets/options/smart-assistant.js b/files/client/custom/src/modules/smart-assistant/views/dashlets/options/smart-assistant.js new file mode 100644 index 0000000..1964b7e --- /dev/null +++ b/files/client/custom/src/modules/smart-assistant/views/dashlets/options/smart-assistant.js @@ -0,0 +1,20 @@ +define('custom:modules/smart-assistant/views/dashlets/options/smart-assistant', ['views/dashlets/options/base'], function (BaseView) { + + return BaseView.extend({ + + templateContent: '
' + + '
', + + data: function () { + return { + alertLimit: this.optionsData.alertLimit || 10, + }; + }, + + fetch: function () { + return { + alertLimit: parseInt(this.$el.find('[name="alertLimit"]').val()) || 10, + }; + }, + }); +}); diff --git a/files/client/custom/src/modules/smart-assistant/views/dashlets/smart-assistant.js b/files/client/custom/src/modules/smart-assistant/views/dashlets/smart-assistant.js new file mode 100644 index 0000000..0343471 --- /dev/null +++ b/files/client/custom/src/modules/smart-assistant/views/dashlets/smart-assistant.js @@ -0,0 +1,72 @@ +define('custom:modules/smart-assistant/views/dashlets/smart-assistant', ['views/dashlet'], function (DashletView) { + + return DashletView.extend({ + + name: 'SmartAssistant', + + templateContent: '' + + '
' + + '
' + + '
' + + '
', + + afterRender: function () { + this.loadSummary(); + }, + + loadSummary: function () { + var self = this; + Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) { + self.renderCards(response.summary || {}); + self.renderAlerts(response.alerts || []); + }).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'}, + ]; + + var html = ''; + cards.forEach(function (c) { + html += '
' + + '
' + c.value + '
' + + '
' + c.label + '
' + + '
'; + }); + + 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('
אין התראות
'); + return; + } + + var severityColors = {critical: '#c62828', warning: '#e65100', info: '#1565c0'}; + var severityIcons = {critical: 'fa-exclamation-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle'}; + + var html = ''; + alerts.slice(0, 10).forEach(function (a) { + var color = severityColors[a.severity] || '#333'; + var icon = severityIcons[a.severity] || 'fa-circle'; + + html += '
' + + '' + + '' + Espo.Utils.escapeString(a.message) + '' + + '
'; + }); + + $alerts.html(html); + }, + + actionRefresh: function () { + this.loadSummary(); + }, + }); +}); diff --git a/files/client/custom/src/modules/smart-assistant/views/floating-chat.js b/files/client/custom/src/modules/smart-assistant/views/floating-chat.js new file mode 100644 index 0000000..2eec8e0 --- /dev/null +++ b/files/client/custom/src/modules/smart-assistant/views/floating-chat.js @@ -0,0 +1,612 @@ +define('custom:modules/smart-assistant/views/floating-chat', ['view'], function (View) { + + return View.extend({ + + templateContent: '' + + '
' + + ' ' + + ' ' + + '
' + + '' + + '', + + conversationId: null, + isOpen: false, + currentMode: 'office', + currentCaseId: null, + currentCaseName: null, + currentView: 'chat', + currentMemoryCategory: null, + memoryData: null, + + events: { + 'click .sa-fab': function () { this.togglePanel(); }, + 'click .sa-close-btn': function () { this.closePanel(); }, + 'click .sa-chat-send': function () { this.sendMessage(); }, + 'click .sa-history-btn': function () { this.showHistory(); }, + 'click .sa-new-btn': function () { this.startNewConversation(); }, + 'click .sa-history-back': function () { this.showChat(); }, + 'click .sa-memory-btn': function () { this.showMemory(); }, + 'click .sa-memory-back': function () { this.showChat(); }, + 'click .sa-add-memory-btn': function () { this.openAddMemoryModal(); }, + 'click .sa-history-item': function (e) { + var key = $(e.currentTarget).data('key'); + if (key) this.loadConversation(key); + }, + 'click .sa-memory-tab': function (e) { + var cat = $(e.currentTarget).data('category'); + this.filterMemory(cat); + }, + 'keypress .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()); + }, + 'click .sa-action-approve': function (e) { + var $btn = $(e.currentTarget); + this.handleAction($btn.data('conversation-id'), $btn.data('action-id'), true); + }, + 'click .sa-action-reject': function (e) { + var $btn = $(e.currentTarget); + this.handleAction($btn.data('conversation-id'), $btn.data('action-id'), false); + }, + }, + + afterRender: function () { + this.$el.find('.sa-chat-messages').attr('data-empty-text', + this.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם העוזר החכם'); + this.loadAlertCount(); + this._setupRouteListener(); + }, + + _setupRouteListener: function () { + var self = this; + if (this._routeListenerSet) return; + this._routeListenerSet = true; + + // Check mode on route changes + if (Espo.router) { + this.listenTo(Espo.router, 'routed', function () { + self._detectMode(); + }); + } + + this._detectMode(); + }, + + _detectMode: function () { + var hash = window.location.hash || ''; + var match = hash.match(/#Case\/view\/([a-zA-Z0-9]+)/); + + if (match) { + this.currentMode = 'case'; + this.currentCaseId = match[1]; + this._loadCaseName(this.currentCaseId); + this.$el.find('.sa-memory-btn').show(); + } else { + this.currentMode = 'office'; + this.currentCaseId = null; + this.currentCaseName = null; + this.$el.find('.sa-memory-btn').hide(); + } + + this._updateModeIndicator(); + }, + + _loadCaseName: function (caseId) { + var self = this; + Espo.Ajax.getRequest('Case/' + caseId, {select: 'name,number'}).then(function (data) { + self.currentCaseName = '#' + (data.number || '') + ' ' + (data.name || ''); + self._updateModeIndicator(); + }).catch(function () {}); + }, + + _updateModeIndicator: function () { + var $badge = this.$el.find('.sa-mode-badge'); + if (this.currentMode === 'case' && this.currentCaseName) { + $badge.text(this.currentCaseName).show(); + } else if (this.currentMode === 'case') { + $badge.text(this.translate('Case Mode', 'labels', 'SmartAssistant')).show(); + } else { + $badge.text(this.translate('Office Mode', 'labels', 'SmartAssistant')).show(); + } + }, + + togglePanel: function () { this.isOpen ? this.closePanel() : this.openPanel(); }, + + openPanel: function () { + this.isOpen = true; + this._detectMode(); + this.$el.find('.sa-chat-panel').css('display', 'flex'); + + if (!this._summaryLoaded && this.currentMode === 'office') this.loadSummary(); + if (!this.conversationId && !this._historyChecked) { + this._historyChecked = true; + this.loadLatestConversation(); + } + this.$el.find('.sa-chat-input').focus(); + }, + + closePanel: function () { + this.isOpen = false; + this.$el.find('.sa-chat-panel').css('display', 'none'); + }, + + showChat: function () { + this.currentView = 'chat'; + this.$el.find('.sa-history-view, .sa-memory-view').hide(); + this.$el.find('.sa-chat-view').show(); + this.$el.find('.sa-chat-input').focus(); + }, + + showHistory: function () { + this.currentView = 'history'; + this.$el.find('.sa-chat-view, .sa-memory-view').hide(); + this.$el.find('.sa-history-view').show(); + this.loadConversationsList(); + }, + + showMemory: function () { + if (this.currentMode !== 'case' || !this.currentCaseId) return; + this.currentView = 'memory'; + this.$el.find('.sa-chat-view, .sa-history-view').hide(); + this.$el.find('.sa-memory-view').show(); + this.loadMemory(); + }, + + startNewConversation: function () { + this.conversationId = null; + this.$el.find('.sa-chat-messages').empty(); + this.showChat(); + }, + + loadConversationsList: function () { + var $list = this.$el.find('.sa-history-list'); + var self = this; + + $list.html('
'); + + Espo.Ajax.getRequest('SmartAssistant/action/conversations').then(function (conversations) { + self.renderConversationsList(conversations); + }).catch(function () { + $list.html('
שגיאה בטעינת שיחות
'); + }); + }, + + renderConversationsList: function (conversations) { + var $list = this.$el.find('.sa-history-list'); + var self = this; + + if (!conversations || conversations.length === 0) { + $list.html('
' + self.escapeHtml(self.translate('No previous conversations', 'labels', 'SmartAssistant')) + '
'); + return; + } + + var html = ''; + conversations.forEach(function (conv) { + var timeStr = self.formatConversationTime(conv.lastMessageAt); + var typeIcon = conv.conversationType === 'case' ? 'fa-gavel' : 'fa-building'; + + html += '
' + + '
' + + '' + + self.escapeHtml(conv.name || 'שיחה') + + '
' + + '
' + + '' + self.escapeHtml(timeStr) + '' + + '' + (conv.messageCount || 0) + ' הודעות' + + '
'; + if (conv.lastMessagePreview) { + html += '
' + self.escapeHtml(conv.lastMessagePreview.substring(0, 80)) + '
'; + } + html += '
'; + }); + + $list.html(html); + }, + + loadConversation: function (conversationKey) { + var self = this; + this.conversationId = conversationKey; + var $messages = this.$el.find('.sa-chat-messages'); + + $messages.html('
טוען שיחה...
'); + this.showChat(); + + Espo.Ajax.getRequest('SmartAssistant/action/conversationMessages', {conversationKey: conversationKey}).then(function (response) { + $messages.empty(); + (response.messages || []).forEach(function (msg) { + var cssClass = msg.role === 'user' ? 'sa-msg-user' : 'sa-msg-assistant'; + var content = msg.role === 'user' ? self.escapeHtml(msg.content) : self.formatResponse(msg.content); + $messages.append('
' + content + '
'); + }); + self.scrollToBottom(); + }).catch(function () { + $messages.html('
שגיאה בטעינת שיחה
'); + }); + }, + + loadLatestConversation: function () { + var self = this; + Espo.Ajax.getRequest('SmartAssistant/action/history').then(function (response) { + if (!response || !response.conversationId || !response.messages || response.messages.length === 0) return; + + var updatedAt = response.updatedAt; + if (updatedAt) { + var today = new Date().toISOString().substring(0, 10); + if (updatedAt.substring(0, 10) !== today) return; + } + + self.conversationId = response.conversationId; + var $messages = self.$el.find('.sa-chat-messages'); + + response.messages.forEach(function (msg) { + var cssClass = msg.role === 'user' ? 'sa-msg-user' : 'sa-msg-assistant'; + var content = msg.role === 'user' ? self.escapeHtml(msg.content) : self.formatResponse(msg.content); + $messages.append('
' + content + '
'); + }); + self.scrollToBottom(); + }).catch(function () {}); + }, + + loadAlertCount: function () { + var self = this; + Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) { + var summary = response.summary || {}; + var count = (summary.criticalAlerts || 0) + (summary.warningAlerts || 0); + self.updateBadge(count); + }).catch(function () {}); + }, + + updateBadge: function (count) { + var $badge = this.$el.find('.sa-fab-badge'); + count > 0 ? $badge.text(count).show() : $badge.hide(); + }, + + loadSummary: function () { + this._summaryLoaded = true; + var self = this; + var $summary = this.$el.find('.sa-chat-summary'); + + Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) { + var summary = response.summary || {}; + var alerts = response.alerts || []; + var critical = alerts.filter(function (a) { return a.severity === 'critical'; }); + + if (critical.length > 0) { + var html = '' + self.escapeHtml(critical.length + ' התראות קריטיות') + ''; + critical.slice(0, 2).forEach(function (a) { + html += '
' + self.escapeHtml('• ' + a.message) + '
'; + }); + $summary.html(html).show(); + } else if (summary.casesNeedingAttention > 0) { + $summary.html(self.escapeHtml(summary.casesNeedingAttention + ' תיקים דורשים תשומת לב')).show(); + } else { + $summary.html('אין התראות קריטיות').show(); + } + + self.updateBadge((summary.criticalAlerts || 0) + (summary.warningAlerts || 0)); + }).catch(function () { $summary.hide(); }); + }, + + sendMessage: function () { + var $input = this.$el.find('.sa-chat-input'); + var message = $input.val().trim(); + if (!message) return; + + var self = this; + var $messages = this.$el.find('.sa-chat-messages'); + var $btn = this.$el.find('.sa-chat-send'); + + $messages.append('
' + this.escapeHtml(message) + '
'); + var $loading = $('
' + + this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושב...') + '
'); + $messages.append($loading); + this.scrollToBottom(); + + $btn.prop('disabled', true); + $input.prop('disabled', true).val(''); + + var payload = { + message: message, + mode: this.currentMode, + conversationId: this.conversationId, + }; + if (this.currentMode === 'case' && this.currentCaseId) { + payload.caseId = this.currentCaseId; + } + + Espo.Ajax.postRequest('SmartAssistant/action/chat', payload).then(function (response) { + self.conversationId = response.conversationId; + $loading.remove(); + + $messages.append('
' + self.formatResponse(response.text) + '
'); + + // Render action items + if (response.actions && response.actions.length > 0) { + response.actions.forEach(function (action) { + var actionHtml = '
' + + '' + + self.escapeHtml(action.displayText || action.tool) + '' + + '
' + + '' + + '' + + '
'; + $messages.append(actionHtml); + }); + } + + self.scrollToBottom(); + $input.prop('disabled', false).focus(); + }).catch(function () { + $loading.remove(); + $messages.append('
' + + self.escapeHtml(self.translate('Error', 'labels', 'SmartAssistant') || 'שגיאה בתקשורת') + '
'); + self.scrollToBottom(); + $input.prop('disabled', false); + $btn.prop('disabled', false); + }); + }, + + handleAction: function (conversationId, actionId, approved) { + var self = this; + var $messages = this.$el.find('.sa-chat-messages'); + + Espo.Ajax.postRequest('SmartAssistant/action/execute', { + conversationId: conversationId, + actionId: actionId, + approved: approved, + }).then(function (result) { + var msg = approved ? (result.message || 'פעולה בוצעה') : 'פעולה נדחתה'; + var cls = approved ? (result.success ? 'sa-msg-assistant' : 'sa-msg-error') : 'sa-msg-loading'; + $messages.append('
' + self.escapeHtml(msg) + '
'); + self.scrollToBottom(); + }).catch(function () { + $messages.append('
שגיאה בביצוע הפעולה
'); + self.scrollToBottom(); + }); + }, + + loadMemory: function () { + if (!this.currentCaseId) return; + var self = this; + var $list = this.$el.find('.sa-memory-list'); + + $list.html('
'); + + Espo.Ajax.getRequest('SmartAssistant/action/caseMemory', {caseId: this.currentCaseId}).then(function (data) { + self.memoryData = data; + self.renderMemoryTabs(data); + self.renderMemoryEntries(data, null); + }).catch(function () { + $list.html('
שגיאה בטעינת זיכרון
'); + }); + }, + + renderMemoryTabs: function (data) { + var $tabs = this.$el.find('.sa-memory-tabs'); + var categoryLabels = { + 'key_facts': 'עובדות', 'strategy': 'אסטרטגיה', 'decisions': 'החלטות', + 'contacts_notes': 'אנשי קשר', 'timeline': 'ציר זמן', + 'documents_notes': 'מסמכים', 'billing_notes': 'חיוב' + }; + + var html = ''; + var categories = Object.keys(data).filter(function (k) { return k !== '_summary'; }); + + categories.forEach(function (cat) { + var label = categoryLabels[cat] || cat; + var count = data[cat] ? data[cat].length : 0; + html += ''; + }); + + $tabs.html(html); + }, + + renderMemoryEntries: function (data, category) { + var $list = this.$el.find('.sa-memory-list'); + var self = this; + var entries = []; + + var importanceLabels = {critical: 'קריטי', high: 'גבוה', normal: 'רגיל', low: 'נמוך'}; + var sourceLabels = {manual: 'ידני', assistant: 'AI', auto: 'אוטומטי'}; + + Object.keys(data).forEach(function (cat) { + if (cat === '_summary') return; + if (category && cat !== category) return; + (data[cat] || []).forEach(function (e) { entries.push(e); }); + }); + + if (entries.length === 0) { + $list.html('
' + self.escapeHtml(self.translate('No memories yet', 'labels', 'SmartAssistant')) + '
'); + return; + } + + var html = ''; + entries.forEach(function (e) { + var badgeClass = 'sa-badge-' + (e.importance || 'normal'); + html += '
' + + '
' + + '' + self.escapeHtml(e.name) + '' + + '
'; + if (e.isPinned) html += ''; + html += '' + (importanceLabels[e.importance] || e.importance) + ''; + html += '' + (sourceLabels[e.source] || e.source) + ''; + html += '
' + + '
' + self.escapeHtml(e.content) + '
' + + '' + + '
'; + }); + + $list.html(html); + }, + + filterMemory: function (category) { + this.$el.find('.sa-memory-tab').removeClass('active'); + this.$el.find('.sa-memory-tab[data-category="' + (category || '') + '"]').addClass('active'); + this.currentMemoryCategory = category || null; + + if (this.memoryData) { + this.renderMemoryEntries(this.memoryData, this.currentMemoryCategory); + } + }, + + openAddMemoryModal: function () { + if (!this.currentCaseId) return; + var self = this; + + this.createView('addMemoryModal', 'custom:modules/smart-assistant/views/case/modals/add-memory', { + caseId: this.currentCaseId, + }, function (view) { + view.render(); + + self.listenToOnce(view, 'saved', function () { + self.loadMemory(); + view.close(); + }); + }); + }, + + scrollToBottom: function () { + var el = this.$el.find('.sa-chat-messages').get(0); + if (el) el.scrollTop = el.scrollHeight; + }, + + formatConversationTime: function (dateStr) { + if (!dateStr) return ''; + var date = new Date(dateStr); + var now = new Date(); + var today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + var yesterday = new Date(today.getTime() - 86400000); + var convDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + var timeStr = ('0' + date.getHours()).slice(-2) + ':' + ('0' + date.getMinutes()).slice(-2); + + if (convDay.getTime() === today.getTime()) return 'היום ' + timeStr; + if (convDay.getTime() === yesterday.getTime()) return 'אתמול ' + timeStr; + return ('0' + date.getDate()).slice(-2) + '/' + ('0' + (date.getMonth() + 1)).slice(-2) + ' ' + timeStr; + }, + + formatResponse: function (text) { + if (!text) return ''; + text = this.escapeHtml(text); + text = text.replace(/\*\*(.*?)\*\*/g, '$1'); + text = text.replace(/\n/g, '
'); + text = text.replace(/^- (.+)/gm, '• $1'); + return text; + }, + + escapeHtml: function (text) { + if (!text) return ''; + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + }); +}); diff --git a/files/client/custom/src/modules/smart-assistant/views/site/navbar.js b/files/client/custom/src/modules/smart-assistant/views/site/navbar.js new file mode 100644 index 0000000..e937c53 --- /dev/null +++ b/files/client/custom/src/modules/smart-assistant/views/site/navbar.js @@ -0,0 +1,32 @@ +define('custom:modules/smart-assistant/views/site/navbar', ['views/site/navbar'], function (NavbarView) { + + return NavbarView.extend({ + + afterRender: function () { + NavbarView.prototype.afterRender.call(this); + this._initFloatingChat(); + }, + + _initFloatingChat: function () { + if (this._floatingChatInitialized) return; + this._floatingChatInitialized = true; + + var $container = $('body > .smart-assistant-fab-container'); + if ($container.length === 0) { + $container = $('
'); + $('body').append($container); + } + + this.createView('floatingChat', 'custom:modules/smart-assistant/views/floating-chat', { + el: '.smart-assistant-fab-container', + }, function (view) { + view.render(); + }); + }, + + onRemove: function () { + $('body > .smart-assistant-fab-container').remove(); + NavbarView.prototype.onRemove.call(this); + }, + }); +}); diff --git a/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-action.js b/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-action.js new file mode 100644 index 0000000..ab671fd --- /dev/null +++ b/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-action.js @@ -0,0 +1,45 @@ +define('custom:modules/smart-assistant/views/stream/notes/smart-action', ['views/stream/note'], function (NoteView) { + + return NoteView.extend({ + + templateContent: '' + + '
' + + '' + + '{{post}}' + + '
', + + data: function () { + var data = NoteView.prototype.data.call(this); + var noteData = this.model.get('data') || {}; + var status = noteData.status || 'executed'; + + data.status = status; + + if (status === 'executed') { + data.icon = 'fa-check-circle'; + } else if (status === 'rejected') { + data.icon = 'fa-ban'; + } else { + data.icon = 'fa-exclamation-triangle'; + } + + return data; + }, + + afterRender: function () { + NoteView.prototype.afterRender.call(this); + + var noteData = this.model.get('data') || {}; + var status = noteData.status || 'executed'; + var $note = this.$el.find('.sa-action-note'); + + if (status === 'executed') { + $note.css({background: '#e8f5e9', color: '#2e7d32'}); + } else if (status === 'rejected') { + $note.css({background: '#f5f5f5', color: '#999'}); + } else { + $note.css({background: '#fce4ec', color: '#c62828'}); + } + }, + }); +}); diff --git a/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-request.js b/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-request.js new file mode 100644 index 0000000..72bbd76 --- /dev/null +++ b/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-request.js @@ -0,0 +1,15 @@ +define('custom:modules/smart-assistant/views/stream/notes/smart-request', ['views/stream/note'], function (NoteView) { + + return NoteView.extend({ + + templateContent: '' + + '
' + + '' + + '{{post}}' + + '
', + + setup: function () { + NoteView.prototype.setup.call(this); + }, + }); +}); diff --git a/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-response.js b/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-response.js new file mode 100644 index 0000000..005da1a --- /dev/null +++ b/files/client/custom/src/modules/smart-assistant/views/stream/notes/smart-response.js @@ -0,0 +1,32 @@ +define('custom:modules/smart-assistant/views/stream/notes/smart-response', ['views/stream/note'], function (NoteView) { + + return NoteView.extend({ + + templateContent: '' + + '
' + + '' + + '{{{formattedPost}}}' + + '{{#if actionCount}}' + + '{{actionCount}} פעולות' + + '{{/if}}' + + '
', + + data: function () { + var data = NoteView.prototype.data.call(this); + var noteData = this.model.get('data') || {}; + + data.formattedPost = this.formatPost(this.model.get('post')); + data.actionCount = (noteData.actions || []).length; + + return data; + }, + + formatPost: function (text) { + if (!text) return ''; + text = this.getHelper().escapeString(text); + text = text.replace(/\*\*(.*?)\*\*/g, '$1'); + text = text.replace(/\n/g, '
'); + return text; + }, + }); +}); diff --git a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageFactory.php b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageFactory.php new file mode 100644 index 0000000..e6cede9 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageFactory.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageInterface.php b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageInterface.php new file mode 100644 index 0000000..caa6e51 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageInterface.php @@ -0,0 +1,12 @@ +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); } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/WebDavFileStorage.php b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/WebDavFileStorage.php new file mode 100644 index 0000000..2ff6746 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/WebDavFileStorage.php @@ -0,0 +1,234 @@ +baseUrl = rtrim($baseUrl, '/'); + $this->username = $username; + $this->password = $password; + $this->webdavPath = $webdavPath; + } + + public function listFolder(string $path): array + { + $propfindXml = ' + + + + + + + + + '; + + $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; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php b/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php new file mode 100644 index 0000000..97f5953 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php @@ -0,0 +1,167 @@ +injectableFactory = $injectableFactory; + $this->acl = $acl; + } + + private function getService(): SmartAssistantService + { + return $this->injectableFactory->create(SmartAssistantService::class); + } + + private function checkAccess(): void + { + if (!$this->acl->checkScope('Case', 'read')) { + throw new Forbidden('No access to Case.'); + } + } + + public function postActionChat(Request $request, Response $response): array + { + $this->checkAccess(); + $data = $request->getParsedBody(); + + if (empty($data->message)) { + throw new BadRequest('message is required.'); + } + + $mode = $data->mode ?? 'office'; + $caseId = $data->caseId ?? null; + + if ($mode === 'case' && empty($caseId)) { + throw new BadRequest('caseId is required for case mode.'); + } + + return $this->getService()->chat( + trim($data->message), + $mode, + $caseId, + $data->conversationId ?? null + ); + } + + public function postActionExecute(Request $request, Response $response): array + { + $this->checkAccess(); + $data = $request->getParsedBody(); + + if (empty($data->conversationId) || empty($data->actionId)) { + throw new BadRequest('conversationId and actionId are required.'); + } + + return $this->getService()->executeAction( + $data->conversationId, + $data->actionId, + (bool) ($data->approved ?? false) + ); + } + + public function getActionSummary(Request $request, Response $response): array + { + $this->checkAccess(); + return $this->getService()->getSummary(); + } + + public function getActionAlerts(Request $request, Response $response): array + { + $this->checkAccess(); + return $this->getService()->getAlerts(); + } + + public function getActionHistory(Request $request, Response $response): array + { + $this->checkAccess(); + $caseId = $request->getQueryParam('caseId'); + return $this->getService()->getHistory($caseId); + } + + public function getActionConversations(Request $request, Response $response): array + { + $this->checkAccess(); + $type = $request->getQueryParam('type'); + return $this->getService()->getConversations($type); + } + + public function getActionConversationMessages(Request $request, Response $response): array + { + $this->checkAccess(); + $key = $request->getQueryParam('conversationKey'); + + if (empty($key)) { + throw new BadRequest('conversationKey is required.'); + } + + return $this->getService()->getConversationMessages($key); + } + + public function getActionSearchHistory(Request $request, Response $response): array + { + $this->checkAccess(); + $query = $request->getQueryParam('query'); + + if (empty($query)) { + throw new BadRequest('query is required.'); + } + + return $this->getService()->searchHistory($query); + } + + public function getActionCaseMemory(Request $request, Response $response): array + { + $this->checkAccess(); + $caseId = $request->getQueryParam('caseId'); + + if (empty($caseId)) { + throw new BadRequest('caseId is required.'); + } + + $category = $request->getQueryParam('category'); + $includeArchived = (bool) $request->getQueryParam('includeArchived'); + + $service = $this->injectableFactory->create(CaseMemoryService::class); + return $service->getMemoriesByCase($caseId, $category, $includeArchived); + } + + public function postActionSaveMemory(Request $request, Response $response): array + { + $this->checkAccess(); + $data = $request->getParsedBody(); + + if (empty($data->caseId) || empty($data->content)) { + throw new BadRequest('caseId and content are required.'); + } + + $service = $this->injectableFactory->create(CaseMemoryService::class); + $entry = $service->createMemory( + $data->caseId, + $data->category ?? 'key_facts', + $data->name ?? mb_substr($data->content, 0, 100), + $data->content, + $data->importance ?? 'normal', + 'manual' + ); + + return [ + 'success' => true, + 'id' => $entry->get('id'), + ]; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Hooks/Case/CaseFieldChangeMemory.php b/files/custom/Espo/Modules/SmartAssistant/Hooks/Case/CaseFieldChangeMemory.php new file mode 100644 index 0000000..67c2545 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Hooks/Case/CaseFieldChangeMemory.php @@ -0,0 +1,74 @@ +injectableFactory = $injectableFactory; + $this->log = $log; + } + + public function afterSave(Entity $entity, array $options): void + { + if (!empty($options['skipSmartAssistantHook'])) return; + + $caseId = $entity->get('id'); + $service = $this->injectableFactory->create(CaseMemoryService::class); + + // Status change + if ($entity->isAttributeChanged('status')) { + $old = $entity->getFetched('status'); + $new = $entity->get('status'); + $service->createMemory( + $caseId, 'timeline', + "שינוי סטטוס: {$old} → {$new}", + "סטטוס התיק שונה מ-\"{$old}\" ל-\"{$new}\"", + 'normal', 'auto', + 'Case', $caseId + ); + } + + // Next hearing change + if ($entity->isAttributeChanged('cNextHearing') && $entity->get('cNextHearing')) { + $service->createMemory( + $caseId, 'timeline', + "דיון נקבע ל-" . $entity->get('cNextHearing'), + "דיון הבא נקבע לתאריך: " . $entity->get('cNextHearing'), + 'high', 'auto', + 'Case', $caseId + ); + } + + // Judge change + if ($entity->isAttributeChanged('cJudge') && $entity->get('cJudge')) { + $service->createMemory( + $caseId, 'key_facts', + "שופט: " . $entity->get('cJudge'), + "השופט/ת בתיק: " . ($entity->get('cJudgeTitle') ?? '') . " " . $entity->get('cJudge'), + 'normal', 'auto', + 'Case', $caseId + ); + } + + // Court change + if ($entity->isAttributeChanged('cCourt') && $entity->get('cCourt')) { + $service->createMemory( + $caseId, 'key_facts', + "בית משפט: " . $entity->get('cCourt'), + "התיק נדון בבית המשפט: " . $entity->get('cCourt'), + 'normal', 'auto', + 'Case', $caseId + ); + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Hooks/Meeting/HearingScheduledMemory.php b/files/custom/Espo/Modules/SmartAssistant/Hooks/Meeting/HearingScheduledMemory.php new file mode 100644 index 0000000..a85f713 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Hooks/Meeting/HearingScheduledMemory.php @@ -0,0 +1,44 @@ +injectableFactory = $injectableFactory; + $this->log = $log; + } + + public function afterSave(Entity $entity, array $options): void + { + if (!$entity->isNew()) return; + if ($entity->get('parentType') !== 'Case' || !$entity->get('parentId')) return; + + $name = $entity->get('name') ?? ''; + $isHearing = mb_stripos($name, 'דיון') !== false || mb_stripos($name, 'hearing') !== false; + if (!$isHearing) return; + + try { + $service = $this->injectableFactory->create(CaseMemoryService::class); + $service->createMemory( + $entity->get('parentId'), + 'timeline', + "דיון: " . $name, + "נקבע דיון: {$name}\nתאריך: " . ($entity->get('dateStart') ?? 'לא ידוע'), + 'high', 'auto', + 'Meeting', $entity->get('id') + ); + } catch (\Exception $e) { + $this->log->warning("SmartAssistant: Failed to create hearing memory: " . $e->getMessage()); + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Case.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Case.json new file mode 100644 index 0000000..a2c317c --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Case.json @@ -0,0 +1,5 @@ +{ + "links": { + "caseMemories": "Case Memory" + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/CaseMemory.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/CaseMemory.json new file mode 100644 index 0000000..4e9064b --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/CaseMemory.json @@ -0,0 +1,49 @@ +{ + "labels": { + "Create CaseMemory": "Add Memory", + "Case Memory": "Case Memory" + }, + "fields": { + "name": "Title", + "case": "Case", + "category": "Category", + "content": "Content", + "source": "Source", + "importance": "Importance", + "sourceEntityType": "Source Entity Type", + "sourceEntityId": "Source Entity ID", + "isPinned": "Pinned", + "isArchived": "Archived", + "assignedUser": "Assigned User", + "teams": "Teams", + "createdAt": "Created At", + "modifiedAt": "Modified At", + "createdBy": "Created By", + "modifiedBy": "Modified By" + }, + "links": { + "case": "Case" + }, + "options": { + "category": { + "key_facts": "Key Facts", + "strategy": "Strategy", + "decisions": "Decisions", + "contacts_notes": "Contact Notes", + "timeline": "Timeline", + "documents_notes": "Document Notes", + "billing_notes": "Billing Notes" + }, + "source": { + "manual": "Manual", + "assistant": "AI Assistant", + "auto": "Automatic" + }, + "importance": { + "low": "Low", + "normal": "Normal", + "high": "High", + "critical": "Critical" + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Global.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Global.json new file mode 100644 index 0000000..448d8ec --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Global.json @@ -0,0 +1,10 @@ +{ + "scopeNames": { + "CaseMemory": "Case Memory", + "SmartAssistant": "Smart Assistant" + }, + "scopeNamesPlural": { + "CaseMemory": "Case Memories", + "SmartAssistant": "Smart Assistant" + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Integration.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Integration.json new file mode 100644 index 0000000..82814f0 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/Integration.json @@ -0,0 +1,11 @@ +{ + "SmartAssistant": "Smart Assistant", + "fields": { + "webhookUrl": "Webhook URL", + "apiKey": "API Key", + "maxMessagesPerHour": "Max Messages Per Hour", + "inactivityWarningDays": "Inactivity Warning Days", + "inactivityCriticalDays": "Inactivity Critical Days", + "upcomingHearingDays": "Upcoming Hearing Days" + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/SmartAssistant.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/SmartAssistant.json new file mode 100644 index 0000000..298d0f2 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/en_US/SmartAssistant.json @@ -0,0 +1,30 @@ +{ + "labels": { + "Smart Assistant": "Smart Assistant", + "Office Mode": "Office Mode", + "Case Mode": "Case Mode", + "Add Memory": "Add Memory", + "Search Memory": "Search Memory", + "New Conversation": "New Conversation", + "Conversation History": "Conversation History", + "Previous Conversations": "Previous Conversations", + "No previous conversations": "No previous conversations", + "Ask the assistant...": "Ask the assistant...", + "Thinking": "Thinking...", + "Error": "Communication error", + "Back": "Back", + "Chat with the assistant": "Chat with the assistant", + "Case Memory": "Case Memory", + "Pin": "Pin", + "Unpin": "Unpin", + "Archive": "Archive", + "All Categories": "All Categories", + "Pinned": "Pinned", + "Memory saved": "Memory saved", + "No memories yet": "No memories yet", + "Open Cases": "Open Cases", + "Overdue Tasks": "Overdue Tasks", + "Hearings This Week": "Hearings This Week", + "Attention Needed": "Attention Needed" + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Case.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Case.json new file mode 100644 index 0000000..fd1b81f --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Case.json @@ -0,0 +1,5 @@ +{ + "links": { + "caseMemories": "זיכרון תיק" + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/CaseMemory.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/CaseMemory.json new file mode 100644 index 0000000..d59fd00 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/CaseMemory.json @@ -0,0 +1,49 @@ +{ + "labels": { + "Create CaseMemory": "הוסף זיכרון", + "Case Memory": "זיכרון תיק" + }, + "fields": { + "name": "כותרת", + "case": "תיק", + "category": "קטגוריה", + "content": "תוכן", + "source": "מקור", + "importance": "חשיבות", + "sourceEntityType": "סוג ישות מקור", + "sourceEntityId": "מזהה ישות מקור", + "isPinned": "מוצמד", + "isArchived": "בארכיון", + "assignedUser": "משתמש אחראי", + "teams": "צוותים", + "createdAt": "נוצר ב", + "modifiedAt": "עודכן ב", + "createdBy": "נוצר ע\"י", + "modifiedBy": "עודכן ע\"י" + }, + "links": { + "case": "תיק" + }, + "options": { + "category": { + "key_facts": "עובדות מרכזיות", + "strategy": "אסטרטגיה", + "decisions": "החלטות", + "contacts_notes": "הערות אנשי קשר", + "timeline": "ציר זמן", + "documents_notes": "הערות מסמכים", + "billing_notes": "הערות חיוב" + }, + "source": { + "manual": "ידני", + "assistant": "עוזר AI", + "auto": "אוטומטי" + }, + "importance": { + "low": "נמוכה", + "normal": "רגילה", + "high": "גבוהה", + "critical": "קריטית" + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Global.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Global.json new file mode 100644 index 0000000..18a3fbf --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Global.json @@ -0,0 +1,10 @@ +{ + "scopeNames": { + "CaseMemory": "זיכרון תיק", + "SmartAssistant": "עוזר חכם" + }, + "scopeNamesPlural": { + "CaseMemory": "זיכרונות תיקים", + "SmartAssistant": "עוזר חכם" + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Integration.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Integration.json new file mode 100644 index 0000000..5dc7191 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Integration.json @@ -0,0 +1,11 @@ +{ + "SmartAssistant": "עוזר חכם", + "fields": { + "webhookUrl": "כתובת Webhook", + "apiKey": "מפתח API", + "maxMessagesPerHour": "מקסימום הודעות לשעה", + "inactivityWarningDays": "ימים לאזהרת חוסר פעילות", + "inactivityCriticalDays": "ימים לאזהרה קריטית", + "upcomingHearingDays": "ימים לפני דיון" + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/SmartAssistant.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/SmartAssistant.json new file mode 100644 index 0000000..e923283 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/SmartAssistant.json @@ -0,0 +1,30 @@ +{ + "labels": { + "Smart Assistant": "עוזר חכם", + "Office Mode": "מצב משרד", + "Case Mode": "מצב תיק", + "Add Memory": "הוסף זיכרון", + "Search Memory": "חפש בזיכרון", + "New Conversation": "שיחה חדשה", + "Conversation History": "היסטוריית שיחות", + "Previous Conversations": "שיחות קודמות", + "No previous conversations": "אין שיחות קודמות", + "Ask the assistant...": "שאל/י את העוזר...", + "Thinking": "חושב...", + "Error": "שגיאה בתקשורת", + "Back": "חזרה", + "Chat with the assistant": "שוחח/י עם העוזר החכם", + "Case Memory": "זיכרון תיק", + "Pin": "הצמד", + "Unpin": "הסר הצמדה", + "Archive": "העבר לארכיון", + "All Categories": "כל הקטגוריות", + "Pinned": "מוצמדים", + "Memory saved": "נשמר בזיכרון", + "No memories yet": "אין זיכרונות עדיין", + "Open Cases": "תיקים פתוחים", + "Overdue Tasks": "משימות באיחור", + "Hearings This Week": "דיונים השבוע", + "Attention Needed": "דורשים תשומת לב" + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/App.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/App.json new file mode 100644 index 0000000..b9f3929 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/App.json @@ -0,0 +1,3 @@ +{ + "navbarView": "custom:modules/smart-assistant/views/site/navbar" +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/dashlets/SmartAssistant.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/dashlets/SmartAssistant.json new file mode 100644 index 0000000..6585296 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/dashlets/SmartAssistant.json @@ -0,0 +1,5 @@ +{ + "view": "custom:modules/smart-assistant/views/dashlets/smart-assistant", + "optionsView": "custom:modules/smart-assistant/views/dashlets/options/smart-assistant", + "aclScope": "Case" +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Case.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Case.json new file mode 100644 index 0000000..3266f1d --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Case.json @@ -0,0 +1,10 @@ +{ + "links": { + "caseMemories": { + "type": "hasMany", + "entity": "CaseMemory", + "foreign": "case", + "layoutRelationshipsDisabled": true + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/CaseMemory.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/CaseMemory.json new file mode 100644 index 0000000..950c9bb --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/CaseMemory.json @@ -0,0 +1,143 @@ +{ + "fields": { + "name": { + "type": "varchar", + "maxLength": 255, + "required": true + }, + "case": { + "type": "link", + "required": true + }, + "category": { + "type": "enum", + "options": [ + "key_facts", + "strategy", + "decisions", + "contacts_notes", + "timeline", + "documents_notes", + "billing_notes" + ], + "default": "key_facts", + "required": true, + "style": { + "key_facts": "primary", + "strategy": "info", + "decisions": "warning", + "contacts_notes": "default", + "timeline": "success", + "documents_notes": "default", + "billing_notes": "default" + } + }, + "content": { + "type": "text", + "required": true + }, + "source": { + "type": "enum", + "options": ["manual", "assistant", "auto"], + "default": "manual", + "readOnly": true + }, + "importance": { + "type": "enum", + "options": ["low", "normal", "high", "critical"], + "default": "normal", + "style": { + "low": "default", + "normal": "primary", + "high": "warning", + "critical": "danger" + } + }, + "isPinned": { + "type": "bool", + "default": false + }, + "isArchived": { + "type": "bool", + "default": false + }, + "sourceEntityType": { + "type": "varchar", + "maxLength": 100, + "readOnly": true + }, + "sourceEntityId": { + "type": "varchar", + "maxLength": 36, + "readOnly": true + }, + "assignedUser": { + "type": "link", + "view": "views/fields/assigned-user" + }, + "teams": { + "type": "linkMultiple", + "view": "views/fields/teams" + }, + "createdAt": { + "type": "datetime", + "readOnly": true + }, + "modifiedAt": { + "type": "datetime", + "readOnly": true + }, + "createdBy": { + "type": "link", + "readOnly": true + }, + "modifiedBy": { + "type": "link", + "readOnly": true + } + }, + "links": { + "case": { + "type": "belongsTo", + "entity": "Case", + "foreign": "caseMemories" + }, + "assignedUser": { + "type": "belongsTo", + "entity": "User" + }, + "teams": { + "type": "hasMany", + "entity": "Team", + "relationName": "entityTeam", + "layoutRelationshipsDisabled": true + }, + "createdBy": { + "type": "belongsTo", + "entity": "User" + }, + "modifiedBy": { + "type": "belongsTo", + "entity": "User" + } + }, + "collection": { + "orderBy": "createdAt", + "order": "desc", + "textFilterFields": ["name", "content"] + }, + "indexes": { + "caseId": { + "columns": ["caseId", "deleted"] + }, + "caseCategory": { + "columns": ["caseId", "category", "deleted"] + }, + "casePinned": { + "columns": ["caseId", "isPinned", "deleted"] + }, + "importance": { + "columns": ["importance", "deleted"] + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Note.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Note.json new file mode 100644 index 0000000..895f7a4 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/Note.json @@ -0,0 +1,12 @@ +{ + "fields": { + "type": { + "options": [ + "__APPEND__", + "SmartRequest", + "SmartResponse", + "SmartAction" + ] + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/integrations/SmartAssistant.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/integrations/SmartAssistant.json new file mode 100644 index 0000000..2905a27 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/integrations/SmartAssistant.json @@ -0,0 +1,30 @@ +{ + "fields": { + "webhookUrl": { + "type": "url", + "required": true + }, + "apiKey": { + "type": "varchar", + "maxLength": 255 + }, + "maxMessagesPerHour": { + "type": "int", + "default": 30 + }, + "inactivityWarningDays": { + "type": "int", + "default": 14 + }, + "inactivityCriticalDays": { + "type": "int", + "default": 30 + }, + "upcomingHearingDays": { + "type": "int", + "default": 7 + } + }, + "allowUserAccess": false, + "view": "views/admin/integrations/edit" +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/detail.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/detail.json new file mode 100644 index 0000000..a08e3f4 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/detail.json @@ -0,0 +1,13 @@ +[ + { + "label": "Overview", + "rows": [ + [{"name": "name"}, {"name": "category"}], + [{"name": "importance"}, {"name": "source"}], + [{"name": "content", "fullWidth": true}], + [{"name": "case"}, {"name": "isPinned"}], + [{"name": "isArchived"}, {"name": "createdAt"}], + [{"name": "assignedUser"}, {"name": "modifiedAt"}] + ] + } +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/detailSmall.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/detailSmall.json new file mode 100644 index 0000000..fde14c5 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/detailSmall.json @@ -0,0 +1,10 @@ +[ + { + "rows": [ + [{"name": "name"}], + [{"name": "category"}], + [{"name": "importance"}], + [{"name": "content"}] + ] + } +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/list.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/list.json new file mode 100644 index 0000000..495200e --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/list.json @@ -0,0 +1,9 @@ +[ + {"name": "name", "width": 30}, + {"name": "category", "width": 15}, + {"name": "importance", "width": 10}, + {"name": "source", "width": 10}, + {"name": "isPinned", "width": 5}, + {"name": "case", "width": 15}, + {"name": "createdAt", "width": 15} +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/listSmall.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/listSmall.json new file mode 100644 index 0000000..ad338d7 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/CaseMemory/listSmall.json @@ -0,0 +1,6 @@ +[ + {"name": "name", "width": 40}, + {"name": "category", "width": 20}, + {"name": "importance", "width": 15}, + {"name": "createdAt", "width": 25} +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/CaseMemory.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/CaseMemory.json new file mode 100644 index 0000000..3107b65 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/CaseMemory.json @@ -0,0 +1,13 @@ +{ + "entity": true, + "object": true, + "module": "SmartAssistant", + "acl": true, + "aclActionList": ["create", "read", "edit", "delete"], + "aclLevelList": ["all", "team", "own", "no"], + "tab": false, + "stream": false, + "disabled": false, + "importable": false, + "customizable": true +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/module.json b/files/custom/Espo/Modules/SmartAssistant/Resources/module.json new file mode 100644 index 0000000..7708347 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/module.json @@ -0,0 +1,9 @@ +{ + "order": 37, + "version": "1.0.0", + "requires": { + "espocrm": ">=8.0.0" + }, + "author": "klear", + "description": "Unified AI Assistant for Legal CRM" +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/routes.json b/files/custom/Espo/Modules/SmartAssistant/Resources/routes.json new file mode 100644 index 0000000..f05e8a0 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/routes.json @@ -0,0 +1,82 @@ +[ + { + "route": "/SmartAssistant/action/chat", + "method": "post", + "params": { + "controller": "SmartAssistant", + "action": "chat" + } + }, + { + "route": "/SmartAssistant/action/execute", + "method": "post", + "params": { + "controller": "SmartAssistant", + "action": "execute" + } + }, + { + "route": "/SmartAssistant/action/summary", + "method": "get", + "params": { + "controller": "SmartAssistant", + "action": "summary" + } + }, + { + "route": "/SmartAssistant/action/alerts", + "method": "get", + "params": { + "controller": "SmartAssistant", + "action": "alerts" + } + }, + { + "route": "/SmartAssistant/action/history", + "method": "get", + "params": { + "controller": "SmartAssistant", + "action": "history" + } + }, + { + "route": "/SmartAssistant/action/conversations", + "method": "get", + "params": { + "controller": "SmartAssistant", + "action": "conversations" + } + }, + { + "route": "/SmartAssistant/action/conversationMessages", + "method": "get", + "params": { + "controller": "SmartAssistant", + "action": "conversationMessages" + } + }, + { + "route": "/SmartAssistant/action/searchHistory", + "method": "get", + "params": { + "controller": "SmartAssistant", + "action": "searchHistory" + } + }, + { + "route": "/SmartAssistant/action/caseMemory", + "method": "get", + "params": { + "controller": "SmartAssistant", + "action": "caseMemory" + } + }, + { + "route": "/SmartAssistant/action/saveMemory", + "method": "post", + "params": { + "controller": "SmartAssistant", + "action": "saveMemory" + } + } +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php b/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php new file mode 100644 index 0000000..a8ceb02 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php @@ -0,0 +1,231 @@ +entityManager = $entityManager; + $this->injectableFactory = $injectableFactory; + $this->log = $log; + } + + public const VALID_STATUSES = [ + 'New' => 'חדש', 'Assigned' => 'בטיפול', 'Pending' => 'ממתין', + 'PendingHearing' => 'ממתין לדיון', 'PendingDecision' => 'ממתין להחלטה', + 'PendingResponse' => 'ממתין לתשובה', 'Negotiation' => 'משא ומתן', + 'Suspended' => 'מושהה', 'ClosedSettlement' => 'סגור - הסכם', + 'ClosedJudgment' => 'סגור - פס״ד', 'Closed' => 'סגור', 'Rejected' => 'נדחה', + ]; + + public function execute(string $tool, array $params, ?string $caseId, string $userId): array + { + $this->log->debug("SmartAssistant: Executing tool={$tool} for case={$caseId}"); + + return match ($tool) { + 'create_task' => $this->createTask($params, $caseId, $userId), + 'add_note' => $this->addNote($params, $caseId, $userId), + 'change_status' => $this->changeStatus($params, $caseId), + 'create_meeting' => $this->createMeeting($params, $caseId, $userId), + 'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId), + 'list_documents' => $this->listDocuments($caseId), + 'analyze_document' => $this->analyzeDocument($params, $caseId), + 'rename_document' => $this->renameDocument($params), + 'save_memory' => $this->saveMemory($params, $caseId, $userId), + default => throw new Error("Unknown tool: {$tool}"), + }; + } + + private function saveMemory(array $params, ?string $caseId, string $userId): array + { + if (!$caseId) { + throw new Error('save_memory requires a case context.'); + } + + $service = $this->injectableFactory->create(CaseMemoryService::class); + $content = $params['content'] ?? ''; + $name = $params['name'] ?? mb_substr($content, 0, 100); + + $entry = $service->createMemory( + $caseId, + $params['category'] ?? 'key_facts', + $name, + $content, + $params['importance'] ?? 'normal', + 'assistant' + ); + + return [ + 'success' => true, + 'message' => "נשמר בזיכרון התיק: \"{$name}\"", + 'entityType' => 'CaseMemory', + 'entityId' => $entry->get('id'), + ]; + } + + private function createTask(array $params, ?string $caseId, string $userId): array + { + $task = $this->entityManager->getNewEntity('Task'); + $task->set([ + '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, + ]); + $this->entityManager->saveEntity($task); + return ['success' => true, 'message' => "משימה \"{$params['name']}\" נוצרה בהצלחה", 'entityType' => 'Task', 'entityId' => $task->get('id')]; + } + + private function addNote(array $params, ?string $caseId, string $userId): array + { + $note = $this->entityManager->getNewEntity('Note'); + $note->set(['type' => 'Post', 'post' => $params['post'] ?? '', 'parentType' => 'Case', 'parentId' => $caseId, 'createdById' => $userId]); + $this->entityManager->saveEntity($note); + return ['success' => true, 'message' => 'הערה נוספה לתיק', 'entityType' => 'Note', 'entityId' => $note->get('id')]; + } + + private function changeStatus(array $params, ?string $caseId): array + { + $case = $this->entityManager->getEntityById('Case', $caseId); + if (!$case) throw new NotFound("Case {$caseId} not found."); + + $oldStatus = $case->get('status'); + $newStatus = $params['status'] ?? null; + if (!$newStatus) throw new Error('status parameter is required.'); + if (!isset(self::VALID_STATUSES[$newStatus])) { + throw new Error("Invalid status '{$newStatus}'. Valid: " . implode(', ', array_keys(self::VALID_STATUSES))); + } + + $case->set('status', $newStatus); + $case->set('cLegalStatusManual', true); + $this->entityManager->saveEntity($case); + + return ['success' => true, 'message' => "סטטוס תיק שונה מ-\"" . (self::VALID_STATUSES[$oldStatus] ?? $oldStatus) . "\" ל-\"" . self::VALID_STATUSES[$newStatus] . "\"", 'entityType' => 'Case', 'entityId' => $caseId]; + } + + private function createMeeting(array $params, ?string $caseId, string $userId): array + { + $meeting = $this->entityManager->getNewEntity('Meeting'); + $meeting->set([ + '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, + ]); + $this->entityManager->saveEntity($meeting); + return ['success' => true, 'message' => "פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')]; + } + + private function scheduleHearing(array $params, ?string $caseId, string $userId): array + { + $case = $this->entityManager->getEntityById('Case', $caseId); + if (!$case) throw new NotFound("Case {$caseId} not found."); + + $dateTime = $this->normalizeDateTime($params['dateTime'] ?? null); + if ($dateTime) { + $case->set('cNextHearing', $dateTime); + $this->entityManager->saveEntity($case); + } + + $meetingName = $params['description'] ?? 'דיון בבית המשפט'; + if (!empty($params['court'])) $meetingName .= ' - ' . $params['court']; + $dateEnd = $dateTime ? date('Y-m-d H:i:s', strtotime($dateTime) + 3600) : null; + + $meeting = $this->entityManager->getNewEntity('Meeting'); + $meeting->set([ + 'name' => $meetingName, 'status' => 'Planned', + 'dateStart' => $dateTime, 'dateEnd' => $dateEnd, + 'description' => $params['description'] ?? null, + 'parentType' => 'Case', 'parentId' => $caseId, 'assignedUserId' => $userId, + ]); + $this->entityManager->saveEntity($meeting); + return ['success' => true, 'message' => "דיון נקבע ל-{$dateTime}", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')]; + } + + private function listDocuments(?string $caseId): array + { + if (!$caseId) return ['success' => true, 'message' => 'לא ניתן להציג מסמכים ללא תיק']; + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + $result = $analyzer->listDocumentsRecursive($caseId); + + if ($result['totalFiles'] === 0) return ['success' => true, 'message' => 'לא נמצאו מסמכים בתיק']; + + $lines = ["נמצאו {$result['totalFiles']} מסמכים בתיק:", '']; + foreach ($result['folders'] as $folder) { + $lines[] = "📁 {$folder['name']} (" . count($folder['files']) . " קבצים)"; + foreach ($folder['files'] as $file) { + $lines[] = " - {$file['name']} (" . $this->formatFileSize($file['size']) . ")"; + } + } + if (!empty($result['rootFiles'])) { + $lines[] = ''; + $lines[] = "📄 קבצים בתיקייה הראשית:"; + foreach ($result['rootFiles'] as $file) { + $lines[] = " - {$file['name']} (" . $this->formatFileSize($file['size']) . ")"; + } + } + + return ['success' => true, 'message' => implode("\n", $lines), 'data' => $result]; + } + + private function analyzeDocument(array $params, ?string $caseId): array + { + $filePath = $params['filePath'] ?? null; + if (!$filePath) throw new Error('filePath parameter is required.'); + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + $lines = ["ניתוח מסמך: " . basename($filePath)]; + $text = $analyzer->extractTextContent($filePath); + + if ($text) { + $lines = array_merge($lines, ['', 'תוכן מתוך המסמך:', '---', $text, '---', '', 'ניתן להשתמש בכלי rename_document כדי לשנות את שם הקובץ.']); + } else { + $lines[] = 'לא ניתן לחלץ טקסט מהמסמך'; + } + + return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]]; + } + + private function renameDocument(array $params): array + { + $sourcePath = $params['sourcePath'] ?? null; + $newName = $params['newName'] ?? null; + if (!$sourcePath) throw new Error('sourcePath parameter is required.'); + if (!$newName) throw new Error('newName parameter is required.'); + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + $result = $analyzer->renameDocument($sourcePath, $newName); + return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result]; + } + + private function normalizeDateTime(?string $value): ?string + { + if ($value === null || $value === '') return null; + $ts = strtotime($value); + return $ts === false ? null : date('Y-m-d H:i:s', $ts); + } + + private function formatFileSize(?int $bytes): string + { + if ($bytes === null) return '?'; + if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB'; + if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB'; + return $bytes . ' B'; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php b/files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php new file mode 100644 index 0000000..03e51bb --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/AlertCalculator.php @@ -0,0 +1,266 @@ +entityManager = $entityManager; + $this->acl = $acl; + } + + public function calculateAlerts(array $thresholds = []): array + { + $inactivityWarningDays = $thresholds['inactivityWarningDays'] ?? 14; + $inactivityCriticalDays = $thresholds['inactivityCriticalDays'] ?? 30; + $upcomingHearingDays = $thresholds['upcomingHearingDays'] ?? 7; + + $alerts = []; + $alerts = array_merge($alerts, $this->findInactiveCases($inactivityWarningDays, $inactivityCriticalDays)); + $alerts = array_merge($alerts, $this->findOverdueTasks()); + $alerts = array_merge($alerts, $this->findUpcomingHearingsWithoutPrep($upcomingHearingDays)); + $alerts = array_merge($alerts, $this->findUnassignedNewCases()); + $alerts = array_merge($alerts, $this->findUpcomingTasks()); + + $severityOrder = ['critical' => 0, 'warning' => 1, 'info' => 2]; + usort($alerts, fn($a, $b) => ($severityOrder[$a['severity']] ?? 3) <=> ($severityOrder[$b['severity']] ?? 3)); + + return $alerts; + } + + public function getSummary(): array + { + $today = date('Y-m-d'); + $weekEnd = date('Y-m-d', strtotime('+7 days')); + + $totalOpenCases = $this->entityManager->getRDBRepository('Case') + ->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(); + + $upcomingHearings = $this->entityManager->getRDBRepository('Case') + ->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count(); + + $alerts = $this->calculateAlerts(); + $criticalCount = count(array_filter($alerts, fn($a) => $a['severity'] === 'critical')); + $warningCount = count(array_filter($alerts, fn($a) => $a['severity'] === 'warning')); + + return [ + 'totalOpenCases' => $totalOpenCases, + 'totalOverdueTasks' => $totalOverdueTasks, + 'upcomingHearings7d' => $upcomingHearings, + 'casesNeedingAttention' => $criticalCount + $warningCount, + 'criticalAlerts' => $criticalCount, + 'warningAlerts' => $warningCount, + ]; + } + + private function findInactiveCases(int $warningDays, int $criticalDays): array + { + $alerts = []; + $now = new \DateTime(); + $warningThreshold = (new \DateTime())->modify("-{$warningDays} days")->format('Y-m-d H:i:s'); + $criticalThreshold = (new \DateTime())->modify("-{$criticalDays} days")->format('Y-m-d H:i:s'); + + $cases = $this->entityManager->getRDBRepository('Case') + ->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt']) + ->where([ + 'status!=' => self::CLOSED_STATUSES, 'deleted' => false, + 'OR' => [ + ['cLastActivityAt<' => $warningThreshold, 'cLastActivityAt!=' => null], + ['cLastActivityAt' => null, 'createdAt<' => $warningThreshold], + ], + ])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find(); + + foreach ($cases as $case) { + $lastActivity = $case->get('cLastActivityAt') ?? $case->get('createdAt'); + if (!$lastActivity) continue; + $daysSince = $now->diff(new \DateTime($lastActivity))->days; + $severity = ($lastActivity < $criticalThreshold) ? 'critical' : 'warning'; + + $alerts[] = [ + 'type' => 'inactive_case', 'severity' => $severity, + 'caseId' => $case->get('id'), 'caseName' => $case->get('name'), + 'assignedUser' => $case->get('assignedUserName'), + 'daysSinceActivity' => $daysSince, + 'message' => $case->get('name') . ' - ' . $daysSince . ' ימים ללא פעילות', + ]; + } + return $alerts; + } + + private function findOverdueTasks(): array + { + $alerts = []; + $today = date('Y-m-d'); + + $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']) + ->order('dateEnd', 'ASC')->limit(0, 50)->find(); + + foreach ($tasks as $task) { + $daysOverdue = (new \DateTime())->diff(new \DateTime($task->get('dateEnd')))->days; + $alerts[] = [ + 'type' => 'overdue_task', 'severity' => 'critical', + 'taskId' => $task->get('id'), 'taskName' => $task->get('name'), + 'caseId' => $task->get('parentId'), 'caseName' => $task->get('parentName'), + 'assignedUser' => $task->get('assignedUserName'), + 'daysOverdue' => $daysOverdue, + 'message' => $task->get('parentName') . ' - משימה "' . $task->get('name') . '" באיחור ' . $daysOverdue . ' ימים', + ]; + } + return $alerts; + } + + private function findUpcomingHearingsWithoutPrep(int $hearingDays): array + { + $alerts = []; + $today = date('Y-m-d'); + $threshold = date('Y-m-d', strtotime("+{$hearingDays} days")); + $recentActivityThreshold = date('Y-m-d H:i:s', strtotime('-3 days')); + $recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days')); + + $cases = $this->entityManager->getRDBRepository('Case') + ->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt']) + ->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false]) + ->order('cNextHearing', 'ASC')->find(); + + foreach ($cases as $case) { + $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; + + $daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days; + $severity = ($daysUntil <= 1) ? 'critical' : 'warning'; + + $alerts[] = [ + 'type' => 'hearing_no_prep', 'severity' => $severity, + 'caseId' => $case->get('id'), 'caseName' => $case->get('name'), + 'assignedUser' => $case->get('assignedUserName'), + 'hearingDate' => $case->get('cNextHearing'), 'daysUntil' => $daysUntil, + 'message' => $case->get('name') . ' - דיון בעוד ' . $daysUntil . ' ימים, אין משימת הכנה', + ]; + } + return $alerts; + } + + private function findUnassignedNewCases(): array + { + $alerts = []; + $threshold = date('Y-m-d H:i:s', strtotime('-3 days')); + + $cases = $this->entityManager->getRDBRepository('Case') + ->select(['id', 'name', 'createdAt'])->where(['status' => 'New', 'createdAt<' => $threshold, 'deleted' => false]) + ->order('createdAt', 'ASC')->limit(0, 20)->find(); + + foreach ($cases as $case) { + $daysSince = (new \DateTime())->diff(new \DateTime($case->get('createdAt')))->days; + $alerts[] = [ + 'type' => 'unassigned_case', 'severity' => 'warning', + 'caseId' => $case->get('id'), 'caseName' => $case->get('name'), + 'daysSinceCreated' => $daysSince, + 'message' => $case->get('name') . ' - תיק חדש לא שובץ (' . $daysSince . ' ימים)', + ]; + } + return $alerts; + } + + private function findUpcomingTasks(): array + { + $alerts = []; + $today = date('Y-m-d'); + $threshold = date('Y-m-d', strtotime('+3 days')); + + $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']) + ->order('dateEnd', 'ASC')->limit(0, 30)->find(); + + foreach ($tasks as $task) { + $daysUntil = (new \DateTime())->diff(new \DateTime($task->get('dateEnd')))->days; + $alerts[] = [ + 'type' => 'upcoming_task', 'severity' => 'info', + 'taskId' => $task->get('id'), 'taskName' => $task->get('name'), + 'caseId' => $task->get('parentId'), 'caseName' => $task->get('parentName'), + 'assignedUser' => $task->get('assignedUserName'), + 'daysUntil' => $daysUntil, + 'message' => $task->get('parentName') . ' - משימה "' . $task->get('name') . '" בעוד ' . $daysUntil . ' ימים', + ]; + } + return $alerts; + } + + public function getCasesByStatus(): array + { + $result = []; + $statuses = ['New', 'Assigned', 'Pending', 'PendingHearing', 'PendingDecision', 'PendingResponse', 'Negotiation', 'Suspended', 'ClosedSettlement', 'ClosedJudgment', 'Closed', 'Rejected']; + + foreach ($statuses as $status) { + $count = $this->entityManager->getRDBRepository('Case')->where(['status' => $status, 'deleted' => false])->count(); + if ($count > 0) $result[$status] = $count; + } + return $result; + } + + public function getWorkloadByUser(): array + { + $userCounts = []; + $cases = $this->entityManager->getRDBRepository('Case') + ->select(['assignedUserId', 'assignedUserName']) + ->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false, 'assignedUserId!=' => null])->find(); + + foreach ($cases as $case) { + $uid = $case->get('assignedUserId'); + if (!isset($userCounts[$uid])) { + $userCounts[$uid] = ['userId' => $uid, 'userName' => $case->get('assignedUserName'), 'openCases' => 0, 'openTasks' => 0]; + } + $userCounts[$uid]['openCases']++; + } + + 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(); + } + + return array_values($userCounts); + } + + public function getUpcomingHearings(int $days = 7): array + { + $today = date('Y-m-d'); + $threshold = date('Y-m-d', strtotime("+{$days} days")); + + $result = []; + $cases = $this->entityManager->getRDBRepository('Case') + ->select(['id', 'name', 'cNextHearing', 'cCourt', 'cJudge', 'assignedUserName', 'status']) + ->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false]) + ->order('cNextHearing', 'ASC')->find(); + + foreach ($cases as $case) { + $result[] = [ + 'caseId' => $case->get('id'), 'caseName' => $case->get('name'), + 'hearingDate' => $case->get('cNextHearing'), 'court' => $case->get('cCourt'), + 'judge' => $case->get('cJudge'), 'assignedUser' => $case->get('assignedUserName'), + ]; + } + return $result; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php b/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php new file mode 100644 index 0000000..9a3e142 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php @@ -0,0 +1,177 @@ +entityManager = $entityManager; + $this->injectableFactory = $injectableFactory; + $this->log = $log; + } + + public function buildContext(string $caseId, string $userId): array + { + $case = $this->entityManager->getEntityById('Case', $caseId); + if (!$case) return ['error' => 'Case not found']; + + return [ + 'case' => $this->getCaseData($case), + 'contacts' => $this->getCaseContacts($case), + 'openTasks' => $this->getOpenTasks($caseId), + 'upcomingMeetings' => $this->getUpcomingMeetings($caseId), + 'recentNotes' => $this->getRecentNotes($caseId), + 'availableTemplates' => $this->getAvailableTemplates(), + 'documents' => $this->getDocumentListing($caseId), + 'currentUser' => $this->getUserData($userId), + 'validStatuses' => ActionExecutor::VALID_STATUSES, + ]; + } + + private function getCaseData($case): array + { + return [ + 'id' => $case->get('id'), + 'name' => $case->get('name'), + 'number' => $case->get('number'), + 'status' => $case->get('status'), + 'type' => $case->get('type'), + 'description' => $case->get('description'), + 'cCourt' => $case->get('cCourt'), + 'cCourtCaseNumber' => $case->get('cCourtCaseNumber'), + 'cJudge' => $case->get('cJudge'), + 'cJudgeTitle' => $case->get('cJudgeTitle'), + 'cNextHearing' => $case->get('cNextHearing'), + 'cFilingDate' => $case->get('cFilingDate'), + 'cLegalAidNumber' => $case->get('cLegalAidNumber'), + 'cLegalAidStatus' => $case->get('cLegalAidStatus'), + 'assignedUserName' => $case->get('assignedUserName'), + 'assignedUserId' => $case->get('assignedUserId'), + 'accountName' => $case->get('accountName'), + 'accountId' => $case->get('accountId'), + ]; + } + + private function getCaseContacts($case): array + { + $contacts = []; + $collection = $this->entityManager->getRDBRepository('Case') + ->getRelation($case, 'contacts')->limit(0, 20)->find(); + + foreach ($collection as $c) { + $contacts[] = [ + 'id' => $c->get('id'), + 'name' => $c->get('name'), + 'role' => $c->get('contactRole') ?? '', + 'phoneNumber' => $c->get('phoneNumber'), + 'emailAddress' => $c->get('emailAddress'), + ]; + } + return $contacts; + } + + private function getOpenTasks(string $caseId): array + { + $tasks = []; + $collection = $this->entityManager->getRDBRepository('Task') + ->where(['parentType' => 'Case', 'parentId' => $caseId, 'status!=' => ['Completed', 'Canceled']]) + ->order('dateEnd', 'ASC')->limit(0, 10)->find(); + + foreach ($collection as $t) { + $tasks[] = [ + 'id' => $t->get('id'), 'name' => $t->get('name'), 'status' => $t->get('status'), + 'dateEnd' => $t->get('dateEnd'), 'priority' => $t->get('priority'), + 'assignedUserName' => $t->get('assignedUserName'), + ]; + } + return $tasks; + } + + private function getUpcomingMeetings(string $caseId): array + { + $meetings = []; + $collection = $this->entityManager->getRDBRepository('Meeting') + ->where(['parentType' => 'Case', 'parentId' => $caseId, 'status' => 'Planned', 'dateStart>=' => date('Y-m-d H:i:s')]) + ->order('dateStart', 'ASC')->limit(0, 5)->find(); + + foreach ($collection as $m) { + $meetings[] = [ + 'id' => $m->get('id'), 'name' => $m->get('name'), + 'dateStart' => $m->get('dateStart'), 'dateEnd' => $m->get('dateEnd'), + 'description' => $m->get('description'), + ]; + } + return $meetings; + } + + private function getRecentNotes(string $caseId): array + { + $notes = []; + $collection = $this->entityManager->getRDBRepository('Note') + ->where(['parentType' => 'Case', 'parentId' => $caseId, 'type' => 'Post']) + ->order('createdAt', 'DESC')->limit(0, 10)->find(); + + foreach ($collection as $n) { + $notes[] = [ + 'post' => $n->get('post'), 'createdAt' => $n->get('createdAt'), + 'createdByName' => $n->get('createdByName'), + ]; + } + return $notes; + } + + private function getAvailableTemplates(): array + { + $templates = []; + $collection = $this->entityManager->getRDBRepository('DocumentTemplate') + ->where(['entityType' => 'Case'])->order('name', 'ASC')->find(); + + foreach ($collection as $t) { + $templates[] = ['id' => $t->get('id'), 'name' => $t->get('name')]; + } + return $templates; + } + + private function getUserData(string $userId): array + { + $user = $this->entityManager->getEntityById('User', $userId); + if (!$user) return ['id' => $userId, 'name' => 'Unknown']; + return ['id' => $user->get('id'), 'name' => $user->get('name')]; + } + + private function getDocumentListing(string $caseId): array + { + try { + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + $result = $analyzer->listDocumentsRecursive($caseId); + + $simplified = ['totalFiles' => $result['totalFiles'], 'folders' => [], 'rootFiles' => []]; + + foreach ($result['folders'] as $folder) { + $files = []; + foreach ($folder['files'] as $file) { + $files[] = ['name' => $file['name'], 'size' => $file['size'], 'mimeType' => $file['mimeType'], 'path' => $file['path']]; + } + $simplified['folders'][] = ['name' => $folder['name'], 'fileCount' => count($files), 'files' => $files]; + } + + foreach ($result['rootFiles'] as $file) { + $simplified['rootFiles'][] = ['name' => $file['name'], 'size' => $file['size'], 'mimeType' => $file['mimeType'], 'path' => $file['path']]; + } + + return $simplified; + } catch (\Exception $e) { + $this->log->debug("SmartAssistant: Document listing unavailable: " . $e->getMessage()); + return []; + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/CaseMemoryContextProvider.php b/files/custom/Espo/Modules/SmartAssistant/Services/CaseMemoryContextProvider.php new file mode 100644 index 0000000..b975788 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/CaseMemoryContextProvider.php @@ -0,0 +1,87 @@ +entityManager = $entityManager; + $this->log = $log; + } + + public function getContextForCase(string $caseId): array + { + $entries = $this->entityManager->getRDBRepository('CaseMemory') + ->where(['caseId' => $caseId, 'isArchived' => false, 'deleted' => false]) + ->order([['isPinned', 'DESC'], ['importance', 'ASC'], ['createdAt', 'DESC']]) + ->find(); + + $grouped = []; + $totalChars = 0; + $totalEntries = 0; + $pinnedCount = 0; + $criticalCount = 0; + + // Priority order for importance + $importancePriority = ['critical' => 0, 'high' => 1, 'normal' => 2, 'low' => 3]; + + // Sort: pinned first, then by importance, then by date + $sorted = []; + foreach ($entries as $entry) { + $sorted[] = $entry; + } + + usort($sorted, function ($a, $b) use ($importancePriority) { + if ($a->get('isPinned') !== $b->get('isPinned')) { + return $b->get('isPinned') <=> $a->get('isPinned'); + } + $aP = $importancePriority[$a->get('importance')] ?? 4; + $bP = $importancePriority[$b->get('importance')] ?? 4; + if ($aP !== $bP) return $aP <=> $bP; + return strcmp($b->get('createdAt') ?? '', $a->get('createdAt') ?? ''); + }); + + foreach ($sorted as $entry) { + $content = $entry->get('content') ?? ''; + $contentLen = mb_strlen($content); + + // Check budget + if ($totalChars + $contentLen > self::MAX_CONTEXT_CHARS && !$entry->get('isPinned') && $entry->get('importance') !== 'critical') { + continue; + } + + $cat = $entry->get('category'); + if (!isset($grouped[$cat])) $grouped[$cat] = []; + + $grouped[$cat][] = [ + 'content' => $content, + 'importance' => $entry->get('importance'), + 'pinned' => $entry->get('isPinned'), + 'createdAt' => $entry->get('createdAt'), + ]; + + $totalChars += $contentLen; + $totalEntries++; + + if ($entry->get('isPinned')) $pinnedCount++; + if ($entry->get('importance') === 'critical') $criticalCount++; + } + + $grouped['_summary'] = [ + 'totalEntries' => $totalEntries, + 'pinnedCount' => $pinnedCount, + 'criticalCount' => $criticalCount, + ]; + + return $grouped; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/CaseMemoryService.php b/files/custom/Espo/Modules/SmartAssistant/Services/CaseMemoryService.php new file mode 100644 index 0000000..477e22b --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/CaseMemoryService.php @@ -0,0 +1,105 @@ +entityManager = $entityManager; + $this->log = $log; + } + + public function createMemory( + string $caseId, + string $category, + string $name, + string $content, + string $importance = 'normal', + string $source = 'manual', + ?string $sourceEntityType = null, + ?string $sourceEntityId = null + ): Entity { + $entry = $this->entityManager->getNewEntity('CaseMemory'); + $entry->set([ + 'name' => $name, + 'caseId' => $caseId, + 'category' => $category, + 'content' => $content, + 'importance' => $importance, + 'source' => $source, + 'sourceEntityType' => $sourceEntityType, + 'sourceEntityId' => $sourceEntityId, + ]); + $this->entityManager->saveEntity($entry); + + $this->log->info("SmartAssistant: Created CaseMemory id={$entry->get('id')} for case={$caseId} category={$category}"); + + return $entry; + } + + public function getMemoriesByCase(string $caseId, ?string $category = null, bool $includeArchived = false): array + { + $where = ['caseId' => $caseId, 'deleted' => false]; + + if ($category) { + $where['category'] = $category; + } + + if (!$includeArchived) { + $where['isArchived'] = false; + } + + $entries = $this->entityManager->getRDBRepository('CaseMemory') + ->where($where) + ->order([['isPinned', 'DESC'], ['createdAt', 'DESC']]) + ->find(); + + $grouped = []; + foreach ($entries as $entry) { + $cat = $entry->get('category'); + if (!isset($grouped[$cat])) $grouped[$cat] = []; + $grouped[$cat][] = [ + 'id' => $entry->get('id'), + 'name' => $entry->get('name'), + 'content' => $entry->get('content'), + 'category' => $cat, + 'importance' => $entry->get('importance'), + 'source' => $entry->get('source'), + 'isPinned' => $entry->get('isPinned'), + 'isArchived' => $entry->get('isArchived'), + 'createdAt' => $entry->get('createdAt'), + 'modifiedAt' => $entry->get('modifiedAt'), + ]; + } + + return $grouped; + } + + public function getPinnedMemories(string $caseId): array + { + $entries = $this->entityManager->getRDBRepository('CaseMemory') + ->where(['caseId' => $caseId, 'isPinned' => true, 'isArchived' => false, 'deleted' => false]) + ->order('createdAt', 'DESC') + ->find(); + + $result = []; + foreach ($entries as $entry) { + $result[] = [ + 'id' => $entry->get('id'), + 'name' => $entry->get('name'), + 'content' => $entry->get('content'), + 'category' => $entry->get('category'), + 'importance' => $entry->get('importance'), + ]; + } + return $result; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/ConversationRepository.php b/files/custom/Espo/Modules/SmartAssistant/Services/ConversationRepository.php new file mode 100644 index 0000000..1cb1301 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/ConversationRepository.php @@ -0,0 +1,154 @@ +entityManager = $entityManager; + $this->user = $user; + $this->log = $log; + } + + public function startOrResume(string $type, ?string $conversationKey = null, ?string $caseId = null): array + { + if ($conversationKey) { + $entity = $this->entityManager->getRDBRepository('AssistantConversation') + ->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne(); + if ($entity) return ['id' => $entity->get('id'), 'conversationKey' => $conversationKey]; + } + + $conversationKey = $conversationKey ?: uniqid($type === 'office' ? 'oconv_' : 'conv_', true); + $entity = $this->entityManager->getNewEntity('AssistantConversation'); + $entity->set([ + 'name' => $type === 'office' ? 'שיחה עם העוזר החכם' : 'שיחה עם העוזר', + 'conversationType' => $type, 'status' => 'Active', + 'conversationKey' => $conversationKey, + 'assignedUserId' => $this->user->getId(), + 'messageCount' => 0, 'messagesData' => [], + 'lastMessageAt' => date('Y-m-d H:i:s'), + ]); + if ($caseId && $type === 'case') $entity->set('caseId', $caseId); + $this->entityManager->saveEntity($entity); + + return ['id' => $entity->get('id'), 'conversationKey' => $conversationKey]; + } + + public function appendMessage(string $conversationKey, string $role, string $content, array $actions = []): void + { + $entity = $this->entityManager->getRDBRepository('AssistantConversation') + ->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne(); + + if (!$entity) { + $this->startOrResume('office', $conversationKey); + $entity = $this->entityManager->getRDBRepository('AssistantConversation') + ->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne(); + if (!$entity) return; + } + + $messages = $entity->get('messagesData') ?? []; + if (is_object($messages)) $messages = (array) $messages; + + $newMessage = ['role' => $role, 'content' => $content, 'timestamp' => date('Y-m-d H:i:s')]; + if (!empty($actions)) $newMessage['actions'] = $actions; + $messages[] = $newMessage; + + if ($role === 'user' && $entity->get('messageCount') === 0) { + $title = mb_substr($content, 0, 100); + if (mb_strlen($content) > 100) $title .= '...'; + $entity->set('name', $title); + } + + $entity->set([ + 'messagesData' => $messages, 'messageCount' => count($messages), + 'lastMessageAt' => date('Y-m-d H:i:s'), + 'lastMessagePreview' => mb_substr($content, 0, 200), + ]); + $this->entityManager->saveEntity($entity); + } + + public function getMessages(string $conversationKey): array + { + $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; + } + + public function getRecentConversations(?string $type = null, ?string $caseId = null, int $limit = 20): array + { + $where = ['assignedUserId' => $this->user->getId(), 'deleted' => false]; + if ($type) $where['conversationType'] = $type; + if ($caseId) $where['caseId'] = $caseId; + + $result = []; + foreach ($this->entityManager->getRDBRepository('AssistantConversation') + ->select(['id', 'name', 'conversationType', 'conversationKey', 'status', 'messageCount', 'lastMessageAt', 'lastMessagePreview', 'caseId', 'caseName']) + ->where($where)->order('lastMessageAt', 'DESC')->limit(0, $limit)->find() as $conv) { + $result[] = [ + 'id' => $conv->get('id'), 'name' => $conv->get('name'), + 'conversationType' => $conv->get('conversationType'), + 'conversationKey' => $conv->get('conversationKey'), + 'status' => $conv->get('status'), 'messageCount' => $conv->get('messageCount'), + 'lastMessageAt' => $conv->get('lastMessageAt'), + 'lastMessagePreview' => $conv->get('lastMessagePreview'), + 'caseId' => $conv->get('caseId'), 'caseName' => $conv->get('caseName'), + ]; + } + return $result; + } + + public function searchConversations(string $query, ?string $type = null, int $limit = 20): array + { + $where = ['deleted' => false, 'OR' => [['name*' => "%{$query}%"], ['lastMessagePreview*' => "%{$query}%"]]]; + if ($type) $where['conversationType'] = $type; + + $result = []; + foreach ($this->entityManager->getRDBRepository('AssistantConversation') + ->select(['id', 'name', 'conversationType', 'conversationKey', 'messageCount', 'lastMessageAt', 'lastMessagePreview', 'caseId', 'caseName', 'assignedUserName']) + ->where($where)->order('lastMessageAt', 'DESC')->limit(0, $limit)->find() as $conv) { + $result[] = [ + 'id' => $conv->get('id'), 'name' => $conv->get('name'), + 'conversationType' => $conv->get('conversationType'), + 'conversationKey' => $conv->get('conversationKey'), + 'messageCount' => $conv->get('messageCount'), + 'lastMessageAt' => $conv->get('lastMessageAt'), + 'lastMessagePreview' => $conv->get('lastMessagePreview'), + 'caseId' => $conv->get('caseId'), 'caseName' => $conv->get('caseName'), + 'assignedUser' => $conv->get('assignedUserName'), + ]; + } + return $result; + } + + public function storePendingActions(string $conversationKey, string $caseId, string $userId, array $actions): void + { + $entity = $this->entityManager->getRDBRepository('AssistantConversation') + ->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne(); + if (!$entity) return; + + $data = $entity->get('messagesData') ?? []; + if (is_object($data)) $data = (array) $data; + for ($i = count($data) - 1; $i >= 0; $i--) { + if (($data[$i]['role'] ?? '') === 'assistant') { $data[$i]['pendingActions'] = $actions; break; } + } + $entity->set('messagesData', $data); + $this->entityManager->saveEntity($entity); + + $dir = 'data/tmp/assistant-conversations'; + if (!is_dir($dir)) mkdir($dir, 0755, true); + file_put_contents($dir . '/' . $conversationKey . '.json', json_encode([ + 'caseId' => $caseId, 'userId' => $userId, 'actions' => $actions, 'createdAt' => date('Y-m-d H:i:s'), + ])); + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php b/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php new file mode 100644 index 0000000..d370979 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php @@ -0,0 +1,179 @@ + ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'], + 'החלטות_בית_משפט' => ['החלטה', 'פסק_דין', 'פסק דין', 'גזר_דין', 'גזר דין', 'צו', 'פרוטוקול', 'פס"ד', 'גז"ד'], + 'חוזים' => ['חוזה', 'הסכם', 'נספח', 'תוספת'], + 'התכתבויות' => ['מכתב', 'פקס', 'fax', 'letter', 'דואר'], + 'מסמכי_זיהוי' => ['ת.ז.', 'ת"ז', 'תעודת_זהות', 'תעודת זהות', 'דרכון', 'רישיון', 'passport', 'תעודה'], + 'ייפויי_כוח' => ['ייפוי כוח', 'ייפוי_כוח', 'יפוי כח', 'יפוי_כח'], + 'חוות_דעת' => ['חוות_דעת', 'חוות דעת', 'חו"ד', 'חוד'], + 'מסמכים_מסיוע_משפטי' => ['סיוע משפטי', 'סיוע_משפטי', 'לשכת סיוע'], + 'מסמכים_מהביטוח_הלאומי' => ['ביטוח לאומי', 'ביטוח_לאומי', 'בל"ל', 'המוסד לביטוח'], + ]; + + public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log) + { + $this->entityManager = $entityManager; + $this->injectableFactory = $injectableFactory; + $this->log = $log; + } + + public function listDocumentsRecursive(string $caseId): array + { + $casePath = $this->getCaseFolderPath($caseId); + if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []]; + + $storage = $this->getStorage(); + $topLevel = $storage->listFolder($casePath); + $folders = []; $rootFiles = []; $totalFiles = 0; + + foreach ($topLevel as $item) { + if ($item['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]; + $totalFiles++; + } + } + } catch (\Exception $e) { + $this->log->warning("SmartAssistant: Failed to list subfolder {$item['path']}: " . $e->getMessage()); + } + $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]; + $totalFiles++; + } + } + + return ['casePath' => $casePath, 'totalFiles' => $totalFiles, 'folders' => $folders, 'rootFiles' => $rootFiles]; + } + + public function extractTextContent(string $filePath): ?string + { + $storage = $this->getStorage(); + $parentPath = dirname($filePath); + $filename = basename($filePath); + + try { $items = $storage->listFolder($parentPath); } 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 (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true); + $tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename; + file_put_contents($tmpFile, $content); + + $text = null; + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + + try { + if ($ext === 'pdf') $text = $this->extractFromPdf($tmpFile); + elseif (in_array($ext, ['docx', 'doc'])) $text = $this->extractFromDocx($tmpFile); + elseif (in_array($ext, ['txt', 'csv', 'log'])) $text = $content; + } catch (\Exception $e) { + $this->log->warning("SmartAssistant: Text extraction failed: " . $e->getMessage()); + } + + 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); + } + + public function renameDocument(string $sourcePath, string $newName): array + { + $storage = $this->getStorage(); + $parentPath = dirname($sourcePath); + $oldName = basename($sourcePath); + + $oldExt = pathinfo($oldName, PATHINFO_EXTENSION); + $newExt = pathinfo($newName, PATHINFO_EXTENSION); + if ($oldExt && (!$newExt || mb_strtolower($newExt) !== mb_strtolower($oldExt))) { + $newName = pathinfo($newName, PATHINFO_FILENAME) . '.' . $oldExt; + } + + $destination = $parentPath . '/' . $newName; + if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName]; + + $storage->move($sourcePath, $destination); + $this->updateDocumentEntity($sourcePath, $destination); + + return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName]; + } + + private function getCaseFolderPath(string $caseId): ?string + { + $case = $this->entityManager->getEntityById('Case', $caseId); + return $case ? ($case->get('nextCloudFolderPath') ?: null) : null; + } + + private function getStorage(): FileStorageInterface + { + return $this->injectableFactory->create(FileStorageFactory::class)->create(); + } + + private function extractFromPdf(string $tmpFile): ?string + { + $outputFile = $tmpFile . '.txt'; + exec("pdftotext -layout " . escapeshellarg($tmpFile) . " " . escapeshellarg($outputFile) . " 2>&1", $output, $returnCode); + if ($returnCode !== 0 || !file_exists($outputFile)) return null; + $text = file_get_contents($outputFile); + unlink($outputFile); + return $text ?: null; + } + + private function extractFromDocx(string $tmpFile): ?string + { + if (!class_exists(\PhpOffice\PhpWord\IOFactory::class)) return null; + $phpWord = \PhpOffice\PhpWord\IOFactory::load($tmpFile); + $text = ''; + foreach ($phpWord->getSections() as $section) { + foreach ($section->getElements() as $element) { + if (method_exists($element, 'getText')) $text .= $element->getText() . "\n"; + elseif (method_exists($element, 'getElements')) { + foreach ($element->getElements() as $child) { + if (method_exists($child, 'getText')) $text .= $child->getText() . "\n"; + } + } + } + } + return $text ?: null; + } + + private function updateDocumentEntity(string $oldPath, string $newPath): void + { + try { + $doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne(); + if ($doc) { + $doc->set('nextCloudPath', $newPath); + $doc->set('nextCloudFilename', basename($newPath)); + $this->entityManager->saveEntity($doc); + } + } catch (\Exception $e) {} + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/OfficeContextBuilder.php b/files/custom/Espo/Modules/SmartAssistant/Services/OfficeContextBuilder.php new file mode 100644 index 0000000..8f46ec9 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/OfficeContextBuilder.php @@ -0,0 +1,79 @@ +entityManager = $entityManager; + $this->acl = $acl; + $this->alertCalculator = $alertCalculator; + } + + public function buildSummaryContext(User $user, array $thresholds = []): array + { + $summary = $this->alertCalculator->getSummary(); + $alerts = array_slice($this->alertCalculator->calculateAlerts($thresholds), 0, 20); + + return [ + 'mode' => 'office', + 'summary' => $summary, + 'alerts' => $alerts, + 'casesByStatus' => $this->alertCalculator->getCasesByStatus(), + 'workloadByUser' => $this->alertCalculator->getWorkloadByUser(), + 'upcomingHearings' => $this->alertCalculator->getUpcomingHearings(7), + 'currentUser' => ['id' => $user->getId(), 'name' => $user->get('name')], + 'today' => date('Y-m-d'), + 'dayOfWeek' => date('l'), + ]; + } + + public function buildCaseDrillDown(string $caseId, User $user): ?array + { + $case = $this->entityManager->getEntityById('Case', $caseId); + if (!$case) return null; + + $caseData = [ + 'id' => $case->get('id'), 'name' => $case->get('name'), 'status' => $case->get('status'), + 'type' => $case->get('type'), 'assignedUser' => $case->get('assignedUserName'), + 'court' => $case->get('cCourt'), 'courtCaseNumber' => $case->get('cCourtCaseNumber'), + 'judge' => $case->get('cJudge'), 'nextHearing' => $case->get('cNextHearing'), + 'lastActivityAt' => $case->get('cLastActivityAt'), + 'lastActivityType' => $case->get('cLastActivityType'), + 'lastActivityDescription' => $case->get('cLastActivityDescription'), + 'createdAt' => $case->get('createdAt'), + ]; + + $contacts = []; + foreach ($this->entityManager->getRDBRepository('Case')->getRelation($case, 'contacts')->limit(0, 20)->find() as $c) { + $contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')]; + } + + $tasks = []; + foreach ($this->entityManager->getRDBRepository('Task') + ->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName']) + ->where(['parentId' => $caseId, 'parentType' => 'Case', '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')]; + } + + $notes = []; + foreach ($this->entityManager->getRDBRepository('Note') + ->select(['id', 'post', 'createdAt', 'createdByName', 'type']) + ->where(['parentId' => $caseId, 'parentType' => 'Case', 'type' => 'Post', 'deleted' => false]) + ->order('createdAt', 'DESC')->limit(0, 5)->find() as $n) { + $notes[] = ['text' => mb_substr($n->get('post') ?? '', 0, 200), 'createdAt' => $n->get('createdAt'), 'createdBy' => $n->get('createdByName')]; + } + + return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes]; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php b/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php new file mode 100644 index 0000000..7a6c391 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php @@ -0,0 +1,482 @@ +entityManager = $entityManager; + $this->injectableFactory = $injectableFactory; + $this->config = $config; + $this->log = $log; + $this->user = $user; + } + + private function getConversationRepo(): ConversationRepository + { + return $this->injectableFactory->create(ConversationRepository::class); + } + + public function chat(string $message, string $mode, ?string $caseId, ?string $conversationId): array + { + $userId = $this->user->getId(); + + // Build context based on mode + if ($mode === 'case' && $caseId) { + $contextBuilder = $this->injectableFactory->create(CaseContextBuilder::class); + $context = $contextBuilder->buildContext($caseId, $userId); + + // Add case memory to context + $memoryProvider = $this->injectableFactory->create(CaseMemoryContextProvider::class); + $context['caseMemory'] = $memoryProvider->getContextForCase($caseId); + $context['mode'] = 'case'; + } else { + $contextBuilder = $this->injectableFactory->create(OfficeContextBuilder::class); + $context = $contextBuilder->buildSummaryContext($this->user, $this->getThresholds()); + + // Auto-detect case drill-down + $drillDown = $this->detectDrillDown($message); + if ($drillDown) { + $context['drillDown'] = $drillDown; + } + } + + // Generate conversation ID + if (!$conversationId) { + $prefix = $mode === 'case' ? 'conv_' : 'oconv_'; + $conversationId = uniqid($prefix, true); + } + + // Persist conversation + try { + $convRepo = $this->getConversationRepo(); + $convRepo->startOrResume($mode, $conversationId, $caseId); + $convRepo->appendMessage($conversationId, 'user', $message); + } catch (\Exception $e) { + $this->log->warning('SmartAssistant: Failed to persist conversation: ' . $e->getMessage()); + } + + // Create stream note (only for case mode) + if ($mode === 'case' && $caseId) { + $this->createStreamNote(self::NOTE_TYPE_REQUEST, $message, $caseId, [ + 'conversationId' => $conversationId, + ]); + } + + // Load conversation history for context + $conversationHistory = []; + try { + $conversationHistory = $this->getConversationRepo()->getMessages($conversationId); + } catch (\Exception $e) { + // 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; + } + } + + 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, + ]; + + 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); + } + } + + // Persist response + try { + $this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $remainingActions); + } catch (\Exception $e) { + $this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage()); + } + + // Stream note for case mode + if ($mode === 'case' && $caseId) { + $this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [ + 'conversationId' => $conversationId, + 'actions' => $remainingActions, + ]); + } + + return [ + 'conversationId' => $conversationId, + 'text' => $responseText, + 'actions' => $remainingActions, + ]; + } + + public function executeAction(string $conversationId, string $actionId, bool $approved): array + { + $conversation = $this->loadConversation($conversationId); + + if (!$conversation) { + throw new NotFound("Conversation {$conversationId} not found."); + } + + $caseId = $conversation['caseId']; + $userId = $conversation['userId']; + $actions = $conversation['actions'] ?? []; + + $action = null; + foreach ($actions as $a) { + if (($a['actionId'] ?? '') === $actionId) { + $action = $a; + break; + } + } + + if (!$action) { + throw new NotFound("Action {$actionId} not found."); + } + + if (!$approved) { + if ($caseId) { + $this->createStreamNote(self::NOTE_TYPE_ACTION, 'פעולה נדחתה: ' . ($action['displayText'] ?? ''), $caseId, [ + 'conversationId' => $conversationId, + 'actionId' => $actionId, + 'status' => 'rejected', + ]); + } + + return ['success' => true, 'status' => 'rejected', 'message' => 'הפעולה נדחתה']; + } + + $executor = $this->injectableFactory->create(ActionExecutor::class); + + try { + $result = $executor->execute($action['tool'], $action['params'] ?? [], $caseId, $userId); + + if ($caseId) { + $this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $caseId, [ + 'conversationId' => $conversationId, + 'actionId' => $actionId, + 'status' => 'executed', + 'entityType' => $result['entityType'] ?? null, + 'entityId' => $result['entityId'] ?? null, + ]); + } + + return $result; + } catch (\Exception $e) { + $this->log->error("SmartAssistant: Action execution failed: " . $e->getMessage()); + + if ($caseId) { + $this->createStreamNote(self::NOTE_TYPE_ACTION, 'שגיאה: ' . $e->getMessage(), $caseId, [ + 'conversationId' => $conversationId, + 'actionId' => $actionId, + 'status' => 'error', + ]); + } + + return ['success' => false, 'message' => 'שגיאה בביצוע הפעולה: ' . $e->getMessage()]; + } + } + + public function getSummary(): array + { + $alertCalc = $this->injectableFactory->create(AlertCalculator::class); + $thresholds = $this->getThresholds(); + + return [ + 'summary' => $alertCalc->getSummary(), + 'alerts' => $alertCalc->calculateAlerts($thresholds), + ]; + } + + public function getAlerts(): array + { + $alertCalc = $this->injectableFactory->create(AlertCalculator::class); + return $alertCalc->calculateAlerts($this->getThresholds()); + } + + public function getHistory(?string $caseId): array + { + if ($caseId) { + // Case mode: return stream notes + $notes = $this->entityManager->getRDBRepository('Note') + ->where([ + 'parentType' => 'Case', + 'parentId' => $caseId, + 'type' => [self::NOTE_TYPE_REQUEST, self::NOTE_TYPE_RESPONSE, self::NOTE_TYPE_ACTION], + ]) + ->order('createdAt', 'DESC') + ->limit(0, 50) + ->find(); + + $history = []; + foreach ($notes as $note) { + $history[] = [ + 'id' => $note->get('id'), + 'type' => $note->get('type'), + 'post' => $note->get('post'), + 'data' => $note->get('data') ?? (object) [], + 'createdAt' => $note->get('createdAt'), + 'createdByName' => $note->get('createdByName'), + ]; + } + + return ['list' => array_reverse($history), 'total' => count($history)]; + } + + // Office mode: return latest conversation from today + $conversations = $this->getConversationRepo()->getRecentConversations('office', null, 1); + + if (empty($conversations)) return []; + + $latest = $conversations[0]; + $messages = $this->getConversationRepo()->getMessages($latest['conversationKey']); + + return [ + 'conversationId' => $latest['conversationKey'], + 'messages' => $messages, + 'updatedAt' => $latest['lastMessageAt'], + ]; + } + + public function getConversations(?string $type): array + { + return $this->getConversationRepo()->getRecentConversations($type, null, 50); + } + + public function getConversationMessages(string $key): array + { + return [ + 'conversationId' => $key, + 'messages' => $this->getConversationRepo()->getMessages($key), + ]; + } + + public function searchHistory(string $query): array + { + return $this->getConversationRepo()->searchConversations($query); + } + + private function detectDrillDown(string $message): ?array + { + $cases = $this->entityManager->getRDBRepository('Case') + ->select(['id', 'name']) + ->where([ + 'status!=' => ['ClosedSettlement', 'ClosedJudgment', 'Closed', 'Rejected'], + 'deleted' => false, + ]) + ->find(); + + foreach ($cases as $case) { + if (mb_stripos($message, $case->get('name')) !== false) { + $contextBuilder = $this->injectableFactory->create(OfficeContextBuilder::class); + return $contextBuilder->buildCaseDrillDown($case->get('id'), $this->user); + } + } + + return null; + } + + private function callWebhook(string $message, array $context, string $conversationId, array $history, string $mode): array + { + $webhookUrl = $this->getWebhookUrl(); + + if (!$webhookUrl) { + throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.'); + } + + $payload = json_encode([ + 'message' => $message, + 'context' => $context, + 'conversationId' => $conversationId, + 'conversationHistory' => $history, + 'mode' => $mode, + ]); + + $headers = ['Content-Type: application/json', 'Accept: application/json']; + + $apiKey = $this->getApiKey(); + if ($apiKey) { + $headers[] = 'X-Api-Key: ' . $apiKey; + } + + $ch = curl_init($webhookUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 120, + CURLOPT_CONNECTTIMEOUT => 10, + ]); + + $responseBody = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($error) { + $this->log->error("SmartAssistant: Webhook error: {$error}"); + throw new Error("Failed to connect to assistant: {$error}"); + } + + if ($httpCode !== 200) { + $this->log->error("SmartAssistant: Webhook HTTP {$httpCode}: {$responseBody}"); + throw new Error("Assistant returned error (HTTP {$httpCode})"); + } + + $response = json_decode($responseBody, true); + if (!$response) { + throw new Error('Invalid response from assistant'); + } + + return $response; + } + + private function createStreamNote(string $type, string $content, string $caseId, array $data = []): void + { + $note = $this->entityManager->getNewEntity('Note'); + $note->set([ + 'type' => $type, + 'post' => $content, + 'parentType' => 'Case', + 'parentId' => $caseId, + 'data' => (object) $data, + ]); + $this->entityManager->saveEntity($note); + } + + 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; + } + } + } + return null; + } + + 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; + } + } + } + return null; + } + + 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, + ]; + } + } + return []; + } + + private function saveConversationFile(string $conversationId, ?string $caseId, string $userId, array $actions): void + { + $dir = 'data/tmp/assistant-conversations'; + if (!is_dir($dir)) mkdir($dir, 0755, true); + file_put_contents($dir . '/' . $conversationId . '.json', json_encode([ + 'caseId' => $caseId, + 'userId' => $userId, + 'actions' => $actions, + 'createdAt' => date('Y-m-d H:i:s'), + ])); + } + + private function loadConversation(string $conversationId): ?array + { + if (isset(self::$conversations[$conversationId])) { + return self::$conversations[$conversationId]; + } + + $file = 'data/tmp/assistant-conversations/' . basename($conversationId) . '.json'; + if (file_exists($file)) { + $data = json_decode(file_get_contents($file), true); + if (isset($data['createdAt']) && (time() - strtotime($data['createdAt'])) > 86400) { + unlink($file); + return null; + } + return $data; + } + + return null; + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..bc8fa7c --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "Smart Assistant", + "module": "SmartAssistant", + "description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and n8n integration", + "author": "klear", + "version": "1.0.0", + "acceptableVersions": [">=8.0.0"], + "releaseDate": "2026-03-23", + "php": [">=8.1"] +}