Compare commits

...

10 Commits

Author SHA1 Message Date
chaim 78beb82c7d chore: bump version to 2.11.0 (CaseFiles dual-backend integration — P5/P6/P7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 11:15:48 +00:00
chaim 7811943178 feat: place AI-generated documents in the CaseFiles case folder (P7)
GenericTemplateGenerator and FreeDocumentGenerator now, when CaseFilesCore is
installed (soft-detected), set the generated Document's folderId to the case
"מסמכים" folder via CaseFolderService so it shows in the CaseDocs panel.
Existing Case link + NetworkStorage upload unchanged.

Verified on dev (P7_OK): php -l clean on both generators; relate/find junction
consistency confirmed; folder placement works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 10:53:24 +00:00
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
52 changed files with 1516 additions and 171 deletions
File diff suppressed because one or more lines are too long
+62
View File
@@ -0,0 +1,62 @@
# SmartAssistant — Architecture (developer reference)
> Internal developer doc. Not shipped to clients. Customer overview: `README.md`. Security/dead-code findings: `_AUDIT/SmartAssistant/findings.md`.
**Module:** `SmartAssistant` · **Client module:** `smart-assistant` · **Load order:** 37 · **Requires:** EspoCRM ≥ 8.0.0, PHP ≥ 8.1
## What it is
A floating AI legal assistant. A chat widget (case mode / office mode) sends the user message plus rich context (case data, case memory, behavioral rules, prompt sections, learned skills, user profile) to the **shira-hermes** FastAPI backend via a webhook; shira returns response text + a list of tools, which `ActionExecutor` runs against the CRM (task/call/meeting CRUD, status changes, document + memory ops, rule saving), optionally looping tool results back to shira (agentic loop, max 3). Also computes office-wide alerts. Owns 6 entities (AssistantPrompt, AssistantRule, AssistantSkill, CaseMemory, UserProfile, AssistantConversation) and extends `Note` with 3 timeline note types.
## File-by-file
### Lifecycle
| File | Purpose |
|---|---|
| `scripts/AfterInstall.php` | Idempotently seeds 7 default AssistantPrompt sections (`skipAll`; admin edits win). No uninstall script (seeded entities persist). |
### Controllers
| File | Purpose |
|---|---|
| `Controllers/SmartAssistant.php` | ~30 actions: chat, execute, executeTool, alerts, summary, history, conversations, caseMemory, saveMemory, document read/analyze/upload/generate/rename/list/browse/write/move. (S1S6 — **ACL fixed 2026-06-20**: `assertCaseAccess()` on chat/caseMemory/saveMemory/history; document-read endpoints require `caseId` + ACL + `isPathWithinCase()` containment.) |
| `Controllers/{AssistantRule,AssistantPrompt,AssistantSkill,CaseMemory,UserProfile}.php` | Thin Record CRUD. **AssistantRule has no owner field — S5.** |
### Services
| File | Purpose |
|---|---|
| `SmartAssistantService.php` | Orchestrator: webhook call, agentic loop, conversation persistence, stream notes (SmartRequest/Response/Action), office drill-down. (**fixed 2026-06-20**: `detectDrillDown` ACL-filters matched cases (S6); dead static `$conversations` removed (D1).) Forwards espocrmApiKey — S9 still open. |
| `ConversationRepository.php` | File-based conversation JSON under `data/conversations/`. |
| `ActionExecutor.php` | Executes all shira tools. (**ACL fixed 2026-06-20**: resolves the acting user in `execute()`; central `CASE_TOOL_ACCESS` record-ACL gate for case-bound tools (S1); `assertEntityAccess(...,'delete')` on every delete tool (S4); `save_rule` admin-only (S5); plugin tools with a caseId require case edit — also closes LegalAssistance S1.) |
| `CaseContextBuilder.php` / `OfficeContextBuilder.php` | Build case / office context dicts. **Case context IDOR S3.** |
| `CaseMemoryContextProvider.php`, `AssistantRuleContextProvider.php`, `AssistantPromptContextProvider.php`, `AssistantSkillContextProvider.php`, `UserProfileContextProvider.php` | Load memories/rules/prompts/skills/profile into the prompt. |
| `CaseMemoryService.php` | CaseMemory CRUD (category/importance/pinned). **IDOR S3.** |
| `DocumentAnalyzer.php` | Text extraction/analysis; uses NetworkStorageIntegration NetworkDocumentService. (**fixed 2026-06-20**: added `isPathWithinCase()` containment helper the controller calls before any read — S2.) |
| `CaseFolderManager.php` | Write/rename/move constrained to the case folder (`resolveSafePath`/`sanitizeRelSegments` — safe). |
| `GenericTemplateGenerator.php` / `FreeDocumentGenerator.php` | Template-based / free-form document generation. |
| `AlertCalculator.php` | Office alerts (inactive cases, overdue tasks, hearings without prep, unassigned new cases, deadlines). |
### Hooks
| File | Trigger |
|---|---|
| `Hooks/Case/CaseFieldChangeMemory.php` | afterSave — auto-seed CaseMemory on status/judge/court/next-hearing change. |
| `Hooks/Meeting/HearingScheduledMemory.php` | afterSave — memory when a hearing Meeting is created. |
### Resources / client
| Path | Purpose |
|---|---|
| `entityDefs/{AssistantPrompt,AssistantRule,AssistantSkill,CaseMemory,UserProfile,Note,Case}.json` | 6 entities + Note.type extension + Case.caseMemories link. **AssistantRule/UserProfile ACL — S5/S7.** |
| `integrations/SmartAssistant.json` | webhookUrl, apiKey, espocrmApiKey, thresholds, agenticLoop. **Keys are varchar — S8.** |
| `routes.json` (12), `clientDefs/*`, `scopes/*`, `layouts/*`, `dashlets/SmartAssistant.json`, `i18n/{en_US,fa_IR}/*`, `data/default-prompts.json` | Routes, UI, ACL, dashlet, translations, seed prompts. |
| `client/.../src/views/floating-chat.js` | Main chat widget (history, memory browser, mode badge). escape-then-markdown — XSS-safe. |
| `client/.../src/views/dashlets/smart-assistant.js` | Alerts card. **Unescaped caseId href S10.** |
| `client/.../src/views/{case/modals/add-memory,fields/prompt-editor,site/navbar}.js`, `stream/notes/smart-{request,response,action}.js` | Memory modal, prompt editor, navbar inject, timeline note item views. |
## Dependencies
- **NetworkStorageIntegration (hard):** DocumentAnalyzer document ops.
- **shira-hermes (external, hard):** webhook (Integration record `SmartAssistant`). No chat without it.
- **KnowledgeBase → SmartAssistant (REVERSE, critical):** KnowledgeBase reuses this extension's Integration record. **Do not uninstall SmartAssistant while KnowledgeBase is installed.**
- **LegalCrm (soft):** Note.type extension.
## Post-install gotchas
- Configure the `SmartAssistant` Integration (webhookUrl + apiKey + espocrmApiKey) — scope the espocrmApiKey to a minimal API user (S9).
- Known deploy gotchas: a backup-in-modules folder breaks hooks; AfterInstall needs `skipAll`; new role entries need api-key role grant.
- shira-hermes `/opt/data` must be a named volume (else skills/profiles/cases wiped on redeploy).
+37 -102
View File
@@ -1,120 +1,55 @@
# SmartAssistant - עוזר חכם
# SmartAssistant עוזר AI למשרד
**גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
**גרסה:** 2.10.5 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
עוזר AI מאוחד למשרד עורכי דין. צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות,
סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל זיכרון תיק מובנה, ביצוע פעולות באישור המשתמש,
התראות משרד (תיקים לא פעילים, משימות באיחור, דיונים קרובים), ואינטגרציה עם ה‑Stream של התיק.
## תלויות
- Webhook חיצוני (n8n או דומה) לעיבוד AI
- NetworkStorageIntegration / NextCloudIntegration (אופציונלי — לניתוח מסמכים)
- **שירות ה‑AI (shirahermes)** — נדרש; העוזר מדבר איתו דרך Webhook. בלעדיו הצ'אט אינו פעיל.
- **NetworkStorageIntegration** — נדרש לניתוח וקריאת מסמכי התיק.
- **הערה:** אם מותקנת גם **KnowledgeBase**, היא משתמשת בהגדרות האינטגרציה של SmartAssistant — **אין להסיר את SmartAssistant כל עוד KnowledgeBase מותקנת.**
## התקנה
1. התקנה דרך Admin > Extensions
2. הגדרת Webhook: Admin > Integrations > Smart Assistant
## הגדרות אינטגרציה
1. הורד את `SmartAssistant-2.10.5.zip` והתקן דרך **ניהול → הרחבות**.
2. ב**ניהול → אינטגרציות → Smart Assistant** הזן את כתובת ה‑Webhook, מפתח ה‑API, ומפתח ה‑API של EspoCRM.
3. בצע Rebuild ורענון קשיח.
| שדה | תיאור | דוגמה |
|------|--------|--------|
| webhookUrl | כתובת Webhook לשירות AI | `https://n8n.example.com/webhook/assistant/chat` |
| apiKey | מפתח אימות (אופציונלי) | `sk-...` |
| maxMessagesPerHour | הגבלת קצב הודעות | `30` |
| inactivityWarningDays | סף אזהרה לחוסר פעילות בתיק | `14` |
| inactivityCriticalDays | סף קריטי לחוסר פעילות | `30` |
| upcomingHearingDays | חלון דיונים קרובים (ימים) | `7` |
## הגדרות
## אנטיטי: CaseMemory — זיכרון תיק
| הגדרה | תיאור |
|---|---|
| webhookUrl | כתובת שירות ה‑AI (shirahermes). |
| apiKey | מפתח אימות מול שירות ה‑AI. |
| espocrmApiKey | מפתח API שבו שירות ה‑AI חוזר לפנות ל‑EspoCRM (מומלץ לשייך למשתמש API עם הרשאות מצומצמות). |
| ספי התראה | ימי חוסר‑פעילות לאזהרה/קריטי, חלון דיונים קרובים. |
מאגר ידע מובנה לכל תיק, מחולק לקטגוריות:
## מה מתווסף למערכת
| קטגוריה | תיאור |
|----------|--------|
| key_facts | עובדות מפתח |
| strategy | אסטרטגיה |
| decisions | החלטות |
| contacts_notes | הערות על אנשי קשר |
| timeline | ציר זמן |
| documents_notes | הערות על מסמכים |
| billing_notes | הערות חיוב |
- **כפתור צ'אט צף** בכל מסך, עם היסטוריית שיחות ועיון בזיכרון התיק.
- **זיכרון תיק (CaseMemory):** מאגר ידע מובנה לכל תיק (עובדות מפתח, אסטרטגיה, החלטות, ציר זמן ועוד) — נשמר ידנית, ע"י העוזר, או אוטומטית בשינויי תיק.
- **דשבורד התראות** ותצוגות SmartRequest/SmartResponse/SmartAction בציר הזמן של התיק.
- **ניהול מהממשק:** עריכת פרומפטים, כללי התנהגות, מיומנויות ופרופילי משתמש.
**מקורות:** ידני (manual), עוזר AI (assistant), אוטומטי (auto — hooks)
## טיפול בעיות נפוצות
**רמות חשיבות:** low, normal, high, critical
| תסמין | סיבה | פתרון |
|---|---|---|
| הצ'אט לא מגיב | שירות ה‑AI לא מוגדר/לא זמין | בדוק את webhookUrl ושהשירות (shirahermes) רץ. |
| KnowledgeBase מפסיקה לעבוד | הוסרה/בוטלה אינטגרציית SmartAssistant | החזר את אינטגרציית SmartAssistant — KnowledgeBase תלויה בה. |
| ניתוח מסמכים נכשל | NetworkStorageIntegration לא מותקן | התקן את NetworkStorageIntegration. |
## רכיבים
## הרשאות
### Backend (PHP)
העוזר פועל בגבולות ההרשאות של המשתמש: כל פעולה, קריאת מסמך, זיכרון תיק או "צלילה" לתיק במצב משרד
מתבצעת רק על תיקים שהמשתמש מורשה לגשת אליהם, וקריאת מסמכים מוגבלת לתיקיית התיק הרלוונטי בלבד.
יצירת **כללי עוזר** (כללים החלים על כל המשרד) שמורה למנהל מערכת.
| רכיב | קובץ | תיאור |
|-------|------|--------|
| Controller | `Controllers/SmartAssistant.php` | API endpoints |
| שירות ראשי | `Services/SmartAssistantService.php` | תזמור צ'אט, שיחות, פעולות |
| בונה הקשר תיק | `Services/CaseContextBuilder.php` | אוסף נתוני תיק מלאים (אנשי קשר, משימות, מסמכים, זיכרון) |
| בונה הקשר משרד | `Services/OfficeContextBuilder.php` | סטטיסטיקות, התראות, עומס עבודה |
| מחשבון התראות | `Services/AlertCalculator.php` | זיהוי תיקים לא פעילים, משימות באיחור, דיונים קרובים |
| מבצע פעולות | `Services/ActionExecutor.php` | ביצוע פעולות שאושרו (משימה, פתק, דיון, שינוי סטטוס) |
| שירות זיכרון | `Services/CaseMemoryService.php` | CRUD לזיכרון תיק |
| ספק הקשר זיכרון | `Services/CaseMemoryContextProvider.php` | הזרקת זיכרון להקשר AI |
| מאגר שיחות | `Services/ConversationRepository.php` | שמירה ואחזור שיחות |
| מנתח מסמכים | `Services/DocumentAnalyzer.php` | סריקת מסמכים וסיווג |
---
### Hooks
| Hook | תיאור |
|------|--------|
| `Case/CaseFieldChangeMemory` | יצירת זיכרון אוטומטית בשינוי שדות תיק (סטטוס, שופט, דיון) |
| `Meeting/HearingScheduledMemory` | יצירת זיכרון בקביעת דיון חדש |
### Frontend (JavaScript)
| רכיב | תיאור |
|-------|--------|
| `floating-chat.js` | ממשק צ'אט צף (FAB) עם מגירת היסטוריה וזיכרון |
| `dashlets/smart-assistant.js` | דשלט עם מדדים והתראות |
| `stream/notes/smart-request.js` | תצוגת הודעת משתמש ב-Stream |
| `stream/notes/smart-response.js` | תצוגת תשובת עוזר ב-Stream |
| `stream/notes/smart-action.js` | תצוגת פעולה שבוצעה ב-Stream |
| `case/modals/add-memory.js` | מודל להוספת זיכרון ידנית |
| `site/navbar.js` | אינטגרציית התראות בסרגל ניווט |
## API Routes
| נתיב | Method | תיאור |
|-------|--------|--------|
| `/SmartAssistant/action/chat` | POST | שליחת הודעה לעוזר |
| `/SmartAssistant/action/execute` | POST | ביצוע פעולה שאושרה |
| `/SmartAssistant/action/summary` | GET | סיכום משרדי + התראות |
| `/SmartAssistant/action/alerts` | GET | רשימת התראות |
| `/SmartAssistant/action/history` | GET | היסטוריית שיחות |
| `/SmartAssistant/action/conversations` | GET | רשימת שיחות אחרונות |
| `/SmartAssistant/action/conversationMessages` | GET | הודעות שיחה ספציפית |
| `/SmartAssistant/action/searchHistory` | GET | חיפוש בשיחות |
| `/SmartAssistant/action/caseMemory` | GET | אחזור זיכרון תיק |
| `/SmartAssistant/action/saveMemory` | POST | שמירת זיכרון ידנית |
## פעולות עוזר
### פעולות מיידיות (ללא אישור)
- `list_documents` — רשימת מסמכים בתיק
- `save_memory` — שמירת זיכרון תיק
### פעולות הדורשות אישור
- `create_task` — יצירת משימה
- `add_note` — הוספת הערה
- `change_status` — שינוי סטטוס תיק
- `create_meeting` — יצירת פגישה
- `schedule_hearing` — קביעת דיון
- `analyze_document` — ניתוח מסמך
- `rename_document` — שינוי שם מסמך
## מערכת התראות
| סוג | סף ברירת מחדל | חומרה |
|------|----------------|--------|
| תיק לא פעיל | 14 יום | אזהרה |
| תיק לא פעיל | 30 יום | קריטי |
| משימה באיחור | תאריך יעד עבר | קריטי |
| דיון קרוב | 7 ימים | אזהרה |
| תיק ללא שיוך | חדש ללא assignedUser | אזהרה |
| משימה בעדיפות גבוהה | 5 ימים | אזהרה |
> תיעוד טכני למפתחים (שירותים, פעולות, hooks, זרימת ה‑webhook): `ARCHITECTURE.md`.
@@ -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',
});
}
}
},
});
});
@@ -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
{
}
@@ -8,6 +8,8 @@ use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\InjectableFactory;
use Espo\Core\Acl;
use Espo\Core\Exceptions\NotFound;
use Espo\ORM\EntityManager;
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
@@ -22,12 +24,14 @@ class SmartAssistant
private InjectableFactory $injectableFactory;
private Acl $acl;
private User $user;
private EntityManager $entityManager;
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user, EntityManager $entityManager)
{
$this->injectableFactory = $injectableFactory;
$this->acl = $acl;
$this->user = $user;
$this->entityManager = $entityManager;
}
private function getService(): SmartAssistantService
@@ -42,6 +46,24 @@ class SmartAssistant
}
}
/**
* Record-level ACL on a specific case. The scope check in checkAccess() is
* not enough — without this, any user with Case-scope read could read/write
* the data of ANY case by passing its id (findings S2/S3).
*/
private function assertCaseAccess(string $caseId, string $action = 'read'): void
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) {
throw new NotFound('Case not found.');
}
if (!$this->acl->checkEntity($case, $action)) {
throw new Forbidden('No access to this case.');
}
}
public function getActionStatus(Request $request, Response $response): array
{
return $this->getService()->getStatus();
@@ -63,6 +85,10 @@ class SmartAssistant
throw new BadRequest('caseId is required for case mode.');
}
if ($mode === 'case') {
$this->assertCaseAccess($caseId, 'read');
}
return $this->getService()->chat(
trim($data->message),
$mode,
@@ -103,6 +129,11 @@ class SmartAssistant
{
$this->checkAccess();
$caseId = $request->getQueryParam('caseId');
if (!empty($caseId)) {
$this->assertCaseAccess($caseId, 'read');
}
return $this->getService()->getHistory($caseId);
}
@@ -146,6 +177,8 @@ class SmartAssistant
throw new BadRequest('caseId is required.');
}
$this->assertCaseAccess($caseId, 'read');
$category = $request->getQueryParam('category');
$includeArchived = (bool) $request->getQueryParam('includeArchived');
@@ -162,6 +195,8 @@ class SmartAssistant
throw new BadRequest('caseId and content are required.');
}
$this->assertCaseAccess($data->caseId, 'edit');
$service = $this->injectableFactory->create(CaseMemoryService::class);
$entry = $service->createMemory(
$data->caseId,
@@ -206,9 +241,20 @@ class SmartAssistant
throw new BadRequest('filePath is required.');
}
$caseId = $data->caseId ?? null;
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$this->assertCaseAccess($caseId, 'read');
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
if (!$analyzer->isPathWithinCase($filePath, $caseId)) {
throw new Forbidden('File is outside the case folder.');
}
$text = $analyzer->extractFullText($filePath, $maxLength);
return [
@@ -229,7 +275,18 @@ class SmartAssistant
throw new BadRequest('filePath is required.');
}
$caseId = $data->caseId ?? null;
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$this->assertCaseAccess($caseId, 'read');
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
if (!$analyzer->isPathWithinCase($filePath, $caseId)) {
throw new Forbidden('File is outside the case folder.');
}
return $analyzer->getDocumentBytes($filePath);
}
@@ -243,9 +300,22 @@ class SmartAssistant
throw new BadRequest('filePaths is required (array of file paths).');
}
$caseId = $data->caseId ?? null;
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$this->assertCaseAccess($caseId, 'read');
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
foreach ((array) $filePaths as $fp) {
if (!$analyzer->isPathWithinCase($fp, $caseId)) {
throw new Forbidden('One or more files are outside the case folder.');
}
}
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
return [
@@ -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,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,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 כשהיא צריכה אותו."
}
}
@@ -2,11 +2,17 @@
"scopeNames": {
"CaseMemory": "זיכרון תיק",
"AssistantRule": "כלל התנהגות",
"UserProfile": "פרופיל משתמש",
"AssistantSkill": "מיומנות שירה",
"AssistantPrompt": "פרומפט שירה",
"SmartAssistant": "עוזר חכם"
},
"scopeNamesPlural": {
"CaseMemory": "זיכרונות תיקים",
"AssistantRule": "כללי התנהגות",
"UserProfile": "פרופילי משתמשים",
"AssistantSkill": "מיומנויות שירה",
"AssistantPrompt": "פרומפטים של שירה",
"SmartAssistant": "עוזר חכם"
}
}
@@ -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,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 @@
{
"controller": "controllers/record",
"boolFilterList": ["onlyMy"],
"filterList": [{"name": "active"}, {"name": "inactive"}],
"color": "#7c8caf",
"iconClass": "fas fa-user-cog"
}
@@ -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,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,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
}
}
}
@@ -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
}
@@ -6,6 +6,8 @@
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "team", "own", "no"],
"stream": false,
"tab": false,
"disabled": 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
}
@@ -3,8 +3,11 @@
namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\Core\AclManager;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
use Espo\Core\Utils\Metadata;
@@ -15,13 +18,52 @@ class ActionExecutor
private InjectableFactory $injectableFactory;
private Log $log;
private Metadata $metadata;
private AclManager $aclManager;
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log, Metadata $metadata)
/** The user on whose behalf the current tool batch runs (set in execute()). */
private ?Entity $actingUser = null;
public function __construct(EntityManager $entityManager, InjectableFactory $injectableFactory, Log $log, Metadata $metadata, AclManager $aclManager)
{
$this->entityManager = $entityManager;
$this->injectableFactory = $injectableFactory;
$this->log = $log;
$this->metadata = $metadata;
$this->aclManager = $aclManager;
}
/**
* Tools that act within a Case context, and the ACL action each requires on
* that Case. EspoCRM does NOT auto-ACL agent tools, so executeTool would
* otherwise let any user with Case-scope read create/modify/read data on
* ANY case by passing its id. The central gate in execute() closes that.
*/
private const CASE_TOOL_ACCESS = [
'create_task' => 'edit',
'add_note' => 'edit',
'change_status' => 'edit',
'create_meeting' => 'edit',
'create_call' => 'edit',
'schedule_hearing' => 'edit',
'save_memory' => 'edit',
'upload_document' => 'edit',
'generate_document' => 'edit',
'merge_documents' => 'edit',
'list_documents' => 'read',
'analyze_document' => 'read',
'read_document' => 'read',
'read_multiple_documents' => 'read',
];
/**
* Resolve the acting user and assert record-level access on the entity.
* Used by the delete tools, which key off an entityId rather than a caseId.
*/
private function assertEntityAccess(Entity $entity, string $action): void
{
if (!$this->actingUser || !$this->aclManager->checkEntity($this->actingUser, $entity, $action)) {
throw new Forbidden('No access to the requested record.');
}
}
public const VALID_STATUSES = [
@@ -36,6 +78,32 @@ class ActionExecutor
{
$this->log->debug("SmartAssistant: Executing tool={$tool} for case={$caseId}");
// Resolve the acting user once; every ACL check below is on their behalf.
$this->actingUser = $this->entityManager->getEntityById('User', $userId);
if (!$this->actingUser) {
throw new Forbidden('Invalid user context.');
}
// Central record-level ACL gate for every case-bound tool. Without this,
// a generic "Case read" scope check (in the controller) transitively
// authorised writing to and reading from ANY case (findings S1/S3).
if (isset(self::CASE_TOOL_ACCESS[$tool])) {
if (!$caseId) {
throw new Error("Tool {$tool} requires a case context.");
}
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) {
throw new NotFound('Case not found.');
}
if (!$this->aclManager->checkEntity($this->actingUser, $case, self::CASE_TOOL_ACCESS[$tool])) {
throw new Forbidden('No access to this case.');
}
}
return match ($tool) {
'create_task' => $this->createTask($params, $caseId, $userId),
'add_note' => $this->addNote($params, $caseId, $userId),
@@ -91,6 +159,14 @@ class ActionExecutor
private function saveRule(array $params, string $userId): array
{
// AssistantRule rows are injected into the system prompt for other users
// (case/office/global scope), so creating them is an admin-only operation.
// Without this, any user could plant firm-wide instructions (stored
// prompt injection — finding S5).
if (!$this->actingUser || !$this->actingUser->get('isAdmin')) {
throw new Forbidden('Only an administrator can create assistant rules.');
}
$name = $params['name'] ?? '';
$rule = $params['rule'] ?? '';
if (!$name || !$rule) {
@@ -237,6 +313,8 @@ class ActionExecutor
}
$name = $entity->get('name') ?? '';
$this->assertEntityAccess($entity, 'delete');
$this->entityManager->removeEntity($entity);
return [
@@ -260,6 +338,8 @@ class ActionExecutor
}
$name = $entity->get('name') ?? '';
$this->assertEntityAccess($entity, 'delete');
$this->entityManager->removeEntity($entity);
return [
@@ -282,6 +362,8 @@ class ActionExecutor
throw new NotFound("Note {$entityId} not found.");
}
$this->assertEntityAccess($entity, 'delete');
$this->entityManager->removeEntity($entity);
return [
@@ -305,6 +387,8 @@ class ActionExecutor
}
$name = $entity->get('name') ?? '';
$this->assertEntityAccess($entity, 'delete');
$this->entityManager->removeEntity($entity);
return [
@@ -604,6 +688,22 @@ class ActionExecutor
throw new Error("Unknown tool: {$tool}");
}
// Plugin tools (e.g. LegalAssistance's report generator) act on a case
// when one is supplied. Enforce record-level edit ACL here so a plugin
// tool can't be driven against a case the user can't access — this is
// the central gate that also closes LegalAssistance finding S1.
if ($caseId) {
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) {
throw new NotFound('Case not found.');
}
if (!$this->actingUser || !$this->aclManager->checkEntity($this->actingUser, $case, 'edit')) {
throw new Forbidden('No access to this case.');
}
}
$this->log->debug("SmartAssistant: Executing plugin tool={$tool} handler={$handlerClass}");
$handler = $this->injectableFactory->create($handlerClass);
@@ -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,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;
}
}
@@ -11,6 +11,7 @@ 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
@@ -34,14 +35,32 @@ class CaseFolderManager
*/
public function writeFile(string $caseId, string $relPath, string $content): array
{
$absInsideStorage = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$client = $this->getNds()->getClient();
$parent = $this->dirnameRel($absInsideStorage);
if ($parent !== '') {
$client->createFolderRecursive($parent);
// 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}");
@@ -61,7 +80,12 @@ class CaseFolderManager
*/
public function createFolder(string $caseId, string $relPath): array
{
$abs = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$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);
@@ -91,6 +115,12 @@ class CaseFolderManager
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);
@@ -124,7 +154,11 @@ class CaseFolderManager
public function moveItem(string $caseId, string $sourceRelPath, string $targetRelPath): array
{
$absSource = $this->resolveSafePath($caseId, $sourceRelPath, requireExists: true);
$absTarget = $this->resolveSafePath($caseId, $targetRelPath, requireExists: false);
$absTarget = $this->resolveSafePath(
$caseId,
$this->sanitizeRelSegments($this->normalize($targetRelPath)),
requireExists: false
);
$client = $this->getNds()->getClient();
@@ -297,6 +331,26 @@ class CaseFolderManager
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, '/');
@@ -346,6 +346,36 @@ class DocumentAnalyzer
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
* source: 'stored' | 'computed' | 'none'
*/
/**
* Containment check: is $filePath inside the given case's storage folder?
* Used to stop a user with access to one case from reading another case's
* files by passing a raw absolute/relative path (finding S2). Rejects any
* path containing '..' and requires a resolvable case folder.
*/
public function isPathWithinCase(string $filePath, string $caseId): bool
{
$normalized = str_replace('\\', '/', (string) $filePath);
if ($normalized === '' || str_contains($normalized, '..')) {
return false;
}
$caseFolder = $this->describeCaseFolderPath($caseId)['path'] ?? null;
if (!$caseFolder) {
return false;
}
$needle = ltrim(rtrim(str_replace('\\', '/', $caseFolder), '/'), '/');
$candidate = ltrim($normalized, '/');
if ($needle === '') {
return false;
}
return $candidate === $needle || str_starts_with($candidate, $needle . '/');
}
public function describeCaseFolderPath(string $caseId): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
@@ -9,6 +9,7 @@ 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;
@@ -50,9 +51,16 @@ class FreeDocumentGenerator
$docxBinary = $this->buildDocx($title, $body, $recipient);
$sanitizedTitle = $this->sanitizeFileName($title);
$today = date('Y-m-d');
$fileName = "{$today} - {$sanitizedTitle}.docx";
// 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([
@@ -67,23 +75,71 @@ class FreeDocumentGenerator
$document = $this->entityManager->getNewEntity('Document');
$document->set([
'name' => $title,
'name' => $documentName,
'fileId' => $attachment->getId(),
'fileName' => $fileName,
'assignedUserId' => $this->user->getId(),
]);
$this->entityManager->saveEntity($document);
// 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);
// CaseFiles backend: place the Document in the case "מסמכים" folder.
$caseFilesServiceClass = 'Espo\\Modules\\CaseFilesCore\\Services\\CaseFolderService';
if (class_exists($caseFilesServiceClass)) {
try {
$cfFolder = $this->injectableFactory->create($caseFilesServiceClass)
->getOrCreateSubfolder('Case', $caseId, 'מסמכים');
$document->set('folderId', $cfFolder->getId());
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true, 'silent' => true]);
} catch (\Throwable $e) {
$this->log->warning('FreeDocumentGenerator: CaseFiles folder placement failed: ' . $e->getMessage());
}
}
$networkPath = null;
try {
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
if ($nds->isEnabled()) {
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
$networkPath = $result['path'] ?? null;
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.
@@ -94,10 +150,10 @@ class FreeDocumentGenerator
}
$this->log->info(
"FreeDocumentGenerator: Created '{$title}' (type={$documentType}) for Case {$caseId}"
"FreeDocumentGenerator: Created '{$documentName}' (type={$documentType}) for Case {$caseId}"
);
$message = "✅ המסמך \"{$title}\" נוצר בהצלחה וצורף לתיק";
$message = "✅ המסמך \"{$documentName}\" נוצר בהצלחה וצורף לתיק";
if ($networkPath) {
$message .= " ולתיקיית הרשת";
}
@@ -106,7 +162,7 @@ class FreeDocumentGenerator
return [
'success' => true,
'documentId' => $document->getId(),
'documentName' => $title,
'documentName' => $documentName,
'fileName' => $fileName,
'networkPath' => $networkPath,
'message' => $message,
@@ -265,11 +321,4 @@ class FreeDocumentGenerator
$year = $dt->format('Y');
return "{$day} ב{$month} {$year}";
}
private function sanitizeFileName(string $name): string
{
$name = preg_replace('/[\\/:*?"<>|]/', '', $name);
$name = preg_replace('/\s+/', ' ', $name);
return trim(mb_substr($name, 0, 100));
}
}
@@ -10,6 +10,7 @@ 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
@@ -72,14 +73,23 @@ class GenericTemplateGenerator
@unlink($outputPath);
@unlink($localTemplatePath);
// Step 6: Build document name
$contactName = $this->getContactName($case);
$docName = $documentSubject ?? "{$templateName}{$contactName}";
// 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' => $docName . '.docx',
'name' => $fileName,
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'role' => 'Attachment',
'size' => strlen($content),
@@ -92,16 +102,74 @@ class GenericTemplateGenerator
$document->set([
'name' => $docName,
'fileId' => $attachment->getId(),
'fileName' => $docName . '.docx',
'fileName' => $fileName,
'assignedUserId' => $this->user->getId(),
]);
$this->entityManager->saveEntity($document);
// 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);
// CaseFiles backend: place the Document in the case "מסמכים" folder.
$caseFilesServiceClass = 'Espo\\Modules\\CaseFilesCore\\Services\\CaseFolderService';
if (class_exists($caseFilesServiceClass)) {
try {
$cfFolder = $this->injectableFactory->create($caseFilesServiceClass)
->getOrCreateSubfolder('Case', $caseId, 'מסמכים');
$document->set('folderId', $cfFolder->getId());
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true, 'silent' => true]);
} catch (\Throwable $e) {
$this->log->warning('GenericTemplateGenerator: CaseFiles folder placement failed: ' . $e->getMessage());
}
}
// 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 [
@@ -190,24 +258,4 @@ class GenericTemplateGenerator
$processor->saveAs($outputPath);
}
private function getContactName($case): string
{
$contactId = $case->get('contactId');
if (!$contactId) {
$ids = $case->getLinkMultipleIdList('contacts');
if (!empty($ids)) {
$contactId = $ids[0];
}
}
if ($contactId) {
$contact = $this->entityManager->getEntityById('Contact', $contactId);
if ($contact) {
return trim($contact->get('firstName') . ' ' . $contact->get('lastName'));
}
}
return $case->get('name') ?? '';
}
}
@@ -5,6 +5,7 @@ namespace Espo\Modules\SmartAssistant\Services;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\InjectableFactory;
use Espo\Core\AclManager;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
use Espo\ORM\EntityManager;
@@ -35,21 +36,22 @@ class SmartAssistantService
private Config $config;
private Log $log;
private User $user;
private static array $conversations = [];
private AclManager $aclManager;
public function __construct(
EntityManager $entityManager,
InjectableFactory $injectableFactory,
Config $config,
Log $log,
User $user
User $user,
AclManager $aclManager
) {
$this->entityManager = $entityManager;
$this->injectableFactory = $injectableFactory;
$this->config = $config;
$this->log = $log;
$this->user = $user;
$this->aclManager = $aclManager;
}
private function getConversationRepo(): ConversationRepository
@@ -85,6 +87,18 @@ class SmartAssistantService
$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_';
@@ -474,6 +488,13 @@ class SmartAssistantService
foreach ($cases as $case) {
if (mb_stripos($message, $case->get('name')) !== false) {
// Record-level ACL: only drill into a case the user may read.
// Without this, naming any case in office-mode chat would surface
// its data regardless of access (finding S6).
if (!$this->aclManager->checkEntity($this->user, $case, 'read')) {
continue;
}
$contextBuilder = $this->injectableFactory->create(OfficeContextBuilder::class);
return $contextBuilder->buildCaseDrillDown($case->get('id'), $this->user);
}
@@ -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') ?? '');
}
}
+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear",
"version": "2.9.1",
"version": "2.11.0",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-05-26",
"releaseDate": "2026-06-21",
"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");
}
}