diff --git a/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php b/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php index dab702a..65840c6 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php +++ b/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php @@ -10,6 +10,7 @@ use Espo\Core\InjectableFactory; use Espo\Core\Acl; use Espo\Modules\SmartAssistant\Services\SmartAssistantService; use Espo\Modules\SmartAssistant\Services\CaseMemoryService; +use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer; class SmartAssistant { @@ -169,4 +170,89 @@ class SmartAssistant '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, + ]; + } } diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/integrations/SmartAssistant.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/integrations/SmartAssistant.json index 516b9e2..fcf1033 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/integrations/SmartAssistant.json +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/integrations/SmartAssistant.json @@ -27,6 +27,18 @@ "upcomingHearingDays": { "type": "int", "default": 7 + }, + "maxDocumentChars": { + "type": "int", + "default": 100000 + }, + "agenticLoopEnabled": { + "type": "bool", + "default": true + }, + "maxAgenticLoops": { + "type": "int", + "default": 3 } }, "allowUserAccess": false, diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php b/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php index 62fa4b1..3927843 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php @@ -49,7 +49,10 @@ class ActionExecutor 'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId), 'list_documents' => $this->listDocuments($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), + 'batch_rename_documents' => $this->batchRenameDocuments($params), 'upload_document' => $this->uploadDocument($params, $caseId), 'generate_document' => $this->generateDocument($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]]; } + 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 { $sourcePath = $params['sourcePath'] ?? null; @@ -395,6 +457,37 @@ class ActionExecutor 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 { if (!$caseId) { diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php b/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php index e07a8dd..151827e 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php @@ -11,13 +11,14 @@ use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface; 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 TMP_DIR = 'data/tmp/assistant-docs'; private EntityManager $entityManager; private InjectableFactory $injectableFactory; private Log $log; + private ?int $maxTextLength = null; private const CATEGORY_PATTERNS = [ 'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'], @@ -73,6 +74,13 @@ class DocumentAnalyzer } 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(); $filename = basename($filePath); @@ -98,8 +106,41 @@ class DocumentAnalyzer 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); + + $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 @@ -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 { return $this->getNetworkDocumentService()->getClient(); diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php b/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php index 9cb912a..dc3bee9 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php @@ -18,7 +18,17 @@ class SmartAssistantService public const NOTE_TYPE_ACTION = 'SmartAction'; // 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 InjectableFactory $injectableFactory; @@ -105,68 +115,20 @@ class SmartAssistantService // 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 $effectiveCaseId = $caseId; if (!$effectiveCaseId && !empty($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) { - $tool = $action['tool'] ?? ''; - $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); - } + $responseText = $loopResult['responseText']; + $executedActions = $loopResult['executedActions']; // Persist response 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 { $conversation = $this->loadConversation($conversationId); diff --git a/manifest.json b/manifest.json index 0032260..d137a18 100644 --- a/manifest.json +++ b/manifest.json @@ -3,11 +3,11 @@ "module": "SmartAssistant", "description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration", "author": "klear", - "version": "2.5.5", + "version": "2.6.0", "acceptableVersions": [ ">=8.0.0" ], - "releaseDate": "2026-04-06", + "releaseDate": "2026-04-09", "php": [ ">=8.1" ]