diff --git a/.taskmaster/state.json b/.taskmaster/state.json index cc83a5a..c518cce 100644 --- a/.taskmaster/state.json +++ b/.taskmaster/state.json @@ -2,5 +2,5 @@ "currentTag": "master", "lastSwitched": "2026-04-14T06:05:06.044Z", "branchTagMapping": {}, - "migrationNoticeShown": false + "migrationNoticeShown": true } \ No newline at end of file diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index b7c1c9f..e37daf9 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2,21 +2,38 @@ "master": { "tasks": [ { - "id": 1, + "id": "1", "title": "fix: broaden task detection to include Contact-linked tasks", "description": "AlertCalculator, CaseContextBuilder, and OfficeContextBuilder only queried tasks with parentType='Case', missing tasks linked to case contacts (parentType='Contact'). This caused false 'no preparation task' alerts in Shira's daily standup.", - "status": "in-progress", + "status": "done", "priority": "high", "dependencies": [], "details": "Root cause confirmed via production API: preparation tasks had parentType='Contact' because Shira created them linked to the contact entity. Fix broadens all task queries to include both Case and Contact parent types, plus NhActivity-linked tasks.", "testStrategy": "Deploy to staging, trigger standup for a case with Contact-linked tasks, verify no false alert.", - "subtasks": [] + "subtasks": [], + "updatedAt": "2026-04-14T06:08:19.494Z" + }, + { + "id": "2", + "title": "Network-storage diagnostics + folder discovery", + "description": "Expose diagnostics when a case folder can't be listed and add endpoints (findCaseFolder, setCaseFolderPath) so Shira can locate and persist the correct network-storage path.", + "details": "", + "testStrategy": "", + "status": "in-progress", + "dependencies": [], + "priority": "medium", + "subtasks": [], + "updatedAt": "2026-04-19T06:11:17.422Z" } ], "metadata": { - "created": "2026-04-14T06:05:06.042Z", - "updated": "2026-04-14T09:15:00.000Z", - "description": "Tasks for master context" + "version": "1.0.0", + "lastModified": "2026-04-19T06:11:17.423Z", + "taskCount": 2, + "completedCount": 1, + "tags": [ + "master" + ] } } -} +} \ No newline at end of file diff --git a/README.md b/README.md index 67241ed..4c21ea0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SmartAssistant - עוזר חכם -**גרסה:** 2.7.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0 +**גרסה:** 2.7.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0 ## תיאור עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM. diff --git a/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php b/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php index e904d4b..cdd874c 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php +++ b/files/custom/Espo/Modules/SmartAssistant/Controllers/SmartAssistant.php @@ -217,6 +217,20 @@ class SmartAssistant ]; } + public function postActionGetDocumentBytes(Request $request, Response $response): array + { + $this->checkAccess(); + $data = $request->getParsedBody(); + + $filePath = $data->filePath ?? null; + if (empty($filePath)) { + throw new BadRequest('filePath is required.'); + } + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + return $analyzer->getDocumentBytes($filePath); + } + public function postActionReadMultipleDocuments(Request $request, Response $response): array { $this->checkAccess(); @@ -279,6 +293,75 @@ class SmartAssistant ]; } + public function postActionListDocuments(Request $request, Response $response): array + { + $this->checkAccess(); + $data = $request->getParsedBody(); + + $caseId = $data->caseId ?? null; + if (empty($caseId)) { + throw new BadRequest('caseId is required.'); + } + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + return $analyzer->listDocumentsRecursive($caseId); + } + + public function postActionBrowseFolder(Request $request, Response $response): array + { + $this->checkAccess(); + $data = $request->getParsedBody(); + + $path = $data->path ?? null; + if ($path === null) { + throw new BadRequest('path is required.'); + } + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + return [ + 'path' => $path, + 'entries' => $analyzer->browseFolder((string) $path), + ]; + } + + public function postActionFindCaseFolder(Request $request, Response $response): array + { + $this->checkAccess(); + $data = $request->getParsedBody(); + + $query = $data->query ?? null; + if (empty($query)) { + throw new BadRequest('query is required.'); + } + + $limit = isset($data->limit) ? max(1, min((int) $data->limit, 50)) : 20; + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + return [ + 'query' => $query, + 'matches' => $analyzer->findCaseFolder((string) $query, $limit), + ]; + } + + public function postActionSetCaseFolderPath(Request $request, Response $response): array + { + $this->checkAccess(); + if (!$this->acl->checkScope('Case', 'edit')) { + throw new Forbidden('No edit access to Case.'); + } + + $data = $request->getParsedBody(); + $caseId = $data->caseId ?? null; + $path = $data->path ?? null; + + if (empty($caseId) || empty($path)) { + throw new BadRequest('caseId and path are required.'); + } + + $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); + return $analyzer->setCaseFolderPath((string) $caseId, (string) $path); + } + public function postActionGenerateFromTemplate(Request $request, Response $response): array { $this->checkAccess(); diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php b/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php index fb0c05a..7c9685d 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/CaseContextBuilder.php @@ -33,6 +33,7 @@ class CaseContextBuilder 'recentNotes' => $this->getRecentNotes($caseId), 'availableTemplates' => $this->getAvailableTemplates(), 'documents' => $this->getDocumentListing($caseId), + 'signatureRequests' => $this->getSignatureRequests($caseId), 'currentUser' => $this->getUserData($userId), 'validStatuses' => ActionExecutor::VALID_STATUSES, ]; @@ -202,13 +203,51 @@ class CaseContextBuilder return ['id' => $user->get('id'), 'name' => $user->get('name')]; } + private function getSignatureRequests(string $caseId): array + { + try { + $collection = $this->entityManager->getRDBRepository('SignatureRequest') + ->where([ + 'caseId' => $caseId, + 'status!=' => ['Completed', 'Voided'], + ]) + ->order('createdAt', 'DESC') + ->limit(0, 10) + ->find(); + + $requests = []; + + foreach ($collection as $sr) { + $requests[] = [ + 'id' => $sr->get('id'), + 'name' => $sr->get('name'), + 'status' => $sr->get('status'), + 'sentAt' => $sr->get('sentAt'), + ]; + } + + return $requests; + } catch (\Exception $e) { + // SignatureRequest entity might not exist if DigitalSignature not installed + return []; + } + } + private function getDocumentListing(string $caseId): array { try { $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); $result = $analyzer->listDocumentsRecursive($caseId); - $simplified = ['totalFiles' => $result['totalFiles'], 'folders' => [], 'rootFiles' => []]; + $simplified = [ + 'totalFiles' => $result['totalFiles'], + 'folders' => [], + 'rootFiles' => [], + 'resolvedPath' => $result['resolvedPath'] ?? null, + 'pathSource' => $result['pathSource'] ?? null, + 'folderExists' => $result['folderExists'] ?? null, + 'diagnostic' => $result['reason'] ?? null, + ]; foreach ($result['folders'] as $folder) { $files = []; diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php b/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php index 151827e..0323065 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php @@ -41,11 +41,67 @@ class DocumentAnalyzer public function listDocumentsRecursive(string $caseId): array { - $casePath = $this->getCaseFolderPath($caseId); - if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []]; + $description = $this->describeCaseFolderPath($caseId); + $casePath = $description['path']; + + if (!$casePath) { + return [ + 'casePath' => null, + 'totalFiles' => 0, + 'folders' => [], + 'rootFiles' => [], + 'resolvedPath' => null, + 'pathSource' => $description['source'], + 'folderExists' => false, + 'reason' => $description['error'] ?? 'Case has no networkStorageFolderPath and no default could be computed.', + ]; + } $client = $this->getStorageClient(); - $topLevel = $client->listFolder($casePath); + + try { + $folderExists = $client->exists($casePath); + } catch (\Exception $e) { + return [ + 'casePath' => $casePath, + 'totalFiles' => 0, + 'folders' => [], + 'rootFiles' => [], + 'resolvedPath' => $casePath, + 'pathSource' => $description['source'], + 'folderExists' => false, + 'reason' => "Storage client failed while checking '{$casePath}': " . $e->getMessage(), + ]; + } + + if (!$folderExists) { + return [ + 'casePath' => $casePath, + 'totalFiles' => 0, + 'folders' => [], + 'rootFiles' => [], + 'resolvedPath' => $casePath, + 'pathSource' => $description['source'], + 'folderExists' => false, + 'reason' => "Folder '{$casePath}' (source: {$description['source']}) does not exist on storage.", + ]; + } + + try { + $topLevel = $client->listFolder($casePath); + } catch (\Exception $e) { + return [ + 'casePath' => $casePath, + 'totalFiles' => 0, + 'folders' => [], + 'rootFiles' => [], + 'resolvedPath' => $casePath, + 'pathSource' => $description['source'], + 'folderExists' => true, + 'reason' => "Failed to list folder '{$casePath}': " . $e->getMessage(), + ]; + } + $folders = []; $rootFiles = []; $totalFiles = 0; foreach ($topLevel as $item) { @@ -70,7 +126,16 @@ class DocumentAnalyzer } } - return ['casePath' => $casePath, 'totalFiles' => $totalFiles, 'folders' => $folders, 'rootFiles' => $rootFiles]; + return [ + 'casePath' => $casePath, + 'totalFiles' => $totalFiles, + 'folders' => $folders, + 'rootFiles' => $rootFiles, + 'resolvedPath' => $casePath, + 'pathSource' => $description['source'], + 'folderExists' => true, + 'reason' => $totalFiles === 0 ? "Folder '{$casePath}' exists but is empty." : null, + ]; } public function extractTextContent(string $filePath): ?string @@ -115,6 +180,85 @@ class DocumentAnalyzer return $text; } + /** + * Fetch raw file bytes + metadata for OCR/vision fallback. + * + * @return array{success: bool, fileName: string, mimeType: string, sizeBytes: int, base64: ?string, error: ?string} + */ + public function getDocumentBytes(string $filePath): array + { + $fileName = basename($filePath); + $client = $this->getStorageClient(); + + try { + $content = $client->downloadFile($filePath); + } catch (\Exception $e) { + return [ + 'success' => false, + 'fileName' => $fileName, + 'mimeType' => '', + 'sizeBytes' => 0, + 'base64' => null, + 'error' => 'Failed to download: ' . $e->getMessage(), + ]; + } + + $sizeBytes = strlen($content); + if ($sizeBytes > self::MAX_FILE_SIZE) { + return [ + 'success' => false, + 'fileName' => $fileName, + 'mimeType' => '', + 'sizeBytes' => $sizeBytes, + 'base64' => null, + 'error' => 'File too large (max 10MB).', + ]; + } + + $mimeType = $this->guessMimeType($fileName, $content); + + return [ + 'success' => true, + 'fileName' => $fileName, + 'mimeType' => $mimeType, + 'sizeBytes' => $sizeBytes, + 'base64' => base64_encode($content), + 'error' => null, + ]; + } + + private function guessMimeType(string $fileName, string $content): string + { + $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); + $map = [ + 'pdf' => 'application/pdf', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'doc' => 'application/msword', + 'txt' => 'text/plain', + 'csv' => 'text/csv', + 'log' => 'text/plain', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'tiff' => 'image/tiff', + 'tif' => 'image/tiff', + 'webp' => 'image/webp', + ]; + if (isset($map[$ext])) { + return $map[$ext]; + } + // Fallback: finfo + if (function_exists('finfo_buffer')) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_buffer($finfo, $content); + finfo_close($finfo); + if ($mime) return $mime; + } + return 'application/octet-stream'; + } + public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array { $results = []; @@ -192,24 +336,160 @@ class DocumentAnalyzer } public function getCaseFolderPath(string $caseId): ?string + { + return $this->describeCaseFolderPath($caseId)['path']; + } + + /** + * Resolve the case folder path with diagnostic metadata. + * + * @return array{path: ?string, source: string, error: ?string, caseExists: bool} + * source: 'stored' | 'computed' | 'none' + */ + public function describeCaseFolderPath(string $caseId): array { $case = $this->entityManager->getEntityById('Case', $caseId); - if (!$case) return null; + if (!$case) { + return ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false]; + } - // Try stored path first $storedPath = $case->get('networkStorageFolderPath') ?: null; - if ($storedPath) return $storedPath; + if ($storedPath) { + return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true]; + } - // Fall back to computed path from NetworkDocumentService try { $nds = $this->getNetworkDocumentService(); - return $nds->getEntityFolderPath('Case', $caseId); + $computed = $nds->getEntityFolderPath('Case', $caseId); + if ($computed) { + return ['path' => $computed, 'source' => 'computed', 'error' => null, 'caseExists' => true]; + } + return ['path' => null, 'source' => 'none', 'error' => 'No stored path, and default path computation returned null.', 'caseExists' => true]; } catch (\Exception $e) { - $this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage()); - return null; + $this->log->warning("SmartAssistant: Failed to compute case folder path: " . $e->getMessage()); + return ['path' => null, 'source' => 'none', 'error' => $e->getMessage(), 'caseExists' => true]; } } + /** + * List the immediate contents of an arbitrary folder path on network storage. + * Manual override for when auto-resolution of a case folder fails. + * + * @return array + */ + public function browseFolder(string $path): array + { + $client = $this->getStorageClient(); + $entries = []; + foreach ($client->listFolder($path) as $item) { + $isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']); + $entries[] = [ + 'name' => $item['name'] ?? '', + 'path' => $item['path'] ?? '', + 'type' => $isFolder ? 'folder' : 'file', + 'size' => $item['size'] ?? null, + 'mimeType' => $item['mimeType'] ?? null, + 'modified' => $item['modified'] ?? null, + ]; + } + return $entries; + } + + /** + * Search the storage for folders matching a query (case-insensitive substring). + * Scans root and one level deep. Returns sorted matches. + * + * @return array + */ + public function findCaseFolder(string $query, int $limit = 20): array + { + $normalized = trim($query); + if ($normalized === '') return []; + + $client = $this->getStorageClient(); + $needle = mb_strtolower($normalized); + $matches = []; + + try { + $roots = $client->listFolder(''); + } catch (\Exception $e) { + $this->log->warning("SmartAssistant: findCaseFolder root listing failed: " . $e->getMessage()); + return []; + } + + foreach ($roots as $item) { + $isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']); + if (!$isFolder) continue; + + $name = (string) ($item['name'] ?? ''); + $path = (string) ($item['path'] ?? $name); + + $score = $this->fuzzyScore($name, $needle); + if ($score > 0) { + $matches[] = ['path' => $path, 'name' => $name, 'level' => 0, 'score' => $score]; + } + + // One level deeper, but only inspect folder names — don't list files to keep it cheap. + try { + foreach ($client->listFolder($path) as $sub) { + $subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']); + if (!$subIsFolder) continue; + $subName = (string) ($sub['name'] ?? ''); + $subPath = (string) ($sub['path'] ?? ($path . '/' . $subName)); + $subScore = $this->fuzzyScore($subName, $needle); + if ($subScore > 0) { + $matches[] = ['path' => $subPath, 'name' => $subName, 'level' => 1, 'score' => $subScore]; + } + } + } catch (\Exception $e) { + // Non-fatal: skip unreadable subfolders silently. + } + } + + usort($matches, fn($a, $b) => $b['score'] <=> $a['score']); + return array_slice($matches, 0, $limit); + } + + /** + * Persist the resolved folder path on the Case so future calls skip discovery. + */ + public function setCaseFolderPath(string $caseId, string $path): array + { + $case = $this->entityManager->getEntityById('Case', $caseId); + if (!$case) { + throw new Error("Case '{$caseId}' not found."); + } + + $client = $this->getStorageClient(); + if (!$client->exists($path)) { + throw new Error("Path '{$path}' does not exist on storage."); + } + + $case->set('networkStorageFolderPath', $path); + $this->entityManager->saveEntity($case); + + return ['success' => true, 'caseId' => $caseId, 'path' => $path]; + } + + private function fuzzyScore(string $haystack, string $needleLower): int + { + $hay = mb_strtolower($haystack); + if ($hay === $needleLower) return 100; + if (mb_strpos($hay, $needleLower) !== false) return 80; + + // Token overlap (whitespace/dash/underscore). + $hayTokens = preg_split('/[\s\-_]+/u', $hay) ?: []; + $needleTokens = preg_split('/[\s\-_]+/u', $needleLower) ?: []; + $hits = 0; + foreach ($needleTokens as $nt) { + if ($nt === '') continue; + foreach ($hayTokens as $ht) { + if ($ht !== '' && mb_strpos($ht, $nt) !== false) { $hits++; break; } + } + } + return $hits > 0 ? 40 + $hits * 5 : 0; + } + private function getMaxTextLength(): int { if ($this->maxTextLength !== null) { diff --git a/manifest.json b/manifest.json index 1b92b9b..3621dbd 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.7.3", + "version": "2.8.0", "acceptableVersions": [ ">=8.0.0" ], - "releaseDate": "2026-04-14", + "releaseDate": "2026-04-19", "php": [ ">=8.1" ]