Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78beb82c7d | |||
| 7811943178 | |||
| c1476d5c52 | |||
| 68b3536084 | |||
| 97f7607b2b | |||
| bd1d484e74 | |||
| d266380e6d | |||
| 1d200fc3f6 | |||
| 65faf99e11 | |||
| e40f3d4534 | |||
| 89b3f5844e | |||
| 37a8b67b95 | |||
| 39d9093bf6 |
File diff suppressed because one or more lines are too long
@@ -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. (S1–S6 — **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).
|
||||||
@@ -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 (shira‑hermes)** — נדרש; העוזר מדבר איתו דרך 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 (shira‑hermes). |
|
||||||
|
| apiKey | מפתח אימות מול שירות ה‑AI. |
|
||||||
|
| espocrmApiKey | מפתח API שבו שירות ה‑AI חוזר לפנות ל‑EspoCRM (מומלץ לשייך למשתמש API עם הרשאות מצומצמות). |
|
||||||
|
| ספי התראה | ימי חוסר‑פעילות לאזהרה/קריטי, חלון דיונים קרובים. |
|
||||||
|
|
||||||
מאגר ידע מובנה לכל תיק, מחולק לקטגוריות:
|
## מה מתווסף למערכת
|
||||||
|
|
||||||
| קטגוריה | תיאור |
|
- **כפתור צ'אט צף** בכל מסך, עם היסטוריית שיחות ועיון בזיכרון התיק.
|
||||||
|----------|--------|
|
- **זיכרון תיק (CaseMemory):** מאגר ידע מובנה לכל תיק (עובדות מפתח, אסטרטגיה, החלטות, ציר זמן ועוד) — נשמר ידנית, ע"י העוזר, או אוטומטית בשינויי תיק.
|
||||||
| key_facts | עובדות מפתח |
|
- **דשבורד התראות** ותצוגות SmartRequest/SmartResponse/SmartAction בציר הזמן של התיק.
|
||||||
| strategy | אסטרטגיה |
|
- **ניהול מהממשק:** עריכת פרומפטים, כללי התנהגות, מיומנויות ופרופילי משתמש.
|
||||||
| decisions | החלטות |
|
|
||||||
| contacts_notes | הערות על אנשי קשר |
|
|
||||||
| timeline | ציר זמן |
|
|
||||||
| documents_notes | הערות על מסמכים |
|
|
||||||
| billing_notes | הערות חיוב |
|
|
||||||
|
|
||||||
**מקורות:** ידני (manual), עוזר AI (assistant), אוטומטי (auto — hooks)
|
## טיפול בעיות נפוצות
|
||||||
|
|
||||||
**רמות חשיבות:** low, normal, high, critical
|
| תסמין | סיבה | פתרון |
|
||||||
|
|---|---|---|
|
||||||
|
| הצ'אט לא מגיב | שירות ה‑AI לא מוגדר/לא זמין | בדוק את webhookUrl ושהשירות (shira‑hermes) רץ. |
|
||||||
|
| 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
|
> תיעוד טכני למפתחים (שירותים, פעולות, hooks, זרימת ה‑webhook): `ARCHITECTURE.md`.
|
||||||
|
|
||||||
| 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 ימים | אזהרה |
|
|
||||||
|
|||||||
@@ -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,11 +8,15 @@ use Espo\Core\Exceptions\BadRequest;
|
|||||||
use Espo\Core\Exceptions\Forbidden;
|
use Espo\Core\Exceptions\Forbidden;
|
||||||
use Espo\Core\InjectableFactory;
|
use Espo\Core\InjectableFactory;
|
||||||
use Espo\Core\Acl;
|
use Espo\Core\Acl;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
use Espo\ORM\EntityManager;
|
||||||
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
|
||||||
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
|
||||||
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
|
||||||
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
|
||||||
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\FreeDocumentGenerator;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\CaseFolderManager;
|
||||||
use Espo\Entities\User;
|
use Espo\Entities\User;
|
||||||
|
|
||||||
class SmartAssistant
|
class SmartAssistant
|
||||||
@@ -20,12 +24,14 @@ class SmartAssistant
|
|||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
private Acl $acl;
|
private Acl $acl;
|
||||||
private User $user;
|
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->injectableFactory = $injectableFactory;
|
||||||
$this->acl = $acl;
|
$this->acl = $acl;
|
||||||
$this->user = $user;
|
$this->user = $user;
|
||||||
|
$this->entityManager = $entityManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getService(): SmartAssistantService
|
private function getService(): SmartAssistantService
|
||||||
@@ -40,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
|
public function getActionStatus(Request $request, Response $response): array
|
||||||
{
|
{
|
||||||
return $this->getService()->getStatus();
|
return $this->getService()->getStatus();
|
||||||
@@ -61,6 +85,10 @@ class SmartAssistant
|
|||||||
throw new BadRequest('caseId is required for case mode.');
|
throw new BadRequest('caseId is required for case mode.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($mode === 'case') {
|
||||||
|
$this->assertCaseAccess($caseId, 'read');
|
||||||
|
}
|
||||||
|
|
||||||
return $this->getService()->chat(
|
return $this->getService()->chat(
|
||||||
trim($data->message),
|
trim($data->message),
|
||||||
$mode,
|
$mode,
|
||||||
@@ -101,6 +129,11 @@ class SmartAssistant
|
|||||||
{
|
{
|
||||||
$this->checkAccess();
|
$this->checkAccess();
|
||||||
$caseId = $request->getQueryParam('caseId');
|
$caseId = $request->getQueryParam('caseId');
|
||||||
|
|
||||||
|
if (!empty($caseId)) {
|
||||||
|
$this->assertCaseAccess($caseId, 'read');
|
||||||
|
}
|
||||||
|
|
||||||
return $this->getService()->getHistory($caseId);
|
return $this->getService()->getHistory($caseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +177,8 @@ class SmartAssistant
|
|||||||
throw new BadRequest('caseId is required.');
|
throw new BadRequest('caseId is required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertCaseAccess($caseId, 'read');
|
||||||
|
|
||||||
$category = $request->getQueryParam('category');
|
$category = $request->getQueryParam('category');
|
||||||
$includeArchived = (bool) $request->getQueryParam('includeArchived');
|
$includeArchived = (bool) $request->getQueryParam('includeArchived');
|
||||||
|
|
||||||
@@ -160,6 +195,8 @@ class SmartAssistant
|
|||||||
throw new BadRequest('caseId and content are required.');
|
throw new BadRequest('caseId and content are required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertCaseAccess($data->caseId, 'edit');
|
||||||
|
|
||||||
$service = $this->injectableFactory->create(CaseMemoryService::class);
|
$service = $this->injectableFactory->create(CaseMemoryService::class);
|
||||||
$entry = $service->createMemory(
|
$entry = $service->createMemory(
|
||||||
$data->caseId,
|
$data->caseId,
|
||||||
@@ -204,9 +241,20 @@ class SmartAssistant
|
|||||||
throw new BadRequest('filePath is required.');
|
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;
|
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
|
||||||
|
|
||||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
$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);
|
$text = $analyzer->extractFullText($filePath, $maxLength);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -227,7 +275,18 @@ class SmartAssistant
|
|||||||
throw new BadRequest('filePath is required.');
|
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);
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
|
||||||
|
if (!$analyzer->isPathWithinCase($filePath, $caseId)) {
|
||||||
|
throw new Forbidden('File is outside the case folder.');
|
||||||
|
}
|
||||||
|
|
||||||
return $analyzer->getDocumentBytes($filePath);
|
return $analyzer->getDocumentBytes($filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,9 +300,22 @@ class SmartAssistant
|
|||||||
throw new BadRequest('filePaths is required (array of file paths).');
|
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;
|
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
|
||||||
|
|
||||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
$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);
|
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -384,4 +456,117 @@ class SmartAssistant
|
|||||||
|
|
||||||
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
|
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,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 כשהיא צריכה אותו."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"markedForDeletionAt": "סומן למחיקה בתאריך",
|
||||||
|
"markedForDeletionBy": "סומן למחיקה על ידי"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"markedForDeletionAt": "מסמך זה סומן למחיקה על ידי שירה ומחכה לאישור ידני של עו\"ד למחיקה הפיזית."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,17 @@
|
|||||||
"scopeNames": {
|
"scopeNames": {
|
||||||
"CaseMemory": "זיכרון תיק",
|
"CaseMemory": "זיכרון תיק",
|
||||||
"AssistantRule": "כלל התנהגות",
|
"AssistantRule": "כלל התנהגות",
|
||||||
|
"UserProfile": "פרופיל משתמש",
|
||||||
|
"AssistantSkill": "מיומנות שירה",
|
||||||
|
"AssistantPrompt": "פרומפט שירה",
|
||||||
"SmartAssistant": "עוזר חכם"
|
"SmartAssistant": "עוזר חכם"
|
||||||
},
|
},
|
||||||
"scopeNamesPlural": {
|
"scopeNamesPlural": {
|
||||||
"CaseMemory": "זיכרונות תיקים",
|
"CaseMemory": "זיכרונות תיקים",
|
||||||
"AssistantRule": "כללי התנהגות",
|
"AssistantRule": "כללי התנהגות",
|
||||||
|
"UserProfile": "פרופילי משתמשים",
|
||||||
|
"AssistantSkill": "מיומנויות שירה",
|
||||||
|
"AssistantPrompt": "פרומפטים של שירה",
|
||||||
"SmartAssistant": "עוזר חכם"
|
"SmartAssistant": "עוזר חכם"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"labels": {
|
||||||
|
"Create UserProfile": "צור פרופיל משתמש"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"name": "שם",
|
||||||
|
"user": "משתמש",
|
||||||
|
"content": "תוכן הפרופיל",
|
||||||
|
"isActive": "פעיל",
|
||||||
|
"createdAt": "נוצר",
|
||||||
|
"modifiedAt": "עודכן",
|
||||||
|
"createdBy": "נוצר על ידי",
|
||||||
|
"modifiedBy": "עודכן על ידי"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"content": "טקסט חופשי שמוזרק ל-prompt של שירה תחת '=== ABOUT THIS USER ===' לכל שיחה של המשתמש הזה. שמור עובדות מפתח: תפקיד, תחומי התמחות, העדפות תקשורת, נושאי טיק שמטופלים."
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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": "כל רשומות הזיכרון מכל התיקים. גישה ישירה לחיפוש/עריכה — לצד כניסה רגילה מתוך כל תיק."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"controller": "controllers/record",
|
||||||
|
"color": "#d99848",
|
||||||
|
"iconClass": "fas fa-file-code"
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"controller": "controllers/record",
|
||||||
|
"color": "#5e8c61",
|
||||||
|
"iconClass": "fas fa-list-check",
|
||||||
|
"boolFilterList": ["onlyMy"],
|
||||||
|
"filterList": [
|
||||||
|
{"name": "active"},
|
||||||
|
{"name": "inactive"}
|
||||||
|
]
|
||||||
|
}
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"controller": "controllers/record",
|
||||||
|
"color": "#aa72b3",
|
||||||
|
"iconClass": "fas fa-magic"
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"controller": "controllers/record",
|
||||||
|
"color": "#9b59b6",
|
||||||
|
"iconClass": "fas fa-brain",
|
||||||
|
"boolFilterList": ["onlyMy"],
|
||||||
|
"filterList": [
|
||||||
|
{"name": "pinned"},
|
||||||
|
{"name": "byCategory"}
|
||||||
|
]
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"controller": "controllers/record",
|
||||||
|
"boolFilterList": ["onlyMy"],
|
||||||
|
"filterList": [{"name": "active"}, {"name": "inactive"}],
|
||||||
|
"color": "#7c8caf",
|
||||||
|
"iconClass": "fas fa-user-cog"
|
||||||
|
}
|
||||||
+76
@@ -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"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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
|
||||||
|
}
|
||||||
+4
-2
@@ -6,6 +6,8 @@
|
|||||||
"aclActionList": ["create", "read", "edit", "delete"],
|
"aclActionList": ["create", "read", "edit", "delete"],
|
||||||
"aclLevelList": ["all", "team", "own", "no"],
|
"aclLevelList": ["all", "team", "own", "no"],
|
||||||
"stream": false,
|
"stream": false,
|
||||||
"tab": false,
|
"tab": true,
|
||||||
"disabled": false
|
"disabled": false,
|
||||||
|
"importable": false,
|
||||||
|
"customizable": true
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -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,
|
"acl": true,
|
||||||
"aclActionList": ["create", "read", "edit", "delete"],
|
"aclActionList": ["create", "read", "edit", "delete"],
|
||||||
"aclLevelList": ["all", "team", "own", "no"],
|
"aclLevelList": ["all", "team", "own", "no"],
|
||||||
"tab": false,
|
"tab": true,
|
||||||
"stream": false,
|
"stream": false,
|
||||||
"disabled": false,
|
"disabled": false,
|
||||||
"importable": 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;
|
namespace Espo\Modules\SmartAssistant\Services;
|
||||||
|
|
||||||
use Espo\Core\Exceptions\Error;
|
use Espo\Core\Exceptions\Error;
|
||||||
|
use Espo\Core\Exceptions\Forbidden;
|
||||||
use Espo\Core\Exceptions\NotFound;
|
use Espo\Core\Exceptions\NotFound;
|
||||||
use Espo\Core\InjectableFactory;
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Core\AclManager;
|
||||||
|
use Espo\ORM\Entity;
|
||||||
use Espo\ORM\EntityManager;
|
use Espo\ORM\EntityManager;
|
||||||
use Espo\Core\Utils\Log;
|
use Espo\Core\Utils\Log;
|
||||||
use Espo\Core\Utils\Metadata;
|
use Espo\Core\Utils\Metadata;
|
||||||
@@ -15,13 +18,52 @@ class ActionExecutor
|
|||||||
private InjectableFactory $injectableFactory;
|
private InjectableFactory $injectableFactory;
|
||||||
private Log $log;
|
private Log $log;
|
||||||
private Metadata $metadata;
|
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->entityManager = $entityManager;
|
||||||
$this->injectableFactory = $injectableFactory;
|
$this->injectableFactory = $injectableFactory;
|
||||||
$this->log = $log;
|
$this->log = $log;
|
||||||
$this->metadata = $metadata;
|
$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 = [
|
public const VALID_STATUSES = [
|
||||||
@@ -36,6 +78,32 @@ class ActionExecutor
|
|||||||
{
|
{
|
||||||
$this->log->debug("SmartAssistant: Executing tool={$tool} for case={$caseId}");
|
$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) {
|
return match ($tool) {
|
||||||
'create_task' => $this->createTask($params, $caseId, $userId),
|
'create_task' => $this->createTask($params, $caseId, $userId),
|
||||||
'add_note' => $this->addNote($params, $caseId, $userId),
|
'add_note' => $this->addNote($params, $caseId, $userId),
|
||||||
@@ -91,6 +159,14 @@ class ActionExecutor
|
|||||||
|
|
||||||
private function saveRule(array $params, string $userId): array
|
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'] ?? '';
|
$name = $params['name'] ?? '';
|
||||||
$rule = $params['rule'] ?? '';
|
$rule = $params['rule'] ?? '';
|
||||||
if (!$name || !$rule) {
|
if (!$name || !$rule) {
|
||||||
@@ -237,6 +313,8 @@ class ActionExecutor
|
|||||||
}
|
}
|
||||||
|
|
||||||
$name = $entity->get('name') ?? '';
|
$name = $entity->get('name') ?? '';
|
||||||
|
$this->assertEntityAccess($entity, 'delete');
|
||||||
|
|
||||||
$this->entityManager->removeEntity($entity);
|
$this->entityManager->removeEntity($entity);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -260,6 +338,8 @@ class ActionExecutor
|
|||||||
}
|
}
|
||||||
|
|
||||||
$name = $entity->get('name') ?? '';
|
$name = $entity->get('name') ?? '';
|
||||||
|
$this->assertEntityAccess($entity, 'delete');
|
||||||
|
|
||||||
$this->entityManager->removeEntity($entity);
|
$this->entityManager->removeEntity($entity);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -282,6 +362,8 @@ class ActionExecutor
|
|||||||
throw new NotFound("Note {$entityId} not found.");
|
throw new NotFound("Note {$entityId} not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->assertEntityAccess($entity, 'delete');
|
||||||
|
|
||||||
$this->entityManager->removeEntity($entity);
|
$this->entityManager->removeEntity($entity);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -305,6 +387,8 @@ class ActionExecutor
|
|||||||
}
|
}
|
||||||
|
|
||||||
$name = $entity->get('name') ?? '';
|
$name = $entity->get('name') ?? '';
|
||||||
|
$this->assertEntityAccess($entity, 'delete');
|
||||||
|
|
||||||
$this->entityManager->removeEntity($entity);
|
$this->entityManager->removeEntity($entity);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -604,6 +688,22 @@ class ActionExecutor
|
|||||||
throw new Error("Unknown tool: {$tool}");
|
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}");
|
$this->log->debug("SmartAssistant: Executing plugin tool={$tool} handler={$handlerClass}");
|
||||||
|
|
||||||
$handler = $this->injectableFactory->create($handlerClass);
|
$handler = $this->injectableFactory->create($handlerClass);
|
||||||
|
|||||||
@@ -69,11 +69,12 @@ class AlertCalculator
|
|||||||
{
|
{
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
$now = new \DateTime();
|
$now = new \DateTime();
|
||||||
|
$today = $now->format('Y-m-d');
|
||||||
$warningThreshold = (new \DateTime())->modify("-{$warningDays} days")->format('Y-m-d H:i:s');
|
$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');
|
$criticalThreshold = (new \DateTime())->modify("-{$criticalDays} days")->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
$cases = $this->entityManager->getRDBRepository('Case')
|
$cases = $this->entityManager->getRDBRepository('Case')
|
||||||
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt'])
|
->select(['id', 'name', 'status', 'assignedUserName', 'cLastActivityAt', 'createdAt', 'cNextHearing'])
|
||||||
->where([
|
->where([
|
||||||
'status!=' => self::CLOSED_STATUSES, 'deleted' => false,
|
'status!=' => self::CLOSED_STATUSES, 'deleted' => false,
|
||||||
'OR' => [
|
'OR' => [
|
||||||
@@ -83,6 +84,17 @@ class AlertCalculator
|
|||||||
])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find();
|
])->order('cLastActivityAt', 'ASC')->limit(0, 50)->find();
|
||||||
|
|
||||||
foreach ($cases as $case) {
|
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');
|
$lastActivity = $case->get('cLastActivityAt') ?? $case->get('createdAt');
|
||||||
if (!$lastActivity) continue;
|
if (!$lastActivity) continue;
|
||||||
$daysSince = $now->diff(new \DateTime($lastActivity))->days;
|
$daysSince = $now->diff(new \DateTime($lastActivity))->days;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,10 +188,20 @@ class CaseContextBuilder
|
|||||||
{
|
{
|
||||||
$templates = [];
|
$templates = [];
|
||||||
$collection = $this->entityManager->getRDBRepository('DocumentTemplate')
|
$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) {
|
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;
|
return $templates;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -346,6 +346,36 @@ class DocumentAnalyzer
|
|||||||
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
|
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
|
||||||
* source: 'stored' | 'computed' | 'none'
|
* 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
|
public function describeCaseFolderPath(string $caseId): array
|
||||||
{
|
{
|
||||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
@@ -533,20 +563,37 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
private function extractFromDocx(string $tmpFile): ?string
|
private function extractFromDocx(string $tmpFile): ?string
|
||||||
{
|
{
|
||||||
if (!class_exists(\PhpOffice\PhpWord\IOFactory::class)) return null;
|
// PHPWord (vendor/phpoffice/phpword) ships with the EspoCRM image but
|
||||||
$phpWord = \PhpOffice\PhpWord\IOFactory::load($tmpFile);
|
// is NOT in the composer PSR-4 autoload map — class_exists silently
|
||||||
$text = '';
|
// returns false. Rather than fix the autoloader (rebuild of the image)
|
||||||
foreach ($phpWord->getSections() as $section) {
|
// we parse word/document.xml directly. This is also more robust:
|
||||||
foreach ($section->getElements() as $element) {
|
// PHPWord's element walker only goes one level deep, missing text in
|
||||||
if (method_exists($element, 'getText')) $text .= $element->getText() . "\n";
|
// tables, hyperlinks, and footnotes.
|
||||||
elseif (method_exists($element, 'getElements')) {
|
$zip = new \ZipArchive();
|
||||||
foreach ($element->getElements() as $child) {
|
if ($zip->open($tmpFile) !== true) {
|
||||||
if (method_exists($child, 'getText')) $text .= $child->getText() . "\n";
|
$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
|
private function updateDocumentEntity(string $oldPath, string $newPath): void
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<?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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use Espo\ORM\EntityManager;
|
|||||||
use Espo\Entities\User;
|
use Espo\Entities\User;
|
||||||
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
|
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
|
||||||
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
|
||||||
use PhpOffice\PhpWord\TemplateProcessor;
|
use PhpOffice\PhpWord\TemplateProcessor;
|
||||||
|
|
||||||
class GenericTemplateGenerator
|
class GenericTemplateGenerator
|
||||||
@@ -72,14 +73,23 @@ class GenericTemplateGenerator
|
|||||||
@unlink($outputPath);
|
@unlink($outputPath);
|
||||||
@unlink($localTemplatePath);
|
@unlink($localTemplatePath);
|
||||||
|
|
||||||
// Step 6: Build document name
|
// Step 6: Build subject-only document name (rule N1) — no contact,
|
||||||
$contactName = $this->getContactName($case);
|
// no em-dash. Defaults to the template name when no subject given.
|
||||||
$docName = $documentSubject ?? "{$templateName} — {$contactName}";
|
$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
|
// Step 7: Create Attachment + Document
|
||||||
$attachment = $this->entityManager->getNewEntity('Attachment');
|
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||||
$attachment->set([
|
$attachment->set([
|
||||||
'name' => $docName . '.docx',
|
'name' => $fileName,
|
||||||
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
'role' => 'Attachment',
|
'role' => 'Attachment',
|
||||||
'size' => strlen($content),
|
'size' => strlen($content),
|
||||||
@@ -92,16 +102,74 @@ class GenericTemplateGenerator
|
|||||||
$document->set([
|
$document->set([
|
||||||
'name' => $docName,
|
'name' => $docName,
|
||||||
'fileId' => $attachment->getId(),
|
'fileId' => $attachment->getId(),
|
||||||
'fileName' => $docName . '.docx',
|
'fileName' => $fileName,
|
||||||
'assignedUserId' => $this->user->getId(),
|
'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
|
// Link to Case
|
||||||
$this->entityManager->getRDBRepository('Case')
|
$this->entityManager->getRDBRepository('Case')
|
||||||
->getRelation($case, 'documents')
|
->getRelation($case, 'documents')
|
||||||
->relate($document);
|
->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}");
|
$this->log->info("GenericTemplateGenerator: Created '{$docName}' from template '{$templateName}' for Case {$caseId}");
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -190,24 +258,4 @@ class GenericTemplateGenerator
|
|||||||
|
|
||||||
$processor->saveAs($outputPath);
|
$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\Error;
|
||||||
use Espo\Core\Exceptions\NotFound;
|
use Espo\Core\Exceptions\NotFound;
|
||||||
use Espo\Core\InjectableFactory;
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Core\AclManager;
|
||||||
use Espo\Core\Utils\Config;
|
use Espo\Core\Utils\Config;
|
||||||
use Espo\Core\Utils\Log;
|
use Espo\Core\Utils\Log;
|
||||||
use Espo\ORM\EntityManager;
|
use Espo\ORM\EntityManager;
|
||||||
@@ -35,21 +36,22 @@ class SmartAssistantService
|
|||||||
private Config $config;
|
private Config $config;
|
||||||
private Log $log;
|
private Log $log;
|
||||||
private User $user;
|
private User $user;
|
||||||
|
private AclManager $aclManager;
|
||||||
private static array $conversations = [];
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
EntityManager $entityManager,
|
EntityManager $entityManager,
|
||||||
InjectableFactory $injectableFactory,
|
InjectableFactory $injectableFactory,
|
||||||
Config $config,
|
Config $config,
|
||||||
Log $log,
|
Log $log,
|
||||||
User $user
|
User $user,
|
||||||
|
AclManager $aclManager
|
||||||
) {
|
) {
|
||||||
$this->entityManager = $entityManager;
|
$this->entityManager = $entityManager;
|
||||||
$this->injectableFactory = $injectableFactory;
|
$this->injectableFactory = $injectableFactory;
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
$this->log = $log;
|
$this->log = $log;
|
||||||
$this->user = $user;
|
$this->user = $user;
|
||||||
|
$this->aclManager = $aclManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getConversationRepo(): ConversationRepository
|
private function getConversationRepo(): ConversationRepository
|
||||||
@@ -85,6 +87,18 @@ class SmartAssistantService
|
|||||||
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
|
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
|
||||||
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
|
$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
|
// Generate conversation ID
|
||||||
if (!$conversationId) {
|
if (!$conversationId) {
|
||||||
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
|
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
|
||||||
@@ -474,6 +488,13 @@ class SmartAssistantService
|
|||||||
|
|
||||||
foreach ($cases as $case) {
|
foreach ($cases as $case) {
|
||||||
if (mb_stripos($message, $case->get('name')) !== false) {
|
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);
|
$contextBuilder = $this->injectableFactory->create(OfficeContextBuilder::class);
|
||||||
return $contextBuilder->buildCaseDrillDown($case->get('id'), $this->user);
|
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
@@ -3,11 +3,11 @@
|
|||||||
"module": "SmartAssistant",
|
"module": "SmartAssistant",
|
||||||
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
|
||||||
"author": "klear",
|
"author": "klear",
|
||||||
"version": "2.8.2",
|
"version": "2.11.0",
|
||||||
"acceptableVersions": [
|
"acceptableVersions": [
|
||||||
">=8.0.0"
|
">=8.0.0"
|
||||||
],
|
],
|
||||||
"releaseDate": "2026-05-13",
|
"releaseDate": "2026-06-21",
|
||||||
"php": [
|
"php": [
|
||||||
">=8.1"
|
">=8.1"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user