From 87bc4b67707275ec99aa25779fa5068b3b1c842b Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 5 Apr 2026 14:53:27 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20SmartAssistant=20v2.3.0=20=E2=80=94=20d?= =?UTF-8?q?ocument=20tools=20+=20NetworkStorage=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add upload_document, generate_document, merge_documents tools - Rewrite DocumentAnalyzer to use NetworkStorageIntegration instead of custom WebDAV client - Delete FileStorage classes (FileStorageInterface, FileStorageFactory, NextCloudFileStorage, WebDavFileStorage) - Use networkStorageFolderPath instead of nextCloudFolderPath Co-Authored-By: Claude Opus 4.6 (1M context) --- .../FileStorage/FileStorageFactory.php | 54 ---- .../FileStorage/FileStorageInterface.php | 12 - .../FileStorage/NextCloudFileStorage.php | 21 -- .../Classes/FileStorage/WebDavFileStorage.php | 234 ------------------ .../Services/ActionExecutor.php | 96 +++++++ .../Services/DocumentAnalyzer.php | 96 +++++-- .../Services/SmartAssistantService.php | 2 +- manifest.json | 4 +- 8 files changed, 168 insertions(+), 351 deletions(-) delete mode 100644 files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageFactory.php delete mode 100644 files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageInterface.php delete mode 100644 files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/NextCloudFileStorage.php delete mode 100644 files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/WebDavFileStorage.php diff --git a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageFactory.php b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageFactory.php deleted file mode 100644 index e6cede9..0000000 --- a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageFactory.php +++ /dev/null @@ -1,54 +0,0 @@ -config = $config; - $this->injectableFactory = $injectableFactory; - $this->log = $log; - } - - public function create(?string $userId = null): FileStorageInterface - { - $storageType = $this->config->get('assistantFileStorage', 'nextcloud'); - - return match ($storageType) { - 'nextcloud' => $this->createNextCloud($userId), - 'webdav' => $this->createWebDav(), - default => throw new Error("Unknown file storage type: {$storageType}"), - }; - } - - private function createNextCloud(?string $userId): NextCloudFileStorage - { - $nextCloudService = $this->injectableFactory->create(NextCloudService::class); - return new NextCloudFileStorage($nextCloudService->getClientForUser($userId)); - } - - private function createWebDav(): WebDavFileStorage - { - $url = $this->config->get('assistantWebDavUrl'); - $user = $this->config->get('assistantWebDavUser'); - $password = $this->config->get('assistantWebDavPassword'); - $basePath = $this->config->get('assistantWebDavBasePath', '/webdav'); - - if (empty($url) || empty($user) || empty($password)) { - throw new Error('WebDAV file storage is not configured.'); - } - - return new WebDavFileStorage($url, $user, $password, $basePath); - } -} diff --git a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageInterface.php b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageInterface.php deleted file mode 100644 index caa6e51..0000000 --- a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/FileStorageInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -client = $client; - } - - public function listFolder(string $path): array { return $this->client->listFolder($path); } - public function downloadFile(string $path): string { return $this->client->downloadFile($path); } - public function move(string $source, string $destination): bool { return $this->client->move($source, $destination); } - public function createFolder(string $path): bool { return $this->client->createFolderRecursive($path); } - public function exists(string $path): bool { return $this->client->exists($path); } -} diff --git a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/WebDavFileStorage.php b/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/WebDavFileStorage.php deleted file mode 100644 index 2ff6746..0000000 --- a/files/custom/Espo/Modules/SmartAssistant/Classes/FileStorage/WebDavFileStorage.php +++ /dev/null @@ -1,234 +0,0 @@ -baseUrl = rtrim($baseUrl, '/'); - $this->username = $username; - $this->password = $password; - $this->webdavPath = $webdavPath; - } - - public function listFolder(string $path): array - { - $propfindXml = ' - - - - - - - - - '; - - $response = $this->request('PROPFIND', $path, $propfindXml, [ - 'Content-Type: application/xml', - 'Depth: 1', - ]); - - if ($response['status'] !== 207) { - throw new Error("Failed to list folder: HTTP " . $response['status']); - } - - return $this->parsePropfindResponse($response['body'], $path); - } - - public function downloadFile(string $path): string - { - $response = $this->request('GET', $path); - - if ($response['status'] !== 200) { - throw new Error("Failed to download file: HTTP " . $response['status']); - } - - return $response['body']; - } - - public function move(string $source, string $destination): bool - { - $destUrl = $this->buildUrl($destination); - - $response = $this->request('MOVE', $source, null, [ - 'Destination: ' . $destUrl, - 'Overwrite: F', - ]); - - if ($response['status'] !== 201 && $response['status'] !== 204) { - throw new Error("Failed to move: HTTP " . $response['status']); - } - - return true; - } - - public function createFolder(string $path): bool - { - $parts = array_filter(explode('/', $path)); - $currentPath = ''; - - foreach ($parts as $part) { - $currentPath .= '/' . $part; - $response = $this->request('MKCOL', $currentPath); - - // 201 = created, 405 = already exists - if ($response['status'] !== 201 && $response['status'] !== 405) { - throw new Error("Failed to create folder: HTTP " . $response['status']); - } - } - - return true; - } - - public function exists(string $path): bool - { - try { - $response = $this->request('PROPFIND', $path, null, ['Depth: 0']); - return $response['status'] === 207; - } catch (\Exception $e) { - return false; - } - } - - private function buildUrl(string $path): string - { - $pathParts = explode('/', ltrim($path, '/')); - $encodedParts = array_map('rawurlencode', $pathParts); - - return $this->baseUrl . $this->webdavPath . '/' . implode('/', $encodedParts); - } - - /** - * @return array{status: int, body: string} - */ - private function request( - string $method, - string $path, - ?string $body = null, - array $headers = [] - ): array { - $url = $this->buildUrl($path); - - $ch = curl_init(); - - $allHeaders = array_merge([ - 'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password), - ], $headers); - - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $allHeaders, - CURLOPT_TIMEOUT => 120, - CURLOPT_SSL_VERIFYPEER => true, - ]); - - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - - $responseBody = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlError = curl_error($ch); - curl_close($ch); - - if ($curlError) { - throw new Error("WebDAV connection error: $curlError"); - } - - return [ - 'status' => $httpCode, - 'body' => $responseBody, - ]; - } - - private function parsePropfindResponse(string $xml, string $basePath): array - { - $items = []; - $normalizedBasePath = '/' . trim($basePath, '/'); - - $doc = new \DOMDocument(); - $doc->loadXML($xml); - - $xpath = new \DOMXPath($doc); - $xpath->registerNamespace('d', 'DAV:'); - - $responses = $xpath->query('//d:response'); - - foreach ($responses as $response) { - $hrefNodes = $xpath->query('d:href', $response); - if ($hrefNodes->length === 0) { - continue; - } - - $href = urldecode($hrefNodes->item(0)->textContent); - - // Extract relative path by removing webdav prefix - $relativePath = $href; - $webdavPrefix = $this->webdavPath . '/'; - if (($pos = strpos($href, $webdavPrefix)) !== false) { - $relativePath = substr($href, $pos + strlen($webdavPrefix)); - } - $relativePath = '/' . trim($relativePath, '/'); - - // Skip the queried folder itself - if ($relativePath === $normalizedBasePath || $relativePath === $normalizedBasePath . '/') { - continue; - } - - $propstat = $xpath->query('d:propstat/d:prop', $response)->item(0); - if (!$propstat) { - continue; - } - - $resourceType = $xpath->query('d:resourcetype/d:collection', $propstat); - $isFolder = $resourceType->length > 0; - - $contentLength = $xpath->query('d:getcontentlength', $propstat); - $size = $contentLength->length > 0 ? (int) $contentLength->item(0)->textContent : null; - - $lastModified = $xpath->query('d:getlastmodified', $propstat); - $modified = $lastModified->length > 0 ? $lastModified->item(0)->textContent : null; - - $contentType = $xpath->query('d:getcontenttype', $propstat); - $mimeType = $contentType->length > 0 ? $contentType->item(0)->textContent : null; - - $items[] = [ - 'name' => basename($relativePath), - 'path' => $relativePath, - 'isFolder' => $isFolder, - 'size' => $size, - 'mimeType' => $isFolder ? 'folder' : $mimeType, - 'modified' => $modified, - ]; - } - - usort($items, function ($a, $b) { - if ($a['isFolder'] !== $b['isFolder']) { - return $b['isFolder'] <=> $a['isFolder']; - } - return strcasecmp($a['name'], $b['name']); - }); - - return $items; - } -} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php b/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php index 09e4cff..2c2a983 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/ActionExecutor.php @@ -42,6 +42,9 @@ class ActionExecutor 'list_documents' => $this->listDocuments($caseId), 'analyze_document' => $this->analyzeDocument($params, $caseId), 'rename_document' => $this->renameDocument($params), + 'upload_document' => $this->uploadDocument($params, $caseId), + 'generate_document' => $this->generateDocument($params, $caseId), + 'merge_documents' => $this->mergeDocuments($params, $caseId), 'save_memory' => $this->saveMemory($params, $caseId, $userId), default => throw new Error("Unknown tool: {$tool}"), }; @@ -243,6 +246,99 @@ class ActionExecutor return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result]; } + private function uploadDocument(array $params, ?string $caseId): array + { + if (!$caseId) { + throw new Error('upload_document requires a case context.'); + } + + $fileName = $params['fileName'] ?? null; + $content = $params['content'] ?? null; + if (!$fileName) throw new Error('fileName parameter is required.'); + if (!$content) throw new Error('content parameter is required (base64-encoded file data).'); + + $decoded = base64_decode($content, true); + if ($decoded === false) { + throw new Error('content is not valid base64.'); + } + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + $result = $analyzer->uploadDocument($caseId, $fileName, $decoded, $params['folder'] ?? null); + + $msg = "✅ מסמך \"{$fileName}\" הועלה בהצלחה"; + if (!empty($params['folder'])) { + $msg .= " לתיקייה \"{$params['folder']}\""; + } + + return ['success' => true, 'message' => $msg, 'data' => $result]; + } + + private function generateDocument(array $params, ?string $caseId): array + { + if (!$caseId) { + throw new Error('generate_document requires a case context.'); + } + + $templateId = $params['templateId'] ?? null; + if (!$templateId) throw new Error('templateId parameter is required.'); + + $template = $this->entityManager->getEntityById('DocumentTemplate', $templateId); + if (!$template) throw new NotFound("Template {$templateId} not found."); + + $documentSubject = $params['documentSubject'] ?? null; + + $templateService = $this->injectableFactory->create( + \Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService::class + ); + + $result = $templateService->createFromTemplate($templateId, 'Case', $caseId, $documentSubject); + + if (empty($result['success'])) { + throw new Error('Failed to generate document from template.'); + } + + $templateName = $template->get('name') ?? 'document'; + $fileName = $result['fileName'] ?? "{$templateName}.docx"; + + $msg = "✅ מסמך \"{$fileName}\" נוצר בהצלחה מתבנית \"{$templateName}\""; + + return [ + 'success' => true, + 'message' => $msg, + 'data' => ['fileName' => $fileName], + ]; + } + + private function mergeDocuments(array $params, ?string $caseId): array + { + if (!$caseId) { + throw new Error('merge_documents requires a case context.'); + } + + $documentIds = $params['documentIds'] ?? []; + if (empty($documentIds)) throw new Error('documentIds parameter is required (array of document IDs).'); + + $documents = []; + foreach ($documentIds as $docId) { + $doc = $this->entityManager->getEntityById('Document', $docId); + if (!$doc) throw new NotFound("Document {$docId} not found."); + $documents[] = $doc; + } + + $mergerService = $this->injectableFactory->create( + \Espo\Modules\NetworkStorageIntegration\Services\DocumentMergerService::class + ); + + $options = $params['options'] ?? []; + $mergedPath = $mergerService->mergeWithLabels($documents, $options); + + return [ + 'success' => true, + 'message' => "✅ " . count($documents) . " מסמכים מוזגו בהצלחה", + 'data' => ['path' => $mergedPath, 'documentCount' => count($documents)], + ]; + } + private function normalizeDateTime(?string $value): ?string { if ($value === null || $value === '') return null; diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php b/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php index d370979..e07a8dd 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php @@ -6,8 +6,8 @@ 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; +use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService; +use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface; class DocumentAnalyzer { @@ -43,17 +43,19 @@ class DocumentAnalyzer $casePath = $this->getCaseFolderPath($caseId); if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []]; - $storage = $this->getStorage(); - $topLevel = $storage->listFolder($casePath); + $client = $this->getStorageClient(); + $topLevel = $client->listFolder($casePath); $folders = []; $rootFiles = []; $totalFiles = 0; foreach ($topLevel as $item) { - if ($item['isFolder']) { + $isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']); + if ($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]; + foreach ($client->listFolder($item['path']) as $sub) { + $subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']); + if (!$subIsFolder) { + $subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null]; $totalFiles++; } } @@ -62,7 +64,7 @@ class DocumentAnalyzer } $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]; + $rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'] ?? null, 'mimeType' => $item['mimeType'] ?? null, 'modified' => $item['modified'] ?? null]; $totalFiles++; } } @@ -72,17 +74,12 @@ class DocumentAnalyzer public function extractTextContent(string $filePath): ?string { - $storage = $this->getStorage(); - $parentPath = dirname($filePath); + $client = $this->getStorageClient(); $filename = basename($filePath); - try { $items = $storage->listFolder($parentPath); } catch (\Exception $e) { return null; } + try { $content = $client->downloadFile($filePath); } 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 (strlen($content) > self::MAX_FILE_SIZE) return null; if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true); $tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename; @@ -107,7 +104,7 @@ class DocumentAnalyzer public function renameDocument(string $sourcePath, string $newName): array { - $storage = $this->getStorage(); + $client = $this->getStorageClient(); $parentPath = dirname($sourcePath); $oldName = basename($sourcePath); @@ -120,21 +117,66 @@ class DocumentAnalyzer $destination = $parentPath . '/' . $newName; if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName]; - $storage->move($sourcePath, $destination); + $client->move($sourcePath, $destination); $this->updateDocumentEntity($sourcePath, $destination); return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName]; } - private function getCaseFolderPath(string $caseId): ?string + public function uploadDocument(string $caseId, string $fileName, string $content, ?string $subfolder = null): array { - $case = $this->entityManager->getEntityById('Case', $caseId); - return $case ? ($case->get('nextCloudFolderPath') ?: null) : null; + $casePath = $this->getCaseFolderPath($caseId); + if (!$casePath) { + throw new Error('Case does not have a document folder configured (networkStorageFolderPath).'); + } + + $client = $this->getStorageClient(); + + $targetDir = $casePath; + if ($subfolder) { + $targetDir = $casePath . '/' . trim($subfolder, '/'); + if (!$client->exists($targetDir)) { + $client->createFolderRecursive($targetDir); + } + } + + $targetPath = $targetDir . '/' . $fileName; + $client->uploadFile($targetPath, $content); + + return [ + 'path' => $targetPath, + 'fileName' => $fileName, + 'folder' => $subfolder, + ]; } - private function getStorage(): FileStorageInterface + public function getCaseFolderPath(string $caseId): ?string { - return $this->injectableFactory->create(FileStorageFactory::class)->create(); + $case = $this->entityManager->getEntityById('Case', $caseId); + if (!$case) return null; + + // Try stored path first + $storedPath = $case->get('networkStorageFolderPath') ?: null; + if ($storedPath) return $storedPath; + + // Fall back to computed path from NetworkDocumentService + try { + $nds = $this->getNetworkDocumentService(); + return $nds->getEntityFolderPath('Case', $caseId); + } catch (\Exception $e) { + $this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage()); + return null; + } + } + + private function getStorageClient(): StorageClientInterface + { + return $this->getNetworkDocumentService()->getClient(); + } + + private function getNetworkDocumentService(): NetworkDocumentService + { + return $this->injectableFactory->create(NetworkDocumentService::class); } private function extractFromPdf(string $tmpFile): ?string @@ -168,10 +210,10 @@ class DocumentAnalyzer private function updateDocumentEntity(string $oldPath, string $newPath): void { try { - $doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne(); + $doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne(); if ($doc) { - $doc->set('nextCloudPath', $newPath); - $doc->set('nextCloudFilename', basename($newPath)); + $doc->set('networkStoragePath', $newPath); + $doc->set('networkStorageFileName', basename($newPath)); $this->entityManager->saveEntity($doc); } } catch (\Exception $e) {} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php b/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php index 151d379..c8af48b 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php @@ -18,7 +18,7 @@ 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', 'save_memory']; + private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory']; private EntityManager $entityManager; private InjectableFactory $injectableFactory; diff --git a/manifest.json b/manifest.json index aa6485c..24e9972 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.2.0", + "version": "2.3.0", "acceptableVersions": [ ">=8.0.0" ], - "releaseDate": "2026-04-04", + "releaseDate": "2026-04-05", "php": [ ">=8.1" ]