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/Controllers/SmartAssistant.php
T
chaim f400f2fec7 feat: add getDocumentBytes endpoint for OCR + SignatureRequest context
- New POST /api/v1/SmartAssistant/action/getDocumentBytes returns
  {success, fileName, mimeType, sizeBytes, base64} for use by
  shira-hermes Claude Vision OCR fallback.
- DocumentAnalyzer: new getDocumentBytes() method + guessMimeType helper.
- CaseContextBuilder: expose signatureRequests in case context (for
  DigitalSignature integration — gracefully empty if entity absent).
- README: sync version badge.
- Version bump 2.7.3 → 2.8.0 (new public endpoint).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:05 +00:00

388 lines
12 KiB
PHP

<?php
namespace Espo\Modules\SmartAssistant\Controllers;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Exceptions\BadRequest;
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\Modules\SmartAssistant\Services\GenericTemplateGenerator;
use Espo\Entities\User;
class SmartAssistant
{
private InjectableFactory $injectableFactory;
private Acl $acl;
private User $user;
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
{
$this->injectableFactory = $injectableFactory;
$this->acl = $acl;
$this->user = $user;
}
private function getService(): SmartAssistantService
{
return $this->injectableFactory->create(SmartAssistantService::class);
}
private function checkAccess(): void
{
if (!$this->acl->checkScope('Case', 'read')) {
throw new Forbidden('No access to Case.');
}
}
public function getActionStatus(Request $request, Response $response): array
{
return $this->getService()->getStatus();
}
public function postActionChat(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
if (empty($data->message)) {
throw new BadRequest('message is required.');
}
$mode = $data->mode ?? 'office';
$caseId = $data->caseId ?? null;
if ($mode === 'case' && empty($caseId)) {
throw new BadRequest('caseId is required for case mode.');
}
return $this->getService()->chat(
trim($data->message),
$mode,
$caseId,
$data->conversationId ?? null
);
}
public function postActionExecute(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
if (empty($data->conversationId) || empty($data->actionId)) {
throw new BadRequest('conversationId and actionId are required.');
}
return $this->getService()->executeAction(
$data->conversationId,
$data->actionId,
(bool) ($data->approved ?? false)
);
}
public function getActionSummary(Request $request, Response $response): array
{
$this->checkAccess();
return $this->getService()->getSummary();
}
public function getActionAlerts(Request $request, Response $response): array
{
$this->checkAccess();
return $this->getService()->getAlerts();
}
public function getActionHistory(Request $request, Response $response): array
{
$this->checkAccess();
$caseId = $request->getQueryParam('caseId');
return $this->getService()->getHistory($caseId);
}
public function getActionConversations(Request $request, Response $response): array
{
$this->checkAccess();
$type = $request->getQueryParam('type');
return $this->getService()->getConversations($type);
}
public function getActionConversationMessages(Request $request, Response $response): array
{
$this->checkAccess();
$key = $request->getQueryParam('conversationKey');
if (empty($key)) {
throw new BadRequest('conversationKey is required.');
}
return $this->getService()->getConversationMessages($key);
}
public function getActionSearchHistory(Request $request, Response $response): array
{
$this->checkAccess();
$query = $request->getQueryParam('query');
if (empty($query)) {
throw new BadRequest('query is required.');
}
return $this->getService()->searchHistory($query);
}
public function getActionCaseMemory(Request $request, Response $response): array
{
$this->checkAccess();
$caseId = $request->getQueryParam('caseId');
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$category = $request->getQueryParam('category');
$includeArchived = (bool) $request->getQueryParam('includeArchived');
$service = $this->injectableFactory->create(CaseMemoryService::class);
return $service->getMemoriesByCase($caseId, $category, $includeArchived);
}
public function postActionSaveMemory(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
if (empty($data->caseId) || empty($data->content)) {
throw new BadRequest('caseId and content are required.');
}
$service = $this->injectableFactory->create(CaseMemoryService::class);
$entry = $service->createMemory(
$data->caseId,
$data->category ?? 'key_facts',
$data->name ?? mb_substr($data->content, 0, 100),
$data->content,
$data->importance ?? 'normal',
'manual'
);
return [
'success' => true,
'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 = json_decode(json_encode($data->params ?? []), true) ?? [];
$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 postActionGetDocumentBytes(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePath = $data->filePath ?? null;
if (empty($filePath)) {
throw new BadRequest('filePath is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->getDocumentBytes($filePath);
}
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,
];
}
public function postActionListDocuments(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->listDocumentsRecursive($caseId);
}
public function postActionBrowseFolder(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$path = $data->path ?? null;
if ($path === null) {
throw new BadRequest('path is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return [
'path' => $path,
'entries' => $analyzer->browseFolder((string) $path),
];
}
public function postActionFindCaseFolder(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$query = $data->query ?? null;
if (empty($query)) {
throw new BadRequest('query is required.');
}
$limit = isset($data->limit) ? max(1, min((int) $data->limit, 50)) : 20;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return [
'query' => $query,
'matches' => $analyzer->findCaseFolder((string) $query, $limit),
];
}
public function postActionSetCaseFolderPath(Request $request, Response $response): array
{
$this->checkAccess();
if (!$this->acl->checkScope('Case', 'edit')) {
throw new Forbidden('No edit access to Case.');
}
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$path = $data->path ?? null;
if (empty($caseId) || empty($path)) {
throw new BadRequest('caseId and path are required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->setCaseFolderPath((string) $caseId, (string) $path);
}
public function postActionGenerateFromTemplate(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$templateId = $data->templateId ?? null;
$caseId = $data->caseId ?? null;
if (empty($templateId)) {
throw new BadRequest('templateId is required.');
}
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$documentSubject = $data->documentSubject ?? null;
$customPlaceholders = (array) ($data->customPlaceholders ?? []);
$generator = $this->injectableFactory->create(GenericTemplateGenerator::class);
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
}
}