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