Compare commits

..

4 Commits

Author SHA1 Message Date
chaim f84b2f1cef feat: add executeTool endpoint for direct plugin tool invocation
Adds POST /SmartAssistant/action/executeTool that accepts {tool, params, caseId}
and routes directly to the plugin tool handler via ActionExecutor. This allows
the AI Gateway to call plugin tools (like generate_direct_access_report) without
going through the conversation approval flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:31:16 +00:00
chaim 2a8046b737 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>
2026-04-09 11:24:45 +00:00
chaim f319f981f9 chore: bump version to 2.5.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:04:07 +00:00
chaim bf98495198 refactor: remove getLegalAidData from CaseContextBuilder
LegalAid entity no longer exists — context builder cleaned up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:12:43 +00:00
8 changed files with 449 additions and 95 deletions
@@ -9,17 +9,22 @@ use Espo\Core\Exceptions\Forbidden;
use Espo\Core\InjectableFactory;
use Espo\Core\Acl;
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
use Espo\Entities\User;
class SmartAssistant
{
private InjectableFactory $injectableFactory;
private Acl $acl;
private User $user;
public function __construct(InjectableFactory $injectableFactory, Acl $acl)
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
{
$this->injectableFactory = $injectableFactory;
$this->acl = $acl;
$this->user = $user;
}
private function getService(): SmartAssistantService
@@ -169,4 +174,107 @@ class SmartAssistant
'id' => $entry->get('id'),
];
}
public function postActionExecuteTool(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
if (empty($data->tool)) {
throw new BadRequest('tool is required.');
}
$tool = $data->tool;
$params = (array) ($data->params ?? []);
$caseId = $data->caseId ?? null;
$executor = $this->injectableFactory->create(ActionExecutor::class);
return $executor->execute($tool, $params, $caseId, $this->user->getId());
}
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": {
"type": "int",
"default": 7
},
"maxDocumentChars": {
"type": "int",
"default": 100000
},
"agenticLoopEnabled": {
"type": "bool",
"default": true
},
"maxAgenticLoops": {
"type": "int",
"default": 3
}
},
"allowUserAccess": false,
@@ -86,5 +86,13 @@
"controller": "SmartAssistant",
"action": "saveMemory"
}
},
{
"route": "/SmartAssistant/action/executeTool",
"method": "post",
"params": {
"controller": "SmartAssistant",
"action": "executeTool"
}
}
]
@@ -49,7 +49,10 @@ class ActionExecutor
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
'list_documents' => $this->listDocuments($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),
'batch_rename_documents' => $this->batchRenameDocuments($params),
'upload_document' => $this->uploadDocument($params, $caseId),
'generate_document' => $this->generateDocument($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]];
}
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
{
$sourcePath = $params['sourcePath'] ?? null;
@@ -395,6 +457,37 @@ class ActionExecutor
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
{
if (!$caseId) {
@@ -27,7 +27,6 @@ class CaseContextBuilder
return [
'case' => $this->getCaseData($case),
'contacts' => $this->getCaseContacts($case),
'legalAid' => $this->getLegalAidData($caseId),
'openTasks' => $this->getOpenTasks($caseId),
'upcomingMeetings' => $this->getUpcomingMeetings($caseId),
'recentCalls' => $this->getRecentCalls($caseId),
@@ -81,38 +80,6 @@ class CaseContextBuilder
return $contacts;
}
private function getLegalAidData(string $caseId): ?array
{
$legalAid = $this->entityManager
->getRDBRepository('LegalAid')
->where(['caseId' => $caseId])
->findOne();
if (!$legalAid) {
return null;
}
return [
'id' => $legalAid->get('id'),
'status' => $legalAid->get('status'),
'legalAidNumber' => $legalAid->get('legalAidNumber'),
'contactName' => $legalAid->get('contactName'),
'aidType' => $legalAid->get('aidType'),
'urgencyLevel' => $legalAid->get('urgencyLevel'),
'appointmentDate' => $legalAid->get('appointmentDate'),
'filingDeadline' => $legalAid->get('filingDeadline'),
'firstContactDate' => $legalAid->get('firstContactDate'),
'meetingDate' => $legalAid->get('meetingDate'),
'contactStatus' => $legalAid->get('contactStatus'),
'proceedingNumber' => $legalAid->get('proceedingNumber'),
'courtName' => $legalAid->get('courtName'),
'proceedingSubject' => $legalAid->get('proceedingSubject'),
'recommendationType' => $legalAid->get('recommendationType'),
'additionalAidNeeded' => $legalAid->get('additionalAidNeeded'),
'notes' => $legalAid->get('notes'),
];
}
private function getOpenTasks(string $caseId): array
{
$tasks = [];
@@ -11,13 +11,14 @@ use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
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 TMP_DIR = 'data/tmp/assistant-docs';
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Log $log;
private ?int $maxTextLength = null;
private const CATEGORY_PATTERNS = [
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
@@ -73,6 +74,13 @@ class DocumentAnalyzer
}
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);
@@ -98,8 +106,41 @@ class DocumentAnalyzer
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);
$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
@@ -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
{
return $this->getNetworkDocumentService()->getClient();
@@ -18,7 +18,17 @@ class SmartAssistantService
public const NOTE_TYPE_ACTION = 'SmartAction';
// 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 InjectableFactory $injectableFactory;
@@ -105,68 +115,20 @@ class SmartAssistantService
// 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
$effectiveCaseId = $caseId;
if (!$effectiveCaseId && !empty($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) {
$tool = $action['tool'] ?? '';
$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);
}
$responseText = $loopResult['responseText'];
$executedActions = $loopResult['executedActions'];
// Persist response
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
{
$conversation = $this->loadConversation($conversationId);
+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.5.3",
"version": "2.6.1",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-04-06",
"releaseDate": "2026-04-09",
"php": [
">=8.1"
]