Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 405ceb7684 | |||
| 2569003f33 | |||
| 289a9cf27f | |||
| 1bb0c06345 | |||
| 59fc8acdc4 | |||
| 586b543a4c | |||
| c3212e71fe | |||
| f84b2f1cef |
@@ -0,0 +1,2 @@
|
||||
*.zip
|
||||
build/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"currentTag": "master",
|
||||
"lastSwitched": "2026-04-14T06:05:06.044Z",
|
||||
"branchTagMapping": {},
|
||||
"migrationNoticeShown": false
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
# SmartAssistant - עוזר חכם
|
||||
|
||||
**גרסה:** 2.0.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
**גרסה:** 2.7.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
|
||||
|
||||
## תיאור
|
||||
עוזר 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'])")
|
||||
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
||||
ZIPNAME="${MODULE}-${VERSION}.zip"
|
||||
BUILDDIR="build"
|
||||
|
||||
# Update version in README.md if it exists
|
||||
if [[ -f README.md ]]; then
|
||||
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILDDIR"
|
||||
echo "Building $ZIPNAME..."
|
||||
rm -f "$ZIPNAME"
|
||||
zip -r "$ZIPNAME" manifest.json files/ \
|
||||
-x "*.DS_Store" "*__MACOSX*" "*.zip"
|
||||
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
|
||||
rm -f "$BUILDDIR/$ZIPNAME"
|
||||
zip -r "$BUILDDIR/$ZIPNAME" manifest.json files/ \
|
||||
-x "*.DS_Store" "*__MACOSX*"
|
||||
echo "✓ Built: $BUILDDIR/$ZIPNAME ($(du -h "$BUILDDIR/$ZIPNAME" | cut -f1))"
|
||||
|
||||
@@ -9,18 +9,23 @@ use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\InjectableFactory;
|
||||
use Espo\Core\Acl;
|
||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
||||
use Espo\Entities\User;
|
||||
|
||||
class SmartAssistant
|
||||
{
|
||||
private InjectableFactory $injectableFactory;
|
||||
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->acl = $acl;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
private function getService(): SmartAssistantService
|
||||
@@ -171,6 +176,24 @@ class SmartAssistant
|
||||
];
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -255,4 +278,27 @@ class SmartAssistant
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,5 +86,13 @@
|
||||
"controller": "SmartAssistant",
|
||||
"action": "saveMemory"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/SmartAssistant/action/executeTool",
|
||||
"method": "post",
|
||||
"params": {
|
||||
"controller": "SmartAssistant",
|
||||
"action": "executeTool"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -46,7 +46,7 @@ class AlertCalculator
|
||||
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
||||
|
||||
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])->count();
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
|
||||
|
||||
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
|
||||
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
||||
@@ -106,7 +106,7 @@ class AlertCalculator
|
||||
|
||||
$tasks = $this->entityManager->getRDBRepository('Task')
|
||||
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
|
||||
->order('dateEnd', 'ASC')->limit(0, 50)->find();
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
@@ -132,7 +132,7 @@ class AlertCalculator
|
||||
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
|
||||
|
||||
$cases = $this->entityManager->getRDBRepository('Case')
|
||||
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt'])
|
||||
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt', 'contactId'])
|
||||
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
|
||||
->order('cNextHearing', 'ASC')->find();
|
||||
|
||||
@@ -140,13 +140,7 @@ class AlertCalculator
|
||||
$lastActivity = $case->get('cLastActivityAt');
|
||||
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
|
||||
|
||||
$openTaskCount = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false])->count();
|
||||
if ($openTaskCount > 0) continue;
|
||||
|
||||
$recentCompleted = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status' => 'Completed', 'modifiedAt>=' => $recentTaskThreshold, 'deleted' => false])->count();
|
||||
if ($recentCompleted > 0) continue;
|
||||
if ($this->caseHasPreparation($case, $recentTaskThreshold)) continue;
|
||||
|
||||
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
|
||||
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
|
||||
@@ -162,6 +156,104 @@ class AlertCalculator
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a case has any evidence of hearing preparation:
|
||||
* 1. Tasks linked directly to the case (parentType='Case')
|
||||
* 2. Tasks linked to the case's contacts (parentType='Contact')
|
||||
* 3. Tasks linked through NhActivity records for this case
|
||||
*/
|
||||
private function caseHasPreparation(\Espo\ORM\Entity $case, string $recentTaskThreshold): bool
|
||||
{
|
||||
$caseId = $case->get('id');
|
||||
|
||||
// Collect all parentId+parentType pairs that relate to this case
|
||||
$parentConditions = [
|
||||
['parentType' => 'Case', 'parentId' => $caseId],
|
||||
];
|
||||
|
||||
// Also check tasks linked to the case's contacts
|
||||
$contactIds = $this->getCaseContactIds($case);
|
||||
if (!empty($contactIds)) {
|
||||
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||
}
|
||||
|
||||
// Check 1: Any open tasks linked to the case or its contacts
|
||||
$openTaskCount = $this->entityManager->getRDBRepository('Task')
|
||||
->where([
|
||||
'OR' => $parentConditions,
|
||||
'status!=' => ['Completed', 'Canceled', 'Deferred'],
|
||||
'deleted' => false,
|
||||
])->count();
|
||||
if ($openTaskCount > 0) return true;
|
||||
|
||||
// Check 2: Any recently completed tasks linked to the case or its contacts
|
||||
$recentCompleted = $this->entityManager->getRDBRepository('Task')
|
||||
->where([
|
||||
'OR' => $parentConditions,
|
||||
'status' => 'Completed',
|
||||
'modifiedAt>=' => $recentTaskThreshold,
|
||||
'deleted' => false,
|
||||
])->count();
|
||||
if ($recentCompleted > 0) return true;
|
||||
|
||||
// Check 3: Tasks linked through NhActivity records for this case
|
||||
$nhActivityTaskCount = $this->entityManager->getRDBRepository('NhActivity')
|
||||
->where([
|
||||
'caseId' => $caseId,
|
||||
'taskId!=' => null,
|
||||
'deleted' => false,
|
||||
])->count();
|
||||
|
||||
if ($nhActivityTaskCount > 0) {
|
||||
$nhActivities = $this->entityManager->getRDBRepository('NhActivity')
|
||||
->select(['taskId'])
|
||||
->where([
|
||||
'caseId' => $caseId,
|
||||
'taskId!=' => null,
|
||||
'deleted' => false,
|
||||
])->find();
|
||||
|
||||
$taskIds = [];
|
||||
foreach ($nhActivities as $nha) {
|
||||
$taskIds[] = $nha->get('taskId');
|
||||
}
|
||||
|
||||
$activeNhTasks = $this->entityManager->getRDBRepository('Task')
|
||||
->where([
|
||||
'id' => $taskIds,
|
||||
'status!=' => ['Canceled'],
|
||||
'deleted' => false,
|
||||
])->count();
|
||||
if ($activeNhTasks > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getCaseContactIds(\Espo\ORM\Entity $case): array
|
||||
{
|
||||
$contactIds = [];
|
||||
|
||||
$primaryContactId = $case->get('contactId');
|
||||
if ($primaryContactId) {
|
||||
$contactIds[] = $primaryContactId;
|
||||
}
|
||||
|
||||
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'contacts')
|
||||
->select(['id'])
|
||||
->find();
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$id = $contact->get('id');
|
||||
if (!in_array($id, $contactIds)) {
|
||||
$contactIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $contactIds;
|
||||
}
|
||||
|
||||
private function findUnassignedNewCases(): array
|
||||
{
|
||||
$alerts = [];
|
||||
@@ -191,7 +283,7 @@ class AlertCalculator
|
||||
|
||||
$tasks = $this->entityManager->getRDBRepository('Task')
|
||||
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => 'Case'])
|
||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
|
||||
->order('dateEnd', 'ASC')->limit(0, 30)->find();
|
||||
|
||||
foreach ($tasks as $task) {
|
||||
@@ -237,7 +329,7 @@ class AlertCalculator
|
||||
|
||||
foreach (array_keys($userCounts) as $uid) {
|
||||
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => 'Case'])->count();
|
||||
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
|
||||
}
|
||||
|
||||
return array_values($userCounts);
|
||||
|
||||
@@ -82,9 +82,18 @@ class CaseContextBuilder
|
||||
|
||||
private function getOpenTasks(string $caseId): array
|
||||
{
|
||||
$parentConditions = [
|
||||
['parentType' => 'Case', 'parentId' => $caseId],
|
||||
];
|
||||
|
||||
$contactIds = $this->getCaseContactIds($caseId);
|
||||
if (!empty($contactIds)) {
|
||||
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||
}
|
||||
|
||||
$tasks = [];
|
||||
$collection = $this->entityManager->getRDBRepository('Task')
|
||||
->where(['parentType' => 'Case', 'parentId' => $caseId, 'status!=' => ['Completed', 'Canceled']])
|
||||
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled']])
|
||||
->order('dateEnd', 'ASC')->limit(0, 10)->find();
|
||||
|
||||
foreach ($collection as $t) {
|
||||
@@ -97,6 +106,32 @@ class CaseContextBuilder
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
private function getCaseContactIds(string $caseId): array
|
||||
{
|
||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||
if (!$case) return [];
|
||||
|
||||
$contactIds = [];
|
||||
$primaryContactId = $case->get('contactId');
|
||||
if ($primaryContactId) {
|
||||
$contactIds[] = $primaryContactId;
|
||||
}
|
||||
|
||||
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'contacts')
|
||||
->select(['id'])
|
||||
->find();
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$id = $contact->get('id');
|
||||
if (!in_array($id, $contactIds)) {
|
||||
$contactIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $contactIds;
|
||||
}
|
||||
|
||||
private function getUpcomingMeetings(string $caseId): array
|
||||
{
|
||||
$meetings = [];
|
||||
|
||||
@@ -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')];
|
||||
}
|
||||
|
||||
$parentConditions = [
|
||||
['parentType' => 'Case', 'parentId' => $caseId],
|
||||
];
|
||||
$contactIds = $this->getCaseContactIds($case);
|
||||
if (!empty($contactIds)) {
|
||||
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||
}
|
||||
|
||||
$tasks = [];
|
||||
foreach ($this->entityManager->getRDBRepository('Task')
|
||||
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
|
||||
->where(['parentId' => $caseId, 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
|
||||
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
|
||||
->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) {
|
||||
$tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')];
|
||||
}
|
||||
@@ -76,4 +84,27 @@ class OfficeContextBuilder
|
||||
|
||||
return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes];
|
||||
}
|
||||
|
||||
private function getCaseContactIds(\Espo\ORM\Entity $case): array
|
||||
{
|
||||
$contactIds = [];
|
||||
$primaryContactId = $case->get('contactId');
|
||||
if ($primaryContactId) {
|
||||
$contactIds[] = $primaryContactId;
|
||||
}
|
||||
|
||||
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||
->getRelation($case, 'contacts')
|
||||
->select(['id'])
|
||||
->find();
|
||||
|
||||
foreach ($contacts as $contact) {
|
||||
$id = $contact->get('id');
|
||||
if (!in_array($id, $contactIds)) {
|
||||
$contactIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $contactIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +122,18 @@ class SmartAssistantService
|
||||
}
|
||||
|
||||
// Execute tools with agentic loop — read tools feed results back to AI
|
||||
$loopResult = $this->executeActionsWithLoop(
|
||||
$message, $context, $conversationId, $conversationHistory,
|
||||
$mode, $effectiveCaseId, $userId
|
||||
);
|
||||
try {
|
||||
$loopResult = $this->executeActionsWithLoop(
|
||||
$message, $context, $conversationId, $conversationHistory,
|
||||
$mode, $effectiveCaseId, $userId
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->log->error("SmartAssistant: Chat failed: " . $e->getMessage());
|
||||
$loopResult = [
|
||||
'responseText' => "⚠️ אירעה שגיאה בתקשורת עם המערכת. אנא נסה שוב בעוד מספר דקות.\n\nפרטי השגיאה: " . $e->getMessage(),
|
||||
'executedActions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$responseText = $loopResult['responseText'];
|
||||
$executedActions = $loopResult['executedActions'];
|
||||
|
||||
+2
-2
@@ -3,11 +3,11 @@
|
||||
"module": "SmartAssistant",
|
||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||
"author": "klear",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.3",
|
||||
"acceptableVersions": [
|
||||
">=8.0.0"
|
||||
],
|
||||
"releaseDate": "2026-04-09",
|
||||
"releaseDate": "2026-04-14",
|
||||
"php": [
|
||||
">=8.1"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user