Compare commits

..

8 Commits

Author SHA1 Message Date
chaim e40f3d4534 fix(2.9.2): add AssistantRule Controller — fixes silent save_rule 404
Without Controllers/AssistantRule.php, EspoCRM responds 404 "Controller
'AssistantRule' does not exist" on every POST/GET /api/v1/AssistantRule,
even though scopes/AssistantRule.json declares entity=true, object=true,
acl=true. The save_rule tool in shira-hermes calls the Record endpoint
directly, gets the 404, falls back to nothing (because the user_id branch
of the fallback rarely matches), and the LLM tells the user " כלל נשמר"
without verification — yet another instance of the hallucination pattern.

A one-line Controller class that extends Espo\Core\Controllers\Record
exposes the standard CRUD verbs and unlocks the storage path that the
rest of the stack already expected. Verified in prod: GET, POST, DELETE
all return 200 after this is in place.

Paired with shira-hermes 06b27e9 which teaches the LLM to actually read
the tool result and never report success on a  response.

Refs Task Master #6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:50:16 +00:00
chaim 89b3f5844e fix(2.9.1): extractFromDocx — drop PHPWord dependency, parse word/document.xml directly
Root cause of the Shira hallucination incident in production case 46 Friedman:
DocumentAnalyzer::extractFromDocx relied on PhpOffice\PhpWord, but on the
EspoCRM prod image the PHPWord files are present in vendor/ yet are NOT
registered in the composer PSR-4 autoload map — class_exists() silently
returned false, the method returned null, and Shira's read_document fell
back to OCR. OCR then only saw the signature image, the AI got "[signature]"
as document content and fabricated the entire CTS appeal as a knee injury.

This change rewrites extractFromDocx to use ZipArchive + a small regex
parser of word/document.xml. Independent of PHPWord, more robust on
tables/footnotes/hyperlinks (which PHPWord's element walker missed at depth
>1), and verified on prod against the same Friedman appeal: 80 paragraphs /
16633 clean chars extracted (vs 0 before).

The paired Python fix in shira-hermes (commit 7b517e1) makes the OCR
fallback also read document.xml, so even if this PHP fix regresses again,
the AI will not be fed "[signature]" as document content.

Refs Task Master #5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:01:44 +00:00
chaim 37a8b67b95 feat(2.9.0): free-form document creation + sandboxed case folder management
- FreeDocumentGenerator: build RTL Hebrew DOCX from lightweight markdown,
  attach to case, copy to network folder.
- CaseFolderManager: writeFile / createFolder / renameItem / moveItem,
  all sandboxed to the case's own root. Physical delete is never exposed;
  markForDeletion moves the file to "<caseRoot>/מסמכים למחיקה/" with a
  "למחיקה - " prefix.
- 6 new controller actions: createDocument, writeToFolder, createFolder,
  renameItem, moveItem, markForDeletion.
- AlertCalculator: silence "X days without activity" alerts for cases
  legitimately waiting on the court (PendingDecision always; PendingHearing
  when cNextHearing is in the future).
- CaseContextBuilder: fix DocumentTemplate query — was filtering on the
  wrong column (entityType vs targetEntityType), which is why Shira's
  TEMPLATES section was always empty. Now respects isActive and surfaces
  category/description.
- Document entity: new markedForDeletionAt + markedForDeletionBy audit fields.

Pairs with shira-hermes 0.4+ which exposes the 9 matching tools to Shira.

Refs Task Master #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:17:15 +00:00
chaim 39d9093bf6 feat: free-form document creation, case folder management, alert refinements
Shira can now create documents from scratch (not just from templates) and has
full sandboxed read/write access to the case's network folder, including a
soft-delete protocol so she cannot physically remove files.

New services:
- FreeDocumentGenerator: builds RTL Hebrew DOCX from lightweight markdown,
  saves as Attachment + Document, links to Case, copies to network folder.
- CaseFolderManager: writeFile/createFolder/renameItem/moveItem operations
  constrained to the case's own root via path normalization; never exposes
  a physical delete. markForDeletion moves the file into
  "<caseRoot>/מסמכים למחיקה/" with a "למחיקה - " prefix so the lawyer can
  audit and delete manually.

New controller actions: createDocument, writeToFolder, createFolder,
renameItem, moveItem, markForDeletion.

