feat: add document reading, multi-doc summarization, and batch rename for Shira

Enable the AI assistant to read full documents from network storage (PDF/DOCX/TXT),
summarize multiple documents, and batch-rename files. Key changes:

- Raise text extraction limit from 2,000 to 100,000 chars (configurable)
- Add agentic loop in SmartAssistantService for multi-turn tool execution
- New tools: read_document, read_multiple_documents, batch_rename_documents
- New API endpoints: readDocument, readMultipleDocuments, batchRename
- New integration settings: maxDocumentChars, agenticLoopEnabled, maxAgenticLoops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-09 11:24:45 +00:00
parent f319f981f9
commit 2a8046b737
6 changed files with 418 additions and 61 deletions
@@ -10,6 +10,7 @@ use Espo\Core\InjectableFactory;
use Espo\Core\Acl; use Espo\Core\Acl;
use Espo\Modules\SmartAssistant\Services\SmartAssistantService; use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
use Espo\Modules\SmartAssistant\Services\CaseMemoryService; use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
class SmartAssistant class SmartAssistant
{ {
@@ -169,4 +170,89 @@ class SmartAssistant
'id' => $entry->get('id'), 'id' => $entry->get('id'),
]; ];
} }
public function postActionReadDocument(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePath = $data->filePath ?? null;
if (empty($filePath)) {
throw new BadRequest('filePath is required.');
}
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$text = $analyzer->extractFullText($filePath, $maxLength);
return [
'success' => $text !== null,
'text' => $text,
'fileName' => basename($filePath),
'charCount' => $text !== null ? mb_strlen($text) : 0,
];
}
public function postActionReadMultipleDocuments(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePaths = $data->filePaths ?? [];
if (empty($filePaths)) {
throw new BadRequest('filePaths is required (array of file paths).');
}
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
return [
'success' => true,
'documents' => $results,
];
}
public function postActionBatchRename(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$renames = $data->renames ?? [];
if (empty($renames)) {
throw new BadRequest('renames is required (array of {sourcePath, newName}).');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = [];
$successCount = 0;
foreach ($renames as $rename) {
$rename = (array) $rename;
$sourcePath = $rename['sourcePath'] ?? null;
$newName = $rename['newName'] ?? null;
if (!$sourcePath || !$newName) {
$results[] = ['success' => false, 'error' => 'Missing sourcePath or newName'];
continue;
}
try {
$result = $analyzer->renameDocument($sourcePath, $newName);
$results[] = ['success' => true, 'oldName' => $result['oldName'], 'newName' => $result['newName']];
$successCount++;
} catch (\Exception $e) {
$results[] = ['success' => false, 'oldName' => basename($sourcePath), 'error' => $e->getMessage()];
}
}
return [
'success' => true,
'totalCount' => count($renames),
'successCount' => $successCount,
'results' => $results,
];
}
} }
@@ -27,6 +27,18 @@
"upcomingHearingDays": { "upcomingHearingDays": {
"type": "int", "type": "int",
"default": 7 "default": 7
},
"maxDocumentChars": {
"type": "int",
"default": 100000
},
"agenticLoopEnabled": {
"type": "bool",
"default": true
},
"maxAgenticLoops": {
"type": "int",
"default": 3
} }
}, },
"allowUserAccess": false, "allowUserAccess": false,
@@ -49,7 +49,10 @@ class ActionExecutor
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId), 'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
'list_documents' => $this->listDocuments($caseId), 'list_documents' => $this->listDocuments($caseId),
'analyze_document' => $this->analyzeDocument($params, $caseId), 'analyze_document' => $this->analyzeDocument($params, $caseId),
'read_document' => $this->readDocument($params, $caseId),
'read_multiple_documents' => $this->readMultipleDocuments($params, $caseId),
'rename_document' => $this->renameDocument($params), 'rename_document' => $this->renameDocument($params),
'batch_rename_documents' => $this->batchRenameDocuments($params),
'upload_document' => $this->uploadDocument($params, $caseId), 'upload_document' => $this->uploadDocument($params, $caseId),
'generate_document' => $this->generateDocument($params, $caseId), 'generate_document' => $this->generateDocument($params, $caseId),
'merge_documents' => $this->mergeDocuments($params, $caseId), 'merge_documents' => $this->mergeDocuments($params, $caseId),
@@ -383,6 +386,65 @@ class ActionExecutor
return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]]; return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]];
} }
private function readDocument(array $params, ?string $caseId): array
{
$filePath = $params['filePath'] ?? null;
if (!$filePath) throw new Error('filePath parameter is required.');
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$text = $analyzer->extractFullText($filePath);
$fileName = basename($filePath);
if ($text === null) {
return ['success' => false, 'message' => "לא ניתן לחלץ טקסט מ-\"{$fileName}\""];
}
return [
'success' => true,
'message' => $text,
'data' => [
'filePath' => $filePath,
'fileName' => $fileName,
'charCount' => mb_strlen($text),
],
];
}
private function readMultipleDocuments(array $params, ?string $caseId): array
{
$filePaths = $params['filePaths'] ?? [];
if (empty($filePaths)) throw new Error('filePaths parameter is required (array of file paths).');
$maxPerFile = $params['maxCharsPerFile'] ?? 50000;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = $analyzer->extractMultipleDocuments($filePaths, $maxPerFile);
$lines = [];
$successCount = 0;
foreach ($results as $r) {
$lines[] = "=== {$r['fileName']} ===";
if (!empty($r['error'])) {
$lines[] = "שגיאה: {$r['error']}";
} elseif ($r['text'] === null) {
$lines[] = "לא ניתן לחלץ טקסט";
} else {
$lines[] = $r['text'];
$successCount++;
}
$lines[] = '';
}
return [
'success' => true,
'message' => implode("\n", $lines),
'data' => [
'totalFiles' => count($filePaths),
'successCount' => $successCount,
],
];
}
private function renameDocument(array $params): array private function renameDocument(array $params): array
{ {
$sourcePath = $params['sourcePath'] ?? null; $sourcePath = $params['sourcePath'] ?? null;
@@ -395,6 +457,37 @@ class ActionExecutor
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result]; return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
} }
private function batchRenameDocuments(array $params): array
{
$renames = $params['renames'] ?? [];
if (empty($renames)) throw new Error('renames parameter is required (array of {sourcePath, newName}).');
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = [];
$successCount = 0;
foreach ($renames as $rename) {
$sourcePath = $rename['sourcePath'] ?? null;
$newName = $rename['newName'] ?? null;
if (!$sourcePath || !$newName) {
$results[] = "שגיאה: חסר sourcePath או newName";
continue;
}
try {
$result = $analyzer->renameDocument($sourcePath, $newName);
$results[] = "\"{$result['oldName']}\"\"{$result['newName']}\"";
$successCount++;
} catch (\Exception $e) {
$results[] = "שגיאה בשינוי שם \"" . basename($sourcePath) . "\": " . $e->getMessage();
}
}
$msg = "שינוי שמות: {$successCount}/" . count($renames) . " הצליחו\n" . implode("\n", $results);
return ['success' => true, 'message' => $msg];
}
private function uploadDocument(array $params, ?string $caseId): array private function uploadDocument(array $params, ?string $caseId): array
{ {
if (!$caseId) { if (!$caseId) {
@@ -11,13 +11,14 @@ use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
class DocumentAnalyzer class DocumentAnalyzer
{ {
private const MAX_TEXT_LENGTH = 2000; private const DEFAULT_MAX_TEXT_LENGTH = 100000;
private const MAX_FILE_SIZE = 10 * 1024 * 1024; private const MAX_FILE_SIZE = 10 * 1024 * 1024;
private const TMP_DIR = 'data/tmp/assistant-docs'; private const TMP_DIR = 'data/tmp/assistant-docs';
private EntityManager $entityManager; private EntityManager $entityManager;
private InjectableFactory $injectableFactory; private InjectableFactory $injectableFactory;
private Log $log; private Log $log;
private ?int $maxTextLength = null;
private const CATEGORY_PATTERNS = [ private const CATEGORY_PATTERNS = [
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'], 'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
@@ -73,6 +74,13 @@ class DocumentAnalyzer
} }
public function extractTextContent(string $filePath): ?string 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(); $client = $this->getStorageClient();
$filename = basename($filePath); $filename = basename($filePath);
@@ -98,8 +106,41 @@ class DocumentAnalyzer
if (file_exists($tmpFile)) unlink($tmpFile); if (file_exists($tmpFile)) unlink($tmpFile);
if ($text === null) return null; 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); $text = trim($text);
if ($maxLength !== null && mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength) . '...';
}
return $text;
}
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 public function renameDocument(string $sourcePath, string $newName): array
@@ -169,6 +210,27 @@ class DocumentAnalyzer
} }
} }
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 private function getStorageClient(): StorageClientInterface
{ {
return $this->getNetworkDocumentService()->getClient(); return $this->getNetworkDocumentService()->getClient();
@@ -18,7 +18,17 @@ class SmartAssistantService
public const NOTE_TYPE_ACTION = 'SmartAction'; public const NOTE_TYPE_ACTION = 'SmartAction';
// All tools execute inline — Shira has full permissions // All tools execute inline — Shira has full permissions
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory']; private const TOOLS_REQUIRING_CASE = [
'change_status', 'schedule_hearing', 'list_documents', 'analyze_document',
'read_document', 'read_multiple_documents', 'upload_document',
'generate_document', 'merge_documents', 'save_memory',
'batch_rename_documents',
];
// Tools whose output is fed back to the AI for a follow-up webhook call
private const AGENTIC_TOOLS = ['analyze_document', 'read_document', 'read_multiple_documents', 'list_documents'];
private const DEFAULT_MAX_AGENTIC_LOOPS = 3;
private EntityManager $entityManager; private EntityManager $entityManager;
private InjectableFactory $injectableFactory; private InjectableFactory $injectableFactory;
@@ -105,68 +115,20 @@ class SmartAssistantService
// Ignore // Ignore
} }
// Call webhook
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
// Process actions — execute all inline (Shira has full permissions)
$actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? '';
$inlineResults = [];
$executedActions = [];
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode // Resolve effective caseId: explicit case mode, or drill-down detection in office mode
$effectiveCaseId = $caseId; $effectiveCaseId = $caseId;
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) { if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
$effectiveCaseId = $context['drillDown']['case']['id']; $effectiveCaseId = $context['drillDown']['case']['id'];
} }
$executor = $this->injectableFactory->create(ActionExecutor::class); // Execute tools with agentic loop — read tools feed results back to AI
$loopResult = $this->executeActionsWithLoop(
$message, $context, $conversationId, $conversationHistory,
$mode, $effectiveCaseId, $userId
);
foreach ($actions as $action) { $responseText = $loopResult['responseText'];
$tool = $action['tool'] ?? ''; $executedActions = $loopResult['executedActions'];
$actionCaseId = $effectiveCaseId;
// Resolve case from action params if not available
if (!$actionCaseId && !empty($action['params']['caseId'])) {
$actionCaseId = $action['params']['caseId'];
}
// Skip tools that require a case if no case context available
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
continue;
}
try {
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
$executedActions[] = [
'tool' => $tool,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
];
// Create stream note for executed action in case context
if ($actionCaseId) {
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
'conversationId' => $conversationId,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
]);
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
}
}
if (!empty($inlineResults)) {
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
}
// Persist response // Persist response
try { try {
@@ -191,6 +153,148 @@ class SmartAssistantService
]; ];
} }
private function executeActionsWithLoop(
string $message,
array $context,
string $conversationId,
array $conversationHistory,
string $mode,
?string $effectiveCaseId,
string $userId
): array {
$agenticEnabled = $this->getIntegrationSetting('agenticLoopEnabled', true);
$maxLoops = (int) $this->getIntegrationSetting('maxAgenticLoops', self::DEFAULT_MAX_AGENTIC_LOOPS);
$executor = $this->injectableFactory->create(ActionExecutor::class);
$loopCount = 0;
$allExecutedActions = [];
$currentHistory = $conversationHistory;
$finalResponseText = '';
while ($loopCount <= $maxLoops) {
// Call webhook (first call uses original message, subsequent calls use empty message)
$currentMessage = $loopCount === 0 ? $message : '';
$response = $this->callWebhook($currentMessage, $context, $conversationId, $currentHistory, $mode);
$actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? '';
// If no actions, we're done
if (empty($actions)) {
$finalResponseText = $responseText;
break;
}
$agenticResults = [];
$inlineResults = [];
foreach ($actions as $action) {
$tool = $action['tool'] ?? '';
$actionCaseId = $effectiveCaseId;
if (!$actionCaseId && !empty($action['params']['caseId'])) {
$actionCaseId = $action['params']['caseId'];
}
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
continue;
}
try {
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
$allExecutedActions[] = [
'tool' => $tool,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
];
if ($actionCaseId) {
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
'conversationId' => $conversationId,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
]);
}
// Separate agentic tools (need re-invocation) from inline tools
if ($agenticEnabled && in_array($tool, self::AGENTIC_TOOLS, true)) {
$agenticResults[] = [
'tool' => $tool,
'params' => $action['params'] ?? [],
'result' => $result['message'] ?? '',
];
} else {
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
}
}
// If no agentic results, we're done — return response with inline results
if (empty($agenticResults)) {
$finalResponseText = $responseText;
if (!empty($inlineResults)) {
$finalResponseText .= "\n\n" . implode("\n\n", $inlineResults);
}
break;
}
// Feed agentic results back to AI for next iteration
$this->log->debug("SmartAssistant: Agentic loop iteration " . ($loopCount + 1) . "" . count($agenticResults) . " tool results to feed back");
// Add the AI response to history
$currentHistory[] = ['role' => 'assistant', 'content' => $responseText];
// Add each tool result to history
foreach ($agenticResults as $ar) {
$currentHistory[] = [
'role' => 'tool_result',
'toolName' => $ar['tool'],
'content' => $ar['result'],
];
}
// Also append any inline results
if (!empty($inlineResults)) {
$currentHistory[] = [
'role' => 'tool_result',
'toolName' => '_inline_actions',
'content' => implode("\n\n", $inlineResults),
];
}
$loopCount++;
}
// Safety: if we exhausted the loop, use the last response
if ($loopCount > $maxLoops && empty($finalResponseText)) {
$finalResponseText = $response['text'] ?? 'הגעתי למגבלת הסיבובים. אנא נסה שוב.';
}
return [
'responseText' => $finalResponseText,
'executedActions' => $allExecutedActions,
];
}
private function getIntegrationSetting(string $key, $default = null)
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
return $data[$key] ?? $default;
}
return $default;
}
public function executeAction(string $conversationId, string $actionId, bool $approved): array public function executeAction(string $conversationId, string $actionId, bool $approved): array
{ {
$conversation = $this->loadConversation($conversationId); $conversation = $this->loadConversation($conversationId);
+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant", "module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration", "description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear", "author": "klear",
"version": "2.5.5", "version": "2.6.0",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"releaseDate": "2026-04-06", "releaseDate": "2026-04-09",
"php": [ "php": [
">=8.1" ">=8.1"
] ]