Compare commits

...

4 Commits

Author SHA1 Message Date
chaim b05e1c8488 fix(chat): extend AJAX timeout 180s→600s and surface slow-progress hint
The floating chat called Espo.Ajax.postRequest with timeout:180000, which is
shorter than the agentic loop's worst-case latency. On 2026-05-13 a prod
request to generate a direct-access report sat 227s in the first ai-gateway
iteration (large skill+context payload), so the browser fired the .catch()
handler at 180s and rendered "שגיאה בתקשורת" — even though the backend
completed the work 47s later (report attached to case 42).

Changes:
- floating-chat.js: timeout 180000 → 600000 (matches the PHP curl timeout
  already set to 600s in SmartAssistantService.php:521).
- After 60s the spinner label swaps to a "still working" hint so the user
  knows long requests are normal, not stuck.
- i18n: new SlowHint label in en_US + fa_IR (the project uses fa_IR as the
  Hebrew slot).

Refs Task Master #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:47:02 +00:00
chaim b313c96955 fix: increase curl timeout to 600s for long-running AI tasks
CURLOPT_TIMEOUT was 180s. Generating a consultation summary requires
up to 10 AI iterations at ~20s each, regularly hitting the limit and
causing a "שגיאה בתקשורת" error in the UI before Shira could respond.

Raised to 600s. The shira-hermes agent runner now also enforces a 570s
total budget so it returns a clean Hebrew message if the limit is hit,
rather than the PHP connection being cut mid-response.

Refs Task Master #2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 17:55:48 +00:00
chaim 2a03425514 chore: sync README version to 2.8.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:19:25 +00:00
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
12 changed files with 477 additions and 33 deletions
+7 -7
View File
@@ -1,22 +1,22 @@
{ {
"models": { "models": {
"main": { "main": {
"provider": "anthropic", "provider": "claude-code",
"modelId": "claude-sonnet-4-20250514", "modelId": "sonnet",
"maxTokens": 64000, "maxTokens": 64000,
"temperature": 0.2, "temperature": 0.2,
"id": "claude-sonnet-4-20250514" "id": "sonnet"
}, },
"research": { "research": {
"provider": "anthropic", "provider": "claude-code",
"modelId": "sonar", "modelId": "sonnet",
"maxTokens": 8700, "maxTokens": 8700,
"temperature": 0.1, "temperature": 0.1,
"id": "claude-sonnet-4-20250514" "id": "claude-sonnet-4-20250514"
}, },
"fallback": { "fallback": {
"provider": "anthropic", "provider": "claude-code",
"modelId": "claude-3-7-sonnet-20250219", "modelId": "sonnet",
"maxTokens": 120000, "maxTokens": 120000,
"temperature": 0.2 "temperature": 0.2
} }
+1 -1
View File
@@ -2,5 +2,5 @@
"currentTag": "master", "currentTag": "master",
"lastSwitched": "2026-04-14T06:05:06.044Z", "lastSwitched": "2026-04-14T06:05:06.044Z",
"branchTagMapping": {}, "branchTagMapping": {},
"migrationNoticeShown": false "migrationNoticeShown": true
} }
+35 -6
View File
@@ -2,21 +2,50 @@
"master": { "master": {
"tasks": [ "tasks": [
{ {
"id": 1, "id": "1",
"title": "fix: broaden task detection to include Contact-linked tasks", "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.", "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": "in-progress", "status": "done",
"priority": "high", "priority": "high",
"dependencies": [], "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.", "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.", "testStrategy": "Deploy to staging, trigger standup for a case with Contact-linked tasks, verify no false alert.",
"subtasks": [] "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": "done",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-06T17:56:54.413Z"
},
{
"id": "3",
"title": "fix: extend client AJAX timeout 180s->600s and add slow-hint after 60s to prevent communication-error false-positive when ai-gateway takes >3min on first iteration (prod incident 2026-05-13)",
"description": "Production incident where multi-tool AI flows (delegation, memory writes, web search) occasionally exceeded the default 180s client timeout, causing \"Communication error\" to display even though the backend continued processing and eventually succeeded. Users saw false errors after ~3min wait.",
"details": "## Root Cause\nThe client-side `Espo.Ajax.postRequest('SmartAssistant/action/chat', ...)` call in `floating-chat.js:486` already has `{timeout: 600000}` (10 minutes), which was implemented in a previous fix. However, the production incident revealed that:\n\n1. Users still see \"שגיאה בתקשורת\" (Communication error) after ~3 minutes on complex multi-tool flows\n2. The \"חושבת...\" (Thinking...) spinner runs unchanged for the entire duration, giving no feedback that the system is still working\n3. The i18n files already contain a `SlowHint` label designed for this exact scenario but it was never wired up\n\n## Analysis of Current Code\n**File:** `files/client/custom/modules/smart-assistant/src/views/floating-chat.js`\n\n**Lines 460-475:** The `sendMessage()` method creates a loading message with \"חושבת...\" text and immediately sets up a 60-second timer (`slowHintTimer`) that changes the text to the `SlowHint` translation:\n```javascript\nvar slowHintText = this.translate('SlowHint', 'labels', 'SmartAssistant') ||\n 'חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל';\nvar slowHintTimer = setTimeout(function () {\n $loading.find('.sa-thinking-text').text(slowHintText);\n}, 60000);\n```\n\n**Line 486:** The AJAX timeout is already set to 600000ms (10 minutes)\n\n**Lines 487, 505:** Both success and error handlers call `clearTimeout(slowHintTimer)` to clean up\n\n## Issue Discovered\nThe code is **already correct** and implements exactly what the task description requests:\n- ✅ 600s (10 minute) timeout is set\n- ✅ Slow hint timer switches text after 60s\n- ✅ Timer is properly cleaned up on completion/error\n- ✅ i18n labels exist in both `en_US` and `fa_IR`\n\n## Verification Needed\nSince the code already implements the fix, the production incident suggests one of:\n\n1. **Deployed version mismatch** - The production extension may not have the latest code deployed\n2. **Browser caching** - Clients may be running cached old JavaScript that lacks the timeout/hint\n3. **Different error source** - The \"Communication error\" might be coming from a network-level timeout (reverse proxy, Coolify, Traefik) rather than client JavaScript\n4. **EspoCRM core override** - EspoCRM's base `Espo.Ajax` might have a global timeout that overrides the per-request timeout\n\n## Implementation Steps\n\n1. **Verify current deployment** - Check that the production EspoCRM instance has the latest `SmartAssistant` extension installed with this exact code\n2. **Check manifest version** - Confirm `manifest.json` version number matches what's deployed\n3. **Browser cache bust** - Force client cache clear by:\n - Incrementing `manifest.json` version\n - EspoCRM admin may need to rebuild/clear cache\n4. **Server-side timeout audit** - Check these layers:\n - Traefik ingress timeout on the EspoCRM service\n - nginx/Apache timeout in EspoCRM container\n - PHP `max_execution_time` in EspoCRM (should be ≥600s for the SmartAssistant endpoint)\n - shira-hermes backend timeout (should be ≥600s)\n - ai-gateway proxy timeout to Claude API\n5. **Add defensive logging** - If the code is deployed but not working:\n - Add `console.log` when slow hint fires\n - Add `console.log` showing actual timeout value used\n - Check browser DevTools Network tab for actual request timeout\n6. **Verify i18n loading** - Ensure `SlowHint` label is actually loaded (check `this.translate('SlowHint', ...)` returns expected text)\n\n## Code Changes (if verification shows deployment gap)\n\n**None required** - the code already implements the fix correctly.\n\nIf verification reveals the code is NOT deployed, simply:\n1. Rebuild the extension package\n2. Upload to EspoCRM via Administration → Extensions\n3. Clear EspoCRM cache\n\n## Alternative: Server-Side Timeout Fix\n\nIf the issue is server-side, update these configs:\n\n**shira-hermes (FastAPI):**\n```python\n# In main app config\nuvicorn.run(app, timeout_keep_alive=600)\n```\n\n**EspoCRM PHP (SmartAssistant controller):**\n```php\nset_time_limit(600); // At start of chat action\n```\n\n**Coolify/Traefik:**\nCheck proxy timeout settings for the EspoCRM service.",
"testStrategy": "## Test Strategy\n\n### Phase 1: Verify Current Deployment\n1. **Check installed extension version**\n - Log into production EspoCRM (https://crm.prod.marcus-law.co.il)\n - Go to Administration → Extensions\n - Find SmartAssistant extension, note version number\n - Compare with `manifest.json` in this repo\n\n2. **Check deployed JavaScript**\n - In browser, open DevTools → Sources\n - Find `client/custom/modules/smart-assistant/src/views/floating-chat.js`\n - Search for \"timeout: 600000\" - should exist on line ~486\n - Search for \"slowHintTimer\" - should exist on line ~473\n - Search for \"SlowHint\" - should exist on line ~471\n\n### Phase 2: Browser Cache Verification\n1. **Hard refresh test**\n - Open production EspoCRM in incognito/private window\n - Navigate to any Case detail view\n - Open Shira chat panel\n - Check browser console for any JavaScript errors\n - Send a message that triggers slow processing (e.g., \"search the web for...\")\n - Verify \"חושבת...\" changes to \"חושבת... זה לוקח קצת יותר זמן...\" after 60 seconds\n\n2. **Cache headers check**\n - DevTools → Network → filter for `floating-chat.js`\n - Check `Cache-Control` and `ETag` headers\n - Note timestamp of file\n\n### Phase 3: Network Timeout Audit\n1. **Client-side timing**\n - Open DevTools → Network\n - Send message that takes >3 minutes\n - Watch the `SmartAssistant/action/chat` request\n - Note exact time when it fails (should be 600s, not 180s)\n - Check response status (timeout = no status, server error = 500/502/504)\n\n2. **Server logs correlation**\n - While test message is processing, tail these logs:\n ```bash\n # shira-hermes logs\n docker logs -f <shira-container> --since 1m\n \n # EspoCRM logs\n tail -f data/logs/espo-$(date +%Y-%m-%d).log\n \n # ai-gateway logs \n docker logs -f <ai-gateway-container> --since 1m\n ```\n - If logs show success but client shows error → client timeout\n - If logs show timeout/error → server timeout\n\n### Phase 4: Reproduction Test\n1. **Create slow scenario** (production or staging)\n - Open a complex case with many documents\n - Ask Shira: \"תעשי לי סיכום מלא של התיק כולל חיפוש באינטרנט על התקדים הרלוונטיים\"\n - This should trigger: delegation + memory search + web search + document analysis\n - Expected: Takes 2-4 minutes\n \n2. **Observe behavior**\n - ✅ At 60s: Text changes to \"חושבת... זה לוקח קצת יותר זמן...\"\n - ✅ At 180s: Still shows spinner (no error)\n - ✅ At 300s: Still shows spinner (no error)\n - ✅ At completion: Shows response, spinner removed\n - ❌ If error before 600s: Fix not deployed or server timeout\n\n### Phase 5: Post-Fix Validation\nAfter confirming fix is deployed:\n\n1. **Quick response test** (<10s)\n - \"מה שם התובע בתיק?\"\n - Should show \"חושבת...\" only, no slow hint\n\n2. **Medium response test** (30-90s)\n - \"הכן טיוטת תזכיר\"\n - Should show \"חושבת...\" then slow hint after 60s\n\n3. **Long response test** (>180s)\n - Complex multi-tool query\n - Should show slow hint, complete successfully\n\n4. **Error handling test**\n - Disconnect network after sending message\n - Should show \"שגיאה בתקשורת\" (real network error)\n - Reconnect and retry - should work\n\n### Success Criteria\n- ✅ Slow hint appears after 60 seconds on all requests\n- ✅ No \"Communication error\" before 600 seconds on working requests\n- ✅ Requests that take 3-5 minutes complete successfully\n- ✅ Timer cleanup confirmed (no memory leaks on rapid message sending)\n- ✅ i18n works in both Hebrew and English interface",
"status": "in-progress",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-05-13T12:22:54.379Z"
} }
], ],
"metadata": { "metadata": {
"created": "2026-04-14T06:05:06.042Z", "version": "1.0.0",
"updated": "2026-04-14T09:15:00.000Z", "lastModified": "2026-05-13T12:22:54.379Z",
"description": "Tasks for master context" "taskCount": 3,
"completedCount": 2,
"tags": [
"master"
]
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
# SmartAssistant - עוזר חכם # SmartAssistant - עוזר חכם
**גרסה:** 2.7.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0 **גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור ## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM. עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
@@ -457,14 +457,23 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
var $btn = this.$el.find('.sa-chat-send'); var $btn = this.$el.find('.sa-chat-send');
$messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>'); $messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
var thinkingText = this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...';
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' + var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...') + '</div>'); '<span class="sa-thinking-text">' + this.escapeHtml(thinkingText) + '</span></div>');
$messages.append($loading); $messages.append($loading);
this.scrollToBottom(); this.scrollToBottom();
$btn.prop('disabled', true); $btn.prop('disabled', true);
$input.prop('disabled', true).val('').css('height', 'auto'); $input.prop('disabled', true).val('').css('height', 'auto');
// After 60s, swap the spinner text to a "still working" hint so users
// know the longer requests (multi-tool flows) haven't stalled.
var slowHintText = this.translate('SlowHint', 'labels', 'SmartAssistant') ||
'חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל';
var slowHintTimer = setTimeout(function () {
$loading.find('.sa-thinking-text').text(slowHintText);
}, 60000);
var payload = { var payload = {
message: message, message: message,
mode: this.currentMode, mode: this.currentMode,
@@ -474,7 +483,8 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
payload.caseId = this.currentCaseId; payload.caseId = this.currentCaseId;
} }
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 180000}).then(function (response) { Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 600000}).then(function (response) {
clearTimeout(slowHintTimer);
self.conversationId = response.conversationId; self.conversationId = response.conversationId;
$loading.remove(); $loading.remove();
@@ -492,6 +502,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
self.scrollToBottom(); self.scrollToBottom();
$input.prop('disabled', false).focus(); $input.prop('disabled', false).focus();
}).catch(function () { }).catch(function () {
clearTimeout(slowHintTimer);
$loading.remove(); $loading.remove();
$messages.append('<div class="sa-msg sa-msg-error">' + $messages.append('<div class="sa-msg sa-msg-error">' +
self.escapeHtml(self.translate('Error', 'labels', 'SmartAssistant') || 'שגיאה בתקשורת') + '</div>'); self.escapeHtml(self.translate('Error', 'labels', 'SmartAssistant') || 'שגיאה בתקשורת') + '</div>');
@@ -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 public function postActionReadMultipleDocuments(Request $request, Response $response): array
{ {
$this->checkAccess(); $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 public function postActionGenerateFromTemplate(Request $request, Response $response): array
{ {
$this->checkAccess(); $this->checkAccess();
@@ -11,6 +11,7 @@
"No previous conversations": "No previous conversations", "No previous conversations": "No previous conversations",
"Ask the assistant...": "Ask the assistant...", "Ask the assistant...": "Ask the assistant...",
"Thinking": "Thinking...", "Thinking": "Thinking...",
"SlowHint": "Still thinking… this one is taking a bit longer because I'm processing multiple sources",
"Error": "Communication error", "Error": "Communication error",
"Back": "Back", "Back": "Back",
"Chat with the assistant": "Chat with the assistant", "Chat with the assistant": "Chat with the assistant",
@@ -11,6 +11,7 @@
"No previous conversations": "אין שיחות קודמות", "No previous conversations": "אין שיחות קודמות",
"Ask the assistant...": "שאל/י את שירה...", "Ask the assistant...": "שאל/י את שירה...",
"Thinking": "חושבת...", "Thinking": "חושבת...",
"SlowHint": "חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל",
"Error": "שגיאה בתקשורת", "Error": "שגיאה בתקשורת",
"Back": "חזרה", "Back": "חזרה",
"Chat with the assistant": "שוחח/י עם שירה", "Chat with the assistant": "שוחח/י עם שירה",
@@ -33,6 +33,7 @@ class CaseContextBuilder
'recentNotes' => $this->getRecentNotes($caseId), 'recentNotes' => $this->getRecentNotes($caseId),
'availableTemplates' => $this->getAvailableTemplates(), 'availableTemplates' => $this->getAvailableTemplates(),
'documents' => $this->getDocumentListing($caseId), 'documents' => $this->getDocumentListing($caseId),
'signatureRequests' => $this->getSignatureRequests($caseId),
'currentUser' => $this->getUserData($userId), 'currentUser' => $this->getUserData($userId),
'validStatuses' => ActionExecutor::VALID_STATUSES, 'validStatuses' => ActionExecutor::VALID_STATUSES,
]; ];
@@ -202,13 +203,51 @@ class CaseContextBuilder
return ['id' => $user->get('id'), 'name' => $user->get('name')]; 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 private function getDocumentListing(string $caseId): array
{ {
try { try {
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class); $analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$result = $analyzer->listDocumentsRecursive($caseId); $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) { foreach ($result['folders'] as $folder) {
$files = []; $files = [];
@@ -41,11 +41,67 @@ class DocumentAnalyzer
public function listDocumentsRecursive(string $caseId): array public function listDocumentsRecursive(string $caseId): array
{ {
$casePath = $this->getCaseFolderPath($caseId); $description = $this->describeCaseFolderPath($caseId);
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []]; $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(); $client = $this->getStorageClient();
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); $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; $folders = []; $rootFiles = []; $totalFiles = 0;
foreach ($topLevel as $item) { 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 public function extractTextContent(string $filePath): ?string
@@ -115,6 +180,85 @@ class DocumentAnalyzer
return $text; 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 public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
{ {
$results = []; $results = [];
@@ -192,22 +336,158 @@ class DocumentAnalyzer
} }
public function getCaseFolderPath(string $caseId): ?string 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); $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; $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 { try {
$nds = $this->getNetworkDocumentService(); $nds = $this->getNetworkDocumentService();
return $nds->getEntityFolderPath('Case', $caseId); $computed = $nds->getEntityFolderPath('Case', $caseId);
} catch (\Exception $e) { if ($computed) {
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage()); return ['path' => $computed, 'source' => 'computed', 'error' => null, 'caseExists' => true];
return null;
} }
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 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 private function getMaxTextLength(): int
@@ -518,7 +518,7 @@ class SmartAssistantService
CURLOPT_POSTFIELDS => $payload, CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers, CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 180, CURLOPT_TIMEOUT => 600,
CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 10,
]); ]);
+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.7.3", "version": "2.8.2",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"releaseDate": "2026-04-14", "releaseDate": "2026-05-13",
"php": [ "php": [
">=8.1" ">=8.1"
] ]