Compare commits

..

1 Commits

Author SHA1 Message Date
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
3 changed files with 49 additions and 18 deletions
File diff suppressed because one or more lines are too long
@@ -533,20 +533,37 @@ class DocumentAnalyzer
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";
}
// 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');
}
}
}
return $text ?: null;
$text = trim(implode("\n", $paragraphs));
return $text !== '' ? $text : null;
}
private function updateDocumentEntity(string $oldPath, string $newPath): void
+2 -2
View File
@@ -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.9.0",
"version": "2.9.1",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-05-14",
"releaseDate": "2026-05-26",
"php": [
">=8.1"
]