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:
@@ -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');
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
+20
@@ -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, '• $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"
|
||||
}
|
||||
+5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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"
|
||||
}
|
||||
+13
@@ -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"}]
|
||||
]
|
||||
}
|
||||
]
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"rows": [
|
||||
[{"name": "name"}],
|
||||
[{"name": "category"}],
|
||||
[{"name": "importance"}],
|
||||
[{"name": "content"}]
|
||||
]
|
||||
}
|
||||
]
|
||||
+9
@@ -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}
|
||||
]
|
||||
+6
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user