87bc4b6770
- 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>
222 lines
9.5 KiB
PHP
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) {}
|
|
}
|
|
}
|