Compare commits

..

72 Commits

Author SHA1 Message Date
chaim c1476d5c52 fix: route document naming through the canonical namer; sanitize LLM-chosen names
Stop self-naming in the document generators and defer to NetworkStorageIntegration's
single namer (rule N1):
- FreeDocumentGenerator and GenericTemplateGenerator no longer bake in a date prefix,
  em-dash or contact name. They set the clean subject, upload explicitly to the case
  folder (the AfterSave hook fires before the Case link exists), then realign the
  Document + Attachment names to the canonical stored name returned by the upload.
- CaseFolderManager sanitizes every model-chosen name server-side: writeFile splits
  dir/basename and de-duplicates via buildStoredFileName; createFolder, renameItem and
  moveItem sanitize their final segment. The LLM can no longer pick a raw filename.

Refs Task Master #7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-03 08:13:46 +00:00
chaim 68b3536084 fix(2.10.4): add missing clientDefs/AssistantRule.json
AssistantRule had no clientDefs file — the same gap that broke CaseMemory
in 2.10.2. Console showed:

  /client/custom/modules/smart-assistant/src/controllers/assistant-rule.js
  Failed to load resource: 404

Adding clientDefs/AssistantRule.json with controller: controllers/record
lets EspoCRM use the default record controller and stops the frontend
from probing for a non-existent module-specific controller file.

(2.10.3 had a force-push race against the auto-built zip, hence the
extra patch version.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:12:18 +00:00
chaim 97f7607b2b fix(2.10.3): expose AssistantRule + CaseMemory in Admin Layout Manager
Two metadata gaps spotted when the user couldn't see Shira entities in
Admin → Layout Manager and got console 404s on /#CaseMemory:

* AssistantRule.json (scopes/) was missing `customizable: true` and
  `importable: false`. Without `customizable: true` EspoCRM hides the
  entity from Admin → Entity Manager → Layouts. Other Shira entities
  already had it; this one was the outlier.

* CaseMemory.json (clientDefs/) didn't exist at all. The Metadata
  endpoint returned an empty object, so the frontend fell through to
  default convention `client/custom/modules/smart-assistant/src/controllers/case-memory.js`
  which doesn't exist → 404 in browser console. Adding clientDefs with
  `controller: controllers/record` + icon + filters lets the standard
  record controller handle the entity without any custom JS file.

Verified on prod after hot-patch:
- Metadata?key=scopes.AssistantRule.customizable → "true"
- Metadata?key=clientDefs.CaseMemory → full object

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:11:53 +00:00
chaim bd1d484e74 fix(2.10.2): AfterInstall must be a no-namespace class
EspoCRM's extension installer (ExtensionManager.php → Base.php:397) does
require_once on scripts/AfterInstall.php and then `new AfterInstall()`
without any namespace prefix. The 2.10.0/2.10.1 version had
`namespace Espo\Modules\SmartAssistant\Scripts;` which resolved at
install time to "Class AfterInstall not found" and aborted the install
with HTTP 500 from KlearBrandingExtension/action/install.

Match the pattern used by GoogleIntegration/scripts/AfterInstall.php:
plain top-level class, no namespace, optional `use` for the Container
type hint. Same behavior (idempotent seed of 7 AssistantPrompt rows
with skipAll), just loadable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:36:42 +00:00
chaim d266380e6d fix(2.10.1): CaseMemory Controller + correct layout path
Two UI-breaking issues discovered after 2.10.0 hot-patch:

* Hitting /#CaseMemory in the navbar returned a 404 because CaseMemory
  was missing a Controller class. The entity used to be accessed only
  through Case relation panels and SmartAssistant action endpoints, so
  the bare REST surface was never wired. Same one-liner fix pattern as
  the AssistantRule controller in 2.9.2.

* Layouts were placed under Resources/metadata/layouts/... but EspoCRM
  expects them at Resources/layouts/... (without the metadata/ prefix).
  Result: every new entity rendered only one column / one row in list
  views because EspoCRM fell back to a generic default layout. Moved
  AssistantPrompt, AssistantRule, AssistantSkill, CaseMemory, UserProfile
  layouts to the canonical path. Verified all five list endpoints now
  serve the configured columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:12:48 +00:00
chaim 1d200fc3f6 fix(installer): seed AssistantPrompt rows with skipAll to bypass beforeSave user-service requirement
The first prod install with v2.10.0 failed silently because saveEntity
invokes beforeSave hooks that require a 'user' service which isn't bound
during the script. Pass skipAll=true so the seed is purely a data insert.

Verified on prod 2026-05-27: all 7 default prompts seeded successfully.
2026-05-27 08:52:55 +00:00
chaim 65faf99e11 feat(2.10.0): Shira management UI — prompts, rules, memory, skills in EspoCRM
Make every text artifact that goes into Shira's system prompt editable from
the EspoCRM admin UI, with code-level defaults kept as a safety floor.

* AssistantRule + CaseMemory: flip `tab: true` so admins can browse/edit from
  the navbar. No longer invisible.

* New entity AssistantPrompt — one row per prompt section (tool_rules,
  writing_style, legal_assistance, personality_case, personality_office,
  other_rules_case, other_rules_office). Edits in the UI override the
  in-code defaults; an inactive or missing row falls back to code.
  Seeded on install with the current Python defaults via AfterInstall.

* New entity UserProfile — replaces /opt/data/profiles/{uid}.md. ACL is
  own/all/no so each lawyer sees their own, admins see all. Injected into
  the prompt's '=== ABOUT THIS USER ===' block.

* New entity AssistantSkill — replaces /opt/data/skills/. Tab-visible.
  Context provider returns metadata only; full body fetched on demand.

* 3 new ContextProviders (AssistantPrompt, UserProfile, AssistantSkill)
  injected into context by SmartAssistantService::chat at the same point
  AssistantRule has been injecting since 2026-05-27.

* 3 new Controller classes (one-liner extends Record) — without these
  EspoCRM returns 404 on the REST endpoint even when scopes/entityDefs
  are correct. Same pattern as the AssistantRule fix in 2.9.2.

* New custom view smart-assistant:views/fields/prompt-editor — monospace
  font, dir=auto for mixed RTL/LTR, taller textarea. Used by all three
  long-text content fields.

* New admin panel 'ניהול שירה' that groups all five surfaces (prompts,
  rules, skills, user profiles, case memory) under one Admin section.

* Hebrew labels for all new scopes added to fa_IR/Global.json.

Paired with shira-hermes (separate commit): prompt_builder.py refactored
to read each section from context['assistantPrompts'] with code defaults
as fallback; save_memory tool now goes through SmartAssistant/action/executeTool
so CaseMemory actually gets populated instead of dropping 'm Notes.

Migration script scripts/migrate_legacy_memory_notes.py in shira-hermes
moves existing 'm Notes into CaseMemory rows (one-shot, idempotent).

Refs Task Master none — schema additions, no destructive changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:33:49 +00:00
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
chaim 405ceb7684 fix(alerts): broaden task detection to include Contact-linked tasks
Tasks with parentType='Contact' were invisible to AlertCalculator,
CaseContextBuilder, and OfficeContextBuilder — all queries only checked
parentType='Case'. This caused false "no preparation task" alerts in
the daily standup even when preparation tasks existed.

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

Refs Task Master #1

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:27:36 +00:00
chaim 7480e7db35 chore: add SmartAssistant v2.5.3 release zip
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:41:58 +00:00
chaim 3c6e54d233 fix: increase chat timeout from 120s to 180s to prevent tool call timeouts
Long operations like generate_initial_report were completing successfully
but the frontend showed "communication error" due to AJAX timeout. Increased
CURL timeout to 180s and frontend AJAX timeout to 180s (3 minutes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:40:57 +00:00
chaim 6c97ba2361 feat: add delete_task tool + expose delete tools to AI gateway
Shira can now delete tasks, meetings, and calls when they're no longer needed.
delete_meeting and delete_call handlers already existed but weren't exposed to the AI.

SmartAssistant v2.5.2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:14:19 +00:00
chaim 94397c809b fix: reorder dashlet tabs — attention first, open cases last
Move "needs attention" tab to the right (first in RTL), make it active
by default, and move "open cases" to the left (last).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:09:50 +00:00
chaim b810c9fcff feat: SmartAssistant v2.5.0 — delete tools + expandable chat panel
Add delete_meeting, delete_call, delete_note tools for Shira.
Add expand/collapse toggle button to the floating chat window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:59:06 +00:00
chaim 0dc26f3b15 feat: SmartAssistant v2.4.0 — create_call tool + behavioral rules system
- Add create_call tool: records phone calls as Call entity (not Meeting)
- Add AssistantRule entity: persistent behavioral rules the AI follows
- Add save_rule tool: AI can save new rules when user requests them
- Add systemInstructions to webhook: call vs meeting guidance, open question tracking
- Add recentCalls to case context for AI awareness
- Add AssistantRuleContextProvider: injects active rules per mode into context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:20:23 +00:00
chaim ca04dd72f8 feat: add plugin tool handler support via metadata lookup
ActionExecutor now checks app.smartAssistant.tools.{name}.handler
metadata for unknown tools before throwing. This allows extensions
to register their own tool handlers without modifying SmartAssistant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 05:08:39 +00:00
chaim 87bc4b6770 feat: SmartAssistant v2.3.0 — document tools + NetworkStorage integration
- Add upload_document, generate_document, merge_documents tools
- Rewrite DocumentAnalyzer to use NetworkStorageIntegration instead of custom WebDAV client
- Delete FileStorage classes (FileStorageInterface, FileStorageFactory, NextCloudFileStorage, WebDavFileStorage)
- Use networkStorageFolderPath instead of nextCloudFolderPath

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:53:27 +00:00
chaim 2fe807b7f9 feat: SmartAssistant v2.2.0 — EspoCRM sends its own credentials to AI Gateway
- EspoCRM sends url + apiKey in every request to the gateway
- New Integration field: espocrmApiKey
- Gateway no longer needs ESPOCRM_URL/ESPOCRM_API_KEY env vars

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 06:46:13 +00:00
chaim 026c32a3f7 chore: bump version to 2.1.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:17:07 +00:00
chaim 2e350e33b9 fix: avatar tight face crop — no gray corners
Inset crop to avoid icon's rounded corners, pure blue background fills entire square.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:16:24 +00:00
chaim 1d7eb19a43 fix: avatar fills full circle + badge outside overflow
- Square avatar image (no circle mask) — CSS border-radius handles clipping
- Badge moved outside .sa-fab to separate wrapper, not clipped by overflow:hidden
- object-fit: cover ensures image fills entire circle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:06:23 +00:00
chaim 0fc53b54b7 chore: bump version to 2.1.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:48:57 +00:00
chaim 4c0bf3a67c fix: circular avatar + visible notification badge
- Avatar image pre-cropped as circle with transparency
- Removed overflow:hidden from FAB so badge is not clipped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:32:13 +00:00
chaim 35c57d36ae feat: SmartAssistant v2.1.0 — full permissions, multi-line input, BiDi tables, Shira avatar
- Shira executes all actions inline (create task, meeting, note) without approval buttons
- Actions work from both case and office mode (auto-resolves case from drill-down)
- Tasks/meetings can be created without a case parent
- Chat input is now multi-line textarea with auto-grow (Shift+Enter for new line)
- Markdown tables render as proper HTML tables with LRM marks for BiDi safety
- Robot icon replaced with Shira's avatar in FAB, chat header, and message bubbles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:21:29 +00:00
chaim 0f77631f75 feat: SmartAssistant v2.0.0 — feminine Hebrew + AI Gateway
- Shira speaks in feminine form (חושבת instead of חושב)
- Neutral verb forms for buttons (הוספת/חיפוש instead of הוסף/חפש)
- Updated fallback texts to reference שירה by name
- Description updated: AI Gateway integration replaces n8n

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:40:48 +00:00
chaim 81fa5f3a08 feat: auto-refresh panels after Shira executes an action
When Shira creates a task, meeting, or other entity, the current
detail view panels now refresh automatically via model.trigger('update-all').
For tasks and meetings, also refreshes the activities/tasks side panels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:53:01 +00:00
chaim cf0e9ad2bc fix: stdClass as array error in ConversationRepository
EspoCRM returns JSON data as stdClass objects, not arrays. Using
array access ($data[$i]['role']) on stdClass causes fatal error.
Fixed by using json_decode(json_encode(...), true) to ensure deep
conversion to associative arrays.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:33:25 +00:00
chaim 07df19b412 fix: stream notes showing empty content - add data() method
SmartRequest and SmartAction note views were missing data() method,
so {{post}} in the template resolved to empty string. Added proper
data() with escapeString formatting, consistent with SmartResponse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:09:05 +00:00
chaim b9542e3980 fix: add missing var self = this in showDetail()
self was resolving to window.self instead of the View instance
inside the forEach callback, causing self.escapeString to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:05:30 +00:00
chaim 4f5b6be884 fix: register stream note views in clientDefs.Note.itemViews
EspoCRM resolves note type views via clientDefs.Note.itemViews metadata.
Without this mapping, it looked for SmartRequest/SmartResponse/SmartAction
in the core views path instead of the module path, causing 404 errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:40:48 +00:00
chaim b8e8c01da5 fix: use self.escapeString() for consistency with EspoCRM API
Replace jQuery text escaping with EspoCRM's built-in View.escapeString()
to maintain consistency with the rest of the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:26:19 +00:00
chaim 62004360e8 fix: replace Espo.Utils.escapeString with jQuery text escaping
Espo.Utils.escapeString does not exist in EspoCRM 8.x, causing
TypeError crash when clicking dashlet cards. Use jQuery .text().html()
for XSS-safe HTML escaping instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:53:47 +00:00
chaim f616065585 fix: dashlet detail shows actual items instead of empty alerts
Cards "תיקים פתוחים" and "דיונים השבוע" showed counts but clicking
revealed "אין פריטים להצגה" because card counts came from direct DB
queries while detail filtered from alerts with stricter criteria.

Added getOpenCaseItems() and getUpcomingHearingItems() to AlertCalculator,
included them in summary API response, and wired dashlet to use them.

Also: renamed extension to SmartAssistant, title to "שירה — עוזרת אישית".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:25:31 +00:00
chaim 1eb856b178 feat: rename assistant to שירה (Shira) 2026-03-31 23:12:31 +00:00
chaim c9d08aa1b9 feat: rename assistant to שירה (Shira) 2026-03-31 23:12:29 +00:00
chaim 233f56eccf feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:59 +00:00
chaim 0157b31249 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:33 +00:00
chaim 49c42e3d20 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:29 +00:00
chaim 4dad65f578 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:10:22 +00:00
chaim f116611c73 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:09:16 +00:00
chaim ff2388e7e1 feat: add status endpoint, hide FAB/dashlet when integration disabled 2026-03-31 23:09:10 +00:00
chaim 92c3cd2cdc release: v1.0.7 2026-03-31 23:01:33 +00:00
chaim 3102bcff77 feat: clickable dashlet cards with detail drill-down 2026-03-31 23:01:19 +00:00
chaim f5c60f4272 release: v1.0.6 2026-03-31 22:56:28 +00:00
chaim 21c3e33635 fix: add getColor to dashlet for EspoCRM v9 compatibility 2026-03-31 22:56:08 +00:00
chaim ffbb648dd5 release: v1.0.5 2026-03-31 22:53:23 +00:00
chaim f915faafdc fix: add getActionItemDataList to dashlet for EspoCRM v9 compatibility 2026-03-31 22:53:10 +00:00
chaim b9abe5f921 release: v1.0.4 — add AssistantConversation entity 2026-03-31 20:33:21 +00:00
chaim a3801deb55 feat: add AssistantConversation i18n (fa_IR/Hebrew) 2026-03-31 20:33:05 +00:00
chaim 300cc58a97 feat: add AssistantConversation i18n (en_US) 2026-03-31 20:33:01 +00:00
chaim c1ea2b7868 feat: add AssistantConversation scope definition 2026-03-31 20:32:53 +00:00
chaim 184cd19285 feat: add AssistantConversation entity definition for chat persistence 2026-03-31 20:32:49 +00:00
chaim 2c34d56c64 release: v1.0.3 2026-03-29 21:34:38 +00:00
chaim d5c8a60193 fix: add clientModule to module.json for client-side template resolution
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:34:18 +00:00
92 changed files with 4891 additions and 556 deletions
+2
View File
@@ -0,0 +1,2 @@
*.zip
build/
+46
View File
@@ -0,0 +1,46 @@
{
"models": {
"main": {
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 64000,
"temperature": 0.2,
"id": "sonnet"
},
"research": {
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 8700,
"temperature": 0.1,
"id": "claude-sonnet-4-20250514"
},
"fallback": {
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 120000,
"temperature": 0.2
}
},
"global": {
"logLevel": "info",
"debug": false,
"defaultNumTasks": 10,
"defaultSubtasks": 5,
"defaultPriority": "medium",
"projectName": "Task Master",
"ollamaBaseURL": "http://localhost:11434/api",
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
"responseLanguage": "English",
"enableCodebaseAnalysis": true,
"enableProxy": false,
"anonymousTelemetry": true,
"userId": "1234567890"
},
"claudeCode": {},
"codexCli": {},
"grokCli": {
"timeout": 120000,
"workingDirectory": null,
"defaultModel": "grok-4-latest"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"currentTag": "master",
"lastSwitched": "2026-04-14T06:05:06.044Z",
"branchTagMapping": {},
"migrationNoticeShown": true
}
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
# SmartAssistant — פרומפט פריסה לסביבת פיתוח
> **העתק את הפרומפט הבא כהודעה פותחת בשיחה חדשה של Claude Code, מתוך תיקיית הפרויקט.**
---
```
## הקשר
אני עובד על הרחבת SmartAssistant ל-EspoCRM — עוזר AI צף למשרד עורכי דין.
הקוד מוכן (v1.0.3), וצריך להשלים את הפריסה בסביבת הפיתוח.
קרא את DEPLOYMENT_REVIEW.md לסיכום מלא של מה בוצע, מה נבדק, ומה נשאר.
## סביבת הפיתוח
- EspoCRM Dev: https://espocrm.dev.marcus-law.co.il (Coolify UUID: c591uqmu49qa5kh7t2z7a2mp)
- n8n: https://n8n.dev.marcus-law.co.il
- Gitea: https://gitea.dev.marcus-law.co.il (repo: espocrm-extensions/SmartAssistant)
- קרדנשיאלים ב-Infisical (תיקיות /espocrm, /n8n)
## המשימות — שלב 1: הכנה לפריסה
בצע את המשימות הבאות בסדר. לפני כל שלב — עדכן אותי מה אתה הולך לעשות וחכה לאישור.
### 1. בדיקת תאימות סביבה
- בדוק גרסת EspoCRM בסביבת dev (צריך >= 8.0.0)
- בדוק גרסת PHP (צריך >= 8.1)
- בדוק שאין שאריות של מודולים ישנים (CrmAssistant / OfficeAssistant הוסרו מהפרויקט)
### 2. בדיקת אנטיטי AssistantConversation
- המודול משתמש באנטיטי AssistantConversation לשמירת שיחות (ראה ConversationRepository.php)
- בדוק אם האנטיטי הזה קיים ב-EspoCRM Dev
- אם לא קיים — צריך ליצור אותו. בדוק את הקוד ב-ConversationRepository.php כדי להבין אילו שדות נדרשים, והכן entityDefs JSON
### 3. בניית n8n Workflow לטיפול בצ'אט
זה הרכיב הקריטי החסר. המודול שולח webhook ל-n8n ומצפה לתשובה.
**Webhook Payload (נשלח מ-EspoCRM):**
```json
{
"message": "הודעת המשתמש",
"context": { ... הקשר תיק/משרד מלא ... },
"conversationId": "conv_xxx",
"conversationHistory": [{ "role": "user/assistant", "content": "..." }],
"mode": "office|case"
}
```
**Response נדרש (חזרה ל-EspoCRM):**
```json
{
"text": "תשובת העוזר",
"actions": [
{
"tool": "create_task|add_note|change_status|create_meeting|schedule_hearing|list_documents|analyze_document|save_memory|rename_document",
"params": { ... },
"actionId": "action_xxx",
"displayText": "תיאור הפעולה למשתמש"
}
]
}
```
**הפעולות שהעוזר יכול לבקש:**
| כלי | פרמטרים | דורש אישור |
|------|----------|------------|
| create_task | name, dateEnd, priority, description | כן |
| add_note | text | כן |
| change_status | status | כן |
| create_meeting | name, dateStart, dateEnd, description | כן |
| create_call | name, dateStart, dateEnd, description, direction, status | כן |
| schedule_hearing | date, description | כן |
| list_documents | — | לא (מיידי) |
| analyze_document | documentId | כן |
| save_memory | name, content, category, importance | לא (מיידי) |
| save_rule | name, rule, scope (global/case/office) | לא (מיידי) |
| rename_document | documentId, newName | כן |
**דרישות ל-Workflow:**
- Webhook trigger שמקבל POST
- שליחת ההודעה + הקשר ל-LLM (Claude/GPT)
- System prompt שמכיר את הכלים הזמינים ואת פורמט התשובה
- החזרת JSON תקין בפורמט למעלה
- שמירת היסטוריית שיחה (conversationId)
- טיפול בשני המצבים (office/case) עם system prompts מתאימים
### 4. הגדרת Integration ב-EspoCRM
אחרי שה-workflow מוכן:
- Admin > Integrations > Smart Assistant
- webhookUrl = כתובת ה-webhook מ-n8n
- apiKey = מפתח אימות (אם הגדרנו)
- שאר הערכים: ברירת מחדל
### 5. בדיקות ידניות ראשונות
אחרי הפריסה:
- [ ] האם ה-FAB (כפתור צ'אט) מופיע?
- [ ] האם ניתן לשלוח הודעה במצב משרד ולקבל תשובה?
- [ ] האם ניתן לנווט לתיק ולשלוח הודעה במצב תיק?
- [ ] האם פעולות מוצגות לאישור?
- [ ] האם ההתראות מופיעות ב-navbar?
- [ ] האם הדשלט מציג נתונים?
## הנחיות
- השתמש ב-MCP tools (Coolify, Gitea, n8n, Infisical) לכל פעולה
- אל תבצע פעולות הרסניות בלי אישור שלי
- אם נתקלת בבעיה — תאר מה קרה ומה ניסית, ותשאל מה לעשות
- עדכן את DEPLOYMENT_REVIEW.md עם כל ממצא חדש
```
---
## הערות שימוש
- **היכן להריץ:** בתיקיית `/home/chaim/espocrm-extensions/SmartAssistant`
- **MCP נדרשים:** coolify-dev, gitea-dev, n8n-dev, infisical
- **משך משוער:** השלב הקריטי הוא בניית ה-n8n workflow (#3) — זה ידרוש עיצוב system prompts ובדיקות
- **מה הפרומפט לא כולל:** הסרת מודולים ישנים (תלוי בממצאי שלב 1), הוספת תרגום he_IL (שיפור עתידי)
+454
View File
@@ -0,0 +1,454 @@
# SmartAssistant v1.0.3 — סיכום מנהלים לפריסה בפיתוח
**תאריך:** 2026-03-31
**גרסה:** 1.0.3 | **תאריך שחרור:** 2026-03-29
**סביבת יעד:** EspoCRM Development (https://espocrm.dev.marcus-law.co.il)
**מחבר:** klear | **דרישות:** EspoCRM >= 8.0.0, PHP >= 8.1
---
## 1. תיאור כללי
SmartAssistant הוא מודול עוזר AI מאוחד למשרד עורכי דין.
המודול מספק:
- **ממשק צ'אט צף** (Floating Action Button) בכל דפי המערכת
- **מצב משרד** — סקירה כללית, התראות, סטטיסטיקות עומס עבודה
- **מצב תיק** — סיוע מעמיק בתיק ספציפי עם הקשר מלא
- **מערכת זיכרון תיק** (CaseMemory) — מאגר ידע מובנה לכל תיק ב-7 קטגוריות
- **ביצוע פעולות באישור** — יצירת משימות, פתקים, דיונים, שינוי סטטוס
- **אינטגרציה עם Stream** — כל אינטראקציה מופיעה בציר הזמן של התיק
- **התראות בסרגל ניווט** — תיקים לא פעילים, משימות באיחור, דיונים קרובים
---
## 2. מה בוצע
### 2.1 Backend (PHP) — 16 קבצים
| רכיב | קובץ | תפקיד |
|-------|------|--------|
| Controller | `Controllers/SmartAssistant.php` | 10 נקודות קצה API (chat, execute, summary, alerts, history, conversations, conversationMessages, searchHistory, caseMemory, saveMemory) |
| שירות ראשי | `Services/SmartAssistantService.php` | תזמור מלא: בניית הקשר → קריאת webhook → טיפול בפעולות → שמירת שיחה |
| בונה הקשר תיק | `Services/CaseContextBuilder.php` | אוסף: נתוני תיק, אנשי קשר, משימות פתוחות, דיונים קרובים, פתקים אחרונים, זיכרון |
| בונה הקשר משרד | `Services/OfficeContextBuilder.php` | סטטיסטיקות כלליות, התראות, עומס עבודה לפי משתמש, drill-down לתיקים |
| מחשבון התראות | `Services/AlertCalculator.php` | 6 סוגי התראות: תיקים לא פעילים (14/30 יום), משימות באיחור, דיונים קרובים, תיקים ללא שיוך, משימות בעדיפות גבוהה |
| מבצע פעולות | `Services/ActionExecutor.php` | 8 כלים: create_task, add_note, change_status, create_meeting, schedule_hearing, list_documents, analyze_document, save_memory, rename_document |
| שירות זיכרון | `Services/CaseMemoryService.php` | CRUD מלא לאנטיטי CaseMemory |
| ספק הקשר זיכרון | `Services/CaseMemoryContextProvider.php` | הזרקת זיכרון מוצמד/חשוב להקשר AI |
| מאגר שיחות | `Services/ConversationRepository.php` | שמירה ואחזור שיחות ב-AssistantConversation |
| מנתח מסמכים | `Services/DocumentAnalyzer.php` | סריקת מסמכים דרך FileStorage |
| Hook - שינוי תיק | `Hooks/Case/CaseFieldChangeMemory.php` | זיכרון אוטומטי בשינוי: סטטוס, שופט, בית משפט, מועד דיון |
| Hook - דיון | `Hooks/Meeting/HearingScheduledMemory.php` | זיכרון אוטומטי בקביעת דיון חדש |
| FileStorage | `Classes/FileStorage/*` | 4 קבצים: Interface, Factory, WebDav, NextCloud |
### 2.2 Frontend (JavaScript) — 9 קבצים
| רכיב | תפקיד |
|-------|--------|
| `floating-chat.js` | ממשק צ'אט צף (~1500 שורות): שליחת הודעות, היסטוריה, זיכרון, אישור/דחיית פעולות, עיצוב RTL, Material Design |
| `site/navbar.js` | אייקון התראות בסרגל ניווט עם badge מספרי |
| `dashlets/smart-assistant.js` | דשלט לדשבורד: סיכום מדדים + התראות אחרונות |
| `dashlets/options/smart-assistant.js` | הגדרות דשלט |
| `case/modals/add-memory.js` | מודל להוספת זיכרון ידנית לתיק |
| `stream/notes/smart-request.js` | תצוגת הודעת משתמש ב-Stream |
| `stream/notes/smart-response.js` | תצוגת תשובת עוזר ב-Stream |
| `stream/notes/smart-action.js` | תצוגת פעולה שבוצעה ב-Stream |
### 2.3 מטא-נתונים וקונפיגורציה — 17 קבצי JSON
- **routes.json** — 10 נתיבי API
- **module.json** — רישום מודול עם clientModule
- **entityDefs/** — CaseMemory (אנטיטי חדש, 15 שדות, 4 אינדקסים), Case (שדה cNextHearing), Note (סוגי stream חדשים)
- **scopes/CaseMemory.json** — הגדרות scope
- **layouts/CaseMemory/** — 4 layouts (list, detail, listSmall, detailSmall)
- **dashlets/SmartAssistant.json** — הגדרת דשלט
- **integrations/SmartAssistant.json** — 6 שדות הגדרה (webhookUrl, apiKey, maxMessagesPerHour, inactivityWarningDays, inactivityCriticalDays, upcomingHearingDays)
- **clientDefs/App.json** — רישום navbar
### 2.4 תרגומים — 10 קבצים
- **en_US** — 5 קבצי תרגום (SmartAssistant, CaseMemory, Case, Global, Integration)
- **fa_IR** — 5 קבצי תרגום מקבילים (עברית/פרסית)
### 2.5 בנייה והפצה
- **build.sh** — סקריפט בנייה: קורא גרסה מ-manifest.json, מעדכן README, יוצר ZIP
- **composer.json** — הגדרת חבילה ל-Composer Registry
- **4 גרסאות ZIP** — 1.0.0, 1.0.1, 1.0.2, 1.0.3
---
## 3. היסטוריית גרסאות ותיקוני באגים
### v1.0.0 — `a148e0f`
- **פיצ'ר ראשוני** — מודול SmartAssistant מלא עם case memory
### v1.0.0 → v1.0.1 — תיקונים
| Commit | תקלה | תיקון |
|--------|-------|--------|
| `0cdbf6d` | דשלט ירש מ-DashletView שלא קיים בגרסאות חדשות | שונה ל-base View + הוספת getTitle() |
| `ffd966a` | לא היה composer.json לפרסום אוטומטי | נוסף composer.json |
| `bd1cd17` | גרסה ב-README לא מתעדכנת אוטומטית | build.sh מעדכן README |
| `95ca8a0` | חסר תיעוד מקיף | נוסף README מלא בעברית |
| `fbe4f82` | נתיבי client לא תואמים לפורמט מודולים של EspoCRM | מיגרציה לנתיבי מודול תקניים |
### v1.0.1 → v1.0.2 — `f380542`
| תקלה | תיקון |
|-------|--------|
| נתיבי CSS ו-script שגויים ב-app/client.json metadata | תיקון הנתיבים ל-format הנכון |
### v1.0.2 → v1.0.3 — `d5c8a60`
| תקלה | תיקון |
|-------|--------|
| EspoCRM לא מצליח לרזולב templates בצד לקוח כי חסר clientModule | הוספת `clientModule` ל-module.json |
### סיכום תקלות שנמצאו ותוקנו
| # | תקלה | חומרה | סטטוס |
|---|-------|--------|--------|
| 1 | דשלט ירש מ-class לא קיים | גבוהה | תוקן v1.0.1 |
| 2 | נתיבי client לא תקניים לפורמט מודולים | גבוהה | תוקן v1.0.1 |
| 3 | נתיבי CSS/script שגויים ב-metadata | בינונית | תוקן v1.0.2 |
| 4 | חסר clientModule לרזולוציית templates | גבוהה | תוקן v1.0.3 |
---
## 4. מה נבדק
### נבדק ידנית (על סמך תיקוני הבאגים)
- רישום מודול וטעינת client-side assets
- תצוגת דשלט בדשבורד
- רזולוציית templates בצד לקוח
- נתיבי metadata (CSS, scripts)
### לא נבדק (אין קבצי טסט בפרויקט)
- **אין בדיקות יחידה (unit tests)** לקוד PHP
- **אין בדיקות אינטגרציה** ל-API endpoints
- **אין בדיקות E2E** לממשק הצ'אט
- **אין CI/CD pipeline** (אין GitHub Actions / Gitea Actions)
---
## 5. דרישות קדם לפריסה בפיתוח
| דרישה | סטטוס | פירוט |
|--------|--------|--------|
| EspoCRM >= 8.0.0 | לאמת | לוודא גרסה בסביבת dev |
| PHP >= 8.1 | לאמת | לוודא גרסה בשרת |
| Webhook URL | **נדרש הגדרה** | יש להגדיר n8n workflow שיקבל את הפניות |
| API Key | אופציונלי | לאימות webhook |
| AssistantConversation entity | לאמת | נדרש שהאנטיטי קיים |
| WebDAV/NextCloud | אופציונלי | רק לפיצ'ר ניתוח מסמכים |
---
## 6. מה נשאר לעשות
### קריטי לפני פריסה
| # | משימה | עדיפות |
|---|--------|---------|
| 1 | **הגדרת n8n workflow** לקבלת webhook מהעוזר (chat endpoint) | קריטי |
| 2 | **בדיקת תאימות** עם גרסת EspoCRM בסביבת dev | קריטי |
| 3 | **לוודא שאין שאריות** של מודולים ישנים בסביבת dev | קריטי |
| 4 | **הגדרת Integration** — webhookUrl + apiKey ב-Admin > Integrations | קריטי |
| 5 | **בדיקה שאנטיטי AssistantConversation קיים** או שצריך ליצור אותו | קריטי |
### חשוב אחרי פריסה
| # | משימה | עדיפות |
|---|--------|---------|
| 6 | **בדיקות ידניות מקיפות** — צ'אט במצב משרד ומצב תיק | גבוהה |
| 7 | **בדיקת פעולות** — create_task, add_note, schedule_hearing, change_status | גבוהה |
| 8 | **בדיקת hooks** — שינוי סטטוס תיק → זיכרון אוטומטי | גבוהה |
| 9 | **בדיקת התראות** — navbar badge + דשלט | בינונית |
| 10 | **בדיקת היסטוריה** — שמירה ואחזור שיחות, חיפוש | בינונית |
### שיפורים עתידיים
| # | משימה | עדיפות |
|---|--------|---------|
| 11 | כתיבת בדיקות יחידה ל-PHP | בינונית |
| 12 | הוספת CI/CD pipeline (Gitea Actions) | בינונית |
| 13 | הוספת תרגום עברי (he_IL) — כרגע en_US + fa_IR בלבד | בינונית |
| 14 | Rate limiting בצד client (כרגע רק בצד server) | נמוכה |
| 15 | הצפנת שיחות | נמוכה |
---
## 7. תיעוד מפורט של הקוד והקבצים
### 7.1 ארכיטקטורה כללית
```
┌─────────────────────────────────────────────────┐
│ Frontend (JS) │
│ floating-chat.js ←→ navbar.js ←→ dashlet.js │
│ │ │ │ │
│ [צ'אט צף] [התראות navbar] [דשלט דשבורד] │
└───────────────────────┬─────────────────────────┘
│ REST API (10 endpoints)
┌───────────────────────┴─────────────────────────┐
│ Controller/SmartAssistant.php │
│ chat | execute | summary | alerts | history ... │
└───────────────────────┬─────────────────────────┘
┌───────────────────────┴─────────────────────────┐
│ SmartAssistantService.php │
│ תזמור: הקשר → webhook → פעולות → שמירה │
├──────────┬──────────┬──────────┬─────────────────┤
│ Context │ Action │ Memory │ Conversation │
│ Builders │ Executor │ Service │ Repository │
├──────────┴──────────┴──────────┴─────────────────┤
│ CaseContext │ OfficeContext │ AlertCalculator │
│ Builder │ Builder │ │
└───────────────┴──────────────┴───────────────────┘
┌─────────┴──────────┐
│ External n8n │
│ Webhook (AI) │
└────────────────────┘
```
### 7.2 זרימת צ'אט (Chat Flow)
```
1. משתמש שולח הודעה ← floating-chat.js
2. POST /SmartAssistant/action/chat { message, caseId?, conversationId }
3. Controller → SmartAssistantService::chat()
4. בניית הקשר:
├─ מצב תיק → CaseContextBuilder (נתוני תיק + אנשי קשר + משימות + דיונים + זיכרון)
└─ מצב משרד → OfficeContextBuilder (סטטיסטיקות + התראות)
5. קריאת webhook חיצוני (n8n) עם: message + context + history
6. קבלת תשובה: { text, actions[] }
7. פעולות מיידיות (list_documents, save_memory) → ביצוע אוטומטי
8. פעולות הדורשות אישור → שמירה + החזרה למשתמש
9. שמירת שיחה ב-ConversationRepository
10. שמירת הודעות ב-Stream (smart-request + smart-response notes)
```
### 7.3 פירוט קבצי Backend
#### `Controllers/SmartAssistant.php` (168 שורות)
- **actionChat()** — מקבל message, caseId, conversationId. בודק ACL ל-Case. קורא ל-SmartAssistantService::chat()
- **actionExecute()** — מקבל actionId, conversationId. מבצע פעולה שאושרה
- **actionSummary()** — מחזיר סיכום משרדי + התראות
- **actionAlerts()** — רשימת התראות מלאה
- **actionHistory()** — היסטוריית שיחות (לפי caseId או אחרונה)
- **actionConversations()** — רשימת שיחות אחרונות
- **actionConversationMessages()** — הודעות שיחה ספציפית
- **actionSearchHistory()** — חיפוש חופשי בשיחות
- **actionCaseMemory()** — זיכרון תיק לפי קטגוריה
- **actionSaveMemory()** — שמירת זיכרון ידנית
#### `Services/SmartAssistantService.php` (~500 שורות)
הקובץ המרכזי ביותר. אחראי על:
- **chat()** — הזרימה המלאה: בניית הקשר, קריאת webhook, עיבוד פעולות, שמירת שיחה
- **callWebhook()** — קריאת CURL ל-n8n עם timeout, headers (X-Api-Key), JSON payload
- **processActions()** — מיון פעולות: מיידיות vs. דורשות אישור
- **executeAction()** — ביצוע פעולה שאושרה דרך ActionExecutor
- **getSummary()** / **getAlerts()** — מצב משרד
- **getConversations()** / **getHistory()** / **searchHistory()** — ניהול שיחות
#### `Services/CaseContextBuilder.php` (~150 שורות)
בונה הקשר עשיר לתיק:
```php
[
'case' => [...], // שדות תיק מרכזיים
'contacts' => [...], // אנשי קשר מקושרים
'openTasks' => [...], // משימות פתוחות (עד 10)
'upcomingMeetings' => [...], // דיונים קרובים (עד 5)
'recentNotes' => [...], // פתקים אחרונים (עד 10)
'memories' => [...], // זיכרון מוצמד + חשוב
'stats' => [...] // מספרי סיכום
]
```
#### `Services/OfficeContextBuilder.php` (~80 שורות)
בונה סיכום משרדי:
```php
[
'totalActiveCases' => int,
'casesByStatus' => [...],
'overdueTasks' => int,
'upcomingHearings' => int,
'userWorkload' => [...], // עומס לפי משתמש
'alerts' => [...]
]
```
#### `Services/ActionExecutor.php` (~250 שורות)
מבצע 8 סוגי פעולות:
| כלי | פרמטרים | פעולה |
|------|----------|--------|
| `create_task` | name, dateEnd, priority, description | יצירת Task מקושר לתיק |
| `add_note` | text | הוספת Post ב-Stream של התיק |
| `change_status` | status | שינוי סטטוס (עם validation מול enum) |
| `create_meeting` | name, dateStart, dateEnd, description | יצירת Meeting |
| `schedule_hearing` | date, description | עדכון cNextHearing + יצירת Meeting |
| `list_documents` | — | רשימת מסמכים (read-only, ללא אישור) |
| `analyze_document` | documentId | ניתוח מסמך דרך DocumentAnalyzer |
| `save_memory` | name, content, category, importance | שמירת CaseMemory (ללא אישור) |
| `rename_document` | documentId, newName | שינוי שם קובץ |
#### `Services/AlertCalculator.php` (~200 שורות)
6 סוגי התראות:
| סוג | לוגיקה | חומרה |
|------|--------|--------|
| תיק לא פעיל (אזהרה) | modifiedAt < now - 14 יום | warning |
| תיק לא פעיל (קריטי) | modifiedAt < now - 30 יום | critical |
| משימה באיחור | dateEnd < now, status != Completed | critical |
| דיון קרוב | dateStart < now + 7 ימים | warning |
| תיק ללא שיוך | status = New, assignedUserId = null | warning |
| משימה בעדיפות גבוהה | priority = Urgent, dateEnd < now + 5 ימים | warning |
#### `Services/CaseMemoryService.php` (106 שורות)
CRUD פשוט:
- **getMemories()** — לפי caseId, עם סינון אופציונלי לפי category
- **saveMemory()** — יצירת רשומת CaseMemory
- **updateMemory()** — עדכון
- **deleteMemory()** — מחיקה רכה
#### `Services/CaseMemoryContextProvider.php`
מזריק זיכרון להקשר AI:
- זיכרון מוצמד (isPinned = true)
- זיכרון בחשיבות high/critical
- ממוין לפי importance DESC, modifiedAt DESC
#### `Services/ConversationRepository.php` (~120 שורות)
- **save()** — שמירת שיחה באנטיטי AssistantConversation
- **getHistory()** — אחזור לפי caseId או אחרון
- **getConversations()** — רשימה ממוינת (עד limit)
- **getMessages()** — הודעות שיחה ספציפית
- **search()** — חיפוש חופשי בתוכן
#### Hooks
- **CaseFieldChangeMemory** — afterSave hook על Case. בודק אם שדות key השתנו (status, cJudge, cCourt, cNextHearing). אם כן, יוצר CaseMemory אוטומטי מסוג `auto` בקטגוריה המתאימה.
- **HearingScheduledMemory** — afterSave hook על Meeting. אם סוג Meeting = "Hearing", יוצר CaseMemory בקטגוריה timeline.
### 7.4 פירוט קבצי Frontend
#### `floating-chat.js` (~1500 שורות)
הקובץ הגדול ביותר. View יחיד שמנהל:
- **תצוגת צ'אט** — קלט הודעה, בועות הודעות (משתמש/עוזר), פעולות בהמתנה
- **תצוגת היסטוריה** — רשימת שיחות קודמות, טעינת שיחה
- **תצוגת זיכרון** — tabs לפי קטגוריה, הוספה/מחיקה/הצמדה
- **RTL layout** — כל ה-CSS מותאם לעברית
- **עיצוב Material Design** — צבע ראשי #5c6bc0 (אינדיגו), אנימציות slide-up
- **רספונסיבי** — breakpoint ב-480px למובייל
- **Inline CSS** — כל הסגנונות מוטמעים בקובץ (לא CSS חיצוני)
#### `site/navbar.js`
- מרחיב את navbar הרגיל של EspoCRM
- מוסיף אייקון התראות עם badge מספרי
- קורא ל-GET /SmartAssistant/action/alerts ומציג ספירה
- רענון אוטומטי כל X דקות
#### `dashlets/smart-assistant.js`
- דשלט לדשבורד הראשי
- מציג: סה"כ תיקים פעילים, משימות באיחור, דיונים קרובים
- רשימת 5 התראות אחרונות
- ACL scope = Case
#### Stream Notes (3 קבצים)
- **smart-request.js** — מציג הודעת משתמש עם אייקון שאלה
- **smart-response.js** — מציג תשובת עוזר עם markdown rendering
- **smart-action.js** — מציג פעולה שבוצעה עם סטטוס (approved/rejected/executed)
#### `case/modals/add-memory.js`
- מודל Espo.Views.Modal סטנדרטי
- שדות: name, category (dropdown), content (textarea), importance (dropdown)
- POST ל-/SmartAssistant/action/saveMemory
### 7.5 אנטיטי CaseMemory — מבנה מלא
```
שדות:
├── name (varchar, required) — כותרת הזיכרון
├── case (link → Case, required) — תיק משויך
├── category (enum) — key_facts | strategy | decisions | contacts_notes | timeline | documents_notes | billing_notes
├── content (text, required) — תוכן מלא
├── source (enum, readOnly) — manual | assistant | auto
├── importance (enum) — low | normal | high | critical
├── isPinned (bool) — הצמדה לגישה מהירה
├── isArchived (bool) — הסתרת רשומות ישנות
├── sourceEntityType (varchar, readOnly) — סוג מקור (למעקב)
├── sourceEntityId (varchar, readOnly) — ID מקור
├── assignedUser (link → User)
├── teams (linkMultiple → Team)
├── createdAt, modifiedAt (datetime)
└── createdBy, modifiedBy (link → User)
אינדקסים:
├── caseId + deleted
├── caseId + category + deleted
├── caseId + isPinned + deleted
└── importance + deleted
```
### 7.6 הגדרות אינטגרציה
**Admin > Integrations > Smart Assistant:**
| שדה | סוג | ברירת מחדל | תיאור |
|------|------|------------|--------|
| webhookUrl | url | — | כתובת webhook (n8n) |
| apiKey | varchar(255) | — | מפתח אימות, נשלח כ-X-Api-Key header |
| maxMessagesPerHour | int | 30 | הגבלת קצב |
| inactivityWarningDays | int | 14 | סף אזהרה לחוסר פעילות |
| inactivityCriticalDays | int | 30 | סף קריטי |
| upcomingHearingDays | int | 7 | חלון דיונים קרובים |
### 7.7 פורמט Webhook
**Request (נשלח ל-n8n):**
```json
{
"message": "מה המצב של תיק כהן?",
"context": {
"case": { "name": "כהן נ' לוי", "status": "Active", ... },
"contacts": [...],
"openTasks": [...],
"memories": [...]
},
"conversationId": "conv_abc123",
"conversationHistory": [
{ "role": "user", "content": "..." },
{ "role": "assistant", "content": "..." }
],
"mode": "case"
}
```
**Response (נדרש מ-n8n):**
```json
{
"text": "תיק כהן פעיל. יש 3 משימות פתוחות ודיון ב-15 באפריל.",
"actions": [
{
"tool": "create_task",
"params": { "name": "הכנת סיכומים לדיון", "dateEnd": "2026-04-14" },
"actionId": "action_xyz789",
"displayText": "יצירת משימה: הכנת סיכומים לדיון"
}
]
}
```
---
## 8. סיכום מוכנות לפריסה
| קריטריון | סטטוס | הערות |
|-----------|--------|--------|
| קוד backend | מוכן | 16 קבצי PHP, ארכיטקטורה נקייה |
| קוד frontend | מוכן | 9 קבצי JS, עיצוב RTL |
| מטא-נתונים | מוכן | תוקן ב-v1.0.1-1.0.3 |
| תרגומים | חלקי | en_US + fa_IR, חסר he_IL ייעודי |
| קובץ התקנה | מוכן | SmartAssistant-1.0.3.zip |
| n8n workflow | **חסר** | צריך לבנות workflow שיטפל ב-chat |
| בדיקות אוטומטיות | **אין** | אין unit/integration/E2E tests |
| CI/CD | **אין** | אין pipeline |
| תיעוד | מוכן | README מקיף בעברית |
**המלצה:** המודול מוכן טכנית לפריסה בסביבת פיתוח. יש להגדיר webhook URL (n8n workflow) לפני שהצ'אט יהיה פונקציונלי. מומלץ לבצע בדיקות ידניות מקיפות לאחר ההתקנה.
+1 -3
View File
@@ -1,12 +1,10 @@
# SmartAssistant - עוזר חכם
**גרסה:** 1.0.2 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
**גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
מחליף את CrmAssistant + OfficeAssistant (deprecated).
## תלויות
- Webhook חיצוני (n8n או דומה) לעיבוד AI
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
Binary file not shown.
Binary file not shown.
+7 -4
View File
@@ -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))"
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -5,38 +5,68 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
name: 'SmartAssistant',
getTitle: function () {
return this.translate('Smart Assistant', 'labels', 'SmartAssistant') || 'Smart Assistant';
return 'שירה — עוזרת אישית';
},
templateContent: '' +
'<div style="direction: rtl; text-align: right; padding: 8px;">' +
'<div class="sa-dashlet-cards" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;"></div>' +
'<div class="sa-dashlet-alerts" style="max-height: 200px; overflow-y: auto;"></div>' +
'<div class="sa-dashlet-detail" style="max-height: 250px; overflow-y: auto;"></div>' +
'</div>',
events: {
'click .sa-card': function (e) {
var type = $(e.currentTarget).data('type');
this.showDetail(type);
},
},
afterRender: function () {
this.loadSummary();
this._alerts = [];
this._summary = {};
this._activeType = 'attention';
var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
if (!result.enabled) {
self.$el.find('.sa-dashlet-cards').html(
'<div style="text-align: center; color: #999; padding: 20px; font-size: 13px;">שירה לא מופעלת. הפעל ב-Admin &gt; Integrations &gt; Smart Assistant</div>'
);
return;
}
self.loadSummary();
}).catch(function () {
self.loadSummary();
});
},
loadSummary: function () {
var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/summary').then(function (response) {
self.renderCards(response.summary || {});
self.renderAlerts(response.alerts || []);
self._summary = response.summary || {};
self._alerts = response.alerts || [];
self._openCases = response.openCases || [];
self._upcomingHearings = response.upcomingHearings || [];
self.renderCards(self._summary);
self.showDetail(self._activeType);
}).catch(function () {});
},
renderCards: function (summary) {
var cards = [
{label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
{label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'},
{label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'},
{label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
{type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
{type: 'overdue', label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'},
{type: 'hearings', label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'},
{type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
];
var self = this;
var html = '';
cards.forEach(function (c) {
html += '<div style="flex: 1; min-width: 80px; background: ' + c.bg + '; padding: 10px; border-radius: 8px; text-align: center;">' +
var active = self._activeType === c.type;
var border = active ? '2px solid ' + c.color : '2px solid transparent';
var cursor = c.value > 0 ? 'pointer' : 'default';
html += '<div class="sa-card" data-type="' + c.type + '" style="flex: 1; min-width: 80px; background: ' + c.bg + '; padding: 10px; border-radius: 8px; text-align: center; cursor: ' + cursor + '; border: ' + border + '; transition: border 0.2s;">' +
'<div style="font-size: 22px; font-weight: bold; color: ' + c.color + ';">' + c.value + '</div>' +
'<div style="font-size: 11px; color: ' + c.color + ';"><span class="fas ' + c.icon + '"></span> ' + c.label + '</div>' +
'</div>';
@@ -45,10 +75,36 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
this.$el.find('.sa-dashlet-cards').html(html);
},
renderAlerts: function (alerts) {
var $alerts = this.$el.find('.sa-dashlet-alerts');
if (alerts.length === 0) {
$alerts.html('<div style="text-align: center; color: #4caf50; padding: 12px;">אין התראות</div>');
showDetail: function (type) {
var self = this;
var $detail = this.$el.find('.sa-dashlet-detail');
if (!type || type === this._activeType && $detail.html()) {
// Toggle off
this._activeType = null;
$detail.html('');
this.renderCards(this._summary);
return;
}
this._activeType = type;
this.renderCards(this._summary);
var alerts = this._alerts;
var items = [];
if (type === 'overdue') {
items = alerts.filter(function (a) { return a.type === 'overdue_task'; });
} else if (type === 'hearings') {
items = this._upcomingHearings || [];
} else if (type === 'attention') {
items = alerts.filter(function (a) { return a.severity === 'critical' || a.severity === 'warning'; });
} else if (type === 'open') {
items = this._openCases || [];
}
if (items.length === 0) {
$detail.html('<div style="text-align: center; color: #999; padding: 12px; font-size: 12px;">אין פריטים להצגה</div>');
return;
}
@@ -56,21 +112,36 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
var severityIcons = {critical: 'fa-exclamation-circle', warning: 'fa-exclamation-triangle', info: 'fa-info-circle'};
var html = '';
alerts.slice(0, 10).forEach(function (a) {
items.forEach(function (a) {
var color = severityColors[a.severity] || '#333';
var icon = severityIcons[a.severity] || 'fa-circle';
var link = a.caseId ? '<a href="#Case/view/' + a.caseId + '" style="color: ' + color + '; text-decoration: none;">' : '<span>';
var linkEnd = a.caseId ? '</a>' : '</span>';
html += '<div style="padding: 6px 0; border-bottom: 1px solid #f5f5f5; font-size: 12px; color: ' + color + ';">' +
html += '<div style="padding: 6px 8px; border-bottom: 1px solid #f0f0f0; font-size: 12px; color: ' + color + ';">' +
'<span class="fas ' + icon + '" style="margin-left: 6px;"></span>' +
'<span>' + Espo.Utils.escapeString(a.message) + '</span>' +
link + self.escapeString(a.message) + linkEnd +
'</div>';
});
$alerts.html(html);
$detail.html(html);
},
actionRefresh: function () {
this.loadSummary();
},
getActionItemDataList: function () {
return [
{
name: 'refresh',
iconHtml: '<span class="fas fa-sync-alt"></span>',
},
];
},
getColor: function () {
return '#5c6bc0';
},
});
});
@@ -0,0 +1,32 @@
/**
* Prompt editor field for AssistantPrompt.content, AssistantSkill.content,
* UserProfile.content. Inherits from the standard text field — adds:
* - monospace font (mixed Hebrew + English + JSON snippets read better)
* - dir="auto" so RTL/LTR works without a manual toggle
* - taller textarea (min 500px) — these prompts run to thousands of chars
*/
define('smart-assistant:views/fields/prompt-editor', ['views/fields/text'], function (Dep) {
return Dep.extend({
rowsDefault: 20,
afterRender: function () {
Dep.prototype.afterRender.call(this);
if (this.isEditMode() || this.isDetailMode()) {
var $area = this.$element;
if ($area && $area.length) {
$area.attr('dir', 'auto');
$area.css({
'font-family': "'Menlo','Consolas','DejaVu Sans Mono',monospace",
'font-size': '13px',
'line-height': '1.45',
'min-height': '500px',
'tab-size': '4',
});
}
}
},
});
});
@@ -3,13 +3,16 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
return View.extend({
templateContent: '' +
'<div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-robot"></span>' +
'<div class="sa-fab-wrapper">' +
' <div class="sa-fab" title="{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}">' +
' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" class="sa-fab-avatar" alt="שירה">' +
' </div>' +
' <span class="sa-fab-badge" style="display:none;"></span>' +
'</div>' +
'<div class="sa-chat-panel" style="display:none;">' +
' <div class="sa-chat-header">' +
' <span class="sa-chat-title">{{translate \'Smart Assistant\' scope=\'SmartAssistant\'}}</span>' +
' <img src="client/custom/modules/smart-assistant/img/shira-avatar.png" style="width: 26px; height: 26px; border-radius: 50%; margin-left: 8px;">' +
' <span class="sa-chat-title">שירה</span>' +
' <span class="sa-mode-badge"></span>' +
' <div class="sa-chat-header-actions">' +
' <button class="btn btn-link sa-memory-btn" type="button" title="{{translate \'Case Memory\' scope=\'SmartAssistant\'}}" style="display:none;">' +
@@ -21,6 +24,9 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
' <button class="btn btn-link sa-new-btn" type="button" title="{{translate \'New Conversation\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-plus"></span>' +
' </button>' +
' <button class="btn btn-link sa-expand-btn" type="button" title="{{translate \'Expand\' scope=\'SmartAssistant\'}}">' +
' <span class="fas fa-expand-alt"></span>' +
' </button>' +
' <button class="btn btn-link sa-close-btn" type="button">' +
' <span class="fas fa-times"></span>' +
' </button>' +
@@ -50,7 +56,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
' <div class="sa-chat-summary" style="display:none;"></div>' +
' <div class="sa-chat-messages"></div>' +
' <div class="sa-chat-input-row">' +
' <input type="text" class="form-control sa-chat-input" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}">' +
' <textarea class="form-control sa-chat-input" rows="1" placeholder="{{translate \'Ask the assistant...\' scope=\'SmartAssistant\'}}"></textarea>' +
' <button class="btn btn-primary sa-chat-send" type="button" disabled>' +
' <span class="fas fa-paper-plane"></span>' +
' </button>' +
@@ -59,10 +65,15 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
'</div>' +
'<style>' +
'.smart-assistant-fab-container { position: fixed; bottom: 24px; left: 24px; z-index: 1050; direction: rtl; font-family: inherit; }' +
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: #5c6bc0; color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s, background 0.2s; font-size: 22px; position: relative; }' +
'.sa-fab:hover { background: #3f51b5; transform: scale(1.08); }' +
'.sa-fab-badge { position: absolute; top: -4px; right: -4px; background: #c62828; color: #fff; border-radius: 10px; min-width: 20px; height: 20px; font-size: 11px; font-weight: bold; display: flex; align-items: center; justify-content: center; padding: 0 5px; }' +
'.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; }' +
'.sa-fab-wrapper { position: relative; display: inline-block; }' +
'.sa-fab { width: 56px; height: 56px; border-radius: 50%; background: transparent; display: flex; align-items: center; justify-content: center; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.25); transition: transform 0.2s; overflow: hidden; }' +
'.sa-fab:hover { transform: scale(1.08); }' +
'.sa-fab-avatar { width: 100%; height: 100%; object-fit: cover; }' +
'.sa-fab-badge { position: absolute; top: -4px; right: -4px; background: #c62828; color: #fff; border-radius: 10px; min-width: 20px; height: 20px; font-size: 11px; font-weight: bold; display: flex; align-items: center; justify-content: center; padding: 0 5px; z-index: 1; }' +
'.sa-chat-panel { position: absolute; bottom: 68px; left: 0; width: 400px; max-height: 600px; background: #fff; border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); display: flex; flex-direction: column; overflow: hidden; animation: sa-slide-up 0.25s ease-out; transition: width 0.3s ease, max-height 0.3s ease; }' +
'.sa-chat-panel.sa-expanded { width: 700px; max-height: 85vh; }' +
'.sa-chat-panel.sa-expanded .sa-chat-messages { max-height: calc(85vh - 180px); }' +
'.sa-chat-panel.sa-expanded .sa-history-list, .sa-chat-panel.sa-expanded .sa-memory-list { max-height: calc(85vh - 200px); }' +
'@keyframes sa-slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }' +
'.sa-chat-header { display: flex; align-items: center; padding: 10px 16px; background: #5c6bc0; color: #fff; gap: 8px; }' +
'.sa-chat-title { font-size: 15px; font-weight: 600; white-space: nowrap; }' +
@@ -76,14 +87,20 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
'.sa-chat-messages:empty::before { content: attr(data-empty-text); display: block; text-align: center; color: #999; padding: 30px 10px; font-size: 13px; }' +
'.sa-msg { margin-bottom: 10px; padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; max-width: 90%; word-wrap: break-word; }' +
'.sa-msg-user { background: #e3f2fd; margin-left: auto; margin-right: 0; text-align: right; }' +
'.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; }' +
'.sa-msg-assistant { background: #f5f0ff; margin-right: auto; margin-left: 0; text-align: right; position: relative; padding-right: 36px; }' +
'.sa-msg-assistant::before { content: ""; position: absolute; right: 6px; top: 6px; width: 24px; height: 24px; border-radius: 50%; background: url(client/custom/modules/smart-assistant/img/shira-avatar.png) center/cover; }' +
'.sa-msg-error { background: #fce4ec; color: #c62828; }' +
'.sa-msg-loading { background: #f5f5f5; color: #999; }' +
'.sa-table { width: 100%; border-collapse: collapse; margin: 6px 0; font-size: 12px; direction: rtl; text-align: right; }' +
'.sa-table th, .sa-table td { border: 1px solid #ddd; padding: 5px 8px; white-space: nowrap; unicode-bidi: plaintext; }' +
'.sa-table th { background: #f0eef8; font-weight: 600; color: #333; }' +
'.sa-table tr:nth-child(even) { background: #fafafa; }' +
'.sa-table tr:hover { background: #f5f0ff; }' +
'.sa-msg-action { background: #fff8e1; padding: 6px 12px; border-radius: 8px; margin-bottom: 8px; font-size: 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }' +
'.sa-action-btns { display: flex; gap: 4px; }' +
'.sa-action-btns .btn { padding: 2px 8px; font-size: 11px; border-radius: 4px; }' +
'.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; }' +
'.sa-chat-input { flex: 1; border-radius: 20px; padding: 8px 14px; font-size: 13px; direction: rtl; }' +
'.sa-chat-input-row { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid #eee; direction: rtl; align-items: flex-end; }' +
'.sa-chat-input { flex: 1; border-radius: 16px; padding: 8px 14px; font-size: 13px; direction: rtl; resize: none; overflow-y: auto; max-height: 120px; min-height: 36px; line-height: 1.4; font-family: inherit; }' +
'.sa-chat-send { border-radius: 50%; width: 36px; height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; }' +
'.sa-history-view, .sa-memory-view { flex: 1; overflow: hidden; display: flex; flex-direction: column; }' +
'.sa-history-header, .sa-memory-header { padding: 8px 14px; border-bottom: 1px solid #eee; direction: rtl; text-align: right; display: flex; justify-content: space-between; align-items: center; }' +
@@ -111,7 +128,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
'.sa-badge-source { background: #f3e5f5; color: #7b1fa2; }' +
'.sa-memory-entry-content { font-size: 12px; color: #555; line-height: 1.4; max-height: 60px; overflow: hidden; }' +
'.sa-memory-entry-meta { font-size: 10px; color: #999; margin-top: 4px; }' +
'@media (max-width: 480px) { .sa-chat-panel { width: calc(100vw - 48px); left: 0; } .smart-assistant-fab-container { bottom: 16px; left: 16px; } }' +
'@media (max-width: 480px) { .sa-chat-panel { width: calc(100vw - 48px); left: 0; } .sa-chat-panel.sa-expanded { width: calc(100vw - 48px); } .smart-assistant-fab-container { bottom: 16px; left: 16px; } }' +
'</style>',
conversationId: null,
@@ -120,12 +137,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
currentCaseId: null,
currentCaseName: null,
currentView: 'chat',
isExpanded: false,
currentMemoryCategory: null,
memoryData: null,
events: {
'click .sa-fab': function () { this.togglePanel(); },
'click .sa-close-btn': function () { this.closePanel(); },
'click .sa-expand-btn': function () { this.toggleExpand(); },
'click .sa-chat-send': function () { this.sendMessage(); },
'click .sa-history-btn': function () { this.showHistory(); },
'click .sa-new-btn': function () { this.startNewConversation(); },
@@ -141,11 +160,14 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
var cat = $(e.currentTarget).data('category');
this.filterMemory(cat);
},
'keypress .sa-chat-input': function (e) {
'keydown .sa-chat-input': function (e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); }
},
'input .sa-chat-input': function (e) {
this.$el.find('.sa-chat-send').prop('disabled', !e.target.value.trim());
// Auto-grow textarea
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 120) + 'px';
},
'click .sa-action-approve': function (e) {
var $btn = $(e.currentTarget);
@@ -158,10 +180,22 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
},
afterRender: function () {
this.$el.find('.sa-chat-messages').attr('data-empty-text',
this.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם העוזר החכם');
this.loadAlertCount();
this._setupRouteListener();
var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
if (!result.enabled) {
self.$el.find('.sa-fab').hide();
return;
}
self.$el.find('.sa-fab').show();
self.$el.find('.sa-chat-messages').attr('data-empty-text',
self.translate('Chat with the assistant', 'labels', 'SmartAssistant') || 'שוחח/י עם שירה');
self.loadAlertCount();
self._setupRouteListener();
}).catch(function () {
self.$el.find('.sa-fab').hide();
});
},
_setupRouteListener: function () {
@@ -234,7 +268,26 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
closePanel: function () {
this.isOpen = false;
this.$el.find('.sa-chat-panel').css('display', 'none');
this.isExpanded = false;
var $panel = this.$el.find('.sa-chat-panel');
$panel.removeClass('sa-expanded').css('display', 'none');
this.$el.find('.sa-expand-btn .fas').removeClass('fa-compress-alt').addClass('fa-expand-alt');
},
toggleExpand: function () {
this.isExpanded = !this.isExpanded;
var $panel = this.$el.find('.sa-chat-panel');
var $icon = this.$el.find('.sa-expand-btn .fas');
if (this.isExpanded) {
$panel.addClass('sa-expanded');
$icon.removeClass('fa-expand-alt').addClass('fa-compress-alt');
} else {
$panel.removeClass('sa-expanded');
$icon.removeClass('fa-compress-alt').addClass('fa-expand-alt');
}
this.scrollToBottom();
},
showChat: function () {
@@ -404,13 +457,22 @@ 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('');
$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,
@@ -421,31 +483,26 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
payload.caseId = this.currentCaseId;
}
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload).then(function (response) {
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 600000}).then(function (response) {
clearTimeout(slowHintTimer);
self.conversationId = response.conversationId;
$loading.remove();
$messages.append('<div class="sa-msg sa-msg-assistant">' + self.formatResponse(response.text) + '</div>');
// Render action items
if (response.actions && response.actions.length > 0) {
response.actions.forEach(function (action) {
var actionHtml = '<div class="sa-msg-action">' +
'<span><span class="fas fa-bolt" style="color: #f57c00; margin-left: 4px;"></span>' +
self.escapeHtml(action.displayText || action.tool) + '</span>' +
'<div class="sa-action-btns">' +
'<button class="btn btn-success btn-xs sa-action-approve" data-conversation-id="' + self.escapeHtml(response.conversationId) +
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-check"></span></button>' +
'<button class="btn btn-danger btn-xs sa-action-reject" data-conversation-id="' + self.escapeHtml(response.conversationId) +
'" data-action-id="' + self.escapeHtml(action.actionId) + '"><span class="fas fa-times"></span></button>' +
'</div></div>';
$messages.append(actionHtml);
// Auto-refresh panels if actions were executed
if (response.executedActions && response.executedActions.length > 0) {
response.executedActions.forEach(function (action) {
if (action.entityType) {
self.refreshCurrentView(action.entityType);
}
});
}
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>');
@@ -468,6 +525,10 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
var cls = approved ? (result.success ? 'sa-msg-assistant' : 'sa-msg-error') : 'sa-msg-loading';
$messages.append('<div class="sa-msg ' + cls + '">' + self.escapeHtml(msg) + '</div>');
self.scrollToBottom();
if (approved && result.success) {
self.refreshCurrentView(result.entityType);
}
}).catch(function () {
$messages.append('<div class="sa-msg sa-msg-error">שגיאה בביצוע הפעולה</div>');
self.scrollToBottom();
@@ -595,11 +656,101 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
formatResponse: function (text) {
if (!text) return '';
text = this.escapeHtml(text);
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\n/g, '<br>');
text = text.replace(/^- (.+)/gm, '&bull; $1');
return text;
var self = this;
var LRM = '\u200E';
// Split into lines for table detection
var lines = text.split('\n');
var result = [];
var i = 0;
while (i < lines.length) {
// Detect markdown table: line with |, followed by separator |---|, followed by more | lines
if (lines[i].indexOf('|') !== -1 && i + 1 < lines.length && /^\|?[\s\-:|]+\|/.test(lines[i + 1])) {
var tableLines = [];
var j = i;
// Collect all contiguous lines that contain |
while (j < lines.length && lines[j].indexOf('|') !== -1) {
tableLines.push(lines[j]);
j++;
}
if (tableLines.length >= 2) {
result.push(self.renderMarkdownTable(tableLines, LRM));
i = j;
continue;
}
}
// Regular line — escape and format
var line = self.escapeHtml(lines[i]);
line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Headings
line = line.replace(/^### (.+)/, '<strong style="font-size: 14px;">$1</strong>');
line = line.replace(/^## (.+)/, '<strong style="font-size: 15px;">$1</strong>');
// List items
line = line.replace(/^[-*] (.+)/, '&bull; $1');
// Numbered lists
line = line.replace(/^(\d+)\. (.+)/, '$1. $2');
result.push(line);
i++;
}
return result.join('<br>');
},
renderMarkdownTable: function (lines, LRM) {
var self = this;
var parseRow = function (line) {
// Remove leading/trailing |, split by |
var trimmed = line.replace(/^\|/, '').replace(/\|$/, '');
return trimmed.split('|').map(function (cell) { return cell.trim(); });
};
// First line = headers
var headers = parseRow(lines[0]);
// Skip separator line (line[1])
// Remaining lines = data rows
var rows = [];
for (var i = 2; i < lines.length; i++) {
// Skip if it looks like another separator
if (/^[\s\-:|]+$/.test(lines[i].replace(/\|/g, ''))) continue;
rows.push(parseRow(lines[i]));
}
// Build HTML table with LRM for BiDi safety
var html = '<table class="sa-table" dir="rtl">';
// Header
html += '<thead><tr>';
headers.forEach(function (h) {
var escaped = self.escapeHtml(h).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html += '<th>' + LRM + escaped + LRM + '</th>';
});
html += '</tr></thead>';
// Body
html += '<tbody>';
rows.forEach(function (row) {
html += '<tr>';
for (var c = 0; c < headers.length; c++) {
var cell = row[c] !== undefined ? row[c] : '';
var escaped = self.escapeHtml(cell).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html += '<td>' + LRM + escaped + LRM + '</td>';
}
html += '</tr>';
});
html += '</tbody></table>';
return html;
},
escapeHtml: function (text) {
@@ -608,5 +759,37 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
div.textContent = text;
return div.innerHTML;
},
refreshCurrentView: function (entityType) {
var mainView = this.getParentView();
while (mainView && mainView.getParentView()) {
mainView = mainView.getParentView();
}
if (!mainView) return;
var currentView = mainView.getView('main');
if (!currentView) return;
var recordView = currentView.getView('record');
if (recordView && recordView.model) {
recordView.model.trigger('update-all');
}
if (entityType === 'Task' || entityType === 'Meeting' || entityType === 'Call') {
var sideView = currentView.getView('side');
if (sideView) {
var activities = sideView.getView('activities');
var tasks = sideView.getView('tasks');
if (activities && activities.actionRefresh) activities.actionRefresh();
if (tasks && tasks.actionRefresh) tasks.actionRefresh();
}
}
},
});
});
@@ -5,7 +5,7 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
templateContent: '' +
'<div class="sa-action-note sa-action-{{status}}" style="padding: 6px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
'<span class="fas {{icon}}" style="margin-left: 6px;"></span>' +
'{{post}}' +
'{{{formattedPost}}}' +
'</div>',
data: function () {
@@ -13,6 +13,8 @@ define('modules/smart-assistant/views/stream/notes/smart-action', ['views/stream
var noteData = this.model.get('data') || {};
var status = noteData.status || 'executed';
data.post = this.model.get('post');
data.formattedPost = this.getHelper().escapeString(data.post || '');
data.status = status;
if (status === 'executed') {
@@ -5,11 +5,22 @@ define('modules/smart-assistant/views/stream/notes/smart-request', ['views/strea
templateContent: '' +
'<div style="background: #e3f2fd; padding: 8px 12px; border-radius: 8px; direction: rtl; text-align: right; font-size: 13px;">' +
'<span class="fas fa-user" style="color: #1565c0; margin-left: 6px;"></span>' +
'{{post}}' +
'{{{formattedPost}}}' +
'</div>',
setup: function () {
NoteView.prototype.setup.call(this);
data: function () {
var data = NoteView.prototype.data.call(this);
data.formattedPost = this.formatPost(this.model.get('post'));
return data;
},
formatPost: function (text) {
if (!text) return '';
text = this.getHelper().escapeString(text);
text = text.replace(/\n/g, '<br>');
return text;
},
});
});
@@ -1,54 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\Modules\NextCloudIntegration\Services\NextCloudService;
class FileStorageFactory
{
private Config $config;
private InjectableFactory $injectableFactory;
private Log $log;
public function __construct(Config $config, InjectableFactory $injectableFactory, Log $log)
{
$this->config = $config;
$this->injectableFactory = $injectableFactory;
$this->log = $log;
}
public function create(?string $userId = null): FileStorageInterface
{
$storageType = $this->config->get('assistantFileStorage', 'nextcloud');
return match ($storageType) {
'nextcloud' => $this->createNextCloud($userId),
'webdav' => $this->createWebDav(),
default => throw new Error("Unknown file storage type: {$storageType}"),
};
}
private function createNextCloud(?string $userId): NextCloudFileStorage
{
$nextCloudService = $this->injectableFactory->create(NextCloudService::class);
return new NextCloudFileStorage($nextCloudService->getClientForUser($userId));
}
private function createWebDav(): WebDavFileStorage
{
$url = $this->config->get('assistantWebDavUrl');
$user = $this->config->get('assistantWebDavUser');
$password = $this->config->get('assistantWebDavPassword');
$basePath = $this->config->get('assistantWebDavBasePath', '/webdav');
if (empty($url) || empty($user) || empty($password)) {
throw new Error('WebDAV file storage is not configured.');
}
return new WebDavFileStorage($url, $user, $password, $basePath);
}
}
@@ -1,12 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
interface FileStorageInterface
{
public function listFolder(string $path): array;
public function downloadFile(string $path): string;
public function move(string $source, string $destination): bool;
public function createFolder(string $path): bool;
public function exists(string $path): bool;
}
@@ -1,21 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Modules\NextCloudIntegration\Classes\NextCloud\Client;
class NextCloudFileStorage implements FileStorageInterface
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function listFolder(string $path): array { return $this->client->listFolder($path); }
public function downloadFile(string $path): string { return $this->client->downloadFile($path); }
public function move(string $source, string $destination): bool { return $this->client->move($source, $destination); }
public function createFolder(string $path): bool { return $this->client->createFolderRecursive($path); }
public function exists(string $path): bool { return $this->client->exists($path); }
}
@@ -1,234 +0,0 @@
<?php
namespace Espo\Modules\SmartAssistant\Classes\FileStorage;
use Espo\Core\Exceptions\Error;
/**
* Generic WebDAV file storage adapter.
* Works with Synology NAS, ownCloud, and any standard WebDAV server.
* Uses only DAV-standard operations (no NextCloud/OCS-specific extensions).
*/
class WebDavFileStorage implements FileStorageInterface
{
private string $baseUrl;
private string $username;
private string $password;
private string $webdavPath;
public function __construct(
string $baseUrl,
string $username,
string $password,
string $webdavPath = '/webdav'
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->username = $username;
$this->password = $password;
$this->webdavPath = $webdavPath;
}
public function listFolder(string $path): array
{
$propfindXml = '<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:resourcetype/>
</d:prop>
</d:propfind>';
$response = $this->request('PROPFIND', $path, $propfindXml, [
'Content-Type: application/xml',
'Depth: 1',
]);
if ($response['status'] !== 207) {
throw new Error("Failed to list folder: HTTP " . $response['status']);
}
return $this->parsePropfindResponse($response['body'], $path);
}
public function downloadFile(string $path): string
{
$response = $this->request('GET', $path);
if ($response['status'] !== 200) {
throw new Error("Failed to download file: HTTP " . $response['status']);
}
return $response['body'];
}
public function move(string $source, string $destination): bool
{
$destUrl = $this->buildUrl($destination);
$response = $this->request('MOVE', $source, null, [
'Destination: ' . $destUrl,
'Overwrite: F',
]);
if ($response['status'] !== 201 && $response['status'] !== 204) {
throw new Error("Failed to move: HTTP " . $response['status']);
}
return true;
}
public function createFolder(string $path): bool
{
$parts = array_filter(explode('/', $path));
$currentPath = '';
foreach ($parts as $part) {
$currentPath .= '/' . $part;
$response = $this->request('MKCOL', $currentPath);
// 201 = created, 405 = already exists
if ($response['status'] !== 201 && $response['status'] !== 405) {
throw new Error("Failed to create folder: HTTP " . $response['status']);
}
}
return true;
}
public function exists(string $path): bool
{
try {
$response = $this->request('PROPFIND', $path, null, ['Depth: 0']);
return $response['status'] === 207;
} catch (\Exception $e) {
return false;
}
}
private function buildUrl(string $path): string
{
$pathParts = explode('/', ltrim($path, '/'));
$encodedParts = array_map('rawurlencode', $pathParts);
return $this->baseUrl . $this->webdavPath . '/' . implode('/', $encodedParts);
}
/**
* @return array{status: int, body: string}
*/
private function request(
string $method,
string $path,
?string $body = null,
array $headers = []
): array {
$url = $this->buildUrl($path);
$ch = curl_init();
$allHeaders = array_merge([
'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password),
], $headers);
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $allHeaders,
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
throw new Error("WebDAV connection error: $curlError");
}
return [
'status' => $httpCode,
'body' => $responseBody,
];
}
private function parsePropfindResponse(string $xml, string $basePath): array
{
$items = [];
$normalizedBasePath = '/' . trim($basePath, '/');
$doc = new \DOMDocument();
$doc->loadXML($xml);
$xpath = new \DOMXPath($doc);
$xpath->registerNamespace('d', 'DAV:');
$responses = $xpath->query('//d:response');
foreach ($responses as $response) {
$hrefNodes = $xpath->query('d:href', $response);
if ($hrefNodes->length === 0) {
continue;
}
$href = urldecode($hrefNodes->item(0)->textContent);
// Extract relative path by removing webdav prefix
$relativePath = $href;
$webdavPrefix = $this->webdavPath . '/';
if (($pos = strpos($href, $webdavPrefix)) !== false) {
$relativePath = substr($href, $pos + strlen($webdavPrefix));
}
$relativePath = '/' . trim($relativePath, '/');
// Skip the queried folder itself
if ($relativePath === $normalizedBasePath || $relativePath === $normalizedBasePath . '/') {
continue;
}
$propstat = $xpath->query('d:propstat/d:prop', $response)->item(0);
if (!$propstat) {
continue;
}
$resourceType = $xpath->query('d:resourcetype/d:collection', $propstat);
$isFolder = $resourceType->length > 0;
$contentLength = $xpath->query('d:getcontentlength', $propstat);
$size = $contentLength->length > 0 ? (int) $contentLength->item(0)->textContent : null;
$lastModified = $xpath->query('d:getlastmodified', $propstat);
$modified = $lastModified->length > 0 ? $lastModified->item(0)->textContent : null;
$contentType = $xpath->query('d:getcontenttype', $propstat);
$mimeType = $contentType->length > 0 ? $contentType->item(0)->textContent : null;
$items[] = [
'name' => basename($relativePath),
'path' => $relativePath,
'isFolder' => $isFolder,
'size' => $size,
'mimeType' => $isFolder ? 'folder' : $mimeType,
'modified' => $modified,
];
}
usort($items, function ($a, $b) {
if ($a['isFolder'] !== $b['isFolder']) {
return $b['isFolder'] <=> $a['isFolder'];
}
return strcasecmp($a['name'], $b['name']);
});
return $items;
}
}
@@ -0,0 +1,10 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Standard CRUD over /api/v1/AssistantPrompt. Pattern matches AssistantRule.
*/
class AssistantPrompt extends \Espo\Core\Controllers\Record
{
}
@@ -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
{
}
@@ -0,0 +1,10 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Standard CRUD over /api/v1/AssistantSkill. Pattern matches AssistantRule.
*/
class AssistantSkill extends \Espo\Core\Controllers\Record
{
}
@@ -0,0 +1,17 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Standard CRUD over /api/v1/CaseMemory.
*
* Until SmartAssistant 2.10.0 the CaseMemory entity was created via
* CaseMemoryService::createMemory and listed via Case relation panels —
* never via the bare REST endpoint. Flipping `tab: true` exposes the
* navbar list view, which uses GET /api/v1/CaseMemory and needs this
* Controller class to exist (otherwise EspoCRM returns 404 "Controller
* does not exist"). Same pattern as the AssistantRule fix shipped in 2.9.2.
*/
class CaseMemory extends \Espo\Core\Controllers\Record
{
}
@@ -9,17 +9,25 @@ 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\Modules\SmartAssistant\Services\FreeDocumentGenerator;
use Espo\Modules\SmartAssistant\Services\CaseFolderManager;
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
@@ -34,6 +42,11 @@ class SmartAssistant
}
}
public function getActionStatus(Request $request, Response $response): array
{
return $this->getService()->getStatus();
}
public function postActionChat(Request $request, Response $response): array
{
$this->checkAccess();
@@ -164,4 +177,326 @@ class SmartAssistant
'id' => $entry->get('id'),
];
}
public function postActionExecuteTool(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
if (empty($data->tool)) {
throw new BadRequest('tool is required.');
}
$tool = $data->tool;
$params = json_decode(json_encode($data->params ?? []), true) ?? [];
$caseId = $data->caseId ?? null;
$executor = $this->injectableFactory->create(ActionExecutor::class);
return $executor->execute($tool, $params, $caseId, $this->user->getId());
}
public function postActionReadDocument(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePath = $data->filePath ?? null;
if (empty($filePath)) {
throw new BadRequest('filePath is required.');
}
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$text = $analyzer->extractFullText($filePath, $maxLength);
return [
'success' => $text !== null,
'text' => $text,
'fileName' => basename($filePath),
'charCount' => $text !== null ? mb_strlen($text) : 0,
];
}
public function postActionGetDocumentBytes(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePath = $data->filePath ?? null;
if (empty($filePath)) {
throw new BadRequest('filePath is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->getDocumentBytes($filePath);
}
public function postActionReadMultipleDocuments(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePaths = $data->filePaths ?? [];
if (empty($filePaths)) {
throw new BadRequest('filePaths is required (array of file paths).');
}
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
return [
'success' => true,
'documents' => $results,
];
}
public function postActionBatchRename(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$renames = $data->renames ?? [];
if (empty($renames)) {
throw new BadRequest('renames is required (array of {sourcePath, newName}).');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = [];
$successCount = 0;
foreach ($renames as $rename) {
$rename = (array) $rename;
$sourcePath = $rename['sourcePath'] ?? null;
$newName = $rename['newName'] ?? null;
if (!$sourcePath || !$newName) {
$results[] = ['success' => false, 'error' => 'Missing sourcePath or newName'];
continue;
}
try {
$result = $analyzer->renameDocument($sourcePath, $newName);
$results[] = ['success' => true, 'oldName' => $result['oldName'], 'newName' => $result['newName']];
$successCount++;
} catch (\Exception $e) {
$results[] = ['success' => false, 'oldName' => basename($sourcePath), 'error' => $e->getMessage()];
}
}
return [
'success' => true,
'totalCount' => count($renames),
'successCount' => $successCount,
'results' => $results,
];
}
public function postActionListDocuments(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->listDocumentsRecursive($caseId);
}
public function postActionBrowseFolder(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$path = $data->path ?? null;
if ($path === null) {
throw new BadRequest('path is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return [
'path' => $path,
'entries' => $analyzer->browseFolder((string) $path),
];
}
public function postActionFindCaseFolder(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$query = $data->query ?? null;
if (empty($query)) {
throw new BadRequest('query is required.');
}
$limit = isset($data->limit) ? max(1, min((int) $data->limit, 50)) : 20;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return [
'query' => $query,
'matches' => $analyzer->findCaseFolder((string) $query, $limit),
];
}
public function postActionSetCaseFolderPath(Request $request, Response $response): array
{
$this->checkAccess();
if (!$this->acl->checkScope('Case', 'edit')) {
throw new Forbidden('No edit access to Case.');
}
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$path = $data->path ?? null;
if (empty($caseId) || empty($path)) {
throw new BadRequest('caseId and path are required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->setCaseFolderPath((string) $caseId, (string) $path);
}
public function postActionGenerateFromTemplate(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$templateId = $data->templateId ?? null;
$caseId = $data->caseId ?? null;
if (empty($templateId)) {
throw new BadRequest('templateId is required.');
}
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$documentSubject = $data->documentSubject ?? null;
$customPlaceholders = (array) ($data->customPlaceholders ?? []);
$generator = $this->injectableFactory->create(GenericTemplateGenerator::class);
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
}
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);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Standard CRUD over /api/v1/UserProfile.
*
* Pattern matches AssistantRule.php — without this class EspoCRM returns
* 404 "Controller does not exist" even when the entity is fully defined.
*/
class UserProfile extends \Espo\Core\Controllers\Record
{
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"labels":{"Create AssistantConversation":"New Conversation"},"fields":{"name":"Title","conversationKey":"Conversation Key","conversationType":"Type","status":"Status","assignedUser":"User","case":"Case","caseName":"Case Name","messageCount":"Messages","messagesData":"Messages Data","lastMessageAt":"Last Message","lastMessagePreview":"Preview","createdAt":"Created At","modifiedAt":"Modified At","createdBy":"Created By","modifiedBy":"Modified By"},"links":{"assignedUser":"User","case":"Case"},"options":{"conversationType":{"office":"Office","case":"Case"}}}
@@ -0,0 +1,22 @@
{
"labels": {
"Create AssistantRule": "Create Rule"
},
"fields": {
"name": "Name",
"rule": "Rule",
"scope": "Scope",
"isActive": "Active",
"createdAt": "Created At",
"modifiedAt": "Modified At",
"createdBy": "Created By",
"modifiedBy": "Modified By"
},
"options": {
"scope": {
"global": "Global",
"case": "Case Mode",
"office": "Office Mode"
}
}
}
@@ -1,10 +1,12 @@
{
"scopeNames": {
"CaseMemory": "Case Memory",
"AssistantRule": "Assistant Rule",
"SmartAssistant": "Smart Assistant"
},
"scopeNamesPlural": {
"CaseMemory": "Case Memories",
"AssistantRule": "Assistant Rules",
"SmartAssistant": "Smart Assistant"
}
}
@@ -3,6 +3,7 @@
"fields": {
"webhookUrl": "Webhook URL",
"apiKey": "API Key",
"espocrmApiKey": "EspoCRM API Key",
"maxMessagesPerHour": "Max Messages Per Hour",
"inactivityWarningDays": "Inactivity Warning Days",
"inactivityCriticalDays": "Inactivity Critical Days",
@@ -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 @@
{"labels":{"Create AssistantConversation":"שיחה חדשה"},"fields":{"name":"כותרת","conversationKey":"מזהה שיחה","conversationType":"סוג","status":"סטטוס","assignedUser":"משתמש","case":"תיק","caseName":"שם התיק","messageCount":"הודעות","messagesData":"נתוני הודעות","lastMessageAt":"הודעה אחרונה","lastMessagePreview":"תצוגה מקדימה","createdAt":"נוצר ב","modifiedAt":"עודכן ב","createdBy":"נוצר על ידי","modifiedBy":"עודכן על ידי"},"links":{"assignedUser":"משתמש","case":"תיק"},"options":{"conversationType":{"office":"משרד","case":"תיק"}}}
@@ -0,0 +1,30 @@
{
"labels": {
"Create AssistantPrompt": "צור פרומפט חדש"
},
"fields": {
"name": "שם תיאורי",
"key": "מפתח (key)",
"description": "תיאור — מה הסעיף הזה עושה",
"mode": "מצב",
"displayOrder": "סדר הצגה",
"content": "תוכן הפרומפט",
"isActive": "פעיל",
"createdAt": "נוצר",
"modifiedAt": "עודכן"
},
"options": {
"mode": {
"both": "תיק + משרד",
"case": "תיק בלבד",
"office": "משרד בלבד"
}
},
"tooltips": {
"key": "המפתח שדרכו shira-hermes שולפת את הסעיף הזה. שמות מוכרים: tool_rules, legal_assistance, personality_case, personality_office, writing_style, other_rules_case, other_rules_office. שינוי שם המפתח של סעיף קיים יגרום לכך ש-fallback ל-default יחזור.",
"mode": "אם 'both' — מופיע בכל סשן. 'case' — רק כשנפתח תיק. 'office' — רק במסך המשרד הכללי.",
"displayOrder": "מספר נמוך = מופיע מוקדם יותר ב-prompt. ברירת מחדל 100.",
"content": "הטקסט שיוזרק ל-system prompt. תמיכה ב-RTL אוטומטית. שורה ריקה מסמנת default — קוד ה-Python יחזור לערך המקורי.",
"isActive": "כיבוי = הסעיף מתעלם, fallback ל-default מהקוד."
}
}
@@ -0,0 +1,22 @@
{
"labels": {
"Create AssistantRule": "הוסף כלל"
},
"fields": {
"name": "שם",
"rule": "כלל",
"scope": "היקף",
"isActive": "פעיל",
"createdAt": "נוצר בתאריך",
"modifiedAt": "עודכן בתאריך",
"createdBy": "נוצר על ידי",
"modifiedBy": "עודכן על ידי"
},
"options": {
"scope": {
"global": "גלובלי",
"case": "מצב תיק",
"office": "מצב משרד"
}
}
}
@@ -0,0 +1,21 @@
{
"labels": {
"Create AssistantSkill": "צור מיומנות חדשה"
},
"fields": {
"name": "שם",
"description": "תיאור קצר",
"triggers": "מילות הפעלה",
"content": "תוכן המיומנות (Markdown)",
"version": "גרסה",
"isActive": "פעיל",
"createdAt": "נוצר",
"modifiedAt": "עודכן"
},
"tooltips": {
"name": "שם ייחודי (snake-case או kebab-case). למשל direct-access-report-flow.",
"description": "משפט קצר: מתי כדאי לשירה להפעיל את המיומנות.",
"triggers": "מילים/ביטויים שבהם המיומנות צריכה להופיע — מופרדים בפסיק.",
"content": "ה-Markdown המלא של המיומנות. שירה תקרא אותו דרך read_skill כשהיא צריכה אותו."
}
}
@@ -0,0 +1,9 @@
{
"fields": {
"markedForDeletionAt": "סומן למחיקה בתאריך",
"markedForDeletionBy": "סומן למחיקה על ידי"
},
"tooltips": {
"markedForDeletionAt": "מסמך זה סומן למחיקה על ידי שירה ומחכה לאישור ידני של עו\"ד למחיקה הפיזית."
}
}
@@ -1,10 +1,18 @@
{
"scopeNames": {
"CaseMemory": "זיכרון תיק",
"AssistantRule": "כלל התנהגות",
"UserProfile": "פרופיל משתמש",
"AssistantSkill": "מיומנות שירה",
"AssistantPrompt": "פרומפט שירה",
"SmartAssistant": "עוזר חכם"
},
"scopeNamesPlural": {
"CaseMemory": "זיכרונות תיקים",
"AssistantRule": "כללי התנהגות",
"UserProfile": "פרופילי משתמשים",
"AssistantSkill": "מיומנויות שירה",
"AssistantPrompt": "פרומפטים של שירה",
"SmartAssistant": "עוזר חכם"
}
}
@@ -3,6 +3,7 @@
"fields": {
"webhookUrl": "כתובת Webhook",
"apiKey": "מפתח API",
"espocrmApiKey": "מפתח API של EspoCRM",
"maxMessagesPerHour": "מקסימום הודעות לשעה",
"inactivityWarningDays": "ימים לאזהרת חוסר פעילות",
"inactivityCriticalDays": "ימים לאזהרה קריטית",
@@ -1,19 +1,20 @@
{
"labels": {
"Smart Assistant": "עוזר חכם",
"Smart Assistant": "שירה",
"Office Mode": "מצב משרד",
"Case Mode": "מצב תיק",
"Add Memory": "הוסף זיכרון",
"Search Memory": פש בזיכרון",
"Add Memory": "הוספת זיכרון",
"Search Memory": יפוש בזיכרון",
"New Conversation": "שיחה חדשה",
"Conversation History": "היסטוריית שיחות",
"Previous Conversations": "שיחות קודמות",
"No previous conversations": "אין שיחות קודמות",
"Ask the assistant...": "שאל/י את העוזר...",
"Thinking": "חושב...",
"Ask the assistant...": "שאל/י את שירה...",
"Thinking": "חושבת...",
"SlowHint": "חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל",
"Error": "שגיאה בתקשורת",
"Back": "חזרה",
"Chat with the assistant": "שוחח/י עם העוזר החכם",
"Chat with the assistant": "שוחח/י עם שירה",
"Case Memory": "זיכרון תיק",
"Pin": "הצמד",
"Unpin": "הסר הצמדה",
@@ -0,0 +1,18 @@
{
"labels": {
"Create UserProfile": "צור פרופיל משתמש"
},
"fields": {
"name": "שם",
"user": "משתמש",
"content": "תוכן הפרופיל",
"isActive": "פעיל",
"createdAt": "נוצר",
"modifiedAt": "עודכן",
"createdBy": "נוצר על ידי",
"modifiedBy": "עודכן על ידי"
},
"tooltips": {
"content": "טקסט חופשי שמוזרק ל-prompt של שירה תחת '=== ABOUT THIS USER ===' לכל שיחה של המשתמש הזה. שמור עובדות מפתח: תפקיד, תחומי התמחות, העדפות תקשורת, נושאי טיק שמטופלים."
}
}
@@ -0,0 +1,17 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "key"}],
[{"name": "mode"}, {"name": "displayOrder"}],
[{"name": "isActive"}, false],
[{"name": "description", "fullWidth": true}]
]
},
{
"label": "Prompt",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,8 @@
[
{"name": "name", "link": true},
{"name": "key"},
{"name": "mode"},
{"name": "displayOrder"},
{"name": "isActive"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,11 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "scope"}],
[{"name": "isActive"}, false],
[{"name": "rule", "fullWidth": true}],
[{"name": "createdAt"}, {"name": "createdBy"}]
]
}
]
@@ -0,0 +1,7 @@
[
{"name": "name", "width": 35, "link": true},
{"name": "rule", "width": 30},
{"name": "scope", "width": 10},
{"name": "isActive", "width": 10},
{"name": "createdAt", "width": 15}
]
@@ -0,0 +1,17 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "version"}],
[{"name": "description", "fullWidth": true}],
[{"name": "triggers", "fullWidth": true}],
[{"name": "isActive"}, false]
]
},
{
"label": "Skill",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,7 @@
[
{"name": "name", "link": true},
{"name": "description"},
{"name": "isActive"},
{"name": "version"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,15 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "user"}],
[{"name": "isActive"}, false]
]
},
{
"label": "Profile",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,6 @@
[
{"name": "name", "link": true},
{"name": "user", "link": true},
{"name": "isActive"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,37 @@
{
"shiraManagement": {
"label": "ניהול שירה",
"itemList": [
{
"url": "#AssistantPrompt",
"label": "פרומפטים",
"iconClass": "fas fa-file-code",
"description": "שינוי הסעיפים שמוזרקים ל-system prompt של שירה. עריכה כאן מחליפה את ברירות-המחדל בקוד."
},
{
"url": "#AssistantRule",
"label": "כללי התנהגות",
"iconClass": "fas fa-list-check",
"description": "כללים גלובליים/לפי מצב שיופיעו תחת '=== BEHAVIORAL RULES (MUST FOLLOW) ===' של ה-system prompt."
},
{
"url": "#AssistantSkill",
"label": "מיומנויות (Skills)",
"iconClass": "fas fa-magic",
"description": "תהליכי עבודה מוכנים שהמודל יכול להפעיל. כל מיומנות פעילה מופיעה ב-context."
},
{
"url": "#UserProfile",
"label": "פרופילי משתמשים",
"iconClass": "fas fa-user-cog",
"description": "פרופיל אישי לכל עורך/ת דין. מופיע ב-system prompt תחת '=== ABOUT THIS USER ==='."
},
{
"url": "#CaseMemory",
"label": "זיכרון תיקים",
"iconClass": "fas fa-brain",
"description": "כל רשומות הזיכרון מכל התיקים. גישה ישירה לחיפוש/עריכה — לצד כניסה רגילה מתוך כל תיק."
}
]
}
}
@@ -0,0 +1,5 @@
{
"controller": "controllers/record",
"color": "#d99848",
"iconClass": "fas fa-file-code"
}
@@ -0,0 +1,10 @@
{
"controller": "controllers/record",
"color": "#5e8c61",
"iconClass": "fas fa-list-check",
"boolFilterList": ["onlyMy"],
"filterList": [
{"name": "active"},
{"name": "inactive"}
]
}
@@ -0,0 +1,5 @@
{
"controller": "controllers/record",
"color": "#aa72b3",
"iconClass": "fas fa-magic"
}
@@ -0,0 +1,10 @@
{
"controller": "controllers/record",
"color": "#9b59b6",
"iconClass": "fas fa-brain",
"boolFilterList": ["onlyMy"],
"filterList": [
{"name": "pinned"},
{"name": "byCategory"}
]
}
@@ -0,0 +1,7 @@
{
"itemViews": {
"SmartRequest": "modules/smart-assistant/views/stream/notes/smart-request",
"SmartResponse": "modules/smart-assistant/views/stream/notes/smart-response",
"SmartAction": "modules/smart-assistant/views/stream/notes/smart-action"
}
}
@@ -0,0 +1,7 @@
{
"controller": "controllers/record",
"boolFilterList": ["onlyMy"],
"filterList": [{"name": "active"}, {"name": "inactive"}],
"color": "#7c8caf",
"iconClass": "fas fa-user-cog"
}
@@ -0,0 +1 @@
{"fields":{"name":{"type":"varchar","maxLength":255,"required":true},"conversationKey":{"type":"varchar","maxLength":255,"required":true},"conversationType":{"type":"enum","options":["office","case"],"default":"office","required":true},"status":{"type":"varchar","maxLength":50,"default":"Active"},"assignedUser":{"type":"link"},"case":{"type":"link"},"caseName":{"type":"varchar","maxLength":255},"messageCount":{"type":"int","default":0},"messagesData":{"type":"jsonArray"},"lastMessageAt":{"type":"datetime"},"lastMessagePreview":{"type":"text","maxLength":200},"createdAt":{"type":"datetime","readOnly":true},"modifiedAt":{"type":"datetime","readOnly":true},"createdBy":{"type":"link","readOnly":true},"modifiedBy":{"type":"link","readOnly":true}},"links":{"assignedUser":{"type":"belongsTo","entity":"User"},"case":{"type":"belongsTo","entity":"Case"},"createdBy":{"type":"belongsTo","entity":"User"},"modifiedBy":{"type":"belongsTo","entity":"User"}},"collection":{"orderBy":"lastMessageAt","order":"desc","textFilterFields":["name","lastMessagePreview"]},"indexes":{"conversationKey":{"columns":["conversationKey","deleted"],"type":"unique"},"assignedUser":{"columns":["assignedUserId","deleted"]},"caseId":{"columns":["caseId","deleted"]},"conversationType":{"columns":["conversationType","deleted"]}}}
@@ -0,0 +1,76 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 200,
"required": true,
"trim": true
},
"key": {
"type": "varchar",
"maxLength": 100,
"required": true,
"trim": true,
"lowerCase": true
},
"description": {
"type": "varchar",
"maxLength": 500
},
"mode": {
"type": "enum",
"options": ["both", "case", "office"],
"default": "both",
"required": true,
"style": {
"both": "primary",
"case": "info",
"office": "warning"
}
},
"displayOrder": {
"type": "int",
"default": 100,
"min": 0
},
"content": {
"type": "text",
"required": true,
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 600
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {"type": "belongsTo", "entity": "User"},
"modifiedBy": {"type": "belongsTo", "entity": "User"}
},
"collection": {
"orderBy": "displayOrder",
"order": "asc",
"textFilterFields": ["name", "key", "description", "content"]
},
"indexes": {
"key": {"columns": ["key", "deleted"], "unique": true},
"modeActive": {"columns": ["mode", "isActive", "deleted"]}
}
}
@@ -0,0 +1,56 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 255,
"required": true
},
"rule": {
"type": "text",
"required": true
},
"scope": {
"type": "enum",
"options": ["global", "case", "office"],
"default": "global"
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
}
},
"indexes": {
"isActive": {
"columns": ["isActive", "deleted"]
},
"scope": {
"columns": ["scope", "isActive", "deleted"]
}
}
}
@@ -0,0 +1,62 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 100,
"required": true,
"trim": true
},
"description": {
"type": "varchar",
"maxLength": 500
},
"triggers": {
"type": "text",
"lengthOfMaxDisplay": 200
},
"content": {
"type": "text",
"required": true,
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 400
},
"version": {
"type": "varchar",
"maxLength": 20,
"default": "1.0"
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {"type": "belongsTo", "entity": "User"},
"modifiedBy": {"type": "belongsTo", "entity": "User"}
},
"collection": {
"orderBy": "name",
"order": "asc",
"textFilterFields": ["name", "description", "triggers", "content"]
},
"indexes": {
"name": {"columns": ["name", "deleted"], "unique": true},
"isActive": {"columns": ["isActive", "deleted"]}
}
}
@@ -0,0 +1,19 @@
{
"fields": {
"markedForDeletionAt": {
"type": "datetime",
"readOnly": true,
"tooltip": true
},
"markedForDeletionBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"markedForDeletionBy": {
"type": "belongsTo",
"entity": "User"
}
}
}
@@ -0,0 +1,63 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 255,
"required": true
},
"user": {
"type": "link",
"required": true
},
"content": {
"type": "text",
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 400
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"user": {
"type": "belongsTo",
"entity": "User"
},
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
}
},
"collection": {
"orderBy": "modifiedAt",
"order": "desc",
"textFilterFields": ["name", "content"]
},
"indexes": {
"userId": {
"columns": ["userId", "deleted"],
"unique": true
}
}
}
@@ -8,6 +8,10 @@
"type": "varchar",
"maxLength": 255
},
"espocrmApiKey": {
"type": "varchar",
"maxLength": 255
},
"maxMessagesPerHour": {
"type": "int",
"default": 30
@@ -23,6 +27,18 @@
"upcomingHearingDays": {
"type": "int",
"default": 7
},
"maxDocumentChars": {
"type": "int",
"default": 100000
},
"agenticLoopEnabled": {
"type": "bool",
"default": true
},
"maxAgenticLoops": {
"type": "int",
"default": 3
}
},
"allowUserAccess": false,
@@ -0,0 +1 @@
{"entity":true,"object":true,"module":"SmartAssistant","acl":true,"aclActionList":["create","read","edit","delete"],"aclLevelList":["all","team","own","no"],"tab":false,"stream":false,"disabled":false,"importable":false,"customizable":false}
@@ -0,0 +1,13 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "no"],
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -0,0 +1,13 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "team", "own", "no"],
"stream": false,
"tab": true,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -0,0 +1,13 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "no"],
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -5,7 +5,7 @@
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "team", "own", "no"],
"tab": false,
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
@@ -0,0 +1,13 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "own", "no"],
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -5,5 +5,6 @@
"espocrm": ">=8.0.0"
},
"author": "klear",
"description": "Unified AI Assistant for Legal CRM"
"description": "Unified AI Assistant for Legal CRM",
"clientModule": "smart-assistant"
}
@@ -1,4 +1,12 @@
[
{
"route": "/SmartAssistant/action/status",
"method": "get",
"params": {
"controller": "SmartAssistant",
"action": "status"
}
},
{
"route": "/SmartAssistant/action/chat",
"method": "post",
@@ -78,5 +86,13 @@
"controller": "SmartAssistant",
"action": "saveMemory"
}
},
{
"route": "/SmartAssistant/action/executeTool",
"method": "post",
"params": {
"controller": "SmartAssistant",
"action": "executeTool"
}
}
]
@@ -7,18 +7,21 @@ use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
class ActionExecutor
{
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Log $log;
private Metadata $metadata;
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log)
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log, Metadata $metadata)
{
$this->entityManager = $entityManager;
$this->injectableFactory = $injectableFactory;
$this->log = $log;
$this->metadata = $metadata;
}
public const VALID_STATUSES = [
@@ -38,12 +41,24 @@ class ActionExecutor
'add_note' => $this->addNote($params, $caseId, $userId),
'change_status' => $this->changeStatus($params, $caseId),
'create_meeting' => $this->createMeeting($params, $caseId, $userId),
'create_call' => $this->createCall($params, $caseId, $userId),
'delete_meeting' => $this->deleteMeeting($params),
'delete_call' => $this->deleteCall($params),
'delete_task' => $this->deleteTask($params),
'delete_note' => $this->deleteNote($params),
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
'list_documents' => $this->listDocuments($caseId),
'analyze_document' => $this->analyzeDocument($params, $caseId),
'read_document' => $this->readDocument($params, $caseId),
'read_multiple_documents' => $this->readMultipleDocuments($params, $caseId),
'rename_document' => $this->renameDocument($params),
'batch_rename_documents' => $this->batchRenameDocuments($params),
'upload_document' => $this->uploadDocument($params, $caseId),
'generate_document' => $this->generateDocument($params, $caseId),
'merge_documents' => $this->mergeDocuments($params, $caseId),
'save_memory' => $this->saveMemory($params, $caseId, $userId),
default => throw new Error("Unknown tool: {$tool}"),
'save_rule' => $this->saveRule($params, $userId),
default => $this->executePluginTool($tool, $params, $caseId, $userId),
};
}
@@ -74,28 +89,76 @@ class ActionExecutor
];
}
private function saveRule(array $params, string $userId): array
{
$name = $params['name'] ?? '';
$rule = $params['rule'] ?? '';
if (!$name || !$rule) {
throw new Error('save_rule requires name and rule parameters.');
}
$entity = $this->entityManager->getNewEntity('AssistantRule');
$entity->set([
'name' => $name,
'rule' => $rule,
'scope' => $params['scope'] ?? 'global',
'isActive' => true,
'createdById' => $userId,
]);
$this->entityManager->saveEntity($entity);
return [
'success' => true,
'message' => "✅ כלל חדש נשמר: \"{$name}\"",
'entityType' => 'AssistantRule',
'entityId' => $entity->get('id'),
];
}
private function createTask(array $params, ?string $caseId, string $userId): array
{
$task = $this->entityManager->getNewEntity('Task');
$task->set([
$taskData = [
'name' => $params['name'] ?? 'משימה חדשה',
'status' => 'Not Started',
'priority' => $params['priority'] ?? 'Normal',
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
'description' => $params['description'] ?? null,
'parentType' => 'Case', 'parentId' => $caseId,
'assignedUserId' => $params['assignedUserId'] ?? $userId,
]);
];
if ($caseId) {
$taskData['parentType'] = 'Case';
$taskData['parentId'] = $caseId;
}
$task = $this->entityManager->getNewEntity('Task');
$task->set($taskData);
$this->entityManager->saveEntity($task);
return ['success' => true, 'message' => "משימה \"{$params['name']}\" נוצרה בהצלחה", 'entityType' => 'Task', 'entityId' => $task->get('id')];
$msg = "✅ משימה \"{$params['name']}\" נוצרה בהצלחה";
if ($caseId) {
$case = $this->entityManager->getEntityById('Case', $caseId);
if ($case) {
$msg .= " בתיק " . $case->get('name');
}
}
return ['success' => true, 'message' => $msg, 'entityType' => 'Task', 'entityId' => $task->get('id')];
}
private function addNote(array $params, ?string $caseId, string $userId): array
{
$noteData = ['type' => 'Post', 'post' => $params['post'] ?? '', 'createdById' => $userId];
if ($caseId) {
$noteData['parentType'] = 'Case';
$noteData['parentId'] = $caseId;
}
$note = $this->entityManager->getNewEntity('Note');
$note->set(['type' => 'Post', 'post' => $params['post'] ?? '', 'parentType' => 'Case', 'parentId' => $caseId, 'createdById' => $userId]);
$note->set($noteData);
$this->entityManager->saveEntity($note);
return ['success' => true, 'message' => 'הערה נוספה לתיק', 'entityType' => 'Note', 'entityId' => $note->get('id')];
return ['success' => true, 'message' => 'הערה נוספה' . ($caseId ? ' לתיק' : ''), 'entityType' => 'Note', 'entityId' => $note->get('id')];
}
private function changeStatus(array $params, ?string $caseId): array
@@ -119,16 +182,137 @@ class ActionExecutor
private function createMeeting(array $params, ?string $caseId, string $userId): array
{
$meeting = $this->entityManager->getNewEntity('Meeting');
$meeting->set([
$meetingData = [
'name' => $params['name'] ?? 'פגישה חדשה', 'status' => 'Planned',
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
'description' => $params['description'] ?? null,
'parentType' => 'Case', 'parentId' => $caseId, 'assignedUserId' => $userId,
]);
'assignedUserId' => $userId,
];
if ($caseId) {
$meetingData['parentType'] = 'Case';
$meetingData['parentId'] = $caseId;
}
$meeting = $this->entityManager->getNewEntity('Meeting');
$meeting->set($meetingData);
$this->entityManager->saveEntity($meeting);
return ['success' => true, 'message' => "פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')];
return ['success' => true, 'message' => "פגישה \"{$params['name']}\" נקבעה בהצלחה", 'entityType' => 'Meeting', 'entityId' => $meeting->get('id')];
}
private function createCall(array $params, ?string $caseId, string $userId): array
{
$callData = [
'name' => $params['name'] ?? 'שיחה חדשה',
'status' => $params['status'] ?? 'Held',
'direction' => $params['direction'] ?? 'Outbound',
'dateStart' => $this->normalizeDateTime($params['dateStart'] ?? null),
'dateEnd' => $this->normalizeDateTime($params['dateEnd'] ?? null),
'description' => $params['description'] ?? null,
'assignedUserId' => $userId,
];
if ($caseId) {
$callData['parentType'] = 'Case';
$callData['parentId'] = $caseId;
}
$call = $this->entityManager->getNewEntity('Call');
$call->set($callData);
$this->entityManager->saveEntity($call);
return ['success' => true, 'message' => "✅ שיחה \"{$params['name']}\" תועדה בהצלחה", 'entityType' => 'Call', 'entityId' => $call->get('id')];
}
private function deleteMeeting(array $params): array
{
$entityId = $params['entityId'] ?? null;
if (!$entityId) {
throw new Error('entityId parameter is required.');
}
$entity = $this->entityManager->getEntityById('Meeting', $entityId);
if (!$entity) {
throw new NotFound("Meeting {$entityId} not found.");
}
$name = $entity->get('name') ?? '';
$this->entityManager->removeEntity($entity);
return [
'success' => true,
'message' => "✅ פגישה \"{$name}\" נמחקה בהצלחה",
'entityType' => 'Meeting',
'entityId' => $entityId,
];
}
private function deleteCall(array $params): array
{
$entityId = $params['entityId'] ?? null;
if (!$entityId) {
throw new Error('entityId parameter is required.');
}
$entity = $this->entityManager->getEntityById('Call', $entityId);
if (!$entity) {
throw new NotFound("Call {$entityId} not found.");
}
$name = $entity->get('name') ?? '';
$this->entityManager->removeEntity($entity);
return [
'success' => true,
'message' => "✅ שיחה \"{$name}\" נמחקה בהצלחה",
'entityType' => 'Call',
'entityId' => $entityId,
];
}
private function deleteNote(array $params): array
{
$entityId = $params['entityId'] ?? null;
if (!$entityId) {
throw new Error('entityId parameter is required.');
}
$entity = $this->entityManager->getEntityById('Note', $entityId);
if (!$entity) {
throw new NotFound("Note {$entityId} not found.");
}
$this->entityManager->removeEntity($entity);
return [
'success' => true,
'message' => '✅ הערה נמחקה בהצלחה',
'entityType' => 'Note',
'entityId' => $entityId,
];
}
private function deleteTask(array $params): array
{
$entityId = $params['entityId'] ?? null;
if (!$entityId) {
throw new Error('entityId parameter is required.');
}
$entity = $this->entityManager->getEntityById('Task', $entityId);
if (!$entity) {
throw new NotFound("Task {$entityId} not found.");
}
$name = $entity->get('name') ?? '';
$this->entityManager->removeEntity($entity);
return [
'success' => true,
'message' => "✅ משימה \"{$name}\" נמחקה בהצלחה",
'entityType' => 'Task',
'entityId' => $entityId,
];
}
private function scheduleHearing(array $params, ?string $caseId, string $userId): array
@@ -202,6 +386,65 @@ class ActionExecutor
return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]];
}
private function readDocument(array $params, ?string $caseId): array
{
$filePath = $params['filePath'] ?? null;
if (!$filePath) throw new Error('filePath parameter is required.');
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$text = $analyzer->extractFullText($filePath);
$fileName = basename($filePath);
if ($text === null) {
return ['success' => false, 'message' => "לא ניתן לחלץ טקסט מ-\"{$fileName}\""];
}
return [
'success' => true,
'message' => $text,
'data' => [
'filePath' => $filePath,
'fileName' => $fileName,
'charCount' => mb_strlen($text),
],
];
}
private function readMultipleDocuments(array $params, ?string $caseId): array
{
$filePaths = $params['filePaths'] ?? [];
if (empty($filePaths)) throw new Error('filePaths parameter is required (array of file paths).');
$maxPerFile = $params['maxCharsPerFile'] ?? 50000;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = $analyzer->extractMultipleDocuments($filePaths, $maxPerFile);
$lines = [];
$successCount = 0;
foreach ($results as $r) {
$lines[] = "=== {$r['fileName']} ===";
if (!empty($r['error'])) {
$lines[] = "שגיאה: {$r['error']}";
} elseif ($r['text'] === null) {
$lines[] = "לא ניתן לחלץ טקסט";
} else {
$lines[] = $r['text'];
$successCount++;
}
$lines[] = '';
}
return [
'success' => true,
'message' => implode("\n", $lines),
'data' => [
'totalFiles' => count($filePaths),
'successCount' => $successCount,
],
];
}
private function renameDocument(array $params): array
{
$sourcePath = $params['sourcePath'] ?? null;
@@ -214,6 +457,130 @@ class ActionExecutor
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
}
private function batchRenameDocuments(array $params): array
{
$renames = $params['renames'] ?? [];
if (empty($renames)) throw new Error('renames parameter is required (array of {sourcePath, newName}).');
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = [];
$successCount = 0;
foreach ($renames as $rename) {
$sourcePath = $rename['sourcePath'] ?? null;
$newName = $rename['newName'] ?? null;
if (!$sourcePath || !$newName) {
$results[] = "שגיאה: חסר sourcePath או newName";
continue;
}
try {
$result = $analyzer->renameDocument($sourcePath, $newName);
$results[] = "\"{$result['oldName']}\"\"{$result['newName']}\"";
$successCount++;
} catch (\Exception $e) {
$results[] = "שגיאה בשינוי שם \"" . basename($sourcePath) . "\": " . $e->getMessage();
}
}
$msg = "שינוי שמות: {$successCount}/" . count($renames) . " הצליחו\n" . implode("\n", $results);
return ['success' => true, 'message' => $msg];
}
private function uploadDocument(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('upload_document requires a case context.');
}
$fileName = $params['fileName'] ?? null;
$content = $params['content'] ?? null;
if (!$fileName) throw new Error('fileName parameter is required.');
if (!$content) throw new Error('content parameter is required (base64-encoded file data).');
$decoded = base64_decode($content, true);
if ($decoded === false) {
throw new Error('content is not valid base64.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$result = $analyzer->uploadDocument($caseId, $fileName, $decoded, $params['folder'] ?? null);
$msg = "✅ מסמך \"{$fileName}\" הועלה בהצלחה";
if (!empty($params['folder'])) {
$msg .= " לתיקייה \"{$params['folder']}\"";
}
return ['success' => true, 'message' => $msg, 'data' => $result];
}
private function generateDocument(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('generate_document requires a case context.');
}
$templateId = $params['templateId'] ?? null;
if (!$templateId) throw new Error('templateId parameter is required.');
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
if (!$template) throw new NotFound("Template {$templateId} not found.");
$documentSubject = $params['documentSubject'] ?? null;
$templateService = $this->injectableFactory->create(
\Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService::class
);
$result = $templateService->createFromTemplate($templateId, 'Case', $caseId, $documentSubject);
if (empty($result['success'])) {
throw new Error('Failed to generate document from template.');
}
$templateName = $template->get('name') ?? 'document';
$fileName = $result['fileName'] ?? "{$templateName}.docx";
$msg = "✅ מסמך \"{$fileName}\" נוצר בהצלחה מתבנית \"{$templateName}\"";
return [
'success' => true,
'message' => $msg,
'data' => ['fileName' => $fileName],
];
}
private function mergeDocuments(array $params, ?string $caseId): array
{
if (!$caseId) {
throw new Error('merge_documents requires a case context.');
}
$documentIds = $params['documentIds'] ?? [];
if (empty($documentIds)) throw new Error('documentIds parameter is required (array of document IDs).');
$documents = [];
foreach ($documentIds as $docId) {
$doc = $this->entityManager->getEntityById('Document', $docId);
if (!$doc) throw new NotFound("Document {$docId} not found.");
$documents[] = $doc;
}
$mergerService = $this->injectableFactory->create(
\Espo\Modules\NetworkStorageIntegration\Services\DocumentMergerService::class
);
$options = $params['options'] ?? [];
$mergedPath = $mergerService->mergeWithLabels($documents, $options);
return [
'success' => true,
'message' => "" . count($documents) . " מסמכים מוזגו בהצלחה",
'data' => ['path' => $mergedPath, 'documentCount' => count($documents)],
];
}
private function normalizeDateTime(?string $value): ?string
{
if ($value === null || $value === '') return null;
@@ -228,4 +595,23 @@ class ActionExecutor
if ($bytes >= 1024) return round($bytes / 1024, 1) . ' KB';
return $bytes . ' B';
}
private function executePluginTool(string $tool, array $params, ?string $caseId, string $userId): array
{
$handlerClass = $this->metadata->get(['app', 'smartAssistant', 'tools', $tool, 'handler']);
if (!$handlerClass) {
throw new Error("Unknown tool: {$tool}");
}
$this->log->debug("SmartAssistant: Executing plugin tool={$tool} handler={$handlerClass}");
$handler = $this->injectableFactory->create($handlerClass);
if (!method_exists($handler, 'execute')) {
throw new Error("Plugin tool handler {$handlerClass} must have an execute() method.");
}
return $handler->execute($params, $caseId, $userId);
}
}
@@ -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();
@@ -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;
@@ -106,7 +118,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 +144,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 +152,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 +168,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 +295,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,12 +341,84 @@ 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);
}
public function getOpenCaseItems(int $limit = 30): array
{
$items = [];
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('createdAt', 'DESC')
->limit(0, $limit)
->find();
$statusLabels = [
'New' => 'חדש', 'Assigned' => 'שובץ', 'Pending' => 'ממתין',
'PendingHearing' => 'ממתין לדיון', 'PendingDecision' => 'ממתין להחלטה',
'PendingResponse' => 'ממתין לתגובה', 'Negotiation' => 'משא ומתן', 'Suspended' => 'מושהה',
];
foreach ($cases as $case) {
$status = $case->get('status');
$label = $statusLabels[$status] ?? $status;
$assigned = $case->get('assignedUserName');
$items[] = [
'type' => 'open_case',
'severity' => 'info',
'caseId' => $case->get('id'),
'caseName' => $case->get('name'),
'message' => $case->get('name') . ' — ' . $label . ($assigned ? ' (' . $assigned . ')' : ''),
];
}
return $items;
}
public function getUpcomingHearingItems(int $days = 7): array
{
$today = date('Y-m-d');
$threshold = date('Y-m-d', strtotime("+{$days} days"));
$items = [];
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'cCourt', 'assignedUserName'])
->where([
'cNextHearing>=' => $today,
'cNextHearing<=' => $threshold,
'status!=' => self::CLOSED_STATUSES,
'deleted' => false,
])
->order('cNextHearing', 'ASC')
->find();
foreach ($cases as $case) {
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
$court = $case->get('cCourt');
$severity = ($daysUntil <= 1) ? 'warning' : 'info';
$msg = $case->get('name') . ' — דיון בעוד ' . $daysUntil . ' ימים';
if ($court) {
$msg .= ' (' . $court . ')';
}
$items[] = [
'type' => 'upcoming_hearing',
'severity' => $severity,
'caseId' => $case->get('id'),
'caseName' => $case->get('name'),
'hearingDate' => $case->get('cNextHearing'),
'daysUntil' => $daysUntil,
'message' => $msg,
];
}
return $items;
}
public function getUpcomingHearings(int $days = 7): array
{
$today = date('Y-m-d');
@@ -0,0 +1,52 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
/**
* Loads active prompt sections keyed by their `key` field, filtered by mode.
*
* Returned shape: `["tool_rules" => "...", "writing_style" => "...", ...]`.
* The Python prompt_builder treats a missing key (or empty string) as
* "use the in-code default" — so this provider is purely additive.
*/
class AssistantPromptContextProvider
{
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->entityManager = $entityManager;
$this->log = $log;
}
/**
* @return array<string, string> key => content
*/
public function getPromptsForMode(string $mode): array
{
$entries = $this->entityManager->getRDBRepository('AssistantPrompt')
->where([
'isActive' => true,
'deleted' => false,
'mode' => ['both', $mode],
])
->order('displayOrder', 'ASC')
->find();
$out = [];
foreach ($entries as $entry) {
$key = (string) $entry->get('key');
$content = (string) ($entry->get('content') ?? '');
if ($key === '' || $content === '') {
continue;
}
$out[$key] = $content;
}
return $out;
}
}
@@ -0,0 +1,41 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
class AssistantRuleContextProvider
{
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->entityManager = $entityManager;
$this->log = $log;
}
public function getRulesForMode(string $mode): array
{
$entries = $this->entityManager->getRDBRepository('AssistantRule')
->where([
'isActive' => true,
'deleted' => false,
'scope' => ['global', $mode],
])
->order('createdAt', 'ASC')
->find();
$rules = [];
foreach ($entries as $entry) {
$rules[] = [
'name' => $entry->get('name'),
'rule' => $entry->get('rule'),
'scope' => $entry->get('scope'),
];
}
return $rules;
}
}
@@ -0,0 +1,45 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
/**
* Lists active skills (metadata only — name/description/triggers) for the
* system prompt's "=== SKILLS (learned workflows) ===" hint. The full body
* is fetched on demand by shira-hermes' read_skill tool via REST.
*/
class AssistantSkillContextProvider
{
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->entityManager = $entityManager;
$this->log = $log;
}
/**
* @return array<int, array{name: string, description: string, triggers: string}>
*/
public function listSkills(): array
{
$entries = $this->entityManager->getRDBRepository('AssistantSkill')
->where(['isActive' => true, 'deleted' => false])
->order('name', 'ASC')
->find();
$skills = [];
foreach ($entries as $entry) {
$skills[] = [
'name' => (string) $entry->get('name'),
'description' => (string) ($entry->get('description') ?? ''),
'triggers' => (string) ($entry->get('triggers') ?? ''),
];
}
return $skills;
}
}
@@ -29,9 +29,11 @@ class CaseContextBuilder
'contacts' => $this->getCaseContacts($case),
'openTasks' => $this->getOpenTasks($caseId),
'upcomingMeetings' => $this->getUpcomingMeetings($caseId),
'recentCalls' => $this->getRecentCalls($caseId),
'recentNotes' => $this->getRecentNotes($caseId),
'availableTemplates' => $this->getAvailableTemplates(),
'documents' => $this->getDocumentListing($caseId),
'signatureRequests' => $this->getSignatureRequests($caseId),
'currentUser' => $this->getUserData($userId),
'validStatuses' => ActionExecutor::VALID_STATUSES,
];
@@ -81,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) {
@@ -96,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 = [];
@@ -113,6 +150,24 @@ class CaseContextBuilder
return $meetings;
}
private function getRecentCalls(string $caseId): array
{
$calls = [];
$collection = $this->entityManager->getRDBRepository('Call')
->where(['parentType' => 'Case', 'parentId' => $caseId])
->order('dateStart', 'DESC')->limit(0, 10)->find();
foreach ($collection as $c) {
$calls[] = [
'id' => $c->get('id'), 'name' => $c->get('name'),
'status' => $c->get('status'), 'direction' => $c->get('direction'),
'dateStart' => $c->get('dateStart'), 'dateEnd' => $c->get('dateEnd'),
'description' => $c->get('description'),
];
}
return $calls;
}
private function getRecentNotes(string $caseId): array
{
$notes = [];
@@ -133,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;
}
@@ -148,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,388 @@
<?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;
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
/**
* 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
{
$client = $this->getNds()->getClient();
// Split the (LLM-provided) relPath into folder + filename. Subfolders
// are allowed, but the final filename is sanitized and de-duplicated by
// the canonical namer (rule N1) — never trust the model to name files.
$normalized = $this->normalize($relPath);
if ($normalized === '') {
throw new BadRequest('Path is required.');
}
$slash = strrpos($normalized, '/');
$dirRel = $slash === false ? '' : substr($normalized, 0, $slash);
$baseName = $slash === false ? $normalized : substr($normalized, $slash + 1);
$ext = pathinfo($baseName, PATHINFO_EXTENSION) ?: 'bin';
$subject = pathinfo($baseName, PATHINFO_FILENAME);
$parentAbs = $dirRel === ''
? $this->getCaseRoot($caseId)
: $this->resolveSafePath($caseId, $this->sanitizeRelSegments($dirRel), requireExists: false);
$client->createFolderRecursive($parentAbs);
$storedName = LocalFilesystemClient::buildStoredFileName($client, $parentAbs, $subject, $ext);
$absInsideStorage = $parentAbs . '/' . $storedName;
$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
{
$cleanRel = $this->sanitizeRelSegments($this->normalize($relPath));
if ($cleanRel === '') {
throw new BadRequest('A valid folder name is required.');
}
$abs = $this->resolveSafePath($caseId, $cleanRel, 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.');
}
// Sanitize the model-chosen name (rule N1).
$newName = LocalFilesystemClient::sanitizeFileName($newName);
if ($newName === '') {
throw new BadRequest('newName is empty after sanitization.');
}
$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,
$this->sanitizeRelSegments($this->normalize($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);
}
/**
* Sanitize every segment of an already-normalized storage-relative path
* with the canonical file-name rules (rule N1). Empty segments are dropped.
*/
private function sanitizeRelSegments(string $normalizedPath): string
{
if ($normalizedPath === '') {
return '';
}
$segments = array_map(
static fn (string $seg): string => LocalFilesystemClient::sanitizeFileName($seg),
explode('/', $normalizedPath)
);
$segments = array_filter($segments, static fn (string $seg): bool => $seg !== '');
return implode('/', $segments);
}
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()
);
}
}
}
@@ -55,8 +55,7 @@ class ConversationRepository
if (!$entity) return;
}
$messages = $entity->get('messagesData') ?? [];
if (is_object($messages)) $messages = (array) $messages;
$messages = json_decode(json_encode($entity->get('messagesData') ?? []), true);
$newMessage = ['role' => $role, 'content' => $content, 'timestamp' => date('Y-m-d H:i:s')];
if (!empty($actions)) $newMessage['actions'] = $actions;
@@ -81,8 +80,7 @@ class ConversationRepository
$entity = $this->entityManager->getRDBRepository('AssistantConversation')
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
if (!$entity) return [];
$messages = $entity->get('messagesData') ?? [];
return is_object($messages) ? (array) $messages : $messages;
return json_decode(json_encode($entity->get('messagesData') ?? []), true);
}
public function getRecentConversations(?string $type = null, ?string $caseId = null, int $limit = 20): array
@@ -137,8 +135,7 @@ class ConversationRepository
->where(['conversationKey' => $conversationKey, 'deleted' => false])->findOne();
if (!$entity) return;
$data = $entity->get('messagesData') ?? [];
if (is_object($data)) $data = (array) $data;
$data = json_decode(json_encode($entity->get('messagesData') ?? []), true);
for ($i = count($data) - 1; $i >= 0; $i--) {
if (($data[$i]['role'] ?? '') === 'assistant') { $data[$i]['pendingActions'] = $actions; break; }
}
@@ -6,18 +6,19 @@ use Espo\Core\Exceptions\Error;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageFactory;
use Espo\Modules\SmartAssistant\Classes\FileStorage\FileStorageInterface;
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
class DocumentAnalyzer
{
private const MAX_TEXT_LENGTH = 2000;
private const DEFAULT_MAX_TEXT_LENGTH = 100000;
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
private const TMP_DIR = 'data/tmp/assistant-docs';
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Log $log;
private ?int $maxTextLength = null;
private const CATEGORY_PATTERNS = [
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
@@ -40,20 +41,78 @@ 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();
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(),
];
}
$storage = $this->getStorage();
$topLevel = $storage->listFolder($casePath);
$folders = []; $rootFiles = []; $totalFiles = 0;
foreach ($topLevel as $item) {
if ($item['isFolder']) {
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
if ($isFolder) {
$subFiles = [];
try {
foreach ($storage->listFolder($item['path']) as $sub) {
if (!$sub['isFolder']) {
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'], 'mimeType' => $sub['mimeType'], 'modified' => $sub['modified'] ?? null];
foreach ($client->listFolder($item['path']) as $sub) {
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
if (!$subIsFolder) {
$subFiles[] = ['name' => $sub['name'], 'path' => $sub['path'], 'size' => $sub['size'] ?? null, 'mimeType' => $sub['mimeType'] ?? null, 'modified' => $sub['modified'] ?? null];
$totalFiles++;
}
}
@@ -62,27 +121,38 @@ class DocumentAnalyzer
}
$folders[] = ['name' => $item['name'], 'path' => $item['path'], 'files' => $subFiles];
} else {
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'], 'mimeType' => $item['mimeType'], 'modified' => $item['modified'] ?? null];
$rootFiles[] = ['name' => $item['name'], 'path' => $item['path'], 'size' => $item['size'] ?? null, 'mimeType' => $item['mimeType'] ?? null, 'modified' => $item['modified'] ?? null];
$totalFiles++;
}
}
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
{
$storage = $this->getStorage();
$parentPath = dirname($filePath);
$maxLen = $this->getMaxTextLength();
$text = $this->extractFullText($filePath, $maxLen);
return $text;
}
public function extractFullText(string $filePath, ?int $maxLength = null): ?string
{
$client = $this->getStorageClient();
$filename = basename($filePath);
try { $items = $storage->listFolder($parentPath); } catch (\Exception $e) { return null; }
try { $content = $client->downloadFile($filePath); } catch (\Exception $e) { return null; }
$fileSize = null;
foreach ($items as $item) { if ($item['name'] === $filename) { $fileSize = $item['size']; break; } }
if ($fileSize !== null && $fileSize > self::MAX_FILE_SIZE) return null;
try { $content = $storage->downloadFile($filePath); } catch (\Exception $e) { return null; }
if (strlen($content) > self::MAX_FILE_SIZE) return null;
if (!is_dir(self::TMP_DIR)) mkdir(self::TMP_DIR, 0755, true);
$tmpFile = self::TMP_DIR . '/' . uniqid('doc_') . '_' . $filename;
@@ -101,13 +171,125 @@ class DocumentAnalyzer
if (file_exists($tmpFile)) unlink($tmpFile);
if ($text === null) return null;
if (mb_strlen($text) > self::MAX_TEXT_LENGTH) $text = mb_substr($text, 0, self::MAX_TEXT_LENGTH) . '...';
return trim($text);
$text = trim($text);
if ($maxLength !== null && mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength) . '...';
}
return $text;
}
/**
* Fetch raw file bytes + metadata for OCR/vision fallback.
*
* @return array{success: bool, fileName: string, mimeType: string, sizeBytes: int, base64: ?string, error: ?string}
*/
public function getDocumentBytes(string $filePath): array
{
$fileName = basename($filePath);
$client = $this->getStorageClient();
try {
$content = $client->downloadFile($filePath);
} catch (\Exception $e) {
return [
'success' => false,
'fileName' => $fileName,
'mimeType' => '',
'sizeBytes' => 0,
'base64' => null,
'error' => 'Failed to download: ' . $e->getMessage(),
];
}
$sizeBytes = strlen($content);
if ($sizeBytes > self::MAX_FILE_SIZE) {
return [
'success' => false,
'fileName' => $fileName,
'mimeType' => '',
'sizeBytes' => $sizeBytes,
'base64' => null,
'error' => 'File too large (max 10MB).',
];
}
$mimeType = $this->guessMimeType($fileName, $content);
return [
'success' => true,
'fileName' => $fileName,
'mimeType' => $mimeType,
'sizeBytes' => $sizeBytes,
'base64' => base64_encode($content),
'error' => null,
];
}
private function guessMimeType(string $fileName, string $content): string
{
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
$map = [
'pdf' => 'application/pdf',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc' => 'application/msword',
'txt' => 'text/plain',
'csv' => 'text/csv',
'log' => 'text/plain',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'tiff' => 'image/tiff',
'tif' => 'image/tiff',
'webp' => 'image/webp',
];
if (isset($map[$ext])) {
return $map[$ext];
}
// Fallback: finfo
if (function_exists('finfo_buffer')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_buffer($finfo, $content);
finfo_close($finfo);
if ($mime) return $mime;
}
return 'application/octet-stream';
}
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
{
$results = [];
foreach ($filePaths as $filePath) {
$fileName = basename($filePath);
try {
$text = $this->extractFullText($filePath, $maxPerFile);
$results[] = [
'path' => $filePath,
'fileName' => $fileName,
'text' => $text,
'charCount' => $text !== null ? mb_strlen($text) : 0,
];
} catch (\Exception $e) {
$results[] = [
'path' => $filePath,
'fileName' => $fileName,
'text' => null,
'charCount' => 0,
'error' => $e->getMessage(),
];
}
}
return $results;
}
public function renameDocument(string $sourcePath, string $newName): array
{
$storage = $this->getStorage();
$client = $this->getStorageClient();
$parentPath = dirname($sourcePath);
$oldName = basename($sourcePath);
@@ -120,21 +302,223 @@ class DocumentAnalyzer
$destination = $parentPath . '/' . $newName;
if ($sourcePath === $destination) return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
$storage->move($sourcePath, $destination);
$client->move($sourcePath, $destination);
$this->updateDocumentEntity($sourcePath, $destination);
return ['success' => true, 'oldPath' => $sourcePath, 'newPath' => $destination, 'oldName' => $oldName, 'newName' => $newName];
}
private function getCaseFolderPath(string $caseId): ?string
public function uploadDocument(string $caseId, string $fileName, string $content, ?string $subfolder = null): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
return $case ? ($case->get('nextCloudFolderPath') ?: null) : null;
$casePath = $this->getCaseFolderPath($caseId);
if (!$casePath) {
throw new Error('Case does not have a document folder configured (networkStorageFolderPath).');
}
$client = $this->getStorageClient();
$targetDir = $casePath;
if ($subfolder) {
$targetDir = $casePath . '/' . trim($subfolder, '/');
if (!$client->exists($targetDir)) {
$client->createFolderRecursive($targetDir);
}
}
$targetPath = $targetDir . '/' . $fileName;
$client->uploadFile($targetPath, $content);
return [
'path' => $targetPath,
'fileName' => $fileName,
'folder' => $subfolder,
];
}
private function getStorage(): FileStorageInterface
public function getCaseFolderPath(string $caseId): ?string
{
return $this->injectableFactory->create(FileStorageFactory::class)->create();
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 ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false];
}
$storedPath = $case->get('networkStorageFolderPath') ?: null;
if ($storedPath) {
return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true];
}
try {
$nds = $this->getNetworkDocumentService();
$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 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) {
return $this->maxTextLength;
}
try {
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
$this->maxTextLength = (int) ($data['maxDocumentChars'] ?? self::DEFAULT_MAX_TEXT_LENGTH);
return $this->maxTextLength;
}
} catch (\Exception $e) {
// Ignore
}
$this->maxTextLength = self::DEFAULT_MAX_TEXT_LENGTH;
return $this->maxTextLength;
}
private function getStorageClient(): StorageClientInterface
{
return $this->getNetworkDocumentService()->getClient();
}
private function getNetworkDocumentService(): NetworkDocumentService
{
return $this->injectableFactory->create(NetworkDocumentService::class);
}
private function extractFromPdf(string $tmpFile): ?string
@@ -149,29 +533,46 @@ 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
{
try {
$doc = $this->entityManager->getRDBRepository('Document')->where(['nextCloudPath' => $oldPath])->findOne();
$doc = $this->entityManager->getRDBRepository('Document')->where(['networkStoragePath' => $oldPath])->findOne();
if ($doc) {
$doc->set('nextCloudPath', $newPath);
$doc->set('nextCloudFilename', basename($newPath));
$doc->set('networkStoragePath', $newPath);
$doc->set('networkStorageFileName', basename($newPath));
$this->entityManager->saveEntity($doc);
}
} catch (\Exception $e) {}
@@ -0,0 +1,311 @@
<?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 Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
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);
// Subject-only naming (rule N1): no date prefix. The canonical stored
// name (+ "-{NNN}" on collision) is produced by NetworkDocumentService
// on upload; we then align the Espo Document/Attachment to it so the
// CRM, the attachment and the file on disk never disagree.
$baseName = LocalFilesystemClient::sanitizeFileName($title);
if ($baseName === '') {
$baseName = 'document';
}
$fileName = $baseName . '.docx';
$documentName = $title;
$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' => $documentName,
'fileId' => $attachment->getId(),
'fileName' => $fileName,
'assignedUserId' => $this->user->getId(),
]);
// Suppress the NSI upload hook: it fires on this save, before the Case
// link below exists, so it would file the document in the fallback
// folder. We upload explicitly (with the Case) right after.
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
$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);
if (!empty($result['success'])) {
$networkPath = $result['path'] ?? null;
$storedName = $result['fileName'] ?? $fileName;
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $documentName;
// Align DB names to the canonical stored name.
$document->set([
'name' => $storedBase,
'fileName' => $storedName,
'storageType' => 'network',
'networkStoragePath' => $networkPath,
'networkStorageFileName' => $storedName,
'networkStorageSize' => $result['size'] ?? strlen($docxBinary),
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
]);
$this->entityManager->saveEntity($document, [
'skipNetworkUpload' => true,
'silent' => true,
]);
$attachment->set('name', $storedName);
$this->entityManager->saveEntity($attachment, ['silent' => true]);
// The file now lives in network storage; drop the local copy.
$sourcePath = 'data/upload/' . $attachment->getSourceId();
if (file_exists($sourcePath)) {
@unlink($sourcePath);
}
$fileName = $storedName;
$documentName = $storedBase;
}
}
} 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 '{$documentName}' (type={$documentType}) for Case {$caseId}"
);
$message = "✅ המסמך \"{$documentName}\" נוצר בהצלחה וצורף לתיק";
if ($networkPath) {
$message .= " ולתיקיית הרשת";
}
$message .= ".";
return [
'success' => true,
'documentId' => $document->getId(),
'documentName' => $documentName,
'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}";
}
}
@@ -0,0 +1,248 @@
<?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 Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
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 subject-only document name (rule N1) — no contact,
// no em-dash. Defaults to the template name when no subject given.
$subject = trim((string) ($documentSubject ?? ''));
if ($subject === '') {
$subject = (string) $templateName;
}
$docName = $subject;
$baseName = LocalFilesystemClient::sanitizeFileName($subject);
if ($baseName === '') {
$baseName = 'document';
}
$fileName = $baseName . '.docx';
// Step 7: Create Attachment + Document
$attachment = $this->entityManager->getNewEntity('Attachment');
$attachment->set([
'name' => $fileName,
'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' => $fileName,
'assignedUserId' => $this->user->getId(),
]);
// Suppress the NSI upload hook (fires before the Case link below);
// we upload explicitly with the Case so it lands in the case folder.
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
// Link to Case
$this->entityManager->getRDBRepository('Case')
->getRelation($case, 'documents')
->relate($document);
// Upload to the case folder; align DB names to the canonical stored name.
try {
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
if ($nds->isEnabled()) {
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
if (!empty($result['success'])) {
$storedName = $result['fileName'] ?? $fileName;
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $docName;
$document->set([
'name' => $storedBase,
'fileName' => $storedName,
'storageType' => 'network',
'networkStoragePath' => $result['path'] ?? null,
'networkStorageFileName' => $storedName,
'networkStorageSize' => $result['size'] ?? strlen($content),
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
]);
$this->entityManager->saveEntity($document, [
'skipNetworkUpload' => true,
'silent' => true,
]);
$attachment->set('name', $storedName);
$this->entityManager->saveEntity($attachment, ['silent' => true]);
$sourcePath = 'data/upload/' . $attachment->getSourceId();
if (file_exists($sourcePath)) {
@unlink($sourcePath);
}
$docName = $storedBase;
}
}
} catch (\Throwable $e) {
// Document is safely persisted in Espo; the network copy is best-effort.
$this->log->warning(
"GenericTemplateGenerator: failed to copy to network storage for Case {$caseId}: " .
$e->getMessage()
);
}
$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);
}
}
@@ -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;
}
}
@@ -17,7 +17,18 @@ class SmartAssistantService
public const NOTE_TYPE_RESPONSE = 'SmartResponse';
public const NOTE_TYPE_ACTION = 'SmartAction';
private const READ_ONLY_TOOLS = ['list_documents', 'save_memory'];
// All tools execute inline — Shira has full permissions
private const TOOLS_REQUIRING_CASE = [
'change_status', 'schedule_hearing', 'list_documents', 'analyze_document',
'read_document', 'read_multiple_documents', 'upload_document',
'generate_document', 'merge_documents', 'save_memory',
'batch_rename_documents',
];
// Tools whose output is fed back to the AI for a follow-up webhook call
private const AGENTIC_TOOLS = ['analyze_document', 'read_document', 'read_multiple_documents', 'list_documents'];
private const DEFAULT_MAX_AGENTIC_LOOPS = 3;
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
@@ -70,6 +81,22 @@ class SmartAssistantService
}
}
// Load behavioral rules
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
// Load editable prompt sections (DB overrides for the Python defaults).
$promptProvider = $this->injectableFactory->create(AssistantPromptContextProvider::class);
$context['assistantPrompts'] = $promptProvider->getPromptsForMode($mode === 'case' ? 'case' : 'office');
// Load per-user profile (replaces /opt/data/profiles/{uid}.md).
$profileProvider = $this->injectableFactory->create(UserProfileContextProvider::class);
$context['userProfile'] = $profileProvider->getProfileForUser($userId);
// Load active skills (metadata only; full body fetched via REST when needed).
$skillProvider = $this->injectableFactory->create(AssistantSkillContextProvider::class);
$context['availableSkills'] = $skillProvider->listSkills();
// Generate conversation ID
if (!$conversationId) {
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
@@ -100,56 +127,32 @@ class SmartAssistantService
// Ignore
}
// Call webhook
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
// Process actions
$actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? '';
$inlineResults = [];
$remainingActions = [];
foreach ($actions as $action) {
$tool = $action['tool'] ?? '';
if (in_array($tool, self::READ_ONLY_TOOLS, true)) {
try {
$executor = $this->injectableFactory->create(ActionExecutor::class);
$result = $executor->execute($tool, $action['params'] ?? [], $caseId, $userId);
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Inline execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
}
} else {
$remainingActions[] = $action;
}
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
$effectiveCaseId = $caseId;
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
$effectiveCaseId = $context['drillDown']['case']['id'];
}
if (!empty($inlineResults)) {
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
}
// Store pending actions
if (!empty($remainingActions)) {
self::$conversations[$conversationId] = [
'caseId' => $caseId,
'userId' => $userId,
'actions' => $remainingActions,
// Execute tools with agentic loop — read tools feed results back to AI
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' => [],
];
try {
$this->getConversationRepo()->storePendingActions($conversationId, $caseId ?? '', $userId, $remainingActions);
} catch (\Exception $e) {
$this->log->warning('SmartAssistant: Failed to persist actions: ' . $e->getMessage());
$this->saveConversationFile($conversationId, $caseId, $userId, $remainingActions);
}
}
$responseText = $loopResult['responseText'];
$executedActions = $loopResult['executedActions'];
// Persist response
try {
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $remainingActions);
$this->getConversationRepo()->appendMessage($conversationId, 'assistant', $responseText, $executedActions);
} catch (\Exception $e) {
$this->log->warning('SmartAssistant: Failed to persist response: ' . $e->getMessage());
}
@@ -158,17 +161,160 @@ class SmartAssistantService
if ($mode === 'case' && $caseId) {
$this->createStreamNote(self::NOTE_TYPE_RESPONSE, $responseText, $caseId, [
'conversationId' => $conversationId,
'actions' => $remainingActions,
'actions' => $executedActions,
]);
}
return [
'conversationId' => $conversationId,
'text' => $responseText,
'actions' => $remainingActions,
'actions' => [],
'executedActions' => $executedActions,
];
}
private function executeActionsWithLoop(
string $message,
array $context,
string $conversationId,
array $conversationHistory,
string $mode,
?string $effectiveCaseId,
string $userId
): array {
$agenticEnabled = $this->getIntegrationSetting('agenticLoopEnabled', true);
$maxLoops = (int) $this->getIntegrationSetting('maxAgenticLoops', self::DEFAULT_MAX_AGENTIC_LOOPS);
$executor = $this->injectableFactory->create(ActionExecutor::class);
$loopCount = 0;
$allExecutedActions = [];
$currentHistory = $conversationHistory;
$finalResponseText = '';
while ($loopCount <= $maxLoops) {
// Call webhook (first call uses original message, subsequent calls use empty message)
$currentMessage = $loopCount === 0 ? $message : '';
$response = $this->callWebhook($currentMessage, $context, $conversationId, $currentHistory, $mode);
$actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? '';
// If no actions, we're done
if (empty($actions)) {
$finalResponseText = $responseText;
break;
}
$agenticResults = [];
$inlineResults = [];
foreach ($actions as $action) {
$tool = $action['tool'] ?? '';
$actionCaseId = $effectiveCaseId;
if (!$actionCaseId && !empty($action['params']['caseId'])) {
$actionCaseId = $action['params']['caseId'];
}
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
continue;
}
try {
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
$allExecutedActions[] = [
'tool' => $tool,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
];
if ($actionCaseId) {
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
'conversationId' => $conversationId,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
]);
}
// Separate agentic tools (need re-invocation) from inline tools
if ($agenticEnabled && in_array($tool, self::AGENTIC_TOOLS, true)) {
$agenticResults[] = [
'tool' => $tool,
'params' => $action['params'] ?? [],
'result' => $result['message'] ?? '',
];
} else {
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
}
}
// If no agentic results, we're done — return response with inline results
if (empty($agenticResults)) {
$finalResponseText = $responseText;
if (!empty($inlineResults)) {
$finalResponseText .= "\n\n" . implode("\n\n", $inlineResults);
}
break;
}
// Feed agentic results back to AI for next iteration
$this->log->debug("SmartAssistant: Agentic loop iteration " . ($loopCount + 1) . "" . count($agenticResults) . " tool results to feed back");
// Add the AI response to history
$currentHistory[] = ['role' => 'assistant', 'content' => $responseText];
// Add each tool result to history
foreach ($agenticResults as $ar) {
$currentHistory[] = [
'role' => 'tool_result',
'toolName' => $ar['tool'],
'content' => $ar['result'],
];
}
// Also append any inline results
if (!empty($inlineResults)) {
$currentHistory[] = [
'role' => 'tool_result',
'toolName' => '_inline_actions',
'content' => implode("\n\n", $inlineResults),
];
}
$loopCount++;
}
// Safety: if we exhausted the loop, use the last response
if ($loopCount > $maxLoops && empty($finalResponseText)) {
$finalResponseText = $response['text'] ?? 'הגעתי למגבלת הסיבובים. אנא נסה שוב.';
}
return [
'responseText' => $finalResponseText,
'executedActions' => $allExecutedActions,
];
}
private function getIntegrationSetting(string $key, $default = null)
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
return $data[$key] ?? $default;
}
return $default;
}
public function executeAction(string $conversationId, string $actionId, bool $approved): array
{
$conversation = $this->loadConversation($conversationId);
@@ -236,14 +382,27 @@ class SmartAssistantService
}
}
public function getStatus(): array
{
$webhookUrl = $this->getWebhookUrl();
return [
'enabled' => $webhookUrl !== null,
'webhookConfigured' => !empty($webhookUrl),
];
}
public function getSummary(): array
{
$alertCalc = $this->injectableFactory->create(AlertCalculator::class);
$thresholds = $this->getThresholds();
$hearingDays = $thresholds['upcomingHearingDays'] ?? 7;
return [
'summary' => $alertCalc->getSummary(),
'alerts' => $alertCalc->calculateAlerts($thresholds),
'openCases' => $alertCalc->getOpenCaseItems(),
'upcomingHearings' => $alertCalc->getUpcomingHearingItems($hearingDays),
];
}
@@ -343,12 +502,19 @@ class SmartAssistantService
throw new Error('SmartAssistant webhook URL is not configured. Go to Admin > Integrations > Smart Assistant.');
}
$siteUrl = $this->config->get('siteUrl') ?? '';
$payload = json_encode([
'message' => $message,
'context' => $context,
'conversationId' => $conversationId,
'conversationHistory' => $history,
'mode' => $mode,
'systemInstructions' => $this->getSystemInstructions(),
'espocrm' => [
'url' => rtrim($siteUrl, '/'),
'apiKey' => $this->getEspocrmApiKey(),
],
]);
$headers = ['Content-Type: application/json', 'Accept: application/json'];
@@ -364,7 +530,7 @@ class SmartAssistantService
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 120,
CURLOPT_TIMEOUT => 600,
CURLOPT_CONNECTTIMEOUT => 10,
]);
@@ -406,14 +572,11 @@ class SmartAssistantService
private function getWebhookUrl(): ?string
{
// Try SmartAssistant first, then fallback to old integrations
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
$integration = $this->entityManager->getEntityById('Integration', $name);
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->webhookUrl)) {
return $data->webhookUrl;
}
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->webhookUrl)) {
return $data->webhookUrl;
}
}
return null;
@@ -421,30 +584,58 @@ class SmartAssistantService
private function getApiKey(): ?string
{
foreach (['SmartAssistant', 'CrmAssistant', 'OfficeAssistant'] as $name) {
$integration = $this->entityManager->getEntityById('Integration', $name);
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->apiKey)) {
return $data->apiKey;
}
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->apiKey)) {
return $data->apiKey;
}
}
return null;
}
private function getEspocrmApiKey(): ?string
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = $integration->get('data') ?? (object) [];
if (!empty($data->espocrmApiKey)) {
return $data->espocrmApiKey;
}
}
return null;
}
private function getSystemInstructions(): array
{
return [
'call_vs_meeting' => implode(' ', [
'כשהמשתמש מדווח ששוחח, דיבר, התקשר, או שוחחה עם לקוח/ה — השתמש בכלי create_call (לא create_meeting).',
'כשהמשתמש מדווח שנפגש, קבע פגישה, או תיאם מפגש — השתמש בכלי create_meeting.',
'שיחת טלפון = create_call. פגישה פיזית/זום = create_meeting.',
]),
'open_questions' => implode(' ', [
'אם שאלת את המשתמש שאלה והוא לא ענה עליה בתגובה הבאה — חזור על השאלה בתחילת התשובה הבאה.',
'עקוב אחרי שאלות פתוחות ואל תוותר עליהן עד שהמשתמש עונה או אומר לך לוותר.',
]),
'behavioral_rules' => implode(' ', [
'אם context.assistantRules מכיל כללים — יש ליישם אותם בכל שיחה.',
'כללים גלובליים תקפים תמיד. כללי "case" תקפים רק במצב תיק. כללי "office" תקפים רק במצב משרד.',
'אם המשתמש מבקש להגדיר כלל חדש (למשל "תמיד תעשה X כש-Y") — השתמש בכלי save_rule כדי לשמור אותו.',
]),
];
}
private function getThresholds(): array
{
foreach (['SmartAssistant', 'OfficeAssistant'] as $name) {
$integration = $this->entityManager->getEntityById('Integration', $name);
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
return [
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
];
}
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
return [
'inactivityWarningDays' => $data['inactivityWarningDays'] ?? 14,
'inactivityCriticalDays' => $data['inactivityCriticalDays'] ?? 30,
'upcomingHearingDays' => $data['upcomingHearingDays'] ?? 7,
];
}
return [];
}
@@ -0,0 +1,46 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
/**
* Loads the active UserProfile row for a given user into Shira's context.
*
* Returns the markdown body that becomes the "=== ABOUT THIS USER ===" block
* in the system prompt. Returns "" when no profile exists; the Python prompt
* builder treats empty string as "skip this section".
*/
class UserProfileContextProvider
{
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->entityManager = $entityManager;
$this->log = $log;
}
public function getProfileForUser(string $userId): string
{
if (!$userId) {
return '';
}
$entity = $this->entityManager->getRDBRepository('UserProfile')
->where([
'userId' => $userId,
'isActive' => true,
'deleted' => false,
])
->findOne();
if (!$entity) {
return '';
}
return (string) ($entity->get('content') ?? '');
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

+4 -4
View File
@@ -1,13 +1,13 @@
{
"name": "Smart Assistant",
"name": "SmartAssistant",
"module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and n8n integration",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear",
"version": "1.0.2",
"version": "2.10.5",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-03-29",
"releaseDate": "2026-06-03",
"php": [
">=8.1"
]
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* AfterInstall script for SmartAssistant extension 2.10.x.
*
* Idempotently seeds the AssistantPrompt entity with 7 default prompt
* sections (tool_rules, writing_style, legal_assistance, personality_case,
* personality_office, other_rules_case, other_rules_office). If a row with
* the same `key` already exists it is left alone — admin edits win.
*
* Class must be named `AfterInstall` with NO namespace — that's what the
* EspoCRM extension installer looks for in scripts/AfterInstall.php.
* See application/Espo/Core/Upgrades/ExtensionManager.php scriptNames map.
*/
use Espo\Core\Container;
class AfterInstall
{
public function run(Container $container, $params = null): void
{
$entityManager = $container->get('entityManager');
$log = $container->get('log');
// The JSON file lives inside the installed module resources at runtime.
$resourceFile = 'custom/Espo/Modules/SmartAssistant/Resources/data/default-prompts.json';
if (!is_file($resourceFile)) {
$log->warning("[SmartAssistant] AfterInstall: $resourceFile not found, skipping seed");
return;
}
$json = file_get_contents($resourceFile);
$defaults = json_decode($json, true);
if (!is_array($defaults)) {
$log->warning("[SmartAssistant] AfterInstall: invalid JSON in $resourceFile");
return;
}
$created = 0;
$skipped = 0;
foreach ($defaults as $row) {
$key = $row['key'] ?? null;
if (!$key) {
continue;
}
$existing = $entityManager->getRDBRepository('AssistantPrompt')
->where(['key' => $key, 'deleted' => false])
->findOne();
if ($existing) {
$skipped++;
continue;
}
$entity = $entityManager->getNewEntity('AssistantPrompt');
$entity->set([
'key' => $key,
'name' => $row['name'] ?? $key,
'description' => $row['description'] ?? null,
'mode' => $row['mode'] ?? 'both',
'displayOrder' => $row['displayOrder'] ?? 100,
'content' => $row['content'] ?? '',
'isActive' => true,
]);
// skipAll bypasses beforeSave hooks that require a logged-in user
// service (installer runs without an HTTP session) and ACL checks
// (we're seeding firm-wide defaults; no user is implicated).
$entityManager->saveEntity($entity, ['skipAll' => true]);
$created++;
}
$log->info("[SmartAssistant] AfterInstall: seeded $created AssistantPrompt rows, $skipped already existed");
}
}