feat: initial SmartAssistant module — unified AI assistant with case memory

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.
This commit is contained in:
2026-03-23 14:38:49 +00:00
commit a148e0f99e
49 changed files with 3866 additions and 0 deletions
Executable
+11
View File
@@ -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))"
@@ -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: '' +
'<div style="direction: rtl; text-align: right;">' +
'<h4 style="margin-top: 0;">{{translate \'Add Memory\' scope=\'SmartAssistant\'}}</h4>' +
'<div class="form-group">' +
' <label>{{translate \'name\' category=\'fields\' scope=\'CaseMemory\'}}</label>' +
' <input type="text" class="form-control field-name" maxlength="255">' +
'</div>' +
'<div class="form-group">' +
' <label>{{translate \'category\' category=\'fields\' scope=\'CaseMemory\'}}</label>' +
' <select class="form-control field-category">' +
' <option value="key_facts">{{translateOption \'key_facts\' field=\'category\' scope=\'CaseMemory\'}}</option>' +
' <option value="strategy">{{translateOption \'strategy\' field=\'category\' scope=\'CaseMemory\'}}</option>' +
' <option value="decisions">{{translateOption \'decisions\' field=\'category\' scope=\'CaseMemory\'}}</option>' +
' <option value="contacts_notes">{{translateOption \'contacts_notes\' field=\'category\' scope=\'CaseMemory\'}}</option>' +
' <option value="timeline">{{translateOption \'timeline\' field=\'category\' scope=\'CaseMemory\'}}</option>' +
' <option value="documents_notes">{{translateOption \'documents_notes\' field=\'category\' scope=\'CaseMemory\'}}</option>' +
' <option value="billing_notes">{{translateOption \'billing_notes\' field=\'category\' scope=\'CaseMemory\'}}</option>' +
' </select>' +
'</div>' +
'<div class="form-group">' +
' <label>{{translate \'importance\' category=\'fields\' scope=\'CaseMemory\'}}</label>' +
' <select class="form-control field-importance">' +
' <option value="low">{{translateOption \'low\' field=\'importance\' scope=\'CaseMemory\'}}</option>' +
' <option value="normal" selected>{{translateOption \'normal\' field=\'importance\' scope=\'CaseMemory\'}}</option>' +
' <option value="high">{{translateOption \'high\' field=\'importance\' scope=\'CaseMemory\'}}</option>' +
' <option value="critical">{{translateOption \'critical\' field=\'importance\' scope=\'CaseMemory\'}}</option>' +
' </select>' +
'</div>' +
'<div class="form-group">' +
' <label>{{translate \'content\' category=\'fields\' scope=\'CaseMemory\'}}</label>' +
' <textarea class="form-control field-content" rows="4" style="direction: rtl;"></textarea>' +
'</div>' +
'</div>',
buttonList: [
{
name: 'save',
label: 'Save',
style: 'primary',
},
{
name: 'cancel',
label: 'Cancel',
},
],
setup: function () {
this.caseId = this.options.caseId;
this.headerHtml = '<span class="fas fa-brain" style="margin-left: 6px;"></span> ' +
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');
});
},
});
});
@@ -0,0 +1,20 @@
define('custom:modules/smart-assistant/views/dashlets/options/smart-assistant', ['views/dashlets/options/base'], function (BaseView) {
return BaseView.extend({
templateContent: '<div class="cell form-group"><label>Alert Limit</label>' +
'<input type="number" class="form-control" name="alertLimit" value="{{alertLimit}}" min="1" max="50"></div>',
data: function () {
return {
alertLimit: this.optionsData.alertLimit || 10,
};
},
fetch: function () {
return {
alertLimit: parseInt(this.$el.find('[name="alertLimit"]').val()) || 10,
};
},
});
});
@@ -0,0 +1,72 @@
define('custom:modules/smart-assistant/views/dashlets/smart-assistant', ['views/dashlet'], function (DashletView) {
return DashletView.extend({
name: 'SmartAssistant',
templateContent: '' +
'<div style="direction: rtl; text-align: right; padding: 8px;">' +
'<div class="sa-dashlet-cards" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;"></div>' +
'<div class="sa-dashlet-alerts" style="max-height: 200px; overflow-y: auto;"></div>' +
'</div>',
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 += '<div style="flex: 1; min-width: 80px; background: ' + c.bg + '; padding: 10px; border-radius: 8px; text-align: center;">' +
'<div style="font-size: 22px; font-weight: bold; color: ' + c.color + ';">' + c.value + '</div>' +
'<div style="font-size: 11px; color: ' + c.color + ';"><span class="fas ' + c.icon + '"></span> ' + c.label + '</div>' +
'</div>';
});
this.$el.find('.sa-dashlet-cards').html(html);
},
renderAlerts: function (alerts) {
var $alerts = this.$el.find('.sa-dashlet-alerts');
if (alerts.length === 0) {
$alerts.html('<div style="text-align: center; color: #4caf50; padding: 12px;">אין התראות</div>');
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 += '<div style="padding: 6px 0; border-bottom: 1px solid #f5f5f5; font-size: 12px; color: ' + color + ';">' +
'<span class="fas ' + icon + '" style="margin-left: 6px;"></span>' +
'<span>' + Espo.Utils.escapeString(a.message) + '</span>' +
'</div>';
});
$alerts.html(html);
},
actionRefresh: function () {
this.loadSummary();
},
});
});
@@ -0,0 +1,612 @@
define('custom:modules/smart-assistant/views/floating-chat', ['view'], function (View) {
return View.extend({
templateContent: '' +
'<div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-robot"></span>' +
' <span class="sa-fab-badge" style="display:none;"></span>' +
'</div>' +
'<div class="sa-chat-panel" style="display:none;">' +
' <div class="sa-chat-header">' +
' <span class="sa-chat-title">{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}</span>' +
' <span class="sa-mode-badge"></span>' +
' <div class="sa-chat-header-actions">' +
' <button class="btn btn-link sa-memory-btn" type="button" title="{{translate \'Case Memory\' scope=\'SmartAssistant\'}}" style="display:none;">' +
' <span class="fas fa-brain"></span>' +
' </button>' +
' <button class="btn btn-link sa-history-btn" type="button" title="{{translate \'Previous Conversations\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-list"></span>' +
' </button>' +
' <button class="btn btn-link sa-new-btn" type="button" title="{{translate \'New Conversation\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-plus"></span>' +
' </button>' +
' <button class="btn btn-link sa-close-btn" type="button">' +
' <span class="fas fa-times"></span>' +
' </button>' +
' </div>' +
' </div>' +
' <div class="sa-history-view" style="display:none;">' +
' <div class="sa-history-header">' +
' <button class="btn btn-link sa-history-back" type="button">' +
' <span class="fas fa-arrow-right"></span> {{translate \'Back\' scope=\'SmartAssistant\'}}' +
' </button>' +
' </div>' +
' <div class="sa-history-list"></div>' +
' </div>' +
' <div class="sa-memory-view" style="display:none;">' +
' <div class="sa-memory-header">' +
' <button class="btn btn-link sa-memory-back" type="button">' +
' <span class="fas fa-arrow-right"></span> {{translate \'Back\' scope=\'SmartAssistant\'}}' +
' </button>' +
' <button class="btn btn-sm btn-default sa-add-memory-btn" type="button">' +
' <span class="fas fa-plus"></span> {{translate \'Add Memory\' scope=\'SmartAssistant\'}}' +
' </button>' +
' </div>' +
' <div class="sa-memory-tabs"></div>' +
' <div class="sa-memory-list"></div>' +
' </div>' +
' <div class="sa-chat-view">' +
' <div class="sa-chat-summary" style="display:none;"></div>' +
' <div class="sa-chat-messages"></div>' +
' <div class="sa-chat-input-row">' +
' <input type="text" class="form-control sa-chat-input" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}">' +
' <button class="btn btn-primary sa-chat-send" type="button" disabled>' +
' <span class="fas fa-paper-plane"></span>' +
' </button>' +
' </div>' +
' </div>' +
'</div>' +
'<style>' +
'.smart-assistant-fab-container { position: fixed; bottom: 24px; left: 24px; z-index: 1050; direction: rtl; font-family: inherit; }' +
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: #5c6bc0; color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s, background 0.2s; font-size: 22px; position: relative; }' +
'.sa-fab:hover { background: #3f51b5; transform: scale(1.08); }' +
'.sa-fab-badge { position: absolute; top: -4px; right: -4px; background: #c62828; color: #fff; border-radius: 10px; min-width: 20px; height: 20px; font-size: 11px; font-weight: bold; display: flex; align-items: center; justify-content: center; padding: 0 5px; }' +
'.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; }' +
'@keyframes sa-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }' +
'.sa-chat-header { display: flex; align-items: center; padding: 10px 16px; background: #5c6bc0; color: #fff; gap: 8px; }' +
'.sa-chat-title { font-size: 15px; font-weight: 600; white-space: nowrap; }' +
'.sa-mode-badge { font-size: 11px; background: rgba(255,255,255,0.2); padding: 2px 8px; border-radius: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; }' +
'.sa-chat-header-actions { display: flex; align-items: center; gap: 2px; margin-right: auto; }' +
'.sa-chat-header-actions .btn-link { color: #fff; opacity: 0.8; font-size: 14px; padding: 2px 6px; }' +
'.sa-chat-header-actions .btn-link:hover { opacity: 1; color: #fff; }' +
'.sa-chat-summary { padding: 10px 14px; background: #fff3e0; border-bottom: 1px solid #ffe0b2; font-size: 13px; color: #e65100; direction: rtl; text-align: right; }' +
'.sa-chat-view { display: flex; flex-direction: column; flex: 1; overflow: hidden; }' +
'.sa-chat-messages { flex: 1; overflow-y: auto; padding: 12px 14px; max-height: 380px; min-height: 80px; direction: rtl; text-align: right; }' +
'.sa-chat-messages:empty::before { content: attr(data-empty-text); display: block; text-align: center; color: #999; padding: 30px 10px; font-size: 13px; }' +
'.sa-msg { margin-bottom: 10px; padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; max-width: 90%; word-wrap: break-word; }' +
'.sa-msg-user { background: #e3f2fd; margin-left: auto; margin-right: 0; text-align: right; }' +
'.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; }' +
'.sa-msg-error { background: #fce4ec; color: #c62828; }' +
'.sa-msg-loading { background: #f5f5f5; color: #999; }' +
'.sa-msg-action { background: #fff8e1; padding: 6px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }' +
'.sa-action-btns { display: flex; gap: 4px; }' +
'.sa-action-btns .btn { padding: 2px 8px; font-size: 11px; border-radius: 4px; }' +
'.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; }' +
'.sa-chat-input { flex: 1; border-radius: 20px; padding: 8px 14px; font-size: 13px; direction: rtl; }' +
'.sa-chat-send { border-radius: 50%; width: 36px; height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; }' +
'.sa-history-view, .sa-memory-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }' +
'.sa-history-header, .sa-memory-header { padding: 8px 14px; border-bottom: 1px solid #eee; direction: rtl; text-align: right; display: flex; justify-content: space-between; align-items: center; }' +
'.sa-history-header .btn-link, .sa-memory-header .btn-link { color: #5c6bc0; font-size: 13px; padding: 2px 0; text-decoration: none; }' +
'.sa-history-list, .sa-memory-list { flex: 1; overflow-y: auto; max-height: 440px; direction: rtl; }' +
'.sa-history-item { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.15s; }' +
'.sa-history-item:hover { background: #f5f5ff; }' +
'.sa-history-item-title { font-size: 13px; font-weight: 500; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }' +
'.sa-history-item-meta { font-size: 11px; color: #999; margin-top: 3px; display: flex; justify-content: space-between; }' +
'.sa-history-item-preview { font-size: 12px; color: #666; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: right; }' +
'.sa-history-empty { text-align: center; color: #999; padding: 40px 20px; font-size: 13px; }' +
'.sa-memory-tabs { display: flex; gap: 4px; padding: 8px 14px; flex-wrap: wrap; direction: rtl; border-bottom: 1px solid #f0f0f0; }' +
'.sa-memory-tab { padding: 4px 10px; border-radius: 12px; font-size: 11px; cursor: pointer; background: #f5f5f5; color: #666; border: none; transition: all 0.15s; }' +
'.sa-memory-tab.active { background: #5c6bc0; color: #fff; }' +
'.sa-memory-entry { padding: 10px 16px; border-bottom: 1px solid #f5f5f5; direction: rtl; text-align: right; }' +
'.sa-memory-entry-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }' +
'.sa-memory-entry-title { font-size: 13px; font-weight: 500; }' +
'.sa-memory-entry-badges { display: flex; gap: 4px; }' +
'.sa-memory-entry-badge { padding: 1px 6px; border-radius: 8px; font-size: 10px; }' +
'.sa-badge-critical { background: #ffcdd2; color: #c62828; }' +
'.sa-badge-high { background: #fff3e0; color: #e65100; }' +
'.sa-badge-normal { background: #e3f2fd; color: #1565c0; }' +
'.sa-badge-low { background: #f5f5f5; color: #999; }' +
'.sa-badge-pinned { background: #e8f5e9; color: #2e7d32; }' +
'.sa-badge-source { background: #f3e5f5; color: #7b1fa2; }' +
'.sa-memory-entry-content { font-size: 12px; color: #555; line-height: 1.4; max-height: 60px; overflow: hidden; }' +
'.sa-memory-entry-meta { font-size: 10px; color: #999; margin-top: 4px; }' +
'@media (max-width: 480px) { .sa-chat-panel { width: calc(100vw - 48px); left: 0; } .smart-assistant-fab-container { bottom: 16px; left: 16px; } }' +
'</style>',
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('<div class="sa-history-empty"><span class="fas fa-spinner fa-spin"></span></div>');
Espo.Ajax.getRequest('SmartAssistant/action/conversations').then(function (conversations) {
self.renderConversationsList(conversations);
}).catch(function () {
$list.html('<div class="sa-history-empty">שגיאה בטעינת שיחות</div>');
});
},
renderConversationsList: function (conversations) {
var $list = this.$el.find('.sa-history-list');
var self = this;
if (!conversations || conversations.length === 0) {
$list.html('<div class="sa-history-empty">' + self.escapeHtml(self.translate('No previous conversations', 'labels', 'SmartAssistant')) + '</div>');
return;
}
var html = '';
conversations.forEach(function (conv) {
var timeStr = self.formatConversationTime(conv.lastMessageAt);
var typeIcon = conv.conversationType === 'case' ? 'fa-gavel' : 'fa-building';
html += '<div class="sa-history-item" data-key="' + self.escapeHtml(conv.conversationKey) + '">' +
'<div class="sa-history-item-title">' +
'<span class="fas ' + typeIcon + '" style="color: #5c6bc0; margin-left: 6px; font-size: 12px;"></span>' +
self.escapeHtml(conv.name || 'שיחה') +
'</div>' +
'<div class="sa-history-item-meta">' +
'<span>' + self.escapeHtml(timeStr) + '</span>' +
'<span>' + (conv.messageCount || 0) + ' הודעות</span>' +
'</div>';
if (conv.lastMessagePreview) {
html += '<div class="sa-history-item-preview">' + self.escapeHtml(conv.lastMessagePreview.substring(0, 80)) + '</div>';
}
html += '</div>';
});
$list.html(html);
},
loadConversation: function (conversationKey) {
var self = this;
this.conversationId = conversationKey;
var $messages = this.$el.find('.sa-chat-messages');
$messages.html('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> טוען שיחה...</div>');
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('<div class="sa-msg ' + cssClass + '">' + content + '</div>');
});
self.scrollToBottom();
}).catch(function () {
$messages.html('<div class="sa-msg sa-msg-error">שגיאה בטעינת שיחה</div>');
});
},
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('<div class="sa-msg ' + cssClass + '">' + content + '</div>');
});
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 = '<strong>' + self.escapeHtml(critical.length + ' התראות קריטיות') + '</strong>';
critical.slice(0, 2).forEach(function (a) {
html += '<div style="margin-top: 4px; font-size: 12px;">' + self.escapeHtml('• ' + a.message) + '</div>';
});
$summary.html(html).show();
} else if (summary.casesNeedingAttention > 0) {
$summary.html(self.escapeHtml(summary.casesNeedingAttention + ' תיקים דורשים תשומת לב')).show();
} else {
$summary.html('<span style="color: #4caf50;">אין התראות קריטיות</span>').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('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושב...') + '</div>');
$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('<div class="sa-msg sa-msg-assistant">' + self.formatResponse(response.text) + '</div>');
// Render action items
if (response.actions && response.actions.length > 0) {
response.actions.forEach(function (action) {
var actionHtml = '<div class="sa-msg-action">' +
'<span><span class="fas fa-bolt" style="color: #f57c00; margin-left: 4px;"></span>' +
self.escapeHtml(action.displayText || action.tool) + '</span>' +
'<div class="sa-action-btns">' +
'<button class="btn btn-success btn-xs sa-action-approve" data-conversation-id="' + self.escapeHtml(response.conversationId) +
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-check"></span></button>' +
'<button class="btn btn-danger btn-xs sa-action-reject" data-conversation-id="' + self.escapeHtml(response.conversationId) +
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-times"></span></button>' +
'</div></div>';
$messages.append(actionHtml);
});
}
self.scrollToBottom();
$input.prop('disabled', false).focus();
}).catch(function () {
$loading.remove();
$messages.append('<div class="sa-msg sa-msg-error">' +
self.escapeHtml(self.translate('Error', 'labels', 'SmartAssistant') || 'שגיאה בתקשורת') + '</div>');
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('<div class="sa-msg ' + cls + '">' + self.escapeHtml(msg) + '</div>');
self.scrollToBottom();
}).catch(function () {
$messages.append('<div class="sa-msg sa-msg-error">שגיאה בביצוע הפעולה</div>');
self.scrollToBottom();
});
},
loadMemory: function () {
if (!this.currentCaseId) return;
var self = this;
var $list = this.$el.find('.sa-memory-list');
$list.html('<div class="sa-history-empty"><span class="fas fa-spinner fa-spin"></span></div>');
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('<div class="sa-history-empty">שגיאה בטעינת זיכרון</div>');
});
},
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 = '<button class="sa-memory-tab active" data-category="">הכל</button>';
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 += '<button class="sa-memory-tab" data-category="' + cat + '">' + label + ' (' + count + ')</button>';
});
$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('<div class="sa-history-empty">' + self.escapeHtml(self.translate('No memories yet', 'labels', 'SmartAssistant')) + '</div>');
return;
}
var html = '';
entries.forEach(function (e) {
var badgeClass = 'sa-badge-' + (e.importance || 'normal');
html += '<div class="sa-memory-entry">' +
'<div class="sa-memory-entry-header">' +
'<span class="sa-memory-entry-title">' + self.escapeHtml(e.name) + '</span>' +
'<div class="sa-memory-entry-badges">';
if (e.isPinned) html += '<span class="sa-memory-entry-badge sa-badge-pinned"><span class="fas fa-thumbtack"></span></span>';
html += '<span class="sa-memory-entry-badge ' + badgeClass + '">' + (importanceLabels[e.importance] || e.importance) + '</span>';
html += '<span class="sa-memory-entry-badge sa-badge-source">' + (sourceLabels[e.source] || e.source) + '</span>';
html += '</div></div>' +
'<div class="sa-memory-entry-content">' + self.escapeHtml(e.content) + '</div>' +
'<div class="sa-memory-entry-meta">' + self.formatConversationTime(e.createdAt) + '</div>' +
'</div>';
});
$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, '<strong>$1</strong>');
text = text.replace(/\n/g, '<br>');
text = text.replace(/^- (.+)/gm, '&bull; $1');
return text;
},
escapeHtml: function (text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
});
});
@@ -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 = $('<div class="smart-assistant-fab-container"></div>');
$('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);
},
});
});
@@ -0,0 +1,45 @@
define('custom:modules/smart-assistant/views/stream/notes/smart-action', ['views/stream/note'], function (NoteView) {
return NoteView.extend({
templateContent: '' +
'<div class="sa-action-note sa-action-{{status}}" style="padding: 6px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
'<span class="fas {{icon}}" style="margin-left: 6px;"></span>' +
'{{post}}' +
'</div>',
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'});
}
},
});
});
@@ -0,0 +1,15 @@
define('custom:modules/smart-assistant/views/stream/notes/smart-request', ['views/stream/note'], function (NoteView) {
return NoteView.extend({
templateContent: '' +
'<div style="background: #e3f2fd; padding: 8px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
'<span class="fas fa-user" style="color: #1565c0; margin-left: 6px;"></span>' +
'{{post}}' +
'</div>',
setup: function () {
NoteView.prototype.setup.call(this);
},
});
});
@@ -0,0 +1,32 @@
define('custom:modules/smart-assistant/views/stream/notes/smart-response', ['views/stream/note'], function (NoteView) {
return NoteView.extend({
templateContent: '' +
'<div style="background: #f5f0ff; padding: 8px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
'<span class="fas fa-robot" style="color: #5c6bc0; margin-left: 6px;"></span>' +
'{{{formattedPost}}}' +
'{{#if actionCount}}' +
'<span class="badge" style="background: #ff9800; margin-right: 8px;">{{actionCount}} פעולות</span>' +
'{{/if}}' +
'</div>',
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, '<strong>$1</strong>');
text = text.replace(/\n/g, '<br>');
return text;
},
});
});
@@ -0,0 +1,54 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\Modules\NextCloudIntegration\Services\NextCloudService;
class FileStorageFactory
{
private Config $config;
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(Config $config, InjectableFactory $injectableFactory, Log $log)
{
$this->config = $config;
$this->injectableFactory = $injectableFactory;
$this->log = $log;
}
public function create(?string $userId = null): FileStorageInterface
{
$storageType = $this->config->get('assistantFileStorage', 'nextcloud');
return match ($storageType) {
'nextcloud' => $this->createNextCloud($userId),
'webdav' => $this->createWebDav(),
default => throw new Error("Unknown file storage type: {$storageType}"),
};
}
private function createNextCloud(?string $userId): NextCloudFileStorage
{
$nextCloudService = $this->injectableFactory->create(NextCloudService::class);
return new NextCloudFileStorage($nextCloudService->getClientForUser($userId));
}
private function createWebDav(): WebDavFileStorage
{
$url = $this->config->get('assistantWebDavUrl');
$user = $this->config->get('assistantWebDavUser');
$password = $this->config->get('assistantWebDavPassword');
$basePath = $this->config->get('assistantWebDavBasePath', '/webdav');
if (empty($url) || empty($user) || empty($password)) {
throw new Error('WebDAV file storage is not configured.');
}
return new WebDavFileStorage($url, $user, $password, $basePath);
}
}
@@ -0,0 +1,12 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
interface FileStorageInterface
{
public function listFolder(string $path): array;
public function downloadFile(string $path): string;
public function move(string $source, string $destination): bool;
public function createFolder(string $path): bool;
public function exists(string $path): bool;
}
@@ -0,0 +1,21 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Modules\NextCloudIntegration\Classes\NextCloud\Client;
class NextCloudFileStorage implements FileStorageInterface
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function listFolder(string $path): array { return $this->client->listFolder($path); }
public function downloadFile(string $path): string { return $this->client->downloadFile($path); }
public function move(string $source, string $destination): bool { return $this->client->move($source, $destination); }
public function createFolder(string $path): bool { return $this->client->createFolderRecursive($path); }
public function exists(string $path): bool { return $this->client->exists($path); }
}
@@ -0,0 +1,234 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
/**
* Generic WebDAV file storage adapter.
* Works with Synology NAS, ownCloud, and any standard WebDAV server.
* Uses only DAV-standard operations (no NextCloud/OCS-specific extensions).
*/
class WebDavFileStorage implements FileStorageInterface
{
private string $baseUrl;
private string $username;
private string $password;
private string $webdavPath;
public function __construct(
string $baseUrl,
string $username,
string $password,
string $webdavPath = '/webdav'
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->username = $username;
$this->password = $password;
$this->webdavPath = $webdavPath;
}
public function listFolder(string $path): array
{
$propfindXml = '<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>';
$response = $this->request('PROPFIND', $path, $propfindXml, [
'Content-Type: application/xml',
'Depth: 1',
]);
if ($response['status'] !== 207) {
throw new Error("Failed to list folder: HTTP " . $response['status']);
}
return $this->parsePropfindResponse($response['body'], $path);
}
public function downloadFile(string $path): string
{
$response = $this->request('GET', $path);
if ($response['status'] !== 200) {
throw new Error("Failed to download file: HTTP " . $response['status']);
}
return $response['body'];
}
public function move(string $source, string $destination): bool
{
$destUrl = $this->buildUrl($destination);
$response = $this->request('MOVE', $source, null, [
'Destination: ' . $destUrl,
'Overwrite: F',
]);
if ($response['status'] !== 201 && $response['status'] !== 204) {
throw new Error("Failed to move: HTTP " . $response['status']);
}
return true;
}
public function createFolder(string $path): bool
{
$parts = array_filter(explode('/', $path));
$currentPath = '';
foreach ($parts as $part) {
$currentPath .= '/' . $part;
$response = $this->request('MKCOL', $currentPath);
// 201 = created, 405 = already exists
if ($response['status'] !== 201 && $response['status'] !== 405) {
throw new Error("Failed to create folder: HTTP " . $response['status']);
}
}
return true;
}
public function exists(string $path): bool
{
try {
$response = $this->request('PROPFIND', $path, null, ['Depth: 0']);
return $response['status'] === 207;
} catch (\Exception $e) {
return false;
}
}
private function buildUrl(string $path): string
{
$pathParts = explode('/', ltrim($path, '/'));
$encodedParts = array_map('rawurlencode', $pathParts);
return $this->baseUrl . $this->webdavPath . '/' . implode('/', $encodedParts);
}
/**
* @return array{status: int, body: string}
*/
private function request(
string $method,
string $path,
?string $body = null,
array $headers = []
): array {
$url = $this->buildUrl($path);
$ch = curl_init();
$allHeaders = array_merge([
'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password),
], $headers);
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $allHeaders,
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Error("WebDAV connection error: $curlError");
}
return [
'status' => $httpCode,
'body' => $responseBody,
];
}
private function parsePropfindResponse(string $xml, string $basePath): array
{
$items = [];
$normalizedBasePath = '/' . trim($basePath, '/');
$doc = new \DOMDocument();
$doc->loadXML($xml);
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('d', 'DAV:');
$responses = $xpath->query('//d:response');
foreach ($responses as $response) {
$hrefNodes = $xpath->query('d:href', $response);
if ($hrefNodes->length === 0) {
continue;
}
$href = urldecode($hrefNodes->item(0)->textContent);
// Extract relative path by removing webdav prefix
$relativePath = $href;
$webdavPrefix = $this->webdavPath . '/';
if (($pos = strpos($href, $webdavPrefix)) !== false) {
$relativePath = substr($href, $pos + strlen($webdavPrefix));
}
$relativePath = '/' . trim($relativePath, '/');
// Skip the queried folder itself
if ($relativePath === $normalizedBasePath || $relativePath === $normalizedBasePath . '/') {
continue;
}
$propstat = $xpath->query('d:propstat/d:prop', $response)->item(0);
if (!$propstat) {
continue;
}
$resourceType = $xpath->query('d:resourcetype/d:collection', $propstat);
$isFolder = $resourceType->length > 0;
$contentLength = $xpath->query('d:getcontentlength', $propstat);
$size = $contentLength->length > 0 ? (int) $contentLength->item(0)->textContent : null;
$lastModified = $xpath->query('d:getlastmodified', $propstat);
$modified = $lastModified->length > 0 ? $lastModified->item(0)->textContent : null;
$contentType = $xpath->query('d:getcontenttype', $propstat);
$mimeType = $contentType->length > 0 ? $contentType->item(0)->textContent : null;
$items[] = [
'name' => basename($relativePath),
'path' => $relativePath,
'isFolder' => $isFolder,
'size' => $size,
'mimeType' => $isFolder ? 'folder' : $mimeType,
'modified' => $modified,
];
}
usort($items, function ($a, $b) {
if ($a['isFolder'] !== $b['isFolder']) {
return $b['isFolder'] <=> $a['isFolder'];
}
return strcasecmp($a['name'], $b['name']);
});
return $items;
}
}
@@ -0,0 +1,167 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\InjectableFactory;
use Espo\Core\Acl;
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
class SmartAssistant
{
private InjectableFactory $injectableFactory;
private Acl $acl;
public function __construct(InjectableFactory $injectableFactory, Acl $acl)
{
$this->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'),
];
}
}
@@ -0,0 +1,74 @@
<?php
namespace Espo\Modules\SmartAssistant\Hooks\Case;
use Espo\ORM\Entity;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Log;
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
class CaseFieldChangeMemory
{
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(InjectableFactory $injectableFactory, Log $log)
{
$this->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
);
}
}
}
@@ -0,0 +1,44 @@
<?php
namespace Espo\Modules\SmartAssistant\Hooks\Meeting;
use Espo\ORM\Entity;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Log;
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
class HearingScheduledMemory
{
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(InjectableFactory $injectableFactory, Log $log)
{
$this->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());
}
}
}
@@ -0,0 +1,5 @@
{
"links": {
"caseMemories": "Case Memory"
}
}
@@ -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"
}
}
}
@@ -0,0 +1,10 @@
{
"scopeNames": {
"CaseMemory": "Case Memory",
"SmartAssistant": "Smart Assistant"
},
"scopeNamesPlural": {
"CaseMemory": "Case Memories",
"SmartAssistant": "Smart Assistant"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -0,0 +1,5 @@
{
"links": {
"caseMemories": "זיכרון תיק"
}
}
@@ -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": "קריטית"
}
}
}
@@ -0,0 +1,10 @@
{
"scopeNames": {
"CaseMemory": "זיכרון תיק",
"SmartAssistant": "עוזר חכם"
},
"scopeNamesPlural": {
"CaseMemory": "זיכרונות תיקים",
"SmartAssistant": "עוזר חכם"
}
}
@@ -0,0 +1,11 @@
{
"SmartAssistant": "עוזר חכם",
"fields": {
"webhookUrl": "כתובת Webhook",
"apiKey": "מפתח API",
"maxMessagesPerHour": "מקסימום הודעות לשעה",
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
"inactivityCriticalDays": "ימים לאזהרה קריטית",
"upcomingHearingDays": "ימים לפני דיון"
}
}
@@ -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": "דורשים תשומת לב"
}
}
@@ -0,0 +1,3 @@
{
"navbarView": "custom:modules/smart-assistant/views/site/navbar"
}
@@ -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"
}
@@ -0,0 +1,10 @@
{
"links": {
"caseMemories": {
"type": "hasMany",
"entity": "CaseMemory",
"foreign": "case",
"layoutRelationshipsDisabled": true
}
}
}
@@ -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"]
}
}
}
@@ -0,0 +1,12 @@
{
"fields": {
"type": {
"options": [
"__APPEND__",
"SmartRequest",
"SmartResponse",
"SmartAction"
]
}
}
}
@@ -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"
}
@@ -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"}]
]
}
]
@@ -0,0 +1,10 @@
[
{
"rows": [
[{"name": "name"}],
[{"name": "category"}],
[{"name": "importance"}],
[{"name": "content"}]
]
}
]
@@ -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}
]
@@ -0,0 +1,6 @@
[
{"name": "name", "width": 40},
{"name": "category", "width": 20},
{"name": "importance", "width": 15},
{"name": "createdAt", "width": 25}
]
@@ -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
}
@@ -0,0 +1,9 @@
{
"order": 37,
"version": "1.0.0",
"requires": {
"espocrm": ">=8.0.0"
},
"author": "klear",
"description": "Unified AI Assistant for Legal CRM"
}
@@ -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"
}
}
]
@@ -0,0 +1,231 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
class ActionExecutor
{
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log)
{
$this->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';
}
}
@@ -0,0 +1,266 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Acl;
use Espo\ORM\EntityManager;
class AlertCalculator
{
private EntityManager $entityManager;
private Acl $acl;
private const CLOSED_STATUSES = ['ClosedSettlement', 'ClosedJudgment', 'Closed', 'Rejected'];
public function __construct(EntityManager $entityManager, Acl $acl)
{
$this->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;
}
}
@@ -0,0 +1,177 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
class CaseContextBuilder
{
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log)
{
$this->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 [];
}
}
}
@@ -0,0 +1,87 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
class CaseMemoryContextProvider
{
private const MAX_CONTEXT_CHARS = 4000;
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->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;
}
}
@@ -0,0 +1,105 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\ORM\Entity;
use Espo\Core\Utils\Log;
class CaseMemoryService
{
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->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;
}
}
@@ -0,0 +1,154 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Entities\User;
use Espo\Core\Utils\Log;
class ConversationRepository
{
private EntityManager $entityManager;
private User $user;
private Log $log;
public function __construct(EntityManager $entityManager, User $user, Log $log)
{
$this->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'),
]));
}
}
@@ -0,0 +1,179 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageFactory;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageInterface;
class DocumentAnalyzer
{
private const MAX_TEXT_LENGTH = 2000;
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
private const TMP_DIR = 'data/tmp/assistant-docs';
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Log $log;
private const CATEGORY_PATTERNS = [
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
'החלטות_בית_משפט' => ['החלטה', 'פסק_דין', 'פסק דין', 'גזר_דין', 'גזר דין', 'צו', 'פרוטוקול', 'פס"ד', 'גז"ד'],
'חוזים' => ['חוזה', 'הסכם', 'נספח', 'תוספת'],
'התכתבויות' => ['מכתב', 'פקס', '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) {}
}
}
@@ -0,0 +1,79 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Acl;
use Espo\ORM\EntityManager;
use Espo\Entities\User;
class OfficeContextBuilder
{
private EntityManager $entityManager;
private Acl $acl;
private AlertCalculator $alertCalculator;
public function __construct(EntityManager $entityManager, Acl $acl, AlertCalculator $alertCalculator)
{
$this->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];
}
}
@@ -0,0 +1,482 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
use Espo\Entities\Note;
use Espo\Entities\User;
class SmartAssistantService
{
public const NOTE_TYPE_REQUEST = 'SmartRequest';
public const NOTE_TYPE_RESPONSE = 'SmartResponse';
public const NOTE_TYPE_ACTION = 'SmartAction';
private const READ_ONLY_TOOLS = ['list_documents', 'save_memory'];
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Config $config;
private Log $log;
private User $user;
private static array $conversations = [];
public function __construct(
EntityManager $entityManager,
InjectableFactory $injectableFactory,
Config $config,
Log $log,
User $user
) {
$this->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;
}
}
+10
View File
@@ -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"]
}