This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
SmartAssistant/files/custom/Espo/Modules/SmartAssistant/Services/DocumentAnalyzer.php
T
chaim 87bc4b6770 feat: SmartAssistant v2.3.0 — document tools + NetworkStorage integration
- 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) <noreply@anthropic.com>
2026-04-05 14:53:27 +00:00

222 lines
9.5 KiB
PHP

<?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\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
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' => []];
$client = $this->getStorageClient();
$topLevel = $client->listFolder($casePath);
$folders = []; $rootFiles = []; $totalFiles = 0;
foreach ($topLevel as $item) {
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
if ($isFolder) {
$subFiles = [];
try {
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++;
}
}
} 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'] ?? null, 'mimeType' => $item['mimeType'] ?? null, 'modified' => $item['modified'] ?? null];
$totalFiles++;
}
}
return ['casePath' => $casePath, 'totalFiles' => $totalFiles, 'folders' => $folders, 'rootFiles' => $rootFiles];
}
public function extractTextContent(string $filePath): ?string
{
$client = $this->getStorageClient();
$filename = basename($filePath);
try { $content = $client->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;
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
{
$client = $this->getStorageClient();
$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];
$client->move($sourcePath, $destination);
$this->updateDocumentEntity($sourcePath, $destination);
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
}
public function uploadDocument(string $caseId, string $fileName, string $content, ?string $subfolder = null): array
{
$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,
];
}
public function getCaseFolderPath(string $caseId): ?string
{
$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
{
$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(['networkStoragePath' => $oldPath])->findOne();
if ($doc) {
$doc->set('networkStoragePath', $newPath);
$doc->set('networkStorageFileName', basename($newPath));
$this->entityManager->saveEntity($doc);
}
} catch (\Exception $e) {}
}
}