feat: add document reading, multi-doc summarization, and batch rename for Shira
Enable the AI assistant to read full documents from network storage (PDF/DOCX/TXT), summarize multiple documents, and batch-rename files. Key changes: - Raise text extraction limit from 2,000 to 100,000 chars (configurable) - Add agentic loop in SmartAssistantService for multi-turn tool execution - New tools: read_document, read_multiple_documents, batch_rename_documents - New API endpoints: readDocument, readMultipleDocuments, batchRename - New integration settings: maxDocumentChars, agenticLoopEnabled, maxAgenticLoops Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ use Espo\Core\InjectableFactory;
|
|||||||
use Espo\Core\Acl;
|
use Espo\Core\Acl;
|
||||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||||
|
|
||||||
class SmartAssistant
|
class SmartAssistant
|
||||||
{
|
{
|
||||||
@@ -169,4 +170,89 @@ class SmartAssistant
|
|||||||
'id' => $entry->get('id'),
|
'id' => $entry->get('id'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function postActionReadDocument(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$filePath = $data->filePath ?? null;
|
||||||
|
if (empty($filePath)) {
|
||||||
|
throw new BadRequest('filePath is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$text = $analyzer->extractFullText($filePath, $maxLength);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => $text !== null,
|
||||||
|
'text' => $text,
|
||||||
|
'fileName' => basename($filePath),
|
||||||
|
'charCount' => $text !== null ? mb_strlen($text) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionReadMultipleDocuments(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$filePaths = $data->filePaths ?? [];
|
||||||
|
if (empty($filePaths)) {
|
||||||
|
throw new BadRequest('filePaths is required (array of file paths).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'documents' => $results,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionBatchRename(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$renames = $data->renames ?? [];
|
||||||
|
if (empty($renames)) {
|
||||||
|
throw new BadRequest('renames is required (array of {sourcePath, newName}).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$results = [];
|
||||||
|
$successCount = 0;
|
||||||
|
|
||||||
|
foreach ($renames as $rename) {
|
||||||
|
$rename = (array) $rename;
|
||||||
|
$sourcePath = $rename['sourcePath'] ?? null;
|
||||||
|
$newName = $rename['newName'] ?? null;
|
||||||
|
|
||||||
|
if (!$sourcePath || !$newName) {
|
||||||
|
$results[] = ['success' => false, 'error' => 'Missing sourcePath or newName'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $analyzer->renameDocument($sourcePath, $newName);
|
||||||
|
$results[] = ['success' => true, 'oldName' => $result['oldName'], 'newName' => $result['newName']];
|
||||||
|
$successCount++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$results[] = ['success' => false, 'oldName' => basename($sourcePath), 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'totalCount' => count($renames),
|
||||||
|
'successCount' => $successCount,
|
||||||
|
'results' => $results,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -27,6 +27,18 @@
|
|||||||
"upcomingHearingDays": {
|
"upcomingHearingDays": {
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"default": 7
|
"default": 7
|
||||||
|
},
|
||||||
|
"maxDocumentChars": {
|
||||||
|
"type": "int",
|
||||||
|
"default": 100000
|
||||||
|
},
|
||||||
|
"agenticLoopEnabled": {
|
||||||
|
"type": "bool",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"maxAgenticLoops": {
|
||||||
|
"type": "int",
|
||||||
|
"default": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowUserAccess": false,
|
"allowUserAccess": false,
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ class ActionExecutor
|
|||||||
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
|
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
|
||||||
'list_documents' => $this->listDocuments($caseId),
|
'list_documents' => $this->listDocuments($caseId),
|
||||||
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
||||||
|
'read_document' => $this->readDocument($params, $caseId),
|
||||||
|
'read_multiple_documents' => $this->readMultipleDocuments($params, $caseId),
|
||||||
'rename_document' => $this->renameDocument($params),
|
'rename_document' => $this->renameDocument($params),
|
||||||
|
'batch_rename_documents' => $this->batchRenameDocuments($params),
|
||||||
'upload_document' => $this->uploadDocument($params, $caseId),
|
'upload_document' => $this->uploadDocument($params, $caseId),
|
||||||
'generate_document' => $this->generateDocument($params, $caseId),
|
'generate_document' => $this->generateDocument($params, $caseId),
|
||||||
'merge_documents' => $this->mergeDocuments($params, $caseId),
|
'merge_documents' => $this->mergeDocuments($params, $caseId),
|
||||||
@@ -383,6 +386,65 @@ class ActionExecutor
|
|||||||
return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]];
|
return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function readDocument(array $params, ?string $caseId): array
|
||||||
|
{
|
||||||
|
$filePath = $params['filePath'] ?? null;
|
||||||
|
if (!$filePath) throw new Error('filePath parameter is required.');
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$text = $analyzer->extractFullText($filePath);
|
||||||
|
$fileName = basename($filePath);
|
||||||
|
|
||||||
|
if ($text === null) {
|
||||||
|
return ['success' => false, 'message' => "לא ניתן לחלץ טקסט מ-\"{$fileName}\""];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => $text,
|
||||||
|
'data' => [
|
||||||
|
'filePath' => $filePath,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'charCount' => mb_strlen($text),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readMultipleDocuments(array $params, ?string $caseId): array
|
||||||
|
{
|
||||||
|
$filePaths = $params['filePaths'] ?? [];
|
||||||
|
if (empty($filePaths)) throw new Error('filePaths parameter is required (array of file paths).');
|
||||||
|
|
||||||
|
$maxPerFile = $params['maxCharsPerFile'] ?? 50000;
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$results = $analyzer->extractMultipleDocuments($filePaths, $maxPerFile);
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
$successCount = 0;
|
||||||
|
|
||||||
|
foreach ($results as $r) {
|
||||||
|
$lines[] = "=== {$r['fileName']} ===";
|
||||||
|
if (!empty($r['error'])) {
|
||||||
|
$lines[] = "שגיאה: {$r['error']}";
|
||||||
|
} elseif ($r['text'] === null) {
|
||||||
|
$lines[] = "לא ניתן לחלץ טקסט";
|
||||||
|
} else {
|
||||||
|
$lines[] = $r['text'];
|
||||||
|
$successCount++;
|
||||||
|
}
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => implode("\n", $lines),
|
||||||
|
'data' => [
|
||||||
|
'totalFiles' => count($filePaths),
|
||||||
|
'successCount' => $successCount,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function renameDocument(array $params): array
|
private function renameDocument(array $params): array
|
||||||
{
|
{
|
||||||
$sourcePath = $params['sourcePath'] ?? null;
|
$sourcePath = $params['sourcePath'] ?? null;
|
||||||
@@ -395,6 +457,37 @@ class ActionExecutor
|
|||||||
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
|
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function batchRenameDocuments(array $params): array
|
||||||
|
{
|
||||||
|
$renames = $params['renames'] ?? [];
|
||||||
|
if (empty($renames)) throw new Error('renames parameter is required (array of {sourcePath, newName}).');
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$results = [];
|
||||||
|
$successCount = 0;
|
||||||
|
|
||||||
|
foreach ($renames as $rename) {
|
||||||
|
$sourcePath = $rename['sourcePath'] ?? null;
|
||||||
|
$newName = $rename['newName'] ?? null;
|
||||||
|
|
||||||
|
if (!$sourcePath || !$newName) {
|
||||||
|
$results[] = "שגיאה: חסר sourcePath או newName";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $analyzer->renameDocument($sourcePath, $newName);
|
||||||
|
$results[] = "\"{$result['oldName']}\" → \"{$result['newName']}\"";
|
||||||
|
$successCount++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$results[] = "שגיאה בשינוי שם \"" . basename($sourcePath) . "\": " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = "שינוי שמות: {$successCount}/" . count($renames) . " הצליחו\n" . implode("\n", $results);
|
||||||
|
return ['success' => true, 'message' => $msg];
|
||||||
|
}
|
||||||
|
|
||||||
private function uploadDocument(array $params, ?string $caseId): array
|
private function uploadDocument(array $params, ?string $caseId): array
|
||||||
{
|
{
|
||||||
if (!$caseId) {
|
if (!$caseId) {
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
|
|||||||
|
|
||||||
class DocumentAnalyzer
|
class DocumentAnalyzer
|
||||||
{
|
{
|
||||||
private const MAX_TEXT_LENGTH = 2000;
|
private const DEFAULT_MAX_TEXT_LENGTH = 100000;
|
||||||
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
private const TMP_DIR = 'data/tmp/assistant-docs';
|
private const TMP_DIR = 'data/tmp/assistant-docs';
|
||||||
|
|
||||||
private EntityManager $entityManager;
|
private EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
private Log $log;
|
private Log $log;
|
||||||
|
private ?int $maxTextLength = null;
|
||||||
|
|
||||||
private const CATEGORY_PATTERNS = [
|
private const CATEGORY_PATTERNS = [
|
||||||
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
||||||
@@ -73,6 +74,13 @@ class DocumentAnalyzer
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function extractTextContent(string $filePath): ?string
|
public function extractTextContent(string $filePath): ?string
|
||||||
|
{
|
||||||
|
$maxLen = $this->getMaxTextLength();
|
||||||
|
$text = $this->extractFullText($filePath, $maxLen);
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extractFullText(string $filePath, ?int $maxLength = null): ?string
|
||||||
{
|
{
|
||||||
$client = $this->getStorageClient();
|
$client = $this->getStorageClient();
|
||||||
$filename = basename($filePath);
|
$filename = basename($filePath);
|
||||||
@@ -98,8 +106,41 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
if (file_exists($tmpFile)) unlink($tmpFile);
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
||||||
if ($text === null) return null;
|
if ($text === null) return null;
|
||||||
if (mb_strlen($text) > self::MAX_TEXT_LENGTH) $text = mb_substr($text, 0, self::MAX_TEXT_LENGTH) . '...';
|
|
||||||
return trim($text);
|
$text = trim($text);
|
||||||
|
if ($maxLength !== null && mb_strlen($text) > $maxLength) {
|
||||||
|
$text = mb_substr($text, 0, $maxLength) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($filePaths as $filePath) {
|
||||||
|
$fileName = basename($filePath);
|
||||||
|
try {
|
||||||
|
$text = $this->extractFullText($filePath, $maxPerFile);
|
||||||
|
$results[] = [
|
||||||
|
'path' => $filePath,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'text' => $text,
|
||||||
|
'charCount' => $text !== null ? mb_strlen($text) : 0,
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$results[] = [
|
||||||
|
'path' => $filePath,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'text' => null,
|
||||||
|
'charCount' => 0,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function renameDocument(string $sourcePath, string $newName): array
|
public function renameDocument(string $sourcePath, string $newName): array
|
||||||
@@ -169,6 +210,27 @@ class DocumentAnalyzer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getMaxTextLength(): int
|
||||||
|
{
|
||||||
|
if ($this->maxTextLength !== null) {
|
||||||
|
return $this->maxTextLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
|
if ($integration && $integration->get('enabled')) {
|
||||||
|
$data = (array) ($integration->get('data') ?? []);
|
||||||
|
$this->maxTextLength = (int) ($data['maxDocumentChars'] ?? self::DEFAULT_MAX_TEXT_LENGTH);
|
||||||
|
return $this->maxTextLength;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->maxTextLength = self::DEFAULT_MAX_TEXT_LENGTH;
|
||||||
|
return $this->maxTextLength;
|
||||||
|
}
|
||||||
|
|
||||||
private function getStorageClient(): StorageClientInterface
|
private function getStorageClient(): StorageClientInterface
|
||||||
{
|
{
|
||||||
return $this->getNetworkDocumentService()->getClient();
|
return $this->getNetworkDocumentService()->getClient();
|
||||||
|
|||||||
@@ -18,7 +18,17 @@ class SmartAssistantService
|
|||||||
public const NOTE_TYPE_ACTION = 'SmartAction';
|
public const NOTE_TYPE_ACTION = 'SmartAction';
|
||||||
|
|
||||||
// All tools execute inline — Shira has full permissions
|
// All tools execute inline — Shira has full permissions
|
||||||
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
|
private const TOOLS_REQUIRING_CASE = [
|
||||||
|
'change_status', 'schedule_hearing', 'list_documents', 'analyze_document',
|
||||||
|
'read_document', 'read_multiple_documents', 'upload_document',
|
||||||
|
'generate_document', 'merge_documents', 'save_memory',
|
||||||
|
'batch_rename_documents',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tools whose output is fed back to the AI for a follow-up webhook call
|
||||||
|
private const AGENTIC_TOOLS = ['analyze_document', 'read_document', 'read_multiple_documents', 'list_documents'];
|
||||||
|
|
||||||
|
private const DEFAULT_MAX_AGENTIC_LOOPS = 3;
|
||||||
|
|
||||||
private EntityManager $entityManager;
|
private EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
@@ -105,68 +115,20 @@ class SmartAssistantService
|
|||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call webhook
|
|
||||||
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
|
|
||||||
|
|
||||||
// Process actions — execute all inline (Shira has full permissions)
|
|
||||||
$actions = $response['actions'] ?? [];
|
|
||||||
$responseText = $response['text'] ?? '';
|
|
||||||
$inlineResults = [];
|
|
||||||
$executedActions = [];
|
|
||||||
|
|
||||||
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
|
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
|
||||||
$effectiveCaseId = $caseId;
|
$effectiveCaseId = $caseId;
|
||||||
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
|
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
|
||||||
$effectiveCaseId = $context['drillDown']['case']['id'];
|
$effectiveCaseId = $context['drillDown']['case']['id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
// Execute tools with agentic loop — read tools feed results back to AI
|
||||||
|
$loopResult = $this->executeActionsWithLoop(
|
||||||
|
$message, $context, $conversationId, $conversationHistory,
|
||||||
|
$mode, $effectiveCaseId, $userId
|
||||||
|
);
|
||||||
|
|
||||||
foreach ($actions as $action) {
|
$responseText = $loopResult['responseText'];
|
||||||
$tool = $action['tool'] ?? '';
|
$executedActions = $loopResult['executedActions'];
|
||||||
$actionCaseId = $effectiveCaseId;
|
|
||||||
|
|
||||||
// Resolve case from action params if not available
|
|
||||||
if (!$actionCaseId && !empty($action['params']['caseId'])) {
|
|
||||||
$actionCaseId = $action['params']['caseId'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip tools that require a case if no case context available
|
|
||||||
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
|
|
||||||
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
|
|
||||||
if (!empty($result['message'])) {
|
|
||||||
$inlineResults[] = $result['message'];
|
|
||||||
}
|
|
||||||
$executedActions[] = [
|
|
||||||
'tool' => $tool,
|
|
||||||
'status' => 'executed',
|
|
||||||
'entityType' => $result['entityType'] ?? null,
|
|
||||||
'entityId' => $result['entityId'] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Create stream note for executed action in case context
|
|
||||||
if ($actionCaseId) {
|
|
||||||
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
|
|
||||||
'conversationId' => $conversationId,
|
|
||||||
'status' => 'executed',
|
|
||||||
'entityType' => $result['entityType'] ?? null,
|
|
||||||
'entityId' => $result['entityId'] ?? null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
|
|
||||||
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($inlineResults)) {
|
|
||||||
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist response
|
// Persist response
|
||||||
try {
|
try {
|
||||||
@@ -191,6 +153,148 @@ class SmartAssistantService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function executeActionsWithLoop(
|
||||||
|
string $message,
|
||||||
|
array $context,
|
||||||
|
string $conversationId,
|
||||||
|
array $conversationHistory,
|
||||||
|
string $mode,
|
||||||
|
?string $effectiveCaseId,
|
||||||
|
string $userId
|
||||||
|
): array {
|
||||||
|
$agenticEnabled = $this->getIntegrationSetting('agenticLoopEnabled', true);
|
||||||
|
$maxLoops = (int) $this->getIntegrationSetting('maxAgenticLoops', self::DEFAULT_MAX_AGENTIC_LOOPS);
|
||||||
|
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||||
|
|
||||||
|
$loopCount = 0;
|
||||||
|
$allExecutedActions = [];
|
||||||
|
$currentHistory = $conversationHistory;
|
||||||
|
$finalResponseText = '';
|
||||||
|
|
||||||
|
while ($loopCount <= $maxLoops) {
|
||||||
|
// Call webhook (first call uses original message, subsequent calls use empty message)
|
||||||
|
$currentMessage = $loopCount === 0 ? $message : '';
|
||||||
|
$response = $this->callWebhook($currentMessage, $context, $conversationId, $currentHistory, $mode);
|
||||||
|
|
||||||
|
$actions = $response['actions'] ?? [];
|
||||||
|
$responseText = $response['text'] ?? '';
|
||||||
|
|
||||||
|
// If no actions, we're done
|
||||||
|
if (empty($actions)) {
|
||||||
|
$finalResponseText = $responseText;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agenticResults = [];
|
||||||
|
$inlineResults = [];
|
||||||
|
|
||||||
|
foreach ($actions as $action) {
|
||||||
|
$tool = $action['tool'] ?? '';
|
||||||
|
$actionCaseId = $effectiveCaseId;
|
||||||
|
|
||||||
|
if (!$actionCaseId && !empty($action['params']['caseId'])) {
|
||||||
|
$actionCaseId = $action['params']['caseId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
|
||||||
|
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
|
||||||
|
|
||||||
|
$allExecutedActions[] = [
|
||||||
|
'tool' => $tool,
|
||||||
|
'status' => 'executed',
|
||||||
|
'entityType' => $result['entityType'] ?? null,
|
||||||
|
'entityId' => $result['entityId'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($actionCaseId) {
|
||||||
|
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
|
||||||
|
'conversationId' => $conversationId,
|
||||||
|
'status' => 'executed',
|
||||||
|
'entityType' => $result['entityType'] ?? null,
|
||||||
|
'entityId' => $result['entityId'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate agentic tools (need re-invocation) from inline tools
|
||||||
|
if ($agenticEnabled && in_array($tool, self::AGENTIC_TOOLS, true)) {
|
||||||
|
$agenticResults[] = [
|
||||||
|
'tool' => $tool,
|
||||||
|
'params' => $action['params'] ?? [],
|
||||||
|
'result' => $result['message'] ?? '',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
if (!empty($result['message'])) {
|
||||||
|
$inlineResults[] = $result['message'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
|
||||||
|
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no agentic results, we're done — return response with inline results
|
||||||
|
if (empty($agenticResults)) {
|
||||||
|
$finalResponseText = $responseText;
|
||||||
|
if (!empty($inlineResults)) {
|
||||||
|
$finalResponseText .= "\n\n" . implode("\n\n", $inlineResults);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed agentic results back to AI for next iteration
|
||||||
|
$this->log->debug("SmartAssistant: Agentic loop iteration " . ($loopCount + 1) . " — " . count($agenticResults) . " tool results to feed back");
|
||||||
|
|
||||||
|
// Add the AI response to history
|
||||||
|
$currentHistory[] = ['role' => 'assistant', 'content' => $responseText];
|
||||||
|
|
||||||
|
// Add each tool result to history
|
||||||
|
foreach ($agenticResults as $ar) {
|
||||||
|
$currentHistory[] = [
|
||||||
|
'role' => 'tool_result',
|
||||||
|
'toolName' => $ar['tool'],
|
||||||
|
'content' => $ar['result'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also append any inline results
|
||||||
|
if (!empty($inlineResults)) {
|
||||||
|
$currentHistory[] = [
|
||||||
|
'role' => 'tool_result',
|
||||||
|
'toolName' => '_inline_actions',
|
||||||
|
'content' => implode("\n\n", $inlineResults),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$loopCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: if we exhausted the loop, use the last response
|
||||||
|
if ($loopCount > $maxLoops && empty($finalResponseText)) {
|
||||||
|
$finalResponseText = $response['text'] ?? 'הגעתי למגבלת הסיבובים. אנא נסה שוב.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'responseText' => $finalResponseText,
|
||||||
|
'executedActions' => $allExecutedActions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getIntegrationSetting(string $key, $default = null)
|
||||||
|
{
|
||||||
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
|
if ($integration && $integration->get('enabled')) {
|
||||||
|
$data = (array) ($integration->get('data') ?? []);
|
||||||
|
return $data[$key] ?? $default;
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
public function executeAction(string $conversationId, string $actionId, bool $approved): array
|
public function executeAction(string $conversationId, string $actionId, bool $approved): array
|
||||||
{
|
{
|
||||||
$conversation = $this->loadConversation($conversationId);
|
$conversation = $this->loadConversation($conversationId);
|
||||||
|
|||||||
+2
-2
@@ -3,11 +3,11 @@
|
|||||||
"module": "SmartAssistant",
|
"module": "SmartAssistant",
|
||||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||||
"author": "klear",
|
"author": "klear",
|
||||||
"version": "2.5.5",
|
"version": "2.6.0",
|
||||||
"acceptableVersions": [
|
"acceptableVersions": [
|
||||||
">=8.0.0"
|
">=8.0.0"
|
||||||
],
|
],
|
||||||
"releaseDate": "2026-04-06",
|
"releaseDate": "2026-04-09",
|
||||||
"php": [
|
"php": [
|
||||||
">=8.1"
|
">=8.1"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user