Bug fix (root cause of the empty TEMPLATES section in Shira's case prompt):
CaseContextBuilder was querying DocumentTemplate with `entityType=Case` but
the real column is `targetEntityType`. Also added `isActive=true` filter and
surfaced category/description for better LLM selection.

Alert refinement: AlertCalculator no longer raises "X days without activity"
for cases that are legitimately waiting on the court. PendingDecision is
always silenced; PendingHearing is silenced when cNextHearing is in the
future (unscheduled PendingHearing cases still alert).

Document entity: new `markedForDeletionAt` + `markedForDeletionBy` audit
fields with Hebrew labels.

Refs Task Master #4

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

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

Refs Task Master #3

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

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

Refs Task Master #2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 17:55:48 +00:00
chaim 2a03425514 chore: sync README version to 2.8.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:19:25 +00:00
chaim f400f2fec7 feat: add getDocumentBytes endpoint for OCR + SignatureRequest context
- New POST /api/v1/SmartAssistant/action/getDocumentBytes returns
  {success, fileName, mimeType, sizeBytes, base64} for use by
  shira-hermes Claude Vision OCR fallback.
- DocumentAnalyzer: new getDocumentBytes() method + guessMimeType helper.
- CaseContextBuilder: expose signatureRequests in case context (for
  DigitalSignature integration — gracefully empty if entity absent).
- README: sync version badge.
- Version bump 2.7.3 → 2.8.0 (new public endpoint).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 11:45:05 +00:00
18 changed files with 1342 additions and 45 deletions
+7 -7
View File
@@ -1,22 +1,22 @@
{
"models": {
"main": {
"provider": "anthropic",
"modelId": "claude-sonnet-4-20250514",
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 64000,
"temperature": 0.2,
"id": "claude-sonnet-4-20250514"
"id": "sonnet"
},
"research": {
"provider": "anthropic",
"modelId": "sonar",
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 8700,
"temperature": 0.1,
"id": "claude-sonnet-4-20250514"
},
"fallback": {
"provider": "anthropic",
"modelId": "claude-3-7-sonnet-20250219",
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 120000,
"temperature": 0.2
}
+1 -1
View File
@@ -2,5 +2,5 @@
"currentTag": "master",
"lastSwitched": "2026-04-14T06:05:06.044Z",
"branchTagMapping": {},
"migrationNoticeShown": false
"migrationNoticeShown": true
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
# SmartAssistant - עוזר חכם
**גרסה:** 2.7.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
**גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
@@ -457,14 +457,23 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
var $btn = this.$el.find('.sa-chat-send');
$messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
var thinkingText = this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...';
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...') + '</div>');
'<span class="sa-thinking-text">' + this.escapeHtml(thinkingText) + '</span></div>');
$messages.append($loading);
this.scrollToBottom();
$btn.prop('disabled', true);
$input.prop('disabled', true).val('').css('height', 'auto');
// After 60s, swap the spinner text to a "still working" hint so users
// know the longer requests (multi-tool flows) haven't stalled.
var slowHintText = this.translate('SlowHint', 'labels', 'SmartAssistant') ||
'חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל';
var slowHintTimer = setTimeout(function () {
$loading.find('.sa-thinking-text').text(slowHintText);
}, 60000);
var payload = {
message: message,
mode: this.currentMode,
@@ -474,7 +483,8 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
payload.caseId = this.currentCaseId;
}
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 180000}).then(function (response) {
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 600000}).then(function (response) {
clearTimeout(slowHintTimer);
self.conversationId = response.conversationId;
$loading.remove();
@@ -492,6 +502,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
self.scrollToBottom();
$input.prop('disabled', false).focus();
}).catch(function () {
clearTimeout(slowHintTimer);
$loading.remove();
$messages.append('<div class="sa-msg sa-msg-error">' +
self.escapeHtml(self.translate('Error', 'labels', 'SmartAssistant') || 'שגיאה בתקשורת') + '</div>');
@@ -0,0 +1,16 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Exposes the AssistantRule entity over the standard REST API
* (GET/POST/PUT/DELETE /api/v1/AssistantRule).
*
* Without this controller class EspoCRM responds 404 "Controller does not exist"
* even when the entity, object, and acl flags in scopes/AssistantRule.json are
* set. Inheriting from Record gives us the full CRUD surface — list/search,
* read, create, update, delete — with ACL enforcement.
*/
class AssistantRule extends \Espo\Core\Controllers\Record
{
}
@@ -13,6 +13,8 @@ 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\Modules\SmartAssistant\Services\FreeDocumentGenerator;
use Espo\Modules\SmartAssistant\Services\CaseFolderManager;
use Espo\Entities\User;
class SmartAssistant
@@ -217,6 +219,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 +295,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();
@@ -301,4 +386,117 @@ class SmartAssistant
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
}
public function postActionCreateDocument(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$title = $data->title ?? null;
$body = $data->body ?? null;
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
if (empty($title)) {
throw new BadRequest('title is required.');
}
if (empty($body)) {
throw new BadRequest('body is required.');
}
$documentType = $data->documentType ?? 'letter';
$recipient = $data->recipient ?? null;
$generator = $this->injectableFactory->create(FreeDocumentGenerator::class);
return $generator->generate($caseId, $title, $body, $documentType, $recipient);
}
public function postActionWriteToFolder(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$relPath = $data->relPath ?? null;
$contentBase64 = $data->contentBase64 ?? null;
if (empty($caseId)) throw new BadRequest('caseId is required.');
if (empty($relPath)) throw new BadRequest('relPath is required.');
if (!isset($contentBase64)) throw new BadRequest('contentBase64 is required.');
$content = base64_decode((string) $contentBase64, true);
if ($content === false) {
throw new BadRequest('contentBase64 is not valid base64.');
}
$manager = $this->injectableFactory->create(CaseFolderManager::class);
return $manager->writeFile($caseId, $relPath, $content);
}
public function postActionCreateFolder(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$relPath = $data->relPath ?? null;
if (empty($caseId)) throw new BadRequest('caseId is required.');
if (empty($relPath)) throw new BadRequest('relPath is required.');
$manager = $this->injectableFactory->create(CaseFolderManager::class);
return $manager->createFolder($caseId, $relPath);
}
public function postActionRenameItem(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$currentRelPath = $data->currentRelPath ?? null;
$newName = $data->newName ?? null;
if (empty($caseId)) throw new BadRequest('caseId is required.');
if (empty($currentRelPath)) throw new BadRequest('currentRelPath is required.');
if (empty($newName)) throw new BadRequest('newName is required.');
$manager = $this->injectableFactory->create(CaseFolderManager::class);
return $manager->renameItem($caseId, $currentRelPath, $newName);
}
public function postActionMoveItem(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$sourceRelPath = $data->sourceRelPath ?? null;
$targetRelPath = $data->targetRelPath ?? null;
if (empty($caseId)) throw new BadRequest('caseId is required.');
if (empty($sourceRelPath)) throw new BadRequest('sourceRelPath is required.');
if (empty($targetRelPath)) throw new BadRequest('targetRelPath is required.');
$manager = $this->injectableFactory->create(CaseFolderManager::class);
return $manager->moveItem($caseId, $sourceRelPath, $targetRelPath);
}
public function postActionMarkForDeletion(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$fileRelPath = $data->fileRelPath ?? null;
if (empty($caseId)) throw new BadRequest('caseId is required.');
if (empty($fileRelPath)) throw new BadRequest('fileRelPath is required.');
$manager = $this->injectableFactory->create(CaseFolderManager::class);
return $manager->markForDeletion($caseId, $fileRelPath);
}
}
@@ -11,6 +11,7 @@
"No previous conversations": "No previous conversations",
"Ask the assistant...": "Ask the assistant...",
"Thinking": "Thinking...",
"SlowHint": "Still thinking… this one is taking a bit longer because I'm processing multiple sources",
"Error": "Communication error",
"Back": "Back",
"Chat with the assistant": "Chat with the assistant",
@@ -0,0 +1,9 @@
{
"fields": {
"markedForDeletionAt": "סומן למחיקה בתאריך",
"markedForDeletionBy": "סומן למחיקה על ידי"
},
"tooltips": {
"markedForDeletionAt": "מסמך זה סומן למחיקה על ידי שירה ומחכה לאישור ידני של עו\"ד למחיקה הפיזית."
}
}
@@ -11,6 +11,7 @@
"No previous conversations": "אין שיחות קודמות",
"Ask the assistant...": "שאל/י את שירה...",
"Thinking": "חושבת...",
"SlowHint": "חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל",
"Error": "שגיאה בתקשורת",
"Back": "חזרה",
"Chat with the assistant": "שוחח/י עם שירה",
@@ -0,0 +1,19 @@
{
"fields": {
"markedForDeletionAt": {
"type": "datetime",
"readOnly": true,
"tooltip": true
},
"markedForDeletionBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"markedForDeletionBy": {
"type": "belongsTo",
"entity": "User"
}
}
}
@@ -69,11 +69,12 @@ class AlertCalculator
{
$alerts = [];
$now = new \DateTime();
$today = $now->format('Y-m-d');
$warningThreshold = (new \DateTime())->modify("-{$warningDays} days")->format('Y-m-d H:i:s');
$criticalThreshold = (new \DateTime())->modify("-{$criticalDays} days")->format('Y-m-d H:i:s');
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt', 'cNextHearing'])
->where([
'status!=' => self::CLOSED_STATUSES, 'deleted' => false,
'OR' => [
@@ -83,6 +84,17 @@ class AlertCalculator
])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find();
foreach ($cases as $case) {
$status = $case->get('status');
// Suppress inactivity alerts for cases legitimately awaiting court action.
// PendingDecision: nothing the lawyer can do until the court rules.
// PendingHearing: nothing to do if a future hearing is already scheduled.
if ($status === 'PendingDecision') continue;
if ($status === 'PendingHearing') {
$nextHearing = $case->get('cNextHearing');
if ($nextHearing && $nextHearing >= $today) continue;
}
$lastActivity = $case->get('cLastActivityAt') ?? $case->get('createdAt');
if (!$lastActivity) continue;
$daysSince = $now->diff(new \DateTime($lastActivity))->days;
@@ -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,
];
@@ -187,10 +188,20 @@ class CaseContextBuilder
{
$templates = [];
$collection = $this->entityManager->getRDBRepository('DocumentTemplate')
->where(['entityType' => 'Case'])->order('name', 'ASC')->find();
->where([
'targetEntityType' => 'Case',
'isActive' => true,
])
->order('name', 'ASC')
->find();
foreach ($collection as $t) {
$templates[] = ['id' => $t->get('id'), 'name' => $t->get('name')];
$templates[] = [
'id' => $t->get('id'),
'name' => $t->get('name'),
'category' => $t->get('category'),
'description' => $t->get('description'),
];
}
return $templates;
}
@@ -202,13 +213,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 = [];
@@ -0,0 +1,334 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
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\NetworkDocumentService;
/**
* Wraps NetworkDocumentService and constrains every operation to the case's
* own folder. Never exposes a physical delete — only soft-delete via
* markForDeletion().
*/
class CaseFolderManager
{
private const DELETION_FOLDER_NAME = 'מסמכים למחיקה';
private const DELETION_PREFIX = 'למחיקה - ';
public function __construct(
private EntityManager $entityManager,
private InjectableFactory $injectableFactory,
private Log $log,
private User $user
) {}
/**
* @return array{success: bool, path: string, size: int}
*/
public function writeFile(string $caseId, string $relPath, string $content): array
{
$absInsideStorage = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$client = $this->getNds()->getClient();
$parent = $this->dirnameRel($absInsideStorage);
if ($parent !== '') {
$client->createFolderRecursive($parent);
}
$ok = $client->uploadFile($absInsideStorage, $content);
if (!$ok) {
throw new Error("Failed to write file: {$absInsideStorage}");
}
$this->log->info("CaseFolderManager: wrote '{$absInsideStorage}' (case {$caseId})");
return [
'success' => true,
'path' => $absInsideStorage,
'size' => strlen($content),
];
}
/**
* @return array{success: bool, path: string, alreadyExisted: bool}
*/
public function createFolder(string $caseId, string $relPath): array
{
$abs = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$client = $this->getNds()->getClient();
$alreadyExisted = $client->exists($abs);
if (!$alreadyExisted) {
$ok = $client->createFolderRecursive($abs);
if (!$ok) {
throw new Error("Failed to create folder: {$abs}");
}
}
$this->log->info("CaseFolderManager: created folder '{$abs}' (case {$caseId})");
return [
'success' => true,
'path' => $abs,
'alreadyExisted' => $alreadyExisted,
];
}
/**
* @return array{success: bool, oldPath: string, newPath: string}
*/
public function renameItem(string $caseId, string $currentRelPath, string $newName): array
{
$newName = trim($newName);
if ($newName === '' || str_contains($newName, '/') || str_contains($newName, '\\')) {
throw new BadRequest('newName must be a plain file/folder name without slashes.');
}
$absCurrent = $this->resolveSafePath($caseId, $currentRelPath, requireExists: true);
$parent = $this->dirnameRel($absCurrent);
$absNew = $parent === '' ? $newName : ($parent . '/' . $newName);
// Confirm the new path is still inside the case folder.
$this->assertWithinCaseRoot($caseId, $absNew);
$client = $this->getNds()->getClient();
if ($client->exists($absNew)) {
throw new Error("Target already exists: {$absNew}");
}
$ok = $client->move($absCurrent, $absNew);
if (!$ok) {
throw new Error("Failed to rename: {$absCurrent} -> {$absNew}");
}
$this->log->info("CaseFolderManager: renamed '{$absCurrent}' -> '{$absNew}' (case {$caseId})");
return [
'success' => true,
'oldPath' => $absCurrent,
'newPath' => $absNew,
];
}
/**
* @return array{success: bool, oldPath: string, newPath: string}
*/
public function moveItem(string $caseId, string $sourceRelPath, string $targetRelPath): array
{
$absSource = $this->resolveSafePath($caseId, $sourceRelPath, requireExists: true);
$absTarget = $this->resolveSafePath($caseId, $targetRelPath, requireExists: false);
$client = $this->getNds()->getClient();
$parent = $this->dirnameRel($absTarget);
if ($parent !== '' && !$client->exists($parent)) {
$client->createFolderRecursive($parent);
}
if ($client->exists($absTarget)) {
throw new Error("Target already exists: {$absTarget}");
}
$ok = $client->move($absSource, $absTarget);
if (!$ok) {
throw new Error("Failed to move: {$absSource} -> {$absTarget}");
}
$this->log->info("CaseFolderManager: moved '{$absSource}' -> '{$absTarget}' (case {$caseId})");
return [
'success' => true,
'oldPath' => $absSource,
'newPath' => $absTarget,
];
}
/**
* Soft-delete: move file into the case's "מסמכים למחיקה" folder with a
* "למחיקה - " prefix. Never physically deletes. Also updates the linked
* Document entity (if any) so admins can audit pending deletions.
*
* @return array{success: bool, originalPath: string, newPath: string, deletionFolder: string}
*/
public function markForDeletion(string $caseId, string $fileRelPath): array
{
$absSource = $this->resolveSafePath($caseId, $fileRelPath, requireExists: true);
$caseRoot = $this->getCaseRoot($caseId);
$deletionFolder = $caseRoot . '/' . self::DELETION_FOLDER_NAME;
$client = $this->getNds()->getClient();
if (!$client->exists($deletionFolder)) {
$client->createFolderRecursive($deletionFolder);
}
$baseName = basename($absSource);
$targetName = self::DELETION_PREFIX . $baseName;
$absTarget = $deletionFolder . '/' . $targetName;
// Avoid name collisions with already-marked files.
if ($client->exists($absTarget)) {
$stamp = date('Y-m-d_H-i-s');
$targetName = self::DELETION_PREFIX . $stamp . ' - ' . $baseName;
$absTarget = $deletionFolder . '/' . $targetName;
}
$ok = $client->move($absSource, $absTarget);
if (!$ok) {
throw new Error("Failed to mark for deletion: {$absSource}");
}
// Best-effort: update Document entity if we can find one pointing to this file.
$this->touchDocumentForDeletion($absSource, $absTarget);
$this->log->info(
"CaseFolderManager: marked for deletion '{$absSource}' -> '{$absTarget}' " .
"(case {$caseId}, by user {$this->user->getId()})"
);
return [
'success' => true,
'originalPath' => $absSource,
'newPath' => $absTarget,
'deletionFolder' => $deletionFolder,
];
}
// --- internals ---------------------------------------------------------
private function getNds(): NetworkDocumentService
{
return $this->injectableFactory->create(NetworkDocumentService::class);
}
/**
* Return the storage-relative root folder of the case.
*/
private function getCaseRoot(string $caseId): string
{
$root = $this->getNds()->getEntityFolderPath('Case', $caseId);
if (!$root) {
throw new NotFound("No folder is configured for Case {$caseId}.");
}
$root = $this->normalize($root);
if ($root === '') {
throw new Error("Case {$caseId} resolved to an empty folder path; refusing.");
}
return $root;
}
/**
* Normalize + reject traversal, return storage-relative path inside the case folder.
* If $requireExists, ensures the resolved path exists in storage.
*/
private function resolveSafePath(string $caseId, string $relPath, bool $requireExists): string
{
$relPath = trim((string) $relPath);
if ($relPath === '') {
throw new BadRequest('Path is required.');
}
if (str_starts_with($relPath, '/') || str_starts_with($relPath, '\\')) {
throw new BadRequest('Absolute paths are not allowed.');
}
$caseRoot = $this->getCaseRoot($caseId);
// Allow the caller to either pass a path relative to the case root, OR a
// full storage-relative path that already includes the case root.
$normalizedRel = $this->normalize($relPath);
$candidate = str_starts_with($normalizedRel, $caseRoot . '/') || $normalizedRel === $caseRoot
? $normalizedRel
: $this->normalize($caseRoot . '/' . $normalizedRel);
$this->assertWithinCaseRoot($caseId, $candidate);
if ($requireExists) {
$client = $this->getNds()->getClient();
if (!$client->exists($candidate)) {
throw new NotFound("Path not found: {$candidate}");
}
}
return $candidate;
}
private function assertWithinCaseRoot(string $caseId, string $absInStorage): void
{
$caseRoot = $this->getCaseRoot($caseId);
if ($absInStorage !== $caseRoot && !str_starts_with($absInStorage, $caseRoot . '/')) {
throw new Forbidden("Path '{$absInStorage}' is outside the case folder '{$caseRoot}'.");
}
}
/**
* Canonicalize a storage-relative path:
* - convert '\' to '/'
* - collapse repeated '/'
* - resolve '.' segments
* - reject '..' segments (refuses traversal)
* - strip trailing '/'
*/
private function normalize(string $path): string
{
$path = str_replace('\\', '/', $path);
$segments = explode('/', $path);
$out = [];
foreach ($segments as $seg) {
if ($seg === '' || $seg === '.') {
continue;
}
if ($seg === '..') {
throw new Forbidden('Parent-directory traversal is not allowed.');
}
// Block null bytes and other suspicious characters.
if (preg_match('/[\x00-\x1F]/', $seg)) {
throw new BadRequest('Path contains invalid control characters.');
}
$out[] = $seg;
}
return implode('/', $out);
}
private function dirnameRel(string $path): string
{
$pos = strrpos($path, '/');
if ($pos === false) {
return '';
}
return substr($path, 0, $pos);
}
/**
* Update the Document entity (if any) that pointed to this file, so the
* CRM reflects the soft-delete state.
*/
private function touchDocumentForDeletion(string $oldPath, string $newPath): void
{
try {
$doc = $this->entityManager->getRDBRepository('Document')
->where(['networkStoragePath' => $oldPath])
->findOne();
if (!$doc) {
return;
}
$doc->set([
'networkStoragePath' => $newPath,
'markedForDeletionAt' => date('Y-m-d H:i:s'),
'markedForDeletionById' => $this->user->getId(),
]);
$this->entityManager->saveEntity($doc);
} catch (\Throwable $e) {
$this->log->warning(
"CaseFolderManager: could not update Document entity for '{$oldPath}': " . $e->getMessage()
);
}
}
}
@@ -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) {
@@ -253,20 +533,37 @@ class DocumentAnalyzer
private function extractFromDocx(string $tmpFile): ?string
{
if (!class_exists(\PhpOffice\PhpWord\IOFactory::class)) return null;
$phpWord = \PhpOffice\PhpWord\IOFactory::load($tmpFile);
$text = '';
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (method_exists($element, 'getText')) $text .= $element->getText() . "\n";
elseif (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $child) {
if (method_exists($child, 'getText')) $text .= $child->getText() . "\n";
}
// PHPWord (vendor/phpoffice/phpword) ships with the EspoCRM image but
// is NOT in the composer PSR-4 autoload map — class_exists silently
// returns false. Rather than fix the autoloader (rebuild of the image)
// we parse word/document.xml directly. This is also more robust:
// PHPWord's element walker only goes one level deep, missing text in
// tables, hyperlinks, and footnotes.
$zip = new \ZipArchive();
if ($zip->open($tmpFile) !== true) {
$this->log->warning("SmartAssistant: extractFromDocx: not a valid zip: $tmpFile");
return null;
}
$xml = $zip->getFromName('word/document.xml');
$zip->close();
if ($xml === false || $xml === '') {
$this->log->warning("SmartAssistant: extractFromDocx: word/document.xml missing in $tmpFile");
return null;
}
// Each <w:p> is a paragraph; concatenate <w:t> runs inside it.
// `(?:\s[^>]*)?` keeps us from also matching <w:tab> / <w:tbl>.
$paragraphs = [];
if (preg_match_all('#<w:p(?:\s[^>]*)?>(.*?)</w:p>#s', $xml, $pMatches)) {
foreach ($pMatches[1] as $block) {
if (preg_match_all('#<w:t(?:\s[^>]*)?>(.*?)</w:t>#s', $block, $tMatches)) {
$line = trim(implode('', $tMatches[1]));
if ($line !== '') $paragraphs[] = html_entity_decode($line, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
}
}
return $text ?: null;
$text = trim(implode("\n", $paragraphs));
return $text !== '' ? $text : null;
}
private function updateDocumentEntity(string $oldPath, string $newPath): void
@@ -0,0 +1,275 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Exceptions\BadRequest;
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\NetworkDocumentService;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
class FreeDocumentGenerator
{
private const TEMP_DIR = 'data/tmp/';
private const FONT_NAME = 'David';
private const FONT_SIZE = 12;
public function __construct(
private EntityManager $entityManager,
private InjectableFactory $injectableFactory,
private Log $log,
private User $user
) {}
/**
* @return array{success: bool, documentId: string, documentName: string, fileName: string, networkPath: ?string, message: string}
*/
public function generate(
string $caseId,
string $title,
string $body,
string $documentType = 'letter',
?string $recipient = null
): array {
$title = trim($title);
if ($title === '') {
throw new BadRequest('title is required.');
}
if (trim($body) === '') {
throw new BadRequest('body is required.');
}
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) {
throw new NotFound("Case {$caseId} not found.");
}
$docxBinary = $this->buildDocx($title, $body, $recipient);
$sanitizedTitle = $this->sanitizeFileName($title);
$today = date('Y-m-d');
$fileName = "{$today} - {$sanitizedTitle}.docx";
$attachment = $this->entityManager->getNewEntity('Attachment');
$attachment->set([
'name' => $fileName,
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'role' => 'Attachment',
'size' => strlen($docxBinary),
'relatedType' => 'Document',
]);
$this->entityManager->saveEntity($attachment);
file_put_contents('data/upload/' . $attachment->getId(), $docxBinary);
$document = $this->entityManager->getNewEntity('Document');
$document->set([
'name' => $title,
'fileId' => $attachment->getId(),
'fileName' => $fileName,
'assignedUserId' => $this->user->getId(),
]);
$this->entityManager->saveEntity($document);
$this->entityManager->getRDBRepository('Case')
->getRelation($case, 'documents')
->relate($document);
$networkPath = null;
try {
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
if ($nds->isEnabled()) {
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
$networkPath = $result['path'] ?? null;
}
} catch (\Throwable $e) {
// Document is safely persisted in Espo; the network-storage copy is best-effort.
$this->log->warning(
"FreeDocumentGenerator: failed to copy to network storage for Case {$caseId}: " .
$e->getMessage()
);
}
$this->log->info(
"FreeDocumentGenerator: Created '{$title}' (type={$documentType}) for Case {$caseId}"
);
$message = "✅ המסמך \"{$title}\" נוצר בהצלחה וצורף לתיק";
if ($networkPath) {
$message .= " ולתיקיית הרשת";
}
$message .= ".";
return [
'success' => true,
'documentId' => $document->getId(),
'documentName' => $title,
'fileName' => $fileName,
'networkPath' => $networkPath,
'message' => $message,
];
}
private function buildDocx(string $title, string $body, ?string $recipient): string
{
$phpWord = new PhpWord();
$phpWord->setDefaultFontName(self::FONT_NAME);
$phpWord->setDefaultFontSize(self::FONT_SIZE);
$section = $phpWord->addSection([
'marginLeft' => 1440,
'marginRight' => 1440,
'marginTop' => 1440,
'marginBottom' => 1440,
]);
$rtlPara = ['bidi' => true, 'alignment' => 'right'];
$centerPara = ['bidi' => true, 'alignment' => 'center'];
$titleFont = ['name' => self::FONT_NAME, 'size' => 16, 'bold' => true, 'rtl' => true];
$boldFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'bold' => true, 'rtl' => true];
$normalFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
$section->addText($this->formatHebrewDate(new \DateTime()), $normalFont, $rtlPara);
$section->addTextBreak(1);
if ($recipient !== null && trim($recipient) !== '') {
$section->addText('אל: ' . trim($recipient), $boldFont, $rtlPara);
$section->addTextBreak(1);
}
$section->addText($title, $titleFont, $centerPara);
$section->addTextBreak(1);
$this->renderMarkdown($section, $body);
if (!is_dir(self::TEMP_DIR)) {
mkdir(self::TEMP_DIR, 0775, true);
}
$tmpPath = self::TEMP_DIR . 'free_' . uniqid() . '.docx';
$writer = IOFactory::createWriter($phpWord, 'Word2007');
$writer->save($tmpPath);
$content = file_get_contents($tmpPath);
@unlink($tmpPath);
return $content;
}
private function renderMarkdown($section, string $body): void
{
$rtlPara = ['bidi' => true, 'alignment' => 'right'];
$bulletStyle = ['listType' => \PhpOffice\PhpWord\Style\ListItem::TYPE_BULLET_FILLED];
$bulletFont = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
$h1Size = 16; $h2Size = 14; $h3Size = 13;
$body = str_replace(["\r\n", "\r"], "\n", $body);
$lines = explode("\n", $body);
$i = 0;
$n = count($lines);
while ($i < $n) {
$line = $lines[$i];
$trimmed = ltrim($line);
if (trim($line) === '') {
$section->addTextBreak(1);
$i++;
continue;
}
if (preg_match('/^(#{1,3})\s+(.+)$/u', $trimmed, $m)) {
$level = strlen($m[1]);
$size = match ($level) { 1 => $h1Size, 2 => $h2Size, default => $h3Size };
$section->addText(
$m[2],
['name' => self::FONT_NAME, 'size' => $size, 'bold' => true, 'rtl' => true],
$rtlPara
);
$i++;
continue;
}
if (preg_match('/^[\-\*]\s+(.+)$/u', $trimmed)) {
while ($i < $n && preg_match('/^[\-\*]\s+(.+)$/u', ltrim($lines[$i]), $m2)) {
$plain = preg_replace('/(\*\*|\*)/u', '', $m2[1]);
$section->addListItem($plain, 0, $bulletFont, $bulletStyle, $rtlPara);
$i++;
}
continue;
}
$textRun = $section->addTextRun($rtlPara);
$this->addInlineRuns($textRun, $trimmed);
$i++;
}
}
/**
* Parse **bold** and *italic* into multiple PhpWord runs.
*/
private function addInlineRuns($textRun, string $text): void
{
$base = ['name' => self::FONT_NAME, 'size' => self::FONT_SIZE, 'rtl' => true];
$i = 0;
$len = mb_strlen($text);
$buffer = '';
$flush = function () use (&$buffer, $textRun, $base) {
if ($buffer !== '') {
$textRun->addText($buffer, $base);
$buffer = '';
}
};
while ($i < $len) {
$two = mb_substr($text, $i, 2);
$one = mb_substr($text, $i, 1);
if ($two === '**') {
$end = mb_strpos($text, '**', $i + 2);
if ($end !== false) {
$flush();
$inner = mb_substr($text, $i + 2, $end - $i - 2);
$textRun->addText($inner, array_merge($base, ['bold' => true]));
$i = $end + 2;
continue;
}
} elseif ($one === '*') {
$end = mb_strpos($text, '*', $i + 1);
if ($end !== false) {
$flush();
$inner = mb_substr($text, $i + 1, $end - $i - 1);
$textRun->addText($inner, array_merge($base, ['italic' => true]));
$i = $end + 1;
continue;
}
}
$buffer .= $one;
$i++;
}
$flush();
}
private function formatHebrewDate(\DateTime $dt): string
{
$months = [
1 => 'ינואר', 2 => 'פברואר', 3 => 'מרץ', 4 => 'אפריל',
5 => 'מאי', 6 => 'יוני', 7 => 'יולי', 8 => 'אוגוסט',
9 => 'ספטמבר', 10 => 'אוקטובר', 11 => 'נובמבר', 12 => 'דצמבר',
];
$day = (int) $dt->format('j');
$month = $months[(int) $dt->format('n')];
$year = $dt->format('Y');
return "{$day} ב{$month} {$year}";
}
private function sanitizeFileName(string $name): string
{
$name = preg_replace('/[\\/:*?"<>|]/', '', $name);
$name = preg_replace('/\s+/', ' ', $name);
return trim(mb_substr($name, 0, 100));
}
}
@@ -518,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
View File
@@ -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.3",
"version": "2.9.2",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-04-14",
"releaseDate": "2026-05-27",
"php": [
">=8.1"
]