39d9093bf6
Shira can now create documents from scratch (not just from templates) and has full sandboxed read/write access to the case's network folder, including a soft-delete protocol so she cannot physically remove files. New services: - FreeDocumentGenerator: builds RTL Hebrew DOCX from lightweight markdown, saves as Attachment + Document, links to Case, copies to network folder. - CaseFolderManager: writeFile/createFolder/renameItem/moveItem operations constrained to the case's own root via path normalization; never exposes a physical delete. markForDeletion moves the file into "<caseRoot>/מסמכים למחיקה/" with a "למחיקה - " prefix so the lawyer can audit and delete manually. New controller actions: createDocument, writeToFolder, createFolder, renameItem, moveItem, markForDeletion. Bug fix (root cause of the empty TEMPLATES section in Shira's case prompt): CaseContextBuilder was querying DocumentTemplate with `entityType=Case` but the real column is `targetEntityType`. Also added `isActive=true` filter and surfaced category/description for better LLM selection. Alert refinement: AlertCalculator no longer raises "X days without activity" for cases that are legitimately waiting on the court. PendingDecision is always silenced; PendingHearing is silenced when cNextHearing is in the future (unscheduled PendingHearing cases still alert). Document entity: new `markedForDeletionAt` + `markedForDeletionBy` audit fields with Hebrew labels. Refs Task Master #4 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
503 lines
16 KiB
PHP
503 lines
16 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\Modules\SmartAssistant\Services\FreeDocumentGenerator;
|
|
use Espo\Modules\SmartAssistant\Services\CaseFolderManager;
|
|
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);
|
|
}
|
|
|
|
public function postActionCreateDocument(Request $request, Response $response): array
|
|
{
|
|
$this->checkAccess();
|
|
$data = $request->getParsedBody();
|
|
|
|
$caseId = $data->caseId ?? null;
|
|
$title = $data->title ?? null;
|
|
$body = $data->body ?? null;
|
|
|
|
if (empty($caseId)) {
|
|
throw new BadRequest('caseId is required.');
|
|
}
|
|
if (empty($title)) {
|
|
throw new BadRequest('title is required.');
|
|
}
|
|
if (empty($body)) {
|
|
throw new BadRequest('body is required.');
|
|
}
|
|
|
|
$documentType = $data->documentType ?? 'letter';
|
|
$recipient = $data->recipient ?? null;
|
|
|
|
$generator = $this->injectableFactory->create(FreeDocumentGenerator::class);
|
|
|
|
return $generator->generate($caseId, $title, $body, $documentType, $recipient);
|
|
}
|
|
|
|
public function postActionWriteToFolder(Request $request, Response $response): array
|
|
{
|
|
$this->checkAccess();
|
|
$data = $request->getParsedBody();
|
|
|
|
$caseId = $data->caseId ?? null;
|
|
$relPath = $data->relPath ?? null;
|
|
$contentBase64 = $data->contentBase64 ?? null;
|
|
|
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
|
if (empty($relPath)) throw new BadRequest('relPath is required.');
|
|
if (!isset($contentBase64)) throw new BadRequest('contentBase64 is required.');
|
|
|
|
$content = base64_decode((string) $contentBase64, true);
|
|
if ($content === false) {
|
|
throw new BadRequest('contentBase64 is not valid base64.');
|
|
}
|
|
|
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
|
return $manager->writeFile($caseId, $relPath, $content);
|
|
}
|
|
|
|
public function postActionCreateFolder(Request $request, Response $response): array
|
|
{
|
|
$this->checkAccess();
|
|
$data = $request->getParsedBody();
|
|
|
|
$caseId = $data->caseId ?? null;
|
|
$relPath = $data->relPath ?? null;
|
|
|
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
|
if (empty($relPath)) throw new BadRequest('relPath is required.');
|
|
|
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
|
return $manager->createFolder($caseId, $relPath);
|
|
}
|
|
|
|
public function postActionRenameItem(Request $request, Response $response): array
|
|
{
|
|
$this->checkAccess();
|
|
$data = $request->getParsedBody();
|
|
|
|
$caseId = $data->caseId ?? null;
|
|
$currentRelPath = $data->currentRelPath ?? null;
|
|
$newName = $data->newName ?? null;
|
|
|
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
|
if (empty($currentRelPath)) throw new BadRequest('currentRelPath is required.');
|
|
if (empty($newName)) throw new BadRequest('newName is required.');
|
|
|
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
|
return $manager->renameItem($caseId, $currentRelPath, $newName);
|
|
}
|
|
|
|
public function postActionMoveItem(Request $request, Response $response): array
|
|
{
|
|
$this->checkAccess();
|
|
$data = $request->getParsedBody();
|
|
|
|
$caseId = $data->caseId ?? null;
|
|
$sourceRelPath = $data->sourceRelPath ?? null;
|
|
$targetRelPath = $data->targetRelPath ?? null;
|
|
|
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
|
if (empty($sourceRelPath)) throw new BadRequest('sourceRelPath is required.');
|
|
if (empty($targetRelPath)) throw new BadRequest('targetRelPath is required.');
|
|
|
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
|
return $manager->moveItem($caseId, $sourceRelPath, $targetRelPath);
|
|
}
|
|
|
|
public function postActionMarkForDeletion(Request $request, Response $response): array
|
|
{
|
|
$this->checkAccess();
|
|
$data = $request->getParsedBody();
|
|
|
|
$caseId = $data->caseId ?? null;
|
|
$fileRelPath = $data->fileRelPath ?? null;
|
|
|
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
|
if (empty($fileRelPath)) throw new BadRequest('fileRelPath is required.');
|
|
|
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
|
return $manager->markForDeletion($caseId, $fileRelPath);
|
|
}
|
|
}
|