Compare commits

...

4 Commits

Author SHA1 Message Date
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
chaim 405ceb7684 fix(alerts): broaden task detection to include Contact-linked tasks
Tasks with parentType='Contact' were invisible to AlertCalculator,
CaseContextBuilder, and OfficeContextBuilder — all queries only checked
parentType='Case'. This caused false "no preparation task" alerts in
the daily standup even when preparation tasks existed.

Broadens getSummary, findOverdueTasks, findUpcomingTasks, getWorkloadByUser
to query parentType=['Case','Contact']. Adds caseHasPreparation() helper
that checks Case tasks, Contact tasks, and NhActivity-linked tasks.
Adds getCaseContactIds() to CaseContextBuilder and OfficeContextBuilder.

Refs Task Master #1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:07:04 +00:00
chaim 2569003f33 fix: broaden task detection to include Contact-linked tasks
Tasks created with parentType='Contact' (e.g. preparation calls linked
to a case's contact) were invisible to AlertCalculator, CaseContextBuilder,
and OfficeContextBuilder — all of which only queried parentType='Case'.

This caused false "no preparation task" alerts in Shira's daily standup
even when preparation tasks existed, because they were linked to the
case's Contact instead of the Case entity directly.

Changes:
- AlertCalculator: getSummary, findOverdueTasks, findUpcomingTasks,
  getWorkloadByUser now query parentType=['Case','Contact']
- findUpcomingHearingsWithoutPrep: new caseHasPreparation() helper
  checks Case tasks, Contact tasks, and NhActivity-linked tasks
- CaseContextBuilder.getOpenTasks: OR query across Case + Contact IDs
- OfficeContextBuilder.buildCaseDrillDown: same OR query pattern
- All three classes gain a getCaseContactIds() helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:02:27 +00:00
chaim 289a9cf27f fix: deep-convert nested stdClass params in executeTool endpoint
When the AI Gateway sends nested JSON objects (like legalAnalysis), PHP
decodes them as stdClass. The previous (array) cast only converted the
top level, leaving nested stdClass that caused "Cannot use object as
array" fatal errors. Now uses json_decode(json_encode()) for recursive
array conversion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:59:12 +00:00
10 changed files with 681 additions and 30 deletions
+46
View File
@@ -0,0 +1,46 @@
{
"models": {
"main": {
"provider": "anthropic",
"modelId": "claude-sonnet-4-20250514",
"maxTokens": 64000,
"temperature": 0.2,
"id": "claude-sonnet-4-20250514"
},
"research": {
"provider": "anthropic",
"modelId": "sonar",
"maxTokens": 8700,
"temperature": 0.1,
"id": "claude-sonnet-4-20250514"
},
"fallback": {
"provider": "anthropic",
"modelId": "claude-3-7-sonnet-20250219",
"maxTokens": 120000,
"temperature": 0.2
}
},
"global": {
"logLevel": "info",
"debug": false,
"defaultNumTasks": 10,
"defaultSubtasks": 5,
"defaultPriority": "medium",
"projectName": "Task Master",
"ollamaBaseURL": "http://localhost:11434/api",
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
"responseLanguage": "English",
"enableCodebaseAnalysis": true,
"enableProxy": false,
"anonymousTelemetry": true,
"userId": "1234567890"
},
"claudeCode": {},
"codexCli": {},
"grokCli": {
"timeout": 120000,
"workingDirectory": null,
"defaultModel": "grok-4-latest"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"currentTag": "master",
"lastSwitched": "2026-04-14T06:05:06.044Z",
"branchTagMapping": {},
"migrationNoticeShown": true
}
+39
View File
@@ -0,0 +1,39 @@
{
"master": {
"tasks": [
{
"id": "1",
"title": "fix: broaden task detection to include Contact-linked tasks",
"description": "AlertCalculator, CaseContextBuilder, and OfficeContextBuilder only queried tasks with parentType='Case', missing tasks linked to case contacts (parentType='Contact'). This caused false 'no preparation task' alerts in Shira's daily standup.",
"status": "done",
"priority": "high",
"dependencies": [],
"details": "Root cause confirmed via production API: preparation tasks had parentType='Contact' because Shira created them linked to the contact entity. Fix broadens all task queries to include both Case and Contact parent types, plus NhActivity-linked tasks.",
"testStrategy": "Deploy to staging, trigger standup for a case with Contact-linked tasks, verify no false alert.",
"subtasks": [],
"updatedAt": "2026-04-14T06:08:19.494Z"
},
{
"id": "2",
"title": "Network-storage diagnostics + folder discovery",
"description": "Expose diagnostics when a case folder can't be listed and add endpoints (findCaseFolder, setCaseFolderPath) so Shira can locate and persist the correct network-storage path.",
"details": "",
"testStrategy": "",
"status": "in-progress",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-04-19T06:11:17.422Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-04-19T06:11:17.423Z",
"taskCount": 2,
"completedCount": 1,
"tags": [
"master"
]
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# SmartAssistant - עוזר חכם
**גרסה:** 2.7.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
**גרסה:** 2.7.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
@@ -186,7 +186,7 @@ class SmartAssistant
}
$tool = $data->tool;
$params = (array) ($data->params ?? []);
$params = json_decode(json_encode($data->params ?? []), true) ?? [];
$caseId = $data->caseId ?? null;
$executor = $this->injectableFactory->create(ActionExecutor::class);
@@ -217,6 +217,20 @@ class SmartAssistant
];
}
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();
@@ -279,6 +293,75 @@ class SmartAssistant
];
}
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();
@@ -46,7 +46,7 @@ class AlertCalculator
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])->count();
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
@@ -106,7 +106,7 @@ class AlertCalculator
$tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
->order('dateEnd', 'ASC')->limit(0, 50)->find();
foreach ($tasks as $task) {
@@ -132,7 +132,7 @@ class AlertCalculator
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt'])
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt', 'contactId'])
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('cNextHearing', 'ASC')->find();
@@ -140,13 +140,7 @@ class AlertCalculator
$lastActivity = $case->get('cLastActivityAt');
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
$openTaskCount = $this->entityManager->getRDBRepository('Task')
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false])->count();
if ($openTaskCount > 0) continue;
$recentCompleted = $this->entityManager->getRDBRepository('Task')
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status' => 'Completed', 'modifiedAt>=' => $recentTaskThreshold, 'deleted' => false])->count();
if ($recentCompleted > 0) continue;
if ($this->caseHasPreparation($case, $recentTaskThreshold)) continue;
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
@@ -162,6 +156,104 @@ class AlertCalculator
return $alerts;
}
/**
* Check if a case has any evidence of hearing preparation:
* 1. Tasks linked directly to the case (parentType='Case')
* 2. Tasks linked to the case's contacts (parentType='Contact')
* 3. Tasks linked through NhActivity records for this case
*/
private function caseHasPreparation(\Espo\ORM\Entity $case, string $recentTaskThreshold): bool
{
$caseId = $case->get('id');
// Collect all parentId+parentType pairs that relate to this case
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
// Also check tasks linked to the case's contacts
$contactIds = $this->getCaseContactIds($case);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
// Check 1: Any open tasks linked to the case or its contacts
$openTaskCount = $this->entityManager->getRDBRepository('Task')
->where([
'OR' => $parentConditions,
'status!=' => ['Completed', 'Canceled', 'Deferred'],
'deleted' => false,
])->count();
if ($openTaskCount > 0) return true;
// Check 2: Any recently completed tasks linked to the case or its contacts
$recentCompleted = $this->entityManager->getRDBRepository('Task')
->where([
'OR' => $parentConditions,
'status' => 'Completed',
'modifiedAt>=' => $recentTaskThreshold,
'deleted' => false,
])->count();
if ($recentCompleted > 0) return true;
// Check 3: Tasks linked through NhActivity records for this case
$nhActivityTaskCount = $this->entityManager->getRDBRepository('NhActivity')
->where([
'caseId' => $caseId,
'taskId!=' => null,
'deleted' => false,
])->count();
if ($nhActivityTaskCount > 0) {
$nhActivities = $this->entityManager->getRDBRepository('NhActivity')
->select(['taskId'])
->where([
'caseId' => $caseId,
'taskId!=' => null,
'deleted' => false,
])->find();
$taskIds = [];
foreach ($nhActivities as $nha) {
$taskIds[] = $nha->get('taskId');
}
$activeNhTasks = $this->entityManager->getRDBRepository('Task')
->where([
'id' => $taskIds,
'status!=' => ['Canceled'],
'deleted' => false,
])->count();
if ($activeNhTasks > 0) return true;
}
return false;
}
private function getCaseContactIds(\Espo\ORM\Entity $case): array
{
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
private function findUnassignedNewCases(): array
{
$alerts = [];
@@ -191,7 +283,7 @@ class AlertCalculator
$tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => 'Case'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
->order('dateEnd', 'ASC')->limit(0, 30)->find();
foreach ($tasks as $task) {
@@ -237,7 +329,7 @@ class AlertCalculator
foreach (array_keys($userCounts) as $uid) {
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => 'Case'])->count();
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
}
return array_values($userCounts);
@@ -33,6 +33,7 @@ class CaseContextBuilder
'recentNotes' => $this->getRecentNotes($caseId),
'availableTemplates' => $this->getAvailableTemplates(),
'documents' => $this->getDocumentListing($caseId),
'signatureRequests' => $this->getSignatureRequests($caseId),
'currentUser' => $this->getUserData($userId),
'validStatuses' => ActionExecutor::VALID_STATUSES,
];
@@ -82,9 +83,18 @@ class CaseContextBuilder
private function getOpenTasks(string $caseId): array
{
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
$contactIds = $this->getCaseContactIds($caseId);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
$tasks = [];
$collection = $this->entityManager->getRDBRepository('Task')
->where(['parentType' => 'Case', 'parentId' => $caseId, 'status!=' => ['Completed', 'Canceled']])
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled']])
->order('dateEnd', 'ASC')->limit(0, 10)->find();
foreach ($collection as $t) {
@@ -97,6 +107,32 @@ class CaseContextBuilder
return $tasks;
}
private function getCaseContactIds(string $caseId): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return [];
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
private function getUpcomingMeetings(string $caseId): array
{
$meetings = [];
@@ -167,13 +203,51 @@ class CaseContextBuilder
return ['id' => $user->get('id'), 'name' => $user->get('name')];
}
private function getSignatureRequests(string $caseId): array
{
try {
$collection = $this->entityManager->getRDBRepository('SignatureRequest')
->where([
'caseId' => $caseId,
'status!=' => ['Completed', 'Voided'],
])
->order('createdAt', 'DESC')
->limit(0, 10)
->find();
$requests = [];
foreach ($collection as $sr) {
$requests[] = [
'id' => $sr->get('id'),
'name' => $sr->get('name'),
'status' => $sr->get('status'),
'sentAt' => $sr->get('sentAt'),
];
}
return $requests;
} catch (\Exception $e) {
// SignatureRequest entity might not exist if DigitalSignature not installed
return [];
}
}
private function getDocumentListing(string $caseId): array
{
try {
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$result = $analyzer->listDocumentsRecursive($caseId);
$simplified = ['totalFiles' => $result['totalFiles'], 'folders' => [], 'rootFiles' => []];
$simplified = [
'totalFiles' => $result['totalFiles'],
'folders' => [],
'rootFiles' => [],
'resolvedPath' => $result['resolvedPath'] ?? null,
'pathSource' => $result['pathSource'] ?? null,
'folderExists' => $result['folderExists'] ?? null,
'diagnostic' => $result['reason'] ?? null,
];
foreach ($result['folders'] as $folder) {
$files = [];
@@ -41,11 +41,67 @@ class DocumentAnalyzer
public function listDocumentsRecursive(string $caseId): array
{
$casePath = $this->getCaseFolderPath($caseId);
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
$description = $this->describeCaseFolderPath($caseId);
$casePath = $description['path'];
if (!$casePath) {
return [
'casePath' => null,
'totalFiles' => 0,
'folders' => [],
'rootFiles' => [],
'resolvedPath' => null,
'pathSource' => $description['source'],
'folderExists' => false,
'reason' => $description['error'] ?? 'Case has no networkStorageFolderPath and no default could be computed.',
];
}
$client = $this->getStorageClient();
$topLevel = $client->listFolder($casePath);
try {
$folderExists = $client->exists($casePath);
} catch (\Exception $e) {
return [
'casePath' => $casePath,
'totalFiles' => 0,
'folders' => [],
'rootFiles' => [],
'resolvedPath' => $casePath,
'pathSource' => $description['source'],
'folderExists' => false,
'reason' => "Storage client failed while checking '{$casePath}': " . $e->getMessage(),
];
}
if (!$folderExists) {
return [
'casePath' => $casePath,
'totalFiles' => 0,
'folders' => [],
'rootFiles' => [],
'resolvedPath' => $casePath,
'pathSource' => $description['source'],
'folderExists' => false,
'reason' => "Folder '{$casePath}' (source: {$description['source']}) does not exist on storage.",
];
}
try {
$topLevel = $client->listFolder($casePath);
} catch (\Exception $e) {
return [
'casePath' => $casePath,
'totalFiles' => 0,
'folders' => [],
'rootFiles' => [],
'resolvedPath' => $casePath,
'pathSource' => $description['source'],
'folderExists' => true,
'reason' => "Failed to list folder '{$casePath}': " . $e->getMessage(),
];
}
$folders = []; $rootFiles = []; $totalFiles = 0;
foreach ($topLevel as $item) {
@@ -70,7 +126,16 @@ class DocumentAnalyzer
}
}
return ['casePath' => $casePath, 'totalFiles' => $totalFiles, 'folders' => $folders, 'rootFiles' => $rootFiles];
return [
'casePath' => $casePath,
'totalFiles' => $totalFiles,
'folders' => $folders,
'rootFiles' => $rootFiles,
'resolvedPath' => $casePath,
'pathSource' => $description['source'],
'folderExists' => true,
'reason' => $totalFiles === 0 ? "Folder '{$casePath}' exists but is empty." : null,
];
}
public function extractTextContent(string $filePath): ?string
@@ -115,6 +180,85 @@ class DocumentAnalyzer
return $text;
}
/**
* Fetch raw file bytes + metadata for OCR/vision fallback.
*
* @return array{success: bool, fileName: string, mimeType: string, sizeBytes: int, base64: ?string, error: ?string}
*/
public function getDocumentBytes(string $filePath): array
{
$fileName = basename($filePath);
$client = $this->getStorageClient();
try {
$content = $client->downloadFile($filePath);
} catch (\Exception $e) {
return [
'success' => false,
'fileName' => $fileName,
'mimeType' => '',
'sizeBytes' => 0,
'base64' => null,
'error' => 'Failed to download: ' . $e->getMessage(),
];
}
$sizeBytes = strlen($content);
if ($sizeBytes > self::MAX_FILE_SIZE) {
return [
'success' => false,
'fileName' => $fileName,
'mimeType' => '',
'sizeBytes' => $sizeBytes,
'base64' => null,
'error' => 'File too large (max 10MB).',
];
}
$mimeType = $this->guessMimeType($fileName, $content);
return [
'success' => true,
'fileName' => $fileName,
'mimeType' => $mimeType,
'sizeBytes' => $sizeBytes,
'base64' => base64_encode($content),
'error' => null,
];
}
private function guessMimeType(string $fileName, string $content): string
{
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
$map = [
'pdf' => 'application/pdf',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc' => 'application/msword',
'txt' => 'text/plain',
'csv' => 'text/csv',
'log' => 'text/plain',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'tiff' => 'image/tiff',
'tif' => 'image/tiff',
'webp' => 'image/webp',
];
if (isset($map[$ext])) {
return $map[$ext];
}
// Fallback: finfo
if (function_exists('finfo_buffer')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_buffer($finfo, $content);
finfo_close($finfo);
if ($mime) return $mime;
}
return 'application/octet-stream';
}
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
{
$results = [];
@@ -192,24 +336,160 @@ class DocumentAnalyzer
}
public function getCaseFolderPath(string $caseId): ?string
{
return $this->describeCaseFolderPath($caseId)['path'];
}
/**
* Resolve the case folder path with diagnostic metadata.
*
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
* source: 'stored' | 'computed' | 'none'
*/
public function describeCaseFolderPath(string $caseId): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return null;
if (!$case) {
return ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false];
}
// Try stored path first
$storedPath = $case->get('networkStorageFolderPath') ?: null;
if ($storedPath) return $storedPath;
if ($storedPath) {
return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true];
}
// Fall back to computed path from NetworkDocumentService
try {
$nds = $this->getNetworkDocumentService();
return $nds->getEntityFolderPath('Case', $caseId);
$computed = $nds->getEntityFolderPath('Case', $caseId);
if ($computed) {
return ['path' => $computed, 'source' => 'computed', 'error' => null, 'caseExists' => true];
}
return ['path' => null, 'source' => 'none', 'error' => 'No stored path, and default path computation returned null.', 'caseExists' => true];
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
return null;
$this->log->warning("SmartAssistant: Failed to compute case folder path: " . $e->getMessage());
return ['path' => null, 'source' => 'none', 'error' => $e->getMessage(), 'caseExists' => true];
}
}
/**
* List the immediate contents of an arbitrary folder path on network storage.
* Manual override for when auto-resolution of a case folder fails.
*
* @return array<int, array{name: string, path: string, type: string, size: ?int, mimeType: ?string, modified: ?string}>
*/
public function browseFolder(string $path): array
{
$client = $this->getStorageClient();
$entries = [];
foreach ($client->listFolder($path) as $item) {
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
$entries[] = [
'name' => $item['name'] ?? '',
'path' => $item['path'] ?? '',
'type' => $isFolder ? 'folder' : 'file',
'size' => $item['size'] ?? null,
'mimeType' => $item['mimeType'] ?? null,
'modified' => $item['modified'] ?? null,
];
}
return $entries;
}
/**
* Search the storage for folders matching a query (case-insensitive substring).
* Scans root and one level deep. Returns sorted matches.
*
* @return array<int, array{path: string, name: string, level: int, score: int}>
*/
public function findCaseFolder(string $query, int $limit = 20): array
{
$normalized = trim($query);
if ($normalized === '') return [];
$client = $this->getStorageClient();
$needle = mb_strtolower($normalized);
$matches = [];
try {
$roots = $client->listFolder('');
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: findCaseFolder root listing failed: " . $e->getMessage());
return [];
}
foreach ($roots as $item) {
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
if (!$isFolder) continue;
$name = (string) ($item['name'] ?? '');
$path = (string) ($item['path'] ?? $name);
$score = $this->fuzzyScore($name, $needle);
if ($score > 0) {
$matches[] = ['path' => $path, 'name' => $name, 'level' => 0, 'score' => $score];
}
// One level deeper, but only inspect folder names — don't list files to keep it cheap.
try {
foreach ($client->listFolder($path) as $sub) {
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
if (!$subIsFolder) continue;
$subName = (string) ($sub['name'] ?? '');
$subPath = (string) ($sub['path'] ?? ($path . '/' . $subName));
$subScore = $this->fuzzyScore($subName, $needle);
if ($subScore > 0) {
$matches[] = ['path' => $subPath, 'name' => $subName, 'level' => 1, 'score' => $subScore];
}
}
} catch (\Exception $e) {
// Non-fatal: skip unreadable subfolders silently.
}
}
usort($matches, fn($a, $b) => $b['score'] <=> $a['score']);
return array_slice($matches, 0, $limit);
}
/**
* Persist the resolved folder path on the Case so future calls skip discovery.
*/
public function setCaseFolderPath(string $caseId, string $path): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) {
throw new Error("Case '{$caseId}' not found.");
}
$client = $this->getStorageClient();
if (!$client->exists($path)) {
throw new Error("Path '{$path}' does not exist on storage.");
}
$case->set('networkStorageFolderPath', $path);
$this->entityManager->saveEntity($case);
return ['success' => true, 'caseId' => $caseId, 'path' => $path];
}
private function fuzzyScore(string $haystack, string $needleLower): int
{
$hay = mb_strtolower($haystack);
if ($hay === $needleLower) return 100;
if (mb_strpos($hay, $needleLower) !== false) return 80;
// Token overlap (whitespace/dash/underscore).
$hayTokens = preg_split('/[\s\-_]+/u', $hay) ?: [];
$needleTokens = preg_split('/[\s\-_]+/u', $needleLower) ?: [];
$hits = 0;
foreach ($needleTokens as $nt) {
if ($nt === '') continue;
foreach ($hayTokens as $ht) {
if ($ht !== '' && mb_strpos($ht, $nt) !== false) { $hits++; break; }
}
}
return $hits > 0 ? 40 + $hits * 5 : 0;
}
private function getMaxTextLength(): int
{
if ($this->maxTextLength !== null) {
@@ -58,10 +58,18 @@ class OfficeContextBuilder
$contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')];
}
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
$contactIds = $this->getCaseContactIds($case);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
$tasks = [];
foreach ($this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
->where(['parentId' => $caseId, 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) {
$tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')];
}
@@ -76,4 +84,27 @@ class OfficeContextBuilder
return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes];
}
private function getCaseContactIds(\Espo\ORM\Entity $case): array
{
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
}
+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.7.1",
"version": "2.8.0",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-04-09",
"releaseDate": "2026-04-19",
"php": [
">=8.1"
]