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 89b3f5844e fix(2.9.1): extractFromDocx — drop PHPWord dependency, parse word/document.xml directly
Root cause of the Shira hallucination incident in production case 46 Friedman:
DocumentAnalyzer::extractFromDocx relied on PhpOffice\PhpWord, but on the
EspoCRM prod image the PHPWord files are present in vendor/ yet are NOT
registered in the composer PSR-4 autoload map — class_exists() silently
returned false, the method returned null, and Shira's read_document fell
back to OCR. OCR then only saw the signature image, the AI got "[signature]"
as document content and fabricated the entire CTS appeal as a knee injury.

This change rewrites extractFromDocx to use ZipArchive + a small regex
parser of word/document.xml. Independent of PHPWord, more robust on
tables/footnotes/hyperlinks (which PHPWord's element walker missed at depth
>1), and verified on prod against the same Friedman appeal: 80 paragraphs /
16633 clean chars extracted (vs 0 before).

The paired Python fix in shira-hermes (commit 7b517e1) makes the OCR
fallback also read document.xml, so even if this PHP fix regresses again,
the AI will not be fed "[signature]" as document content.

Refs Task Master #5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:01:44 +00:00

581 lines
22 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 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 = [
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
'החלטות_בית_משפט' => ['החלטה', 'פסק_דין', 'פסק דין', 'גזר_דין', 'גזר דין', 'צו', 'פרוטוקול', 'פס"ד', 'גז"ד'],
'חוזים' => ['חוזה', 'הסכם', 'נספח', 'תוספת'],
'התכתבויות' => ['מכתב', 'פקס', '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
{
$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();
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) {
$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,
'resolvedPath' => $casePath,
'pathSource' => $description['source'],
'folderExists' => true,
'reason' => $totalFiles === 0 ? "Folder '{$casePath}' exists but is empty." : null,
];
}
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);
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;
$text = trim($text);
if ($maxLength !== null && mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength) . '...';
}
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 = [];
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
{
$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
{
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 ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false];
}
$storedPath = $case->get('networkStorageFolderPath') ?: null;
if ($storedPath) {
return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true];
}
try {
$nds = $this->getNetworkDocumentService();
$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 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<int, array{name: string, path: string, type: string, size: ?int, mimeType: ?string, modified: ?string}>
*/
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<int, array{path: string, name: string, level: int, score: int}>
*/
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) {
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();
}
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
{
// PHPWord (vendor/phpoffice/phpword) ships with the EspoCRM image but
// is NOT in the composer PSR-4 autoload map — class_exists silently
// returns false. Rather than fix the autoloader (rebuild of the image)
// we parse word/document.xml directly. This is also more robust:
// PHPWord's element walker only goes one level deep, missing text in
// tables, hyperlinks, and footnotes.
$zip = new \ZipArchive();
if ($zip->open($tmpFile) !== true) {
$this->log->warning("SmartAssistant: extractFromDocx: not a valid zip: $tmpFile");
return null;
}
$xml = $zip->getFromName('word/document.xml');
$zip->close();
if ($xml === false || $xml === '') {
$this->log->warning("SmartAssistant: extractFromDocx: word/document.xml missing in $tmpFile");
return null;
}
// Each <w:p> is a paragraph; concatenate <w:t> runs inside it.
// `(?:\s[^>]*)?` keeps us from also matching <w:tab> / <w:tbl>.
$paragraphs = [];
if (preg_match_all('#<w:p(?:\s[^>]*)?>(.*?)</w:p>#s', $xml, $pMatches)) {
foreach ($pMatches[1] as $block) {
if (preg_match_all('#<w:t(?:\s[^>]*)?>(.*?)</w:t>#s', $block, $tMatches)) {
$line = trim(implode('', $tMatches[1]));
if ($line !== '') $paragraphs[] = html_entity_decode($line, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
}
}
$text = trim(implode("\n", $paragraphs));
return $text !== '' ? $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) {}
}
}