Compare commits

...

13 Commits

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

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

Refs Task Master #1

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:59:12 +00:00
chaim 1bb0c06345 fix: wrap executeActionsWithLoop in try/catch to prevent raw 500 errors
When the AI Gateway returns a non-200 (e.g. tool validation failure),
callWebhook throws an unhandled Error that bubbled up to Slim as a raw
500 page. Now caught gracefully with a Hebrew error message returned
to the user instead of "Slim Application Error".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:51:06 +00:00
chaim 59fc8acdc4 chore: remove bidi-table skill (wrong repo)
bidi-table is a Claude Code skill that was accidentally committed here.
It doesn't belong in the SmartAssistant extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:19:33 +00:00
chaim 586b543a4c chore: remove 28 tracked ZIP files, add .gitignore, build to build/
ZIP release artifacts were tracked in git, bloating the repo and causing
oversized release uploads on Gitea. Build output now goes to build/ which
is gitignored along with *.zip.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:03:27 +00:00
chaim c3212e71fe feat: generic template generation — any WebDAV template usable without code
Add GenericTemplateGenerator that downloads templates from WebDAV,
fills entity placeholders automatically (case/contact/account), and
supports custom placeholders from AI conversation. New API endpoint
generateFromTemplate exposed for the AI Gateway.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:25:07 +00:00
chaim f84b2f1cef feat: add executeTool endpoint for direct plugin tool invocation
Adds POST /SmartAssistant/action/executeTool that accepts {tool, params, caseId}
and routes directly to the plugin tool handler via ActionExecutor. This allows
the AI Gateway to call plugin tools (like generate_direct_access_report) without
going through the conversation approval flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:31:16 +00:00
chaim 2a8046b737 feat: add document reading, multi-doc summarization, and batch rename for Shira
Enable the AI assistant to read full documents from network storage (PDF/DOCX/TXT),
summarize multiple documents, and batch-rename files. Key changes:

- Raise text extraction limit from 2,000 to 100,000 chars (configurable)
- Add agentic loop in SmartAssistantService for multi-turn tool execution
- New tools: read_document, read_multiple_documents, batch_rename_documents
- New API endpoints: readDocument, readMultipleDocuments, batchRename
- New integration settings: maxDocumentChars, agenticLoopEnabled, maxAgenticLoops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:24:45 +00:00
chaim f319f981f9 chore: bump version to 2.5.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:04:07 +00:00
chaim bf98495198 refactor: remove getLegalAidData from CaseContextBuilder
LegalAid entity no longer exists — context builder cleaned up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:12:43 +00:00
chaim c541069cb5 feat: include LegalAid data in case context for AI assistant
Add getLegalAidData() to CaseContextBuilder so Shira can see existing
legal aid information when working on a case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:27:36 +00:00
chaim 7480e7db35 chore: add SmartAssistant v2.5.3 release zip
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:41:58 +00:00
46 changed files with 949 additions and 173 deletions
+2
View File
@@ -0,0 +1,2 @@
*.zip
build/
+46
View File
@@ -0,0 +1,46 @@
{
"models": {
"main": {
"provider": "anthropic",
"modelId": "claude-sonnet-4-20250514",
"maxTokens": 64000,
"temperature": 0.2,
"id": "claude-sonnet-4-20250514"
},
"research": {
"provider": "anthropic",
"modelId": "sonar",
"maxTokens": 8700,
"temperature": 0.1,
"id": "claude-sonnet-4-20250514"
},
"fallback": {
"provider": "anthropic",
"modelId": "claude-3-7-sonnet-20250219",
"maxTokens": 120000,
"temperature": 0.2
}
},
"global": {
"logLevel": "info",
"debug": false,
"defaultNumTasks": 10,
"defaultSubtasks": 5,
"defaultPriority": "medium",
"projectName": "Task Master",
"ollamaBaseURL": "http://localhost:11434/api",
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
"responseLanguage": "English",
"enableCodebaseAnalysis": true,
"enableProxy": false,
"anonymousTelemetry": true,
"userId": "1234567890"
},
"claudeCode": {},
"codexCli": {},
"grokCli": {
"timeout": 120000,
"workingDirectory": null,
"defaultModel": "grok-4-latest"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"currentTag": "master",
"lastSwitched": "2026-04-14T06:05:06.044Z",
"branchTagMapping": {},
"migrationNoticeShown": false
}
+22
View File
@@ -0,0 +1,22 @@
{
"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": "in-progress",
"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": []
}
],
"metadata": {
"created": "2026-04-14T06:05:06.042Z",
"updated": "2026-04-14T09:15:00.000Z",
"description": "Tasks for master context"
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# SmartAssistant - עוזר חכם # SmartAssistant - עוזר חכם
**גרסה:** 2.0.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0 **גרסה:** 2.7.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.
-31
View File
@@ -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
תוכן הטבלה — כותרות ושורות. אם לא צוין, שאל את המשתמש מה להציג.
-62
View File
@@ -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)
+7 -4
View File
@@ -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))"
@@ -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,130 @@ 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 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 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);
}
} }
@@ -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);
@@ -82,9 +82,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 +106,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 = [];
@@ -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 = [
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'], 'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
@@ -73,6 +74,13 @@ class DocumentAnalyzer
} }
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 +106,41 @@ 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;
}
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
@@ -169,6 +210,27 @@ class DocumentAnalyzer
} }
} }
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);
+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant", "module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration", "description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear", "author": "klear",
"version": "2.5.3", "version": "2.7.3",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"releaseDate": "2026-04-06", "releaseDate": "2026-04-14",
"php": [ "php": [
">=8.1" ">=8.1"
] ]