Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b05e1c8488 | |||
| b313c96955 | |||
| 2a03425514 | |||
| f400f2fec7 | |||
| 405ceb7684 | |||
| 2569003f33 | |||
| 289a9cf27f | |||
| 1bb0c06345 | |||
| 59fc8acdc4 | |||
| 586b543a4c | |||
| c3212e71fe | |||
| f84b2f1cef | |||
| 2a8046b737 | |||
| f319f981f9 |
@@ -0,0 +1,2 @@
|
|||||||
|
*.zip
|
||||||
|
build/
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"models": {
|
||||||
|
"main": {
|
||||||
|
"provider": "claude-code",
|
||||||
|
"modelId": "sonnet",
|
||||||
|
"maxTokens": 64000,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"id": "sonnet"
|
||||||
|
},
|
||||||
|
"research": {
|
||||||
|
"provider": "claude-code",
|
||||||
|
"modelId": "sonnet",
|
||||||
|
"maxTokens": 8700,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"id": "claude-sonnet-4-20250514"
|
||||||
|
},
|
||||||
|
"fallback": {
|
||||||
|
"provider": "claude-code",
|
||||||
|
"modelId": "sonnet",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"currentTag": "master",
|
||||||
|
"lastSwitched": "2026-04-14T06:05:06.044Z",
|
||||||
|
"branchTagMapping": {},
|
||||||
|
"migrationNoticeShown": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"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": "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": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lastModified": "2026-05-13T12:22:54.379Z",
|
||||||
|
"taskCount": 3,
|
||||||
|
"completedCount": 2,
|
||||||
|
"tags": [
|
||||||
|
"master"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# SmartAssistant - עוזר חכם
|
# SmartAssistant - עוזר חכם
|
||||||
|
|
||||||
**גרסה:** 2.0.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
**גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||||
|
|
||||||
## תיאור
|
## תיאור
|
||||||
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,31 +0,0 @@
|
|||||||
יצירת טבלה מעוצבת עם תמיכה מלאה בעברית ואנגלית מעורבת.
|
|
||||||
|
|
||||||
כאשר המשתמש מבקש טבלה בכל הקשר — תכנית עבודה, סיכום, השוואה, רשימה — השתמש בפונקציה `bidi_table()` מ-`scripts/bidi_table.py`.
|
|
||||||
|
|
||||||
## הוראות
|
|
||||||
|
|
||||||
1. **תמיד** השתמש ב-Bash כדי להריץ את הסקריפט — אל תנסה לייצר טבלת box-drawing ידנית כי ה-BiDi ישבור אותה.
|
|
||||||
|
|
||||||
2. הרץ כך:
|
|
||||||
```bash
|
|
||||||
python3 -c "
|
|
||||||
import sys; sys.path.insert(0, '/home/chaim/legal-ai')
|
|
||||||
from scripts.bidi_table import bidi_table
|
|
||||||
print(bidi_table(
|
|
||||||
['Header1', 'Header2', 'Header3'],
|
|
||||||
[
|
|
||||||
['value1', 'ערך בעברית', 'mixed ערבוב'],
|
|
||||||
['value2', 'ערך נוסף', 'עוד שורה'],
|
|
||||||
],
|
|
||||||
))
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. כותרות עמודות — עדיף באנגלית (כי שורת הכותרת הכי רגישה ל-BiDi).
|
|
||||||
|
|
||||||
4. תוכן בעברית, באנגלית, או מעורב — הכל עובד בגוף הטבלה.
|
|
||||||
|
|
||||||
5. אם המשתמש מבקש טבלה כחלק ממסמך MD שנכתב לקובץ (לא לטרמינל) — אפשר להשתמש ב-markdown רגיל כי קוראי MD מטפלים ב-RTL בעצמם.
|
|
||||||
|
|
||||||
## $ARGUMENTS
|
|
||||||
תוכן הטבלה — כותרות ושורות. אם לא צוין, שאל את המשתמש מה להציג.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""BiDi-safe box-drawing table renderer for mixed Hebrew/English terminal output.
|
|
||||||
|
|
||||||
Uses LRM (Left-to-Right Mark, U+200E) before box-drawing characters to prevent
|
|
||||||
the BiDi algorithm from breaking table alignment when Hebrew text is present.
|
|
||||||
|
|
||||||
Usage as module:
|
|
||||||
from scripts.bidi_table import bidi_table
|
|
||||||
print(bidi_table(['Col1', 'Col2'], [['val1', 'ערך2']]))
|
|
||||||
|
|
||||||
Usage from CLI:
|
|
||||||
python3 scripts/bidi_table.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
LRM = "\u200E" # Left-to-Right Mark — invisible, prevents BiDi reordering
|
|
||||||
|
|
||||||
|
|
||||||
def bidi_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
||||||
"""Render a box-drawing table safe for mixed RTL/LTR terminal display."""
|
|
||||||
ncols = len(headers)
|
|
||||||
|
|
||||||
# Calculate column widths
|
|
||||||
col_widths = [len(h) for h in headers]
|
|
||||||
for row in rows:
|
|
||||||
for i, cell in enumerate(row[:ncols]):
|
|
||||||
col_widths[i] = max(col_widths[i], len(cell))
|
|
||||||
|
|
||||||
def hline(left: str, mid: str, right: str) -> str:
|
|
||||||
return left + mid.join("─" * (w + 2) for w in col_widths) + right
|
|
||||||
|
|
||||||
def dataline(cells: list[str]) -> str:
|
|
||||||
parts = []
|
|
||||||
for i in range(ncols):
|
|
||||||
cell = cells[i] if i < len(cells) else ""
|
|
||||||
padded = cell + " " * max(0, col_widths[i] - len(cell))
|
|
||||||
parts.append(" " + padded + " ")
|
|
||||||
return LRM + "│" + (LRM + "│").join(parts) + LRM + "│"
|
|
||||||
|
|
||||||
lines = [hline("┌", "┬", "┐")]
|
|
||||||
lines.append(dataline(headers))
|
|
||||||
lines.append(hline("├", "┼", "┤"))
|
|
||||||
for row in rows:
|
|
||||||
lines.append(dataline(row))
|
|
||||||
lines.append(hline("└", "┴", "┘"))
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
table = bidi_table(
|
|
||||||
["File", "Description", "Model", "Step"],
|
|
||||||
[
|
|
||||||
["claims_extractor.py", "חילוץ טענות מכתבי טענות", "Sonnet", "שלב 3 — הבא בתור"],
|
|
||||||
["brainstorm.py", "סיעור מוחות — כיווני נימוק", "Sonnet", "שלב 4"],
|
|
||||||
["block_writer.py", "כתיבת בלוקים של החלטה", "Sonnet/Opus", "שלב 5"],
|
|
||||||
["qa_validator.py", "בדיקת איכות QA", "Sonnet", "שלב 6"],
|
|
||||||
["style_analyzer.py", "ניתוח סגנון דפנה", "Opus", "חד-פעמי"],
|
|
||||||
["learning_loop.py", "למידה מהחלטה סופית", "Sonnet", "סוף תהליך"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
print(table)
|
|
||||||
@@ -4,13 +4,16 @@ set -euo pipefail
|
|||||||
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
|
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
|
||||||
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
||||||
ZIPNAME="${MODULE}-${VERSION}.zip"
|
ZIPNAME="${MODULE}-${VERSION}.zip"
|
||||||
|
BUILDDIR="build"
|
||||||
|
|
||||||
# Update version in README.md if it exists
|
# Update version in README.md if it exists
|
||||||
if [[ -f README.md ]]; then
|
if [[ -f README.md ]]; then
|
||||||
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BUILDDIR"
|
||||||
echo "Building $ZIPNAME..."
|
echo "Building $ZIPNAME..."
|
||||||
rm -f "$ZIPNAME"
|
rm -f "$BUILDDIR/$ZIPNAME"
|
||||||
zip -r "$ZIPNAME" manifest.json files/ \
|
zip -r "$BUILDDIR/$ZIPNAME" manifest.json files/ \
|
||||||
-x "*.DS_Store" "*__MACOSX*" "*.zip"
|
-x "*.DS_Store" "*__MACOSX*"
|
||||||
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
|
echo "✓ Built: $BUILDDIR/$ZIPNAME ($(du -h "$BUILDDIR/$ZIPNAME" | cut -f1))"
|
||||||
|
|||||||
@@ -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>');
|
||||||
|
|||||||
@@ -9,17 +9,23 @@ use Espo\Core\Exceptions\Forbidden;
|
|||||||
use Espo\Core\InjectableFactory;
|
use Espo\Core\InjectableFactory;
|
||||||
use Espo\Core\Acl;
|
use Espo\Core\Acl;
|
||||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
||||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
|
||||||
class SmartAssistant
|
class SmartAssistant
|
||||||
{
|
{
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
private Acl $acl;
|
private Acl $acl;
|
||||||
|
private User $user;
|
||||||
|
|
||||||
public function __construct(InjectableFactory $injectableFactory, Acl $acl)
|
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
|
||||||
{
|
{
|
||||||
$this->injectableFactory = $injectableFactory;
|
$this->injectableFactory = $injectableFactory;
|
||||||
$this->acl = $acl;
|
$this->acl = $acl;
|
||||||
|
$this->user = $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getService(): SmartAssistantService
|
private function getService(): SmartAssistantService
|
||||||
@@ -169,4 +175,213 @@ class SmartAssistant
|
|||||||
'id' => $entry->get('id'),
|
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "שוחח/י עם שירה",
|
||||||
|
|||||||
+12
@@ -27,6 +27,18 @@
|
|||||||
"upcomingHearingDays": {
|
"upcomingHearingDays": {
|
||||||
"type": "int",
|
"type": "int",
|
||||||
"default": 7
|
"default": 7
|
||||||
|
},
|
||||||
|
"maxDocumentChars": {
|
||||||
|
"type": "int",
|
||||||
|
"default": 100000
|
||||||
|
},
|
||||||
|
"agenticLoopEnabled": {
|
||||||
|
"type": "bool",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"maxAgenticLoops": {
|
||||||
|
"type": "int",
|
||||||
|
"default": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowUserAccess": false,
|
"allowUserAccess": false,
|
||||||
|
|||||||
@@ -86,5 +86,13 @@
|
|||||||
"controller": "SmartAssistant",
|
"controller": "SmartAssistant",
|
||||||
"action": "saveMemory"
|
"action": "saveMemory"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/SmartAssistant/action/executeTool",
|
||||||
|
"method": "post",
|
||||||
|
"params": {
|
||||||
|
"controller": "SmartAssistant",
|
||||||
|
"action": "executeTool"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ class ActionExecutor
|
|||||||
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
|
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
|
||||||
'list_documents' => $this->listDocuments($caseId),
|
'list_documents' => $this->listDocuments($caseId),
|
||||||
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
'analyze_document' => $this->analyzeDocument($params, $caseId),
|
||||||
|
'read_document' => $this->readDocument($params, $caseId),
|
||||||
|
'read_multiple_documents' => $this->readMultipleDocuments($params, $caseId),
|
||||||
'rename_document' => $this->renameDocument($params),
|
'rename_document' => $this->renameDocument($params),
|
||||||
|
'batch_rename_documents' => $this->batchRenameDocuments($params),
|
||||||
'upload_document' => $this->uploadDocument($params, $caseId),
|
'upload_document' => $this->uploadDocument($params, $caseId),
|
||||||
'generate_document' => $this->generateDocument($params, $caseId),
|
'generate_document' => $this->generateDocument($params, $caseId),
|
||||||
'merge_documents' => $this->mergeDocuments($params, $caseId),
|
'merge_documents' => $this->mergeDocuments($params, $caseId),
|
||||||
@@ -383,6 +386,65 @@ class ActionExecutor
|
|||||||
return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]];
|
return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function readDocument(array $params, ?string $caseId): array
|
||||||
|
{
|
||||||
|
$filePath = $params['filePath'] ?? null;
|
||||||
|
if (!$filePath) throw new Error('filePath parameter is required.');
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$text = $analyzer->extractFullText($filePath);
|
||||||
|
$fileName = basename($filePath);
|
||||||
|
|
||||||
|
if ($text === null) {
|
||||||
|
return ['success' => false, 'message' => "לא ניתן לחלץ טקסט מ-\"{$fileName}\""];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => $text,
|
||||||
|
'data' => [
|
||||||
|
'filePath' => $filePath,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'charCount' => mb_strlen($text),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readMultipleDocuments(array $params, ?string $caseId): array
|
||||||
|
{
|
||||||
|
$filePaths = $params['filePaths'] ?? [];
|
||||||
|
if (empty($filePaths)) throw new Error('filePaths parameter is required (array of file paths).');
|
||||||
|
|
||||||
|
$maxPerFile = $params['maxCharsPerFile'] ?? 50000;
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$results = $analyzer->extractMultipleDocuments($filePaths, $maxPerFile);
|
||||||
|
|
||||||
|
$lines = [];
|
||||||
|
$successCount = 0;
|
||||||
|
|
||||||
|
foreach ($results as $r) {
|
||||||
|
$lines[] = "=== {$r['fileName']} ===";
|
||||||
|
if (!empty($r['error'])) {
|
||||||
|
$lines[] = "שגיאה: {$r['error']}";
|
||||||
|
} elseif ($r['text'] === null) {
|
||||||
|
$lines[] = "לא ניתן לחלץ טקסט";
|
||||||
|
} else {
|
||||||
|
$lines[] = $r['text'];
|
||||||
|
$successCount++;
|
||||||
|
}
|
||||||
|
$lines[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => implode("\n", $lines),
|
||||||
|
'data' => [
|
||||||
|
'totalFiles' => count($filePaths),
|
||||||
|
'successCount' => $successCount,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function renameDocument(array $params): array
|
private function renameDocument(array $params): array
|
||||||
{
|
{
|
||||||
$sourcePath = $params['sourcePath'] ?? null;
|
$sourcePath = $params['sourcePath'] ?? null;
|
||||||
@@ -395,6 +457,37 @@ class ActionExecutor
|
|||||||
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
|
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function batchRenameDocuments(array $params): array
|
||||||
|
{
|
||||||
|
$renames = $params['renames'] ?? [];
|
||||||
|
if (empty($renames)) throw new Error('renames parameter is required (array of {sourcePath, newName}).');
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
$results = [];
|
||||||
|
$successCount = 0;
|
||||||
|
|
||||||
|
foreach ($renames as $rename) {
|
||||||
|
$sourcePath = $rename['sourcePath'] ?? null;
|
||||||
|
$newName = $rename['newName'] ?? null;
|
||||||
|
|
||||||
|
if (!$sourcePath || !$newName) {
|
||||||
|
$results[] = "שגיאה: חסר sourcePath או newName";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $analyzer->renameDocument($sourcePath, $newName);
|
||||||
|
$results[] = "\"{$result['oldName']}\" → \"{$result['newName']}\"";
|
||||||
|
$successCount++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$results[] = "שגיאה בשינוי שם \"" . basename($sourcePath) . "\": " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = "שינוי שמות: {$successCount}/" . count($renames) . " הצליחו\n" . implode("\n", $results);
|
||||||
|
return ['success' => true, 'message' => $msg];
|
||||||
|
}
|
||||||
|
|
||||||
private function uploadDocument(array $params, ?string $caseId): array
|
private function uploadDocument(array $params, ?string $caseId): array
|
||||||
{
|
{
|
||||||
if (!$caseId) {
|
if (!$caseId) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class AlertCalculator
|
|||||||
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
||||||
|
|
||||||
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
|
$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')
|
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
|
||||||
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
||||||
@@ -106,7 +106,7 @@ class AlertCalculator
|
|||||||
|
|
||||||
$tasks = $this->entityManager->getRDBRepository('Task')
|
$tasks = $this->entityManager->getRDBRepository('Task')
|
||||||
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
->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();
|
->order('dateEnd', 'ASC')->limit(0, 50)->find();
|
||||||
|
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
@@ -132,7 +132,7 @@ class AlertCalculator
|
|||||||
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
|
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
|
||||||
|
|
||||||
$cases = $this->entityManager->getRDBRepository('Case')
|
$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])
|
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
|
||||||
->order('cNextHearing', 'ASC')->find();
|
->order('cNextHearing', 'ASC')->find();
|
||||||
|
|
||||||
@@ -140,13 +140,7 @@ class AlertCalculator
|
|||||||
$lastActivity = $case->get('cLastActivityAt');
|
$lastActivity = $case->get('cLastActivityAt');
|
||||||
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
|
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
|
||||||
|
|
||||||
$openTaskCount = $this->entityManager->getRDBRepository('Task')
|
if ($this->caseHasPreparation($case, $recentTaskThreshold)) continue;
|
||||||
->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;
|
|
||||||
|
|
||||||
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
|
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
|
||||||
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
|
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
|
||||||
@@ -162,6 +156,104 @@ class AlertCalculator
|
|||||||
return $alerts;
|
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
|
private function findUnassignedNewCases(): array
|
||||||
{
|
{
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
@@ -191,7 +283,7 @@ class AlertCalculator
|
|||||||
|
|
||||||
$tasks = $this->entityManager->getRDBRepository('Task')
|
$tasks = $this->entityManager->getRDBRepository('Task')
|
||||||
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
->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();
|
->order('dateEnd', 'ASC')->limit(0, 30)->find();
|
||||||
|
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
@@ -237,7 +329,7 @@ class AlertCalculator
|
|||||||
|
|
||||||
foreach (array_keys($userCounts) as $uid) {
|
foreach (array_keys($userCounts) as $uid) {
|
||||||
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
|
$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);
|
return array_values($userCounts);
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
@@ -82,9 +83,18 @@ class CaseContextBuilder
|
|||||||
|
|
||||||
private function getOpenTasks(string $caseId): array
|
private function getOpenTasks(string $caseId): array
|
||||||
{
|
{
|
||||||
|
$parentConditions = [
|
||||||
|
['parentType' => 'Case', 'parentId' => $caseId],
|
||||||
|
];
|
||||||
|
|
||||||
|
$contactIds = $this->getCaseContactIds($caseId);
|
||||||
|
if (!empty($contactIds)) {
|
||||||
|
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||||
|
}
|
||||||
|
|
||||||
$tasks = [];
|
$tasks = [];
|
||||||
$collection = $this->entityManager->getRDBRepository('Task')
|
$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();
|
->order('dateEnd', 'ASC')->limit(0, 10)->find();
|
||||||
|
|
||||||
foreach ($collection as $t) {
|
foreach ($collection as $t) {
|
||||||
@@ -97,6 +107,32 @@ class CaseContextBuilder
|
|||||||
return $tasks;
|
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
|
private function getUpcomingMeetings(string $caseId): array
|
||||||
{
|
{
|
||||||
$meetings = [];
|
$meetings = [];
|
||||||
@@ -167,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 = [];
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
|
|||||||
|
|
||||||
class DocumentAnalyzer
|
class DocumentAnalyzer
|
||||||
{
|
{
|
||||||
private const MAX_TEXT_LENGTH = 2000;
|
private const DEFAULT_MAX_TEXT_LENGTH = 100000;
|
||||||
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
private const TMP_DIR = 'data/tmp/assistant-docs';
|
private const TMP_DIR = 'data/tmp/assistant-docs';
|
||||||
|
|
||||||
private EntityManager $entityManager;
|
private EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
private Log $log;
|
private Log $log;
|
||||||
|
private ?int $maxTextLength = null;
|
||||||
|
|
||||||
private const CATEGORY_PATTERNS = [
|
private const CATEGORY_PATTERNS = [
|
||||||
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
|
||||||
@@ -40,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();
|
||||||
$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;
|
$folders = []; $rootFiles = []; $totalFiles = 0;
|
||||||
|
|
||||||
foreach ($topLevel as $item) {
|
foreach ($topLevel as $item) {
|
||||||
@@ -69,10 +126,26 @@ 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
|
||||||
|
{
|
||||||
|
$maxLen = $this->getMaxTextLength();
|
||||||
|
$text = $this->extractFullText($filePath, $maxLen);
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extractFullText(string $filePath, ?int $maxLength = null): ?string
|
||||||
{
|
{
|
||||||
$client = $this->getStorageClient();
|
$client = $this->getStorageClient();
|
||||||
$filename = basename($filePath);
|
$filename = basename($filePath);
|
||||||
@@ -98,8 +171,120 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
if (file_exists($tmpFile)) unlink($tmpFile);
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
||||||
if ($text === null) return null;
|
if ($text === null) return null;
|
||||||
if (mb_strlen($text) > self::MAX_TEXT_LENGTH) $text = mb_substr($text, 0, self::MAX_TEXT_LENGTH) . '...';
|
|
||||||
return trim($text);
|
$text = trim($text);
|
||||||
|
if ($maxLength !== null && mb_strlen($text) > $maxLength) {
|
||||||
|
$text = mb_substr($text, 0, $maxLength) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = [];
|
||||||
|
|
||||||
|
foreach ($filePaths as $filePath) {
|
||||||
|
$fileName = basename($filePath);
|
||||||
|
try {
|
||||||
|
$text = $this->extractFullText($filePath, $maxPerFile);
|
||||||
|
$results[] = [
|
||||||
|
'path' => $filePath,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'text' => $text,
|
||||||
|
'charCount' => $text !== null ? mb_strlen($text) : 0,
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$results[] = [
|
||||||
|
'path' => $filePath,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'text' => null,
|
||||||
|
'charCount' => 0,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function renameDocument(string $sourcePath, string $newName): array
|
public function renameDocument(string $sourcePath, string $newName): array
|
||||||
@@ -151,24 +336,181 @@ 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);
|
||||||
|
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) {
|
} catch (\Exception $e) {
|
||||||
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
|
$this->log->warning("SmartAssistant: Failed to compute case folder path: " . $e->getMessage());
|
||||||
return null;
|
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) {
|
||||||
|
return $this->maxTextLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
|
if ($integration && $integration->get('enabled')) {
|
||||||
|
$data = (array) ($integration->get('data') ?? []);
|
||||||
|
$this->maxTextLength = (int) ($data['maxDocumentChars'] ?? self::DEFAULT_MAX_TEXT_LENGTH);
|
||||||
|
return $this->maxTextLength;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->maxTextLength = self::DEFAULT_MAX_TEXT_LENGTH;
|
||||||
|
return $this->maxTextLength;
|
||||||
|
}
|
||||||
|
|
||||||
private function getStorageClient(): StorageClientInterface
|
private function getStorageClient(): StorageClientInterface
|
||||||
{
|
{
|
||||||
return $this->getNetworkDocumentService()->getClient();
|
return $this->getNetworkDocumentService()->getClient();
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\SmartAssistant\Services;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\Error;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Core\Utils\Log;
|
||||||
|
use Espo\ORM\EntityManager;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||||
|
use PhpOffice\PhpWord\TemplateProcessor;
|
||||||
|
|
||||||
|
class GenericTemplateGenerator
|
||||||
|
{
|
||||||
|
private const TEMP_DIR = 'data/tmp/';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManager $entityManager,
|
||||||
|
private InjectableFactory $injectableFactory,
|
||||||
|
private Log $log,
|
||||||
|
private User $user
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function generate(string $templateId, string $caseId, ?string $documentSubject = null, array $customPlaceholders = []): array
|
||||||
|
{
|
||||||
|
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
|
||||||
|
if (!$template) {
|
||||||
|
throw new NotFound("Template {$templateId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$template->get('isActive')) {
|
||||||
|
throw new Error("Template is not active.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$templatePath = $template->get('templatePath');
|
||||||
|
if (!$templatePath) {
|
||||||
|
throw new Error("Template path is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
|
if (!$case) {
|
||||||
|
throw new NotFound("Case {$caseId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$templateName = $template->get('name') ?? 'document';
|
||||||
|
|
||||||
|
// Step 1: Download template from WebDAV
|
||||||
|
$localTemplatePath = $this->downloadTemplate($templatePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 2: Build placeholder data from entity fields
|
||||||
|
$templateService = $this->injectableFactory->create(DocumentTemplateService::class);
|
||||||
|
$placeholderData = $templateService->buildPlaceholderData($case);
|
||||||
|
|
||||||
|
// Step 3: Merge custom placeholders (override entity data)
|
||||||
|
foreach ($customPlaceholders as $key => $value) {
|
||||||
|
$placeholderData[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Process template
|
||||||
|
$outputPath = self::TEMP_DIR . uniqid('gen_') . '.docx';
|
||||||
|
if (!is_dir(self::TEMP_DIR)) {
|
||||||
|
mkdir(self::TEMP_DIR, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processTemplate($localTemplatePath, $outputPath, $placeholderData);
|
||||||
|
|
||||||
|
// Step 5: Read generated content
|
||||||
|
$content = file_get_contents($outputPath);
|
||||||
|
@unlink($outputPath);
|
||||||
|
@unlink($localTemplatePath);
|
||||||
|
|
||||||
|
// Step 6: Build document name
|
||||||
|
$contactName = $this->getContactName($case);
|
||||||
|
$docName = $documentSubject ?? "{$templateName} — {$contactName}";
|
||||||
|
|
||||||
|
// Step 7: Create Attachment + Document
|
||||||
|
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||||
|
$attachment->set([
|
||||||
|
'name' => $docName . '.docx',
|
||||||
|
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'role' => 'Attachment',
|
||||||
|
'size' => strlen($content),
|
||||||
|
'relatedType' => 'Document',
|
||||||
|
]);
|
||||||
|
$this->entityManager->saveEntity($attachment);
|
||||||
|
file_put_contents('data/upload/' . $attachment->getId(), $content);
|
||||||
|
|
||||||
|
$document = $this->entityManager->getNewEntity('Document');
|
||||||
|
$document->set([
|
||||||
|
'name' => $docName,
|
||||||
|
'fileId' => $attachment->getId(),
|
||||||
|
'fileName' => $docName . '.docx',
|
||||||
|
'assignedUserId' => $this->user->getId(),
|
||||||
|
]);
|
||||||
|
$this->entityManager->saveEntity($document);
|
||||||
|
|
||||||
|
// Link to Case
|
||||||
|
$this->entityManager->getRDBRepository('Case')
|
||||||
|
->getRelation($case, 'documents')
|
||||||
|
->relate($document);
|
||||||
|
|
||||||
|
$this->log->info("GenericTemplateGenerator: Created '{$docName}' from template '{$templateName}' for Case {$caseId}");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => "✅ מסמך \"{$docName}\" נוצר בהצלחה מתבנית \"{$templateName}\"\n📄 המסמך צורף לתיק.",
|
||||||
|
'entityType' => 'Document',
|
||||||
|
'entityId' => $document->getId(),
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if (isset($localTemplatePath) && file_exists($localTemplatePath)) {
|
||||||
|
@unlink($localTemplatePath);
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadTemplate(string $templatePath): string
|
||||||
|
{
|
||||||
|
// Try local paths first
|
||||||
|
if (file_exists($templatePath)) {
|
||||||
|
return $templatePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$localPath = 'data/document-templates/' . ltrim($templatePath, '/');
|
||||||
|
if (file_exists($localPath)) {
|
||||||
|
return $localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download from NetworkStorage (WebDAV)
|
||||||
|
try {
|
||||||
|
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||||
|
$client = $nds->getClient();
|
||||||
|
|
||||||
|
$integration = $this->entityManager->getEntityById('Integration', 'NetworkStorage');
|
||||||
|
$templatesFolderPath = 'Templates';
|
||||||
|
if ($integration && $integration->get('enabled')) {
|
||||||
|
$data = (array) ($integration->get('data') ?? []);
|
||||||
|
$templatesFolderPath = $data['templatesFolderPath'] ?? 'Templates';
|
||||||
|
}
|
||||||
|
|
||||||
|
$storagePath = $templatesFolderPath . '/' . $templatePath;
|
||||||
|
|
||||||
|
if (!$client->exists($storagePath)) {
|
||||||
|
throw new Error("Template file not found on storage: {$storagePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $client->downloadFile($storagePath);
|
||||||
|
|
||||||
|
if (!is_dir(self::TEMP_DIR)) {
|
||||||
|
mkdir(self::TEMP_DIR, 0775, true);
|
||||||
|
}
|
||||||
|
$tmpPath = self::TEMP_DIR . 'tpl_' . uniqid() . '.docx';
|
||||||
|
file_put_contents($tmpPath, $content);
|
||||||
|
|
||||||
|
return $tmpPath;
|
||||||
|
} catch (Error $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new Error("Failed to download template: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processTemplate(string $inputPath, string $outputPath, array $data): void
|
||||||
|
{
|
||||||
|
if (!class_exists(TemplateProcessor::class, false)) {
|
||||||
|
$autoloader = 'vendor/phpoffice/phpword/src/PhpWord/Autoloader.php';
|
||||||
|
if (file_exists($autoloader)) {
|
||||||
|
require_once $autoloader;
|
||||||
|
\PhpOffice\PhpWord\Autoloader::register();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$processor = new TemplateProcessor($inputPath);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
$value = '';
|
||||||
|
}
|
||||||
|
if (is_array($value)) {
|
||||||
|
$value = implode(', ', $value);
|
||||||
|
} elseif (is_bool($value)) {
|
||||||
|
$value = $value ? 'כן' : 'לא';
|
||||||
|
}
|
||||||
|
$processor->setValue($key, (string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processor->saveAs($outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getContactName($case): string
|
||||||
|
{
|
||||||
|
$contactId = $case->get('contactId');
|
||||||
|
if (!$contactId) {
|
||||||
|
$ids = $case->getLinkMultipleIdList('contacts');
|
||||||
|
if (!empty($ids)) {
|
||||||
|
$contactId = $ids[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contactId) {
|
||||||
|
$contact = $this->entityManager->getEntityById('Contact', $contactId);
|
||||||
|
if ($contact) {
|
||||||
|
return trim($contact->get('firstName') . ' ' . $contact->get('lastName'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $case->get('name') ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,10 +58,18 @@ class OfficeContextBuilder
|
|||||||
$contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')];
|
$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 = [];
|
$tasks = [];
|
||||||
foreach ($this->entityManager->getRDBRepository('Task')
|
foreach ($this->entityManager->getRDBRepository('Task')
|
||||||
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
|
->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) {
|
->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')];
|
$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];
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,17 @@ class SmartAssistantService
|
|||||||
public const NOTE_TYPE_ACTION = 'SmartAction';
|
public const NOTE_TYPE_ACTION = 'SmartAction';
|
||||||
|
|
||||||
// All tools execute inline — Shira has full permissions
|
// All tools execute inline — Shira has full permissions
|
||||||
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
|
private const TOOLS_REQUIRING_CASE = [
|
||||||
|
'change_status', 'schedule_hearing', 'list_documents', 'analyze_document',
|
||||||
|
'read_document', 'read_multiple_documents', 'upload_document',
|
||||||
|
'generate_document', 'merge_documents', 'save_memory',
|
||||||
|
'batch_rename_documents',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tools whose output is fed back to the AI for a follow-up webhook call
|
||||||
|
private const AGENTIC_TOOLS = ['analyze_document', 'read_document', 'read_multiple_documents', 'list_documents'];
|
||||||
|
|
||||||
|
private const DEFAULT_MAX_AGENTIC_LOOPS = 3;
|
||||||
|
|
||||||
private EntityManager $entityManager;
|
private EntityManager $entityManager;
|
||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
@@ -105,68 +115,28 @@ class SmartAssistantService
|
|||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call webhook
|
|
||||||
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
|
|
||||||
|
|
||||||
// Process actions — execute all inline (Shira has full permissions)
|
|
||||||
$actions = $response['actions'] ?? [];
|
|
||||||
$responseText = $response['text'] ?? '';
|
|
||||||
$inlineResults = [];
|
|
||||||
$executedActions = [];
|
|
||||||
|
|
||||||
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
|
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
|
||||||
$effectiveCaseId = $caseId;
|
$effectiveCaseId = $caseId;
|
||||||
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
|
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
|
||||||
$effectiveCaseId = $context['drillDown']['case']['id'];
|
$effectiveCaseId = $context['drillDown']['case']['id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
// Execute tools with agentic loop — read tools feed results back to AI
|
||||||
|
try {
|
||||||
foreach ($actions as $action) {
|
$loopResult = $this->executeActionsWithLoop(
|
||||||
$tool = $action['tool'] ?? '';
|
$message, $context, $conversationId, $conversationHistory,
|
||||||
$actionCaseId = $effectiveCaseId;
|
$mode, $effectiveCaseId, $userId
|
||||||
|
);
|
||||||
// Resolve case from action params if not available
|
} catch (\Exception $e) {
|
||||||
if (!$actionCaseId && !empty($action['params']['caseId'])) {
|
$this->log->error("SmartAssistant: Chat failed: " . $e->getMessage());
|
||||||
$actionCaseId = $action['params']['caseId'];
|
$loopResult = [
|
||||||
}
|
'responseText' => "⚠️ אירעה שגיאה בתקשורת עם המערכת. אנא נסה שוב בעוד מספר דקות.\n\nפרטי השגיאה: " . $e->getMessage(),
|
||||||
|
'executedActions' => [],
|
||||||
// Skip tools that require a case if no case context available
|
];
|
||||||
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
|
|
||||||
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
|
|
||||||
if (!empty($result['message'])) {
|
|
||||||
$inlineResults[] = $result['message'];
|
|
||||||
}
|
|
||||||
$executedActions[] = [
|
|
||||||
'tool' => $tool,
|
|
||||||
'status' => 'executed',
|
|
||||||
'entityType' => $result['entityType'] ?? null,
|
|
||||||
'entityId' => $result['entityId'] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Create stream note for executed action in case context
|
|
||||||
if ($actionCaseId) {
|
|
||||||
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
|
|
||||||
'conversationId' => $conversationId,
|
|
||||||
'status' => 'executed',
|
|
||||||
'entityType' => $result['entityType'] ?? null,
|
|
||||||
'entityId' => $result['entityId'] ?? null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
|
|
||||||
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($inlineResults)) {
|
$responseText = $loopResult['responseText'];
|
||||||
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
|
$executedActions = $loopResult['executedActions'];
|
||||||
}
|
|
||||||
|
|
||||||
// Persist response
|
// Persist response
|
||||||
try {
|
try {
|
||||||
@@ -191,6 +161,148 @@ class SmartAssistantService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function executeActionsWithLoop(
|
||||||
|
string $message,
|
||||||
|
array $context,
|
||||||
|
string $conversationId,
|
||||||
|
array $conversationHistory,
|
||||||
|
string $mode,
|
||||||
|
?string $effectiveCaseId,
|
||||||
|
string $userId
|
||||||
|
): array {
|
||||||
|
$agenticEnabled = $this->getIntegrationSetting('agenticLoopEnabled', true);
|
||||||
|
$maxLoops = (int) $this->getIntegrationSetting('maxAgenticLoops', self::DEFAULT_MAX_AGENTIC_LOOPS);
|
||||||
|
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||||
|
|
||||||
|
$loopCount = 0;
|
||||||
|
$allExecutedActions = [];
|
||||||
|
$currentHistory = $conversationHistory;
|
||||||
|
$finalResponseText = '';
|
||||||
|
|
||||||
|
while ($loopCount <= $maxLoops) {
|
||||||
|
// Call webhook (first call uses original message, subsequent calls use empty message)
|
||||||
|
$currentMessage = $loopCount === 0 ? $message : '';
|
||||||
|
$response = $this->callWebhook($currentMessage, $context, $conversationId, $currentHistory, $mode);
|
||||||
|
|
||||||
|
$actions = $response['actions'] ?? [];
|
||||||
|
$responseText = $response['text'] ?? '';
|
||||||
|
|
||||||
|
// If no actions, we're done
|
||||||
|
if (empty($actions)) {
|
||||||
|
$finalResponseText = $responseText;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agenticResults = [];
|
||||||
|
$inlineResults = [];
|
||||||
|
|
||||||
|
foreach ($actions as $action) {
|
||||||
|
$tool = $action['tool'] ?? '';
|
||||||
|
$actionCaseId = $effectiveCaseId;
|
||||||
|
|
||||||
|
if (!$actionCaseId && !empty($action['params']['caseId'])) {
|
||||||
|
$actionCaseId = $action['params']['caseId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
|
||||||
|
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
|
||||||
|
|
||||||
|
$allExecutedActions[] = [
|
||||||
|
'tool' => $tool,
|
||||||
|
'status' => 'executed',
|
||||||
|
'entityType' => $result['entityType'] ?? null,
|
||||||
|
'entityId' => $result['entityId'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($actionCaseId) {
|
||||||
|
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
|
||||||
|
'conversationId' => $conversationId,
|
||||||
|
'status' => 'executed',
|
||||||
|
'entityType' => $result['entityType'] ?? null,
|
||||||
|
'entityId' => $result['entityId'] ?? null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate agentic tools (need re-invocation) from inline tools
|
||||||
|
if ($agenticEnabled && in_array($tool, self::AGENTIC_TOOLS, true)) {
|
||||||
|
$agenticResults[] = [
|
||||||
|
'tool' => $tool,
|
||||||
|
'params' => $action['params'] ?? [],
|
||||||
|
'result' => $result['message'] ?? '',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
if (!empty($result['message'])) {
|
||||||
|
$inlineResults[] = $result['message'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
|
||||||
|
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no agentic results, we're done — return response with inline results
|
||||||
|
if (empty($agenticResults)) {
|
||||||
|
$finalResponseText = $responseText;
|
||||||
|
if (!empty($inlineResults)) {
|
||||||
|
$finalResponseText .= "\n\n" . implode("\n\n", $inlineResults);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed agentic results back to AI for next iteration
|
||||||
|
$this->log->debug("SmartAssistant: Agentic loop iteration " . ($loopCount + 1) . " — " . count($agenticResults) . " tool results to feed back");
|
||||||
|
|
||||||
|
// Add the AI response to history
|
||||||
|
$currentHistory[] = ['role' => 'assistant', 'content' => $responseText];
|
||||||
|
|
||||||
|
// Add each tool result to history
|
||||||
|
foreach ($agenticResults as $ar) {
|
||||||
|
$currentHistory[] = [
|
||||||
|
'role' => 'tool_result',
|
||||||
|
'toolName' => $ar['tool'],
|
||||||
|
'content' => $ar['result'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also append any inline results
|
||||||
|
if (!empty($inlineResults)) {
|
||||||
|
$currentHistory[] = [
|
||||||
|
'role' => 'tool_result',
|
||||||
|
'toolName' => '_inline_actions',
|
||||||
|
'content' => implode("\n\n", $inlineResults),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$loopCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: if we exhausted the loop, use the last response
|
||||||
|
if ($loopCount > $maxLoops && empty($finalResponseText)) {
|
||||||
|
$finalResponseText = $response['text'] ?? 'הגעתי למגבלת הסיבובים. אנא נסה שוב.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'responseText' => $finalResponseText,
|
||||||
|
'executedActions' => $allExecutedActions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getIntegrationSetting(string $key, $default = null)
|
||||||
|
{
|
||||||
|
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
|
||||||
|
if ($integration && $integration->get('enabled')) {
|
||||||
|
$data = (array) ($integration->get('data') ?? []);
|
||||||
|
return $data[$key] ?? $default;
|
||||||
|
}
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
public function executeAction(string $conversationId, string $actionId, bool $approved): array
|
public function executeAction(string $conversationId, string $actionId, bool $approved): array
|
||||||
{
|
{
|
||||||
$conversation = $this->loadConversation($conversationId);
|
$conversation = $this->loadConversation($conversationId);
|
||||||
@@ -406,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
@@ -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.5.3",
|
"version": "2.8.2",
|
||||||
"acceptableVersions": [
|
"acceptableVersions": [
|
||||||
">=8.0.0"
|
">=8.0.0"
|
||||||
],
|
],
|
||||||
"releaseDate": "2026-04-06",
|
"releaseDate": "2026-05-13",
|
||||||
"php": [
|
"php": [
|
||||||
">=8.1"
|
">=8.1"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user