Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78beb82c7d | |||
| 7811943178 | |||
| c1476d5c52 | |||
| 68b3536084 | |||
| 97f7607b2b | |||
| bd1d484e74 | |||
| d266380e6d | |||
| 1d200fc3f6 | |||
| 65faf99e11 | |||
| e40f3d4534 | |||
| 89b3f5844e | |||
| 37a8b67b95 | |||
| 39d9093bf6 | |||
| b05e1c8488 | |||
| b313c96955 | |||
| 2a03425514 | |||
| f400f2fec7 | |||
| 405ceb7684 | |||
| 2569003f33 | |||
| 289a9cf27f | |||
| 1bb0c06345 | |||
| 59fc8acdc4 | |||
| 586b543a4c | |||
| c3212e71fe |
@@ -0,0 +1,2 @@
|
|||||||
|
*.zip
|
||||||
|
build/
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"models": {
|
||||||
|
"main": {
|
||||||
|
"provider": "claude-code",
|
||||||
|
"modelId": "sonnet",
|
||||||
|
"maxTokens": 64000,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"id": "sonnet"
|
||||||
|
},
|
||||||
|
"research": {
|
||||||
|
"provider": "claude-code",
|
||||||
|
"modelId": "sonnet",
|
||||||
|
"maxTokens": 8700,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"id": "claude-sonnet-4-20250514"
|
||||||
|
},
|
||||||
|
"fallback": {
|
||||||
|
"provider": "claude-code",
|
||||||
|
"modelId": "sonnet",
|
||||||
|
"maxTokens": 120000,
|
||||||
|
"temperature": 0.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"global": {
|
||||||
|
"logLevel": "info",
|
||||||
|
"debug": false,
|
||||||
|
"defaultNumTasks": 10,
|
||||||
|
"defaultSubtasks": 5,
|
||||||
|
"defaultPriority": "medium",
|
||||||
|
"projectName": "Task Master",
|
||||||
|
"ollamaBaseURL": "http://localhost:11434/api",
|
||||||
|
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
|
||||||
|
"responseLanguage": "English",
|
||||||
|
"enableCodebaseAnalysis": true,
|
||||||
|
"enableProxy": false,
|
||||||
|
"anonymousTelemetry": true,
|
||||||
|
"userId": "1234567890"
|
||||||
|
},
|
||||||
|
"claudeCode": {},
|
||||||
|
"codexCli": {},
|
||||||
|
"grokCli": {
|
||||||
|
"timeout": 120000,
|
||||||
|
"workingDirectory": null,
|
||||||
|
"defaultModel": "grok-4-latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"currentTag": "master",
|
||||||
|
"lastSwitched": "2026-04-14T06:05:06.044Z",
|
||||||
|
"branchTagMapping": {},
|
||||||
|
"migrationNoticeShown": true
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -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.0.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 ימים | אזהרה |
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,31 +0,0 @@
|
|||||||
יצירת טבלה מעוצבת עם תמיכה מלאה בעברית ואנגלית מעורבת.
|
|
||||||
|
|
||||||
כאשר המשתמש מבקש טבלה בכל הקשר — תכנית עבודה, סיכום, השוואה, רשימה — השתמש בפונקציה `bidi_table()` מ-`scripts/bidi_table.py`.
|
|
||||||
|
|
||||||
## הוראות
|
|
||||||
|
|
||||||
1. **תמיד** השתמש ב-Bash כדי להריץ את הסקריפט — אל תנסה לייצר טבלת box-drawing ידנית כי ה-BiDi ישבור אותה.
|
|
||||||
|
|
||||||
2. הרץ כך:
|
|
||||||
```bash
|
|
||||||
python3 -c "
|
|
||||||
import sys; sys.path.insert(0, '/home/chaim/legal-ai')
|
|
||||||
from scripts.bidi_table import bidi_table
|
|
||||||
print(bidi_table(
|
|
||||||
['Header1', 'Header2', 'Header3'],
|
|
||||||
[
|
|
||||||
['value1', 'ערך בעברית', 'mixed ערבוב'],
|
|
||||||
['value2', 'ערך נוסף', 'עוד שורה'],
|
|
||||||
],
|
|
||||||
))
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. כותרות עמודות — עדיף באנגלית (כי שורת הכותרת הכי רגישה ל-BiDi).
|
|
||||||
|
|
||||||
4. תוכן בעברית, באנגלית, או מעורב — הכל עובד בגוף הטבלה.
|
|
||||||
|
|
||||||
5. אם המשתמש מבקש טבלה כחלק ממסמך MD שנכתב לקובץ (לא לטרמינל) — אפשר להשתמש ב-markdown רגיל כי קוראי MD מטפלים ב-RTL בעצמם.
|
|
||||||
|
|
||||||
## $ARGUMENTS
|
|
||||||
תוכן הטבלה — כותרות ושורות. אם לא צוין, שאל את המשתמש מה להציג.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""BiDi-safe box-drawing table renderer for mixed Hebrew/English terminal output.
|
|
||||||
|
|
||||||
Uses LRM (Left-to-Right Mark, U+200E) before box-drawing characters to prevent
|
|
||||||
the BiDi algorithm from breaking table alignment when Hebrew text is present.
|
|
||||||
|
|
||||||
Usage as module:
|
|
||||||
from scripts.bidi_table import bidi_table
|
|
||||||
print(bidi_table(['Col1', 'Col2'], [['val1', 'ערך2']]))
|
|
||||||
|
|
||||||
Usage from CLI:
|
|
||||||
python3 scripts/bidi_table.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
LRM = "\u200E" # Left-to-Right Mark — invisible, prevents BiDi reordering
|
|
||||||
|
|
||||||
|
|
||||||
def bidi_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
||||||
"""Render a box-drawing table safe for mixed RTL/LTR terminal display."""
|
|
||||||
ncols = len(headers)
|
|
||||||
|
|
||||||
# Calculate column widths
|
|
||||||
col_widths = [len(h) for h in headers]
|
|
||||||
for row in rows:
|
|
||||||
for i, cell in enumerate(row[:ncols]):
|
|
||||||
col_widths[i] = max(col_widths[i], len(cell))
|
|
||||||
|
|
||||||
def hline(left: str, mid: str, right: str) -> str:
|
|
||||||
return left + mid.join("─" * (w + 2) for w in col_widths) + right
|
|
||||||
|
|
||||||
def dataline(cells: list[str]) -> str:
|
|
||||||
parts = []
|
|
||||||
for i in range(ncols):
|
|
||||||
cell = cells[i] if i < len(cells) else ""
|
|
||||||
padded = cell + " " * max(0, col_widths[i] - len(cell))
|
|
||||||
parts.append(" " + padded + " ")
|
|
||||||
return LRM + "│" + (LRM + "│").join(parts) + LRM + "│"
|
|
||||||
|
|
||||||
lines = [hline("┌", "┬", "┐")]
|
|
||||||
lines.append(dataline(headers))
|
|
||||||
lines.append(hline("├", "┼", "┤"))
|
|
||||||
for row in rows:
|
|
||||||
lines.append(dataline(row))
|
|
||||||
lines.append(hline("└", "┴", "┘"))
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
table = bidi_table(
|
|
||||||
["File", "Description", "Model", "Step"],
|
|
||||||
[
|
|
||||||
["claims_extractor.py", "חילוץ טענות מכתבי טענות", "Sonnet", "שלב 3 — הבא בתור"],
|
|
||||||
["brainstorm.py", "סיעור מוחות — כיווני נימוק", "Sonnet", "שלב 4"],
|
|
||||||
["block_writer.py", "כתיבת בלוקים של החלטה", "Sonnet/Opus", "שלב 5"],
|
|
||||||
["qa_validator.py", "בדיקת איכות QA", "Sonnet", "שלב 6"],
|
|
||||||
["style_analyzer.py", "ניתוח סגנון דפנה", "Opus", "חד-פעמי"],
|
|
||||||
["learning_loop.py", "למידה מהחלטה סופית", "Sonnet", "סוף תהליך"],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
print(table)
|
|
||||||
@@ -4,13 +4,16 @@ set -euo pipefail
|
|||||||
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
|
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
|
||||||
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
|
||||||
ZIPNAME="${MODULE}-${VERSION}.zip"
|
ZIPNAME="${MODULE}-${VERSION}.zip"
|
||||||
|
BUILDDIR="build"
|
||||||
|
|
||||||
# Update version in README.md if it exists
|
# Update version in README.md if it exists
|
||||||
if [[ -f README.md ]]; then
|
if [[ -f README.md ]]; then
|
||||||
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BUILDDIR"
|
||||||
echo "Building $ZIPNAME..."
|
echo "Building $ZIPNAME..."
|
||||||
rm -f "$ZIPNAME"
|
rm -f "$BUILDDIR/$ZIPNAME"
|
||||||
zip -r "$ZIPNAME" manifest.json files/ \
|
zip -r "$BUILDDIR/$ZIPNAME" manifest.json files/ \
|
||||||
-x "*.DS_Store" "*__MACOSX*" "*.zip"
|
-x "*.DS_Store" "*__MACOSX*"
|
||||||
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
|
echo "✓ Built: $BUILDDIR/$ZIPNAME ($(du -h "$BUILDDIR/$ZIPNAME" | cut -f1))"
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -457,14 +457,23 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
var $btn = this.$el.find('.sa-chat-send');
|
var $btn = this.$el.find('.sa-chat-send');
|
||||||
|
|
||||||
$messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
|
$messages.append('<div class="sa-msg sa-msg-user">' + this.escapeHtml(message) + '</div>');
|
||||||
|
var thinkingText = this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...';
|
||||||
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
|
var $loading = $('<div class="sa-msg sa-msg-loading"><span class="fas fa-spinner fa-spin" style="margin-left: 5px;"></span> ' +
|
||||||
this.escapeHtml(this.translate('Thinking', 'labels', 'SmartAssistant') || 'חושבת...') + '</div>');
|
'<span class="sa-thinking-text">' + this.escapeHtml(thinkingText) + '</span></div>');
|
||||||
$messages.append($loading);
|
$messages.append($loading);
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
|
|
||||||
$btn.prop('disabled', true);
|
$btn.prop('disabled', true);
|
||||||
$input.prop('disabled', true).val('').css('height', 'auto');
|
$input.prop('disabled', true).val('').css('height', 'auto');
|
||||||
|
|
||||||
|
// After 60s, swap the spinner text to a "still working" hint so users
|
||||||
|
// know the longer requests (multi-tool flows) haven't stalled.
|
||||||
|
var slowHintText = this.translate('SlowHint', 'labels', 'SmartAssistant') ||
|
||||||
|
'חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל';
|
||||||
|
var slowHintTimer = setTimeout(function () {
|
||||||
|
$loading.find('.sa-thinking-text').text(slowHintText);
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
var payload = {
|
var payload = {
|
||||||
message: message,
|
message: message,
|
||||||
mode: this.currentMode,
|
mode: this.currentMode,
|
||||||
@@ -474,7 +483,8 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
payload.caseId = this.currentCaseId;
|
payload.caseId = this.currentCaseId;
|
||||||
}
|
}
|
||||||
|
|
||||||
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 180000}).then(function (response) {
|
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 600000}).then(function (response) {
|
||||||
|
clearTimeout(slowHintTimer);
|
||||||
self.conversationId = response.conversationId;
|
self.conversationId = response.conversationId;
|
||||||
$loading.remove();
|
$loading.remove();
|
||||||
|
|
||||||
@@ -492,6 +502,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
|
|||||||
self.scrollToBottom();
|
self.scrollToBottom();
|
||||||
$input.prop('disabled', false).focus();
|
$input.prop('disabled', false).focus();
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
|
clearTimeout(slowHintTimer);
|
||||||
$loading.remove();
|
$loading.remove();
|
||||||
$messages.append('<div class="sa-msg sa-msg-error">' +
|
$messages.append('<div class="sa-msg sa-msg-error">' +
|
||||||
self.escapeHtml(self.translate('Error', 'labels', 'SmartAssistant') || 'שגיאה בתקשורת') + '</div>');
|
self.escapeHtml(self.translate('Error', 'labels', 'SmartAssistant') || 'שגיאה בתקשורת') + '</div>');
|
||||||
|
|||||||
@@ -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,10 +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\FreeDocumentGenerator;
|
||||||
|
use Espo\Modules\SmartAssistant\Services\CaseFolderManager;
|
||||||
use Espo\Entities\User;
|
use Espo\Entities\User;
|
||||||
|
|
||||||
class SmartAssistant
|
class SmartAssistant
|
||||||
@@ -19,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
|
||||||
@@ -39,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();
|
||||||
@@ -60,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,
|
||||||
@@ -100,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,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');
|
||||||
|
|
||||||
@@ -159,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,
|
||||||
@@ -185,7 +223,7 @@ class SmartAssistant
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tool = $data->tool;
|
$tool = $data->tool;
|
||||||
$params = (array) ($data->params ?? []);
|
$params = json_decode(json_encode($data->params ?? []), true) ?? [];
|
||||||
$caseId = $data->caseId ?? null;
|
$caseId = $data->caseId ?? null;
|
||||||
|
|
||||||
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
$executor = $this->injectableFactory->create(ActionExecutor::class);
|
||||||
@@ -203,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 [
|
||||||
@@ -216,6 +265,31 @@ class SmartAssistant
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function postActionGetDocumentBytes(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$filePath = $data->filePath ?? null;
|
||||||
|
if (empty($filePath)) {
|
||||||
|
throw new BadRequest('filePath is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
if (empty($caseId)) {
|
||||||
|
throw new BadRequest('caseId is required.');
|
||||||
|
}
|
||||||
|
$this->assertCaseAccess($caseId, 'read');
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
|
||||||
|
if (!$analyzer->isPathWithinCase($filePath, $caseId)) {
|
||||||
|
throw new Forbidden('File is outside the case folder.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $analyzer->getDocumentBytes($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
public function postActionReadMultipleDocuments(Request $request, Response $response): array
|
public function postActionReadMultipleDocuments(Request $request, Response $response): array
|
||||||
{
|
{
|
||||||
$this->checkAccess();
|
$this->checkAccess();
|
||||||
@@ -226,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 [
|
||||||
@@ -277,4 +364,209 @@ class SmartAssistant
|
|||||||
'results' => $results,
|
'results' => $results,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function postActionListDocuments(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
if (empty($caseId)) {
|
||||||
|
throw new BadRequest('caseId is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
return $analyzer->listDocumentsRecursive($caseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionBrowseFolder(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$path = $data->path ?? null;
|
||||||
|
if ($path === null) {
|
||||||
|
throw new BadRequest('path is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
return [
|
||||||
|
'path' => $path,
|
||||||
|
'entries' => $analyzer->browseFolder((string) $path),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionFindCaseFolder(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$query = $data->query ?? null;
|
||||||
|
if (empty($query)) {
|
||||||
|
throw new BadRequest('query is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = isset($data->limit) ? max(1, min((int) $data->limit, 50)) : 20;
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
return [
|
||||||
|
'query' => $query,
|
||||||
|
'matches' => $analyzer->findCaseFolder((string) $query, $limit),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionSetCaseFolderPath(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
if (!$this->acl->checkScope('Case', 'edit')) {
|
||||||
|
throw new Forbidden('No edit access to Case.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$path = $data->path ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId) || empty($path)) {
|
||||||
|
throw new BadRequest('caseId and path are required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
|
return $analyzer->setCaseFolderPath((string) $caseId, (string) $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionGenerateFromTemplate(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$templateId = $data->templateId ?? null;
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
|
||||||
|
if (empty($templateId)) {
|
||||||
|
throw new BadRequest('templateId is required.');
|
||||||
|
}
|
||||||
|
if (empty($caseId)) {
|
||||||
|
throw new BadRequest('caseId is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$documentSubject = $data->documentSubject ?? null;
|
||||||
|
$customPlaceholders = (array) ($data->customPlaceholders ?? []);
|
||||||
|
|
||||||
|
$generator = $this->injectableFactory->create(GenericTemplateGenerator::class);
|
||||||
|
|
||||||
|
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionCreateDocument(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$title = $data->title ?? null;
|
||||||
|
$body = $data->body ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) {
|
||||||
|
throw new BadRequest('caseId is required.');
|
||||||
|
}
|
||||||
|
if (empty($title)) {
|
||||||
|
throw new BadRequest('title is required.');
|
||||||
|
}
|
||||||
|
if (empty($body)) {
|
||||||
|
throw new BadRequest('body is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$documentType = $data->documentType ?? 'letter';
|
||||||
|
$recipient = $data->recipient ?? null;
|
||||||
|
|
||||||
|
$generator = $this->injectableFactory->create(FreeDocumentGenerator::class);
|
||||||
|
|
||||||
|
return $generator->generate($caseId, $title, $body, $documentType, $recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionWriteToFolder(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$relPath = $data->relPath ?? null;
|
||||||
|
$contentBase64 = $data->contentBase64 ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($relPath)) throw new BadRequest('relPath is required.');
|
||||||
|
if (!isset($contentBase64)) throw new BadRequest('contentBase64 is required.');
|
||||||
|
|
||||||
|
$content = base64_decode((string) $contentBase64, true);
|
||||||
|
if ($content === false) {
|
||||||
|
throw new BadRequest('contentBase64 is not valid base64.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->writeFile($caseId, $relPath, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionCreateFolder(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$relPath = $data->relPath ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($relPath)) throw new BadRequest('relPath is required.');
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->createFolder($caseId, $relPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionRenameItem(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$currentRelPath = $data->currentRelPath ?? null;
|
||||||
|
$newName = $data->newName ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($currentRelPath)) throw new BadRequest('currentRelPath is required.');
|
||||||
|
if (empty($newName)) throw new BadRequest('newName is required.');
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->renameItem($caseId, $currentRelPath, $newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionMoveItem(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$sourceRelPath = $data->sourceRelPath ?? null;
|
||||||
|
$targetRelPath = $data->targetRelPath ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($sourceRelPath)) throw new BadRequest('sourceRelPath is required.');
|
||||||
|
if (empty($targetRelPath)) throw new BadRequest('targetRelPath is required.');
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->moveItem($caseId, $sourceRelPath, $targetRelPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function postActionMarkForDeletion(Request $request, Response $response): array
|
||||||
|
{
|
||||||
|
$this->checkAccess();
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$caseId = $data->caseId ?? null;
|
||||||
|
$fileRelPath = $data->fileRelPath ?? null;
|
||||||
|
|
||||||
|
if (empty($caseId)) throw new BadRequest('caseId is required.');
|
||||||
|
if (empty($fileRelPath)) throw new BadRequest('fileRelPath is required.');
|
||||||
|
|
||||||
|
$manager = $this->injectableFactory->create(CaseFolderManager::class);
|
||||||
|
return $manager->markForDeletion($caseId, $fileRelPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\SmartAssistant\Controllers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard CRUD over /api/v1/UserProfile.
|
||||||
|
*
|
||||||
|
* Pattern matches AssistantRule.php — without this class EspoCRM returns
|
||||||
|
* 404 "Controller does not exist" even when the entity is fully defined.
|
||||||
|
*/
|
||||||
|
class UserProfile extends \Espo\Core\Controllers\Record
|
||||||
|
{
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -11,6 +11,7 @@
|
|||||||
"No previous conversations": "No previous conversations",
|
"No previous conversations": "No previous conversations",
|
||||||
"Ask the assistant...": "Ask the assistant...",
|
"Ask the assistant...": "Ask the assistant...",
|
||||||
"Thinking": "Thinking...",
|
"Thinking": "Thinking...",
|
||||||
|
"SlowHint": "Still thinking… this one is taking a bit longer because I'm processing multiple sources",
|
||||||
"Error": "Communication error",
|
"Error": "Communication error",
|
||||||
"Back": "Back",
|
"Back": "Back",
|
||||||
"Chat with the assistant": "Chat with the assistant",
|
"Chat with the assistant": "Chat with the assistant",
|
||||||
|
|||||||
@@ -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": "עוזר חכם"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"No previous conversations": "אין שיחות קודמות",
|
"No previous conversations": "אין שיחות קודמות",
|
||||||
"Ask the assistant...": "שאל/י את שירה...",
|
"Ask the assistant...": "שאל/י את שירה...",
|
||||||
"Thinking": "חושבת...",
|
"Thinking": "חושבת...",
|
||||||
|
"SlowHint": "חושבת... זה לוקח קצת יותר זמן כי אני מעבדת כמה מקורות במקביל",
|
||||||
"Error": "שגיאה בתקשורת",
|
"Error": "שגיאה בתקשורת",
|
||||||
"Back": "חזרה",
|
"Back": "חזרה",
|
||||||
"Chat with the assistant": "שוחח/י עם שירה",
|
"Chat with the assistant": "שוחח/י עם שירה",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class AlertCalculator
|
|||||||
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
||||||
|
|
||||||
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
|
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
|
||||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])->count();
|
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
|
||||||
|
|
||||||
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
|
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
|
||||||
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
|
||||||
@@ -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;
|
||||||
@@ -106,7 +118,7 @@ class AlertCalculator
|
|||||||
|
|
||||||
$tasks = $this->entityManager->getRDBRepository('Task')
|
$tasks = $this->entityManager->getRDBRepository('Task')
|
||||||
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
||||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])
|
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
|
||||||
->order('dateEnd', 'ASC')->limit(0, 50)->find();
|
->order('dateEnd', 'ASC')->limit(0, 50)->find();
|
||||||
|
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
@@ -132,7 +144,7 @@ class AlertCalculator
|
|||||||
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
|
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
|
||||||
|
|
||||||
$cases = $this->entityManager->getRDBRepository('Case')
|
$cases = $this->entityManager->getRDBRepository('Case')
|
||||||
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt'])
|
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt', 'contactId'])
|
||||||
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
|
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
|
||||||
->order('cNextHearing', 'ASC')->find();
|
->order('cNextHearing', 'ASC')->find();
|
||||||
|
|
||||||
@@ -140,13 +152,7 @@ class AlertCalculator
|
|||||||
$lastActivity = $case->get('cLastActivityAt');
|
$lastActivity = $case->get('cLastActivityAt');
|
||||||
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
|
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
|
||||||
|
|
||||||
$openTaskCount = $this->entityManager->getRDBRepository('Task')
|
if ($this->caseHasPreparation($case, $recentTaskThreshold)) continue;
|
||||||
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false])->count();
|
|
||||||
if ($openTaskCount > 0) continue;
|
|
||||||
|
|
||||||
$recentCompleted = $this->entityManager->getRDBRepository('Task')
|
|
||||||
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status' => 'Completed', 'modifiedAt>=' => $recentTaskThreshold, 'deleted' => false])->count();
|
|
||||||
if ($recentCompleted > 0) continue;
|
|
||||||
|
|
||||||
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
|
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
|
||||||
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
|
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
|
||||||
@@ -162,6 +168,104 @@ class AlertCalculator
|
|||||||
return $alerts;
|
return $alerts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a case has any evidence of hearing preparation:
|
||||||
|
* 1. Tasks linked directly to the case (parentType='Case')
|
||||||
|
* 2. Tasks linked to the case's contacts (parentType='Contact')
|
||||||
|
* 3. Tasks linked through NhActivity records for this case
|
||||||
|
*/
|
||||||
|
private function caseHasPreparation(\Espo\ORM\Entity $case, string $recentTaskThreshold): bool
|
||||||
|
{
|
||||||
|
$caseId = $case->get('id');
|
||||||
|
|
||||||
|
// Collect all parentId+parentType pairs that relate to this case
|
||||||
|
$parentConditions = [
|
||||||
|
['parentType' => 'Case', 'parentId' => $caseId],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Also check tasks linked to the case's contacts
|
||||||
|
$contactIds = $this->getCaseContactIds($case);
|
||||||
|
if (!empty($contactIds)) {
|
||||||
|
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 1: Any open tasks linked to the case or its contacts
|
||||||
|
$openTaskCount = $this->entityManager->getRDBRepository('Task')
|
||||||
|
->where([
|
||||||
|
'OR' => $parentConditions,
|
||||||
|
'status!=' => ['Completed', 'Canceled', 'Deferred'],
|
||||||
|
'deleted' => false,
|
||||||
|
])->count();
|
||||||
|
if ($openTaskCount > 0) return true;
|
||||||
|
|
||||||
|
// Check 2: Any recently completed tasks linked to the case or its contacts
|
||||||
|
$recentCompleted = $this->entityManager->getRDBRepository('Task')
|
||||||
|
->where([
|
||||||
|
'OR' => $parentConditions,
|
||||||
|
'status' => 'Completed',
|
||||||
|
'modifiedAt>=' => $recentTaskThreshold,
|
||||||
|
'deleted' => false,
|
||||||
|
])->count();
|
||||||
|
if ($recentCompleted > 0) return true;
|
||||||
|
|
||||||
|
// Check 3: Tasks linked through NhActivity records for this case
|
||||||
|
$nhActivityTaskCount = $this->entityManager->getRDBRepository('NhActivity')
|
||||||
|
->where([
|
||||||
|
'caseId' => $caseId,
|
||||||
|
'taskId!=' => null,
|
||||||
|
'deleted' => false,
|
||||||
|
])->count();
|
||||||
|
|
||||||
|
if ($nhActivityTaskCount > 0) {
|
||||||
|
$nhActivities = $this->entityManager->getRDBRepository('NhActivity')
|
||||||
|
->select(['taskId'])
|
||||||
|
->where([
|
||||||
|
'caseId' => $caseId,
|
||||||
|
'taskId!=' => null,
|
||||||
|
'deleted' => false,
|
||||||
|
])->find();
|
||||||
|
|
||||||
|
$taskIds = [];
|
||||||
|
foreach ($nhActivities as $nha) {
|
||||||
|
$taskIds[] = $nha->get('taskId');
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeNhTasks = $this->entityManager->getRDBRepository('Task')
|
||||||
|
->where([
|
||||||
|
'id' => $taskIds,
|
||||||
|
'status!=' => ['Canceled'],
|
||||||
|
'deleted' => false,
|
||||||
|
])->count();
|
||||||
|
if ($activeNhTasks > 0) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCaseContactIds(\Espo\ORM\Entity $case): array
|
||||||
|
{
|
||||||
|
$contactIds = [];
|
||||||
|
|
||||||
|
$primaryContactId = $case->get('contactId');
|
||||||
|
if ($primaryContactId) {
|
||||||
|
$contactIds[] = $primaryContactId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||||
|
->getRelation($case, 'contacts')
|
||||||
|
->select(['id'])
|
||||||
|
->find();
|
||||||
|
|
||||||
|
foreach ($contacts as $contact) {
|
||||||
|
$id = $contact->get('id');
|
||||||
|
if (!in_array($id, $contactIds)) {
|
||||||
|
$contactIds[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contactIds;
|
||||||
|
}
|
||||||
|
|
||||||
private function findUnassignedNewCases(): array
|
private function findUnassignedNewCases(): array
|
||||||
{
|
{
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
@@ -191,7 +295,7 @@ class AlertCalculator
|
|||||||
|
|
||||||
$tasks = $this->entityManager->getRDBRepository('Task')
|
$tasks = $this->entityManager->getRDBRepository('Task')
|
||||||
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
|
||||||
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => 'Case'])
|
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
|
||||||
->order('dateEnd', 'ASC')->limit(0, 30)->find();
|
->order('dateEnd', 'ASC')->limit(0, 30)->find();
|
||||||
|
|
||||||
foreach ($tasks as $task) {
|
foreach ($tasks as $task) {
|
||||||
@@ -237,7 +341,7 @@ class AlertCalculator
|
|||||||
|
|
||||||
foreach (array_keys($userCounts) as $uid) {
|
foreach (array_keys($userCounts) as $uid) {
|
||||||
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
|
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
|
||||||
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => 'Case'])->count();
|
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_values($userCounts);
|
return array_values($userCounts);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ class CaseContextBuilder
|
|||||||
'recentNotes' => $this->getRecentNotes($caseId),
|
'recentNotes' => $this->getRecentNotes($caseId),
|
||||||
'availableTemplates' => $this->getAvailableTemplates(),
|
'availableTemplates' => $this->getAvailableTemplates(),
|
||||||
'documents' => $this->getDocumentListing($caseId),
|
'documents' => $this->getDocumentListing($caseId),
|
||||||
|
'signatureRequests' => $this->getSignatureRequests($caseId),
|
||||||
'currentUser' => $this->getUserData($userId),
|
'currentUser' => $this->getUserData($userId),
|
||||||
'validStatuses' => ActionExecutor::VALID_STATUSES,
|
'validStatuses' => ActionExecutor::VALID_STATUSES,
|
||||||
];
|
];
|
||||||
@@ -82,9 +83,18 @@ class CaseContextBuilder
|
|||||||
|
|
||||||
private function getOpenTasks(string $caseId): array
|
private function getOpenTasks(string $caseId): array
|
||||||
{
|
{
|
||||||
|
$parentConditions = [
|
||||||
|
['parentType' => 'Case', 'parentId' => $caseId],
|
||||||
|
];
|
||||||
|
|
||||||
|
$contactIds = $this->getCaseContactIds($caseId);
|
||||||
|
if (!empty($contactIds)) {
|
||||||
|
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||||
|
}
|
||||||
|
|
||||||
$tasks = [];
|
$tasks = [];
|
||||||
$collection = $this->entityManager->getRDBRepository('Task')
|
$collection = $this->entityManager->getRDBRepository('Task')
|
||||||
->where(['parentType' => 'Case', 'parentId' => $caseId, 'status!=' => ['Completed', 'Canceled']])
|
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled']])
|
||||||
->order('dateEnd', 'ASC')->limit(0, 10)->find();
|
->order('dateEnd', 'ASC')->limit(0, 10)->find();
|
||||||
|
|
||||||
foreach ($collection as $t) {
|
foreach ($collection as $t) {
|
||||||
@@ -97,6 +107,32 @@ class CaseContextBuilder
|
|||||||
return $tasks;
|
return $tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getCaseContactIds(string $caseId): array
|
||||||
|
{
|
||||||
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
|
if (!$case) return [];
|
||||||
|
|
||||||
|
$contactIds = [];
|
||||||
|
$primaryContactId = $case->get('contactId');
|
||||||
|
if ($primaryContactId) {
|
||||||
|
$contactIds[] = $primaryContactId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||||
|
->getRelation($case, 'contacts')
|
||||||
|
->select(['id'])
|
||||||
|
->find();
|
||||||
|
|
||||||
|
foreach ($contacts as $contact) {
|
||||||
|
$id = $contact->get('id');
|
||||||
|
if (!in_array($id, $contactIds)) {
|
||||||
|
$contactIds[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contactIds;
|
||||||
|
}
|
||||||
|
|
||||||
private function getUpcomingMeetings(string $caseId): array
|
private function getUpcomingMeetings(string $caseId): array
|
||||||
{
|
{
|
||||||
$meetings = [];
|
$meetings = [];
|
||||||
@@ -152,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;
|
||||||
}
|
}
|
||||||
@@ -167,13 +213,51 @@ class CaseContextBuilder
|
|||||||
return ['id' => $user->get('id'), 'name' => $user->get('name')];
|
return ['id' => $user->get('id'), 'name' => $user->get('name')];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getSignatureRequests(string $caseId): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$collection = $this->entityManager->getRDBRepository('SignatureRequest')
|
||||||
|
->where([
|
||||||
|
'caseId' => $caseId,
|
||||||
|
'status!=' => ['Completed', 'Voided'],
|
||||||
|
])
|
||||||
|
->order('createdAt', 'DESC')
|
||||||
|
->limit(0, 10)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
$requests = [];
|
||||||
|
|
||||||
|
foreach ($collection as $sr) {
|
||||||
|
$requests[] = [
|
||||||
|
'id' => $sr->get('id'),
|
||||||
|
'name' => $sr->get('name'),
|
||||||
|
'status' => $sr->get('status'),
|
||||||
|
'sentAt' => $sr->get('sentAt'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $requests;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// SignatureRequest entity might not exist if DigitalSignature not installed
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function getDocumentListing(string $caseId): array
|
private function getDocumentListing(string $caseId): array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
|
||||||
$result = $analyzer->listDocumentsRecursive($caseId);
|
$result = $analyzer->listDocumentsRecursive($caseId);
|
||||||
|
|
||||||
$simplified = ['totalFiles' => $result['totalFiles'], 'folders' => [], 'rootFiles' => []];
|
$simplified = [
|
||||||
|
'totalFiles' => $result['totalFiles'],
|
||||||
|
'folders' => [],
|
||||||
|
'rootFiles' => [],
|
||||||
|
'resolvedPath' => $result['resolvedPath'] ?? null,
|
||||||
|
'pathSource' => $result['pathSource'] ?? null,
|
||||||
|
'folderExists' => $result['folderExists'] ?? null,
|
||||||
|
'diagnostic' => $result['reason'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
foreach ($result['folders'] as $folder) {
|
foreach ($result['folders'] as $folder) {
|
||||||
$files = [];
|
$files = [];
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\SmartAssistant\Services;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\BadRequest;
|
||||||
|
use Espo\Core\Exceptions\Error;
|
||||||
|
use Espo\Core\Exceptions\Forbidden;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Core\Utils\Log;
|
||||||
|
use Espo\ORM\EntityManager;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps NetworkDocumentService and constrains every operation to the case's
|
||||||
|
* own folder. Never exposes a physical delete — only soft-delete via
|
||||||
|
* markForDeletion().
|
||||||
|
*/
|
||||||
|
class CaseFolderManager
|
||||||
|
{
|
||||||
|
private const DELETION_FOLDER_NAME = 'מסמכים למחיקה';
|
||||||
|
private const DELETION_PREFIX = 'למחיקה - ';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManager $entityManager,
|
||||||
|
private InjectableFactory $injectableFactory,
|
||||||
|
private Log $log,
|
||||||
|
private User $user
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, path: string, size: int}
|
||||||
|
*/
|
||||||
|
public function writeFile(string $caseId, string $relPath, string $content): array
|
||||||
|
{
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
|
||||||
|
// Split the (LLM-provided) relPath into folder + filename. Subfolders
|
||||||
|
// are allowed, but the final filename is sanitized and de-duplicated by
|
||||||
|
// the canonical namer (rule N1) — never trust the model to name files.
|
||||||
|
$normalized = $this->normalize($relPath);
|
||||||
|
if ($normalized === '') {
|
||||||
|
throw new BadRequest('Path is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$slash = strrpos($normalized, '/');
|
||||||
|
$dirRel = $slash === false ? '' : substr($normalized, 0, $slash);
|
||||||
|
$baseName = $slash === false ? $normalized : substr($normalized, $slash + 1);
|
||||||
|
|
||||||
|
$ext = pathinfo($baseName, PATHINFO_EXTENSION) ?: 'bin';
|
||||||
|
$subject = pathinfo($baseName, PATHINFO_FILENAME);
|
||||||
|
|
||||||
|
$parentAbs = $dirRel === ''
|
||||||
|
? $this->getCaseRoot($caseId)
|
||||||
|
: $this->resolveSafePath($caseId, $this->sanitizeRelSegments($dirRel), requireExists: false);
|
||||||
|
|
||||||
|
$client->createFolderRecursive($parentAbs);
|
||||||
|
|
||||||
|
$storedName = LocalFilesystemClient::buildStoredFileName($client, $parentAbs, $subject, $ext);
|
||||||
|
$absInsideStorage = $parentAbs . '/' . $storedName;
|
||||||
|
|
||||||
|
$ok = $client->uploadFile($absInsideStorage, $content);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to write file: {$absInsideStorage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("CaseFolderManager: wrote '{$absInsideStorage}' (case {$caseId})");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'path' => $absInsideStorage,
|
||||||
|
'size' => strlen($content),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, path: string, alreadyExisted: bool}
|
||||||
|
*/
|
||||||
|
public function createFolder(string $caseId, string $relPath): array
|
||||||
|
{
|
||||||
|
$cleanRel = $this->sanitizeRelSegments($this->normalize($relPath));
|
||||||
|
if ($cleanRel === '') {
|
||||||
|
throw new BadRequest('A valid folder name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$abs = $this->resolveSafePath($caseId, $cleanRel, requireExists: false);
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
|
||||||
|
$alreadyExisted = $client->exists($abs);
|
||||||
|
if (!$alreadyExisted) {
|
||||||
|
$ok = $client->createFolderRecursive($abs);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to create folder: {$abs}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("CaseFolderManager: created folder '{$abs}' (case {$caseId})");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'path' => $abs,
|
||||||
|
'alreadyExisted' => $alreadyExisted,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, oldPath: string, newPath: string}
|
||||||
|
*/
|
||||||
|
public function renameItem(string $caseId, string $currentRelPath, string $newName): array
|
||||||
|
{
|
||||||
|
$newName = trim($newName);
|
||||||
|
if ($newName === '' || str_contains($newName, '/') || str_contains($newName, '\\')) {
|
||||||
|
throw new BadRequest('newName must be a plain file/folder name without slashes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize the model-chosen name (rule N1).
|
||||||
|
$newName = LocalFilesystemClient::sanitizeFileName($newName);
|
||||||
|
if ($newName === '') {
|
||||||
|
throw new BadRequest('newName is empty after sanitization.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$absCurrent = $this->resolveSafePath($caseId, $currentRelPath, requireExists: true);
|
||||||
|
|
||||||
|
$parent = $this->dirnameRel($absCurrent);
|
||||||
|
$absNew = $parent === '' ? $newName : ($parent . '/' . $newName);
|
||||||
|
|
||||||
|
// Confirm the new path is still inside the case folder.
|
||||||
|
$this->assertWithinCaseRoot($caseId, $absNew);
|
||||||
|
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
if ($client->exists($absNew)) {
|
||||||
|
throw new Error("Target already exists: {$absNew}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $client->move($absCurrent, $absNew);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to rename: {$absCurrent} -> {$absNew}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("CaseFolderManager: renamed '{$absCurrent}' -> '{$absNew}' (case {$caseId})");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'oldPath' => $absCurrent,
|
||||||
|
'newPath' => $absNew,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{success: bool, oldPath: string, newPath: string}
|
||||||
|
*/
|
||||||
|
public function moveItem(string $caseId, string $sourceRelPath, string $targetRelPath): array
|
||||||
|
{
|
||||||
|
$absSource = $this->resolveSafePath($caseId, $sourceRelPath, requireExists: true);
|
||||||
|
$absTarget = $this->resolveSafePath(
|
||||||
|
$caseId,
|
||||||
|
$this->sanitizeRelSegments($this->normalize($targetRelPath)),
|
||||||
|
requireExists: false
|
||||||
|
);
|
||||||
|
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
|
||||||
|
$parent = $this->dirnameRel($absTarget);
|
||||||
|
if ($parent !== '' && !$client->exists($parent)) {
|
||||||
|
$client->createFolderRecursive($parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($client->exists($absTarget)) {
|
||||||
|
throw new Error("Target already exists: {$absTarget}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $client->move($absSource, $absTarget);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to move: {$absSource} -> {$absTarget}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("CaseFolderManager: moved '{$absSource}' -> '{$absTarget}' (case {$caseId})");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'oldPath' => $absSource,
|
||||||
|
'newPath' => $absTarget,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete: move file into the case's "מסמכים למחיקה" folder with a
|
||||||
|
* "למחיקה - " prefix. Never physically deletes. Also updates the linked
|
||||||
|
* Document entity (if any) so admins can audit pending deletions.
|
||||||
|
*
|
||||||
|
* @return array{success: bool, originalPath: string, newPath: string, deletionFolder: string}
|
||||||
|
*/
|
||||||
|
public function markForDeletion(string $caseId, string $fileRelPath): array
|
||||||
|
{
|
||||||
|
$absSource = $this->resolveSafePath($caseId, $fileRelPath, requireExists: true);
|
||||||
|
$caseRoot = $this->getCaseRoot($caseId);
|
||||||
|
|
||||||
|
$deletionFolder = $caseRoot . '/' . self::DELETION_FOLDER_NAME;
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
|
||||||
|
if (!$client->exists($deletionFolder)) {
|
||||||
|
$client->createFolderRecursive($deletionFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseName = basename($absSource);
|
||||||
|
$targetName = self::DELETION_PREFIX . $baseName;
|
||||||
|
$absTarget = $deletionFolder . '/' . $targetName;
|
||||||
|
|
||||||
|
// Avoid name collisions with already-marked files.
|
||||||
|
if ($client->exists($absTarget)) {
|
||||||
|
$stamp = date('Y-m-d_H-i-s');
|
||||||
|
$targetName = self::DELETION_PREFIX . $stamp . ' - ' . $baseName;
|
||||||
|
$absTarget = $deletionFolder . '/' . $targetName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $client->move($absSource, $absTarget);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new Error("Failed to mark for deletion: {$absSource}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: update Document entity if we can find one pointing to this file.
|
||||||
|
$this->touchDocumentForDeletion($absSource, $absTarget);
|
||||||
|
|
||||||
|
$this->log->info(
|
||||||
|
"CaseFolderManager: marked for deletion '{$absSource}' -> '{$absTarget}' " .
|
||||||
|
"(case {$caseId}, by user {$this->user->getId()})"
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'originalPath' => $absSource,
|
||||||
|
'newPath' => $absTarget,
|
||||||
|
'deletionFolder' => $deletionFolder,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals ---------------------------------------------------------
|
||||||
|
|
||||||
|
private function getNds(): NetworkDocumentService
|
||||||
|
{
|
||||||
|
return $this->injectableFactory->create(NetworkDocumentService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the storage-relative root folder of the case.
|
||||||
|
*/
|
||||||
|
private function getCaseRoot(string $caseId): string
|
||||||
|
{
|
||||||
|
$root = $this->getNds()->getEntityFolderPath('Case', $caseId);
|
||||||
|
if (!$root) {
|
||||||
|
throw new NotFound("No folder is configured for Case {$caseId}.");
|
||||||
|
}
|
||||||
|
$root = $this->normalize($root);
|
||||||
|
if ($root === '') {
|
||||||
|
throw new Error("Case {$caseId} resolved to an empty folder path; refusing.");
|
||||||
|
}
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize + reject traversal, return storage-relative path inside the case folder.
|
||||||
|
* If $requireExists, ensures the resolved path exists in storage.
|
||||||
|
*/
|
||||||
|
private function resolveSafePath(string $caseId, string $relPath, bool $requireExists): string
|
||||||
|
{
|
||||||
|
$relPath = trim((string) $relPath);
|
||||||
|
if ($relPath === '') {
|
||||||
|
throw new BadRequest('Path is required.');
|
||||||
|
}
|
||||||
|
if (str_starts_with($relPath, '/') || str_starts_with($relPath, '\\')) {
|
||||||
|
throw new BadRequest('Absolute paths are not allowed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$caseRoot = $this->getCaseRoot($caseId);
|
||||||
|
|
||||||
|
// Allow the caller to either pass a path relative to the case root, OR a
|
||||||
|
// full storage-relative path that already includes the case root.
|
||||||
|
$normalizedRel = $this->normalize($relPath);
|
||||||
|
$candidate = str_starts_with($normalizedRel, $caseRoot . '/') || $normalizedRel === $caseRoot
|
||||||
|
? $normalizedRel
|
||||||
|
: $this->normalize($caseRoot . '/' . $normalizedRel);
|
||||||
|
|
||||||
|
$this->assertWithinCaseRoot($caseId, $candidate);
|
||||||
|
|
||||||
|
if ($requireExists) {
|
||||||
|
$client = $this->getNds()->getClient();
|
||||||
|
if (!$client->exists($candidate)) {
|
||||||
|
throw new NotFound("Path not found: {$candidate}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertWithinCaseRoot(string $caseId, string $absInStorage): void
|
||||||
|
{
|
||||||
|
$caseRoot = $this->getCaseRoot($caseId);
|
||||||
|
if ($absInStorage !== $caseRoot && !str_starts_with($absInStorage, $caseRoot . '/')) {
|
||||||
|
throw new Forbidden("Path '{$absInStorage}' is outside the case folder '{$caseRoot}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize a storage-relative path:
|
||||||
|
* - convert '\' to '/'
|
||||||
|
* - collapse repeated '/'
|
||||||
|
* - resolve '.' segments
|
||||||
|
* - reject '..' segments (refuses traversal)
|
||||||
|
* - strip trailing '/'
|
||||||
|
*/
|
||||||
|
private function normalize(string $path): string
|
||||||
|
{
|
||||||
|
$path = str_replace('\\', '/', $path);
|
||||||
|
$segments = explode('/', $path);
|
||||||
|
$out = [];
|
||||||
|
foreach ($segments as $seg) {
|
||||||
|
if ($seg === '' || $seg === '.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($seg === '..') {
|
||||||
|
throw new Forbidden('Parent-directory traversal is not allowed.');
|
||||||
|
}
|
||||||
|
// Block null bytes and other suspicious characters.
|
||||||
|
if (preg_match('/[\x00-\x1F]/', $seg)) {
|
||||||
|
throw new BadRequest('Path contains invalid control characters.');
|
||||||
|
}
|
||||||
|
$out[] = $seg;
|
||||||
|
}
|
||||||
|
return implode('/', $out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize every segment of an already-normalized storage-relative path
|
||||||
|
* with the canonical file-name rules (rule N1). Empty segments are dropped.
|
||||||
|
*/
|
||||||
|
private function sanitizeRelSegments(string $normalizedPath): string
|
||||||
|
{
|
||||||
|
if ($normalizedPath === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = array_map(
|
||||||
|
static fn (string $seg): string => LocalFilesystemClient::sanitizeFileName($seg),
|
||||||
|
explode('/', $normalizedPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
$segments = array_filter($segments, static fn (string $seg): bool => $seg !== '');
|
||||||
|
|
||||||
|
return implode('/', $segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dirnameRel(string $path): string
|
||||||
|
{
|
||||||
|
$pos = strrpos($path, '/');
|
||||||
|
if ($pos === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return substr($path, 0, $pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the Document entity (if any) that pointed to this file, so the
|
||||||
|
* CRM reflects the soft-delete state.
|
||||||
|
*/
|
||||||
|
private function touchDocumentForDeletion(string $oldPath, string $newPath): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$doc = $this->entityManager->getRDBRepository('Document')
|
||||||
|
->where(['networkStoragePath' => $oldPath])
|
||||||
|
->findOne();
|
||||||
|
if (!$doc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$doc->set([
|
||||||
|
'networkStoragePath' => $newPath,
|
||||||
|
'markedForDeletionAt' => date('Y-m-d H:i:s'),
|
||||||
|
'markedForDeletionById' => $this->user->getId(),
|
||||||
|
]);
|
||||||
|
$this->entityManager->saveEntity($doc);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->log->warning(
|
||||||
|
"CaseFolderManager: could not update Document entity for '{$oldPath}': " . $e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,11 +41,67 @@ class DocumentAnalyzer
|
|||||||
|
|
||||||
public function listDocumentsRecursive(string $caseId): array
|
public function listDocumentsRecursive(string $caseId): array
|
||||||
{
|
{
|
||||||
$casePath = $this->getCaseFolderPath($caseId);
|
$description = $this->describeCaseFolderPath($caseId);
|
||||||
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
|
$casePath = $description['path'];
|
||||||
|
|
||||||
|
if (!$casePath) {
|
||||||
|
return [
|
||||||
|
'casePath' => null,
|
||||||
|
'totalFiles' => 0,
|
||||||
|
'folders' => [],
|
||||||
|
'rootFiles' => [],
|
||||||
|
'resolvedPath' => null,
|
||||||
|
'pathSource' => $description['source'],
|
||||||
|
'folderExists' => false,
|
||||||
|
'reason' => $description['error'] ?? 'Case has no networkStorageFolderPath and no default could be computed.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$client = $this->getStorageClient();
|
$client = $this->getStorageClient();
|
||||||
$topLevel = $client->listFolder($casePath);
|
|
||||||
|
try {
|
||||||
|
$folderExists = $client->exists($casePath);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'casePath' => $casePath,
|
||||||
|
'totalFiles' => 0,
|
||||||
|
'folders' => [],
|
||||||
|
'rootFiles' => [],
|
||||||
|
'resolvedPath' => $casePath,
|
||||||
|
'pathSource' => $description['source'],
|
||||||
|
'folderExists' => false,
|
||||||
|
'reason' => "Storage client failed while checking '{$casePath}': " . $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$folderExists) {
|
||||||
|
return [
|
||||||
|
'casePath' => $casePath,
|
||||||
|
'totalFiles' => 0,
|
||||||
|
'folders' => [],
|
||||||
|
'rootFiles' => [],
|
||||||
|
'resolvedPath' => $casePath,
|
||||||
|
'pathSource' => $description['source'],
|
||||||
|
'folderExists' => false,
|
||||||
|
'reason' => "Folder '{$casePath}' (source: {$description['source']}) does not exist on storage.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$topLevel = $client->listFolder($casePath);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'casePath' => $casePath,
|
||||||
|
'totalFiles' => 0,
|
||||||
|
'folders' => [],
|
||||||
|
'rootFiles' => [],
|
||||||
|
'resolvedPath' => $casePath,
|
||||||
|
'pathSource' => $description['source'],
|
||||||
|
'folderExists' => true,
|
||||||
|
'reason' => "Failed to list folder '{$casePath}': " . $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$folders = []; $rootFiles = []; $totalFiles = 0;
|
$folders = []; $rootFiles = []; $totalFiles = 0;
|
||||||
|
|
||||||
foreach ($topLevel as $item) {
|
foreach ($topLevel as $item) {
|
||||||
@@ -70,7 +126,16 @@ class DocumentAnalyzer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['casePath' => $casePath, 'totalFiles' => $totalFiles, 'folders' => $folders, 'rootFiles' => $rootFiles];
|
return [
|
||||||
|
'casePath' => $casePath,
|
||||||
|
'totalFiles' => $totalFiles,
|
||||||
|
'folders' => $folders,
|
||||||
|
'rootFiles' => $rootFiles,
|
||||||
|
'resolvedPath' => $casePath,
|
||||||
|
'pathSource' => $description['source'],
|
||||||
|
'folderExists' => true,
|
||||||
|
'reason' => $totalFiles === 0 ? "Folder '{$casePath}' exists but is empty." : null,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function extractTextContent(string $filePath): ?string
|
public function extractTextContent(string $filePath): ?string
|
||||||
@@ -115,6 +180,85 @@ class DocumentAnalyzer
|
|||||||
return $text;
|
return $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch raw file bytes + metadata for OCR/vision fallback.
|
||||||
|
*
|
||||||
|
* @return array{success: bool, fileName: string, mimeType: string, sizeBytes: int, base64: ?string, error: ?string}
|
||||||
|
*/
|
||||||
|
public function getDocumentBytes(string $filePath): array
|
||||||
|
{
|
||||||
|
$fileName = basename($filePath);
|
||||||
|
$client = $this->getStorageClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$content = $client->downloadFile($filePath);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'mimeType' => '',
|
||||||
|
'sizeBytes' => 0,
|
||||||
|
'base64' => null,
|
||||||
|
'error' => 'Failed to download: ' . $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sizeBytes = strlen($content);
|
||||||
|
if ($sizeBytes > self::MAX_FILE_SIZE) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'mimeType' => '',
|
||||||
|
'sizeBytes' => $sizeBytes,
|
||||||
|
'base64' => null,
|
||||||
|
'error' => 'File too large (max 10MB).',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$mimeType = $this->guessMimeType($fileName, $content);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'mimeType' => $mimeType,
|
||||||
|
'sizeBytes' => $sizeBytes,
|
||||||
|
'base64' => base64_encode($content),
|
||||||
|
'error' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function guessMimeType(string $fileName, string $content): string
|
||||||
|
{
|
||||||
|
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
|
||||||
|
$map = [
|
||||||
|
'pdf' => 'application/pdf',
|
||||||
|
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'doc' => 'application/msword',
|
||||||
|
'txt' => 'text/plain',
|
||||||
|
'csv' => 'text/csv',
|
||||||
|
'log' => 'text/plain',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'bmp' => 'image/bmp',
|
||||||
|
'tiff' => 'image/tiff',
|
||||||
|
'tif' => 'image/tiff',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
];
|
||||||
|
if (isset($map[$ext])) {
|
||||||
|
return $map[$ext];
|
||||||
|
}
|
||||||
|
// Fallback: finfo
|
||||||
|
if (function_exists('finfo_buffer')) {
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = finfo_buffer($finfo, $content);
|
||||||
|
finfo_close($finfo);
|
||||||
|
if ($mime) return $mime;
|
||||||
|
}
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
|
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
|
||||||
{
|
{
|
||||||
$results = [];
|
$results = [];
|
||||||
@@ -192,24 +336,190 @@ class DocumentAnalyzer
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function getCaseFolderPath(string $caseId): ?string
|
public function getCaseFolderPath(string $caseId): ?string
|
||||||
|
{
|
||||||
|
return $this->describeCaseFolderPath($caseId)['path'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the case folder path with diagnostic metadata.
|
||||||
|
*
|
||||||
|
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
|
||||||
|
* source: 'stored' | 'computed' | 'none'
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Containment check: is $filePath inside the given case's storage folder?
|
||||||
|
* Used to stop a user with access to one case from reading another case's
|
||||||
|
* files by passing a raw absolute/relative path (finding S2). Rejects any
|
||||||
|
* path containing '..' and requires a resolvable case folder.
|
||||||
|
*/
|
||||||
|
public function isPathWithinCase(string $filePath, string $caseId): bool
|
||||||
|
{
|
||||||
|
$normalized = str_replace('\\', '/', (string) $filePath);
|
||||||
|
|
||||||
|
if ($normalized === '' || str_contains($normalized, '..')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$caseFolder = $this->describeCaseFolderPath($caseId)['path'] ?? null;
|
||||||
|
|
||||||
|
if (!$caseFolder) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$needle = ltrim(rtrim(str_replace('\\', '/', $caseFolder), '/'), '/');
|
||||||
|
$candidate = ltrim($normalized, '/');
|
||||||
|
|
||||||
|
if ($needle === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidate === $needle || str_starts_with($candidate, $needle . '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function describeCaseFolderPath(string $caseId): array
|
||||||
{
|
{
|
||||||
$case = $this->entityManager->getEntityById('Case', $caseId);
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
if (!$case) return null;
|
if (!$case) {
|
||||||
|
return ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false];
|
||||||
|
}
|
||||||
|
|
||||||
// Try stored path first
|
|
||||||
$storedPath = $case->get('networkStorageFolderPath') ?: null;
|
$storedPath = $case->get('networkStorageFolderPath') ?: null;
|
||||||
if ($storedPath) return $storedPath;
|
if ($storedPath) {
|
||||||
|
return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true];
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to computed path from NetworkDocumentService
|
|
||||||
try {
|
try {
|
||||||
$nds = $this->getNetworkDocumentService();
|
$nds = $this->getNetworkDocumentService();
|
||||||
return $nds->getEntityFolderPath('Case', $caseId);
|
$computed = $nds->getEntityFolderPath('Case', $caseId);
|
||||||
|
if ($computed) {
|
||||||
|
return ['path' => $computed, 'source' => 'computed', 'error' => null, 'caseExists' => true];
|
||||||
|
}
|
||||||
|
return ['path' => null, 'source' => 'none', 'error' => 'No stored path, and default path computation returned null.', 'caseExists' => true];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
|
$this->log->warning("SmartAssistant: Failed to compute case folder path: " . $e->getMessage());
|
||||||
return null;
|
return ['path' => null, 'source' => 'none', 'error' => $e->getMessage(), 'caseExists' => true];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the immediate contents of an arbitrary folder path on network storage.
|
||||||
|
* Manual override for when auto-resolution of a case folder fails.
|
||||||
|
*
|
||||||
|
* @return array<int, array{name: string, path: string, type: string, size: ?int, mimeType: ?string, modified: ?string}>
|
||||||
|
*/
|
||||||
|
public function browseFolder(string $path): array
|
||||||
|
{
|
||||||
|
$client = $this->getStorageClient();
|
||||||
|
$entries = [];
|
||||||
|
foreach ($client->listFolder($path) as $item) {
|
||||||
|
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||||
|
$entries[] = [
|
||||||
|
'name' => $item['name'] ?? '',
|
||||||
|
'path' => $item['path'] ?? '',
|
||||||
|
'type' => $isFolder ? 'folder' : 'file',
|
||||||
|
'size' => $item['size'] ?? null,
|
||||||
|
'mimeType' => $item['mimeType'] ?? null,
|
||||||
|
'modified' => $item['modified'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the storage for folders matching a query (case-insensitive substring).
|
||||||
|
* Scans root and one level deep. Returns sorted matches.
|
||||||
|
*
|
||||||
|
* @return array<int, array{path: string, name: string, level: int, score: int}>
|
||||||
|
*/
|
||||||
|
public function findCaseFolder(string $query, int $limit = 20): array
|
||||||
|
{
|
||||||
|
$normalized = trim($query);
|
||||||
|
if ($normalized === '') return [];
|
||||||
|
|
||||||
|
$client = $this->getStorageClient();
|
||||||
|
$needle = mb_strtolower($normalized);
|
||||||
|
$matches = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$roots = $client->listFolder('');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->log->warning("SmartAssistant: findCaseFolder root listing failed: " . $e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($roots as $item) {
|
||||||
|
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
|
||||||
|
if (!$isFolder) continue;
|
||||||
|
|
||||||
|
$name = (string) ($item['name'] ?? '');
|
||||||
|
$path = (string) ($item['path'] ?? $name);
|
||||||
|
|
||||||
|
$score = $this->fuzzyScore($name, $needle);
|
||||||
|
if ($score > 0) {
|
||||||
|
$matches[] = ['path' => $path, 'name' => $name, 'level' => 0, 'score' => $score];
|
||||||
|
}
|
||||||
|
|
||||||
|
// One level deeper, but only inspect folder names — don't list files to keep it cheap.
|
||||||
|
try {
|
||||||
|
foreach ($client->listFolder($path) as $sub) {
|
||||||
|
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
|
||||||
|
if (!$subIsFolder) continue;
|
||||||
|
$subName = (string) ($sub['name'] ?? '');
|
||||||
|
$subPath = (string) ($sub['path'] ?? ($path . '/' . $subName));
|
||||||
|
$subScore = $this->fuzzyScore($subName, $needle);
|
||||||
|
if ($subScore > 0) {
|
||||||
|
$matches[] = ['path' => $subPath, 'name' => $subName, 'level' => 1, 'score' => $subScore];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Non-fatal: skip unreadable subfolders silently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($matches, fn($a, $b) => $b['score'] <=> $a['score']);
|
||||||
|
return array_slice($matches, 0, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the resolved folder path on the Case so future calls skip discovery.
|
||||||
|
*/
|
||||||
|
public function setCaseFolderPath(string $caseId, string $path): array
|
||||||
|
{
|
||||||
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
|
if (!$case) {
|
||||||
|
throw new Error("Case '{$caseId}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = $this->getStorageClient();
|
||||||
|
if (!$client->exists($path)) {
|
||||||
|
throw new Error("Path '{$path}' does not exist on storage.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$case->set('networkStorageFolderPath', $path);
|
||||||
|
$this->entityManager->saveEntity($case);
|
||||||
|
|
||||||
|
return ['success' => true, 'caseId' => $caseId, 'path' => $path];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fuzzyScore(string $haystack, string $needleLower): int
|
||||||
|
{
|
||||||
|
$hay = mb_strtolower($haystack);
|
||||||
|
if ($hay === $needleLower) return 100;
|
||||||
|
if (mb_strpos($hay, $needleLower) !== false) return 80;
|
||||||
|
|
||||||
|
// Token overlap (whitespace/dash/underscore).
|
||||||
|
$hayTokens = preg_split('/[\s\-_]+/u', $hay) ?: [];
|
||||||
|
$needleTokens = preg_split('/[\s\-_]+/u', $needleLower) ?: [];
|
||||||
|
$hits = 0;
|
||||||
|
foreach ($needleTokens as $nt) {
|
||||||
|
if ($nt === '') continue;
|
||||||
|
foreach ($hayTokens as $ht) {
|
||||||
|
if ($ht !== '' && mb_strpos($ht, $nt) !== false) { $hits++; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $hits > 0 ? 40 + $hits * 5 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
private function getMaxTextLength(): int
|
private function getMaxTextLength(): int
|
||||||
{
|
{
|
||||||
if ($this->maxTextLength !== null) {
|
if ($this->maxTextLength !== null) {
|
||||||
@@ -253,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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Espo\Modules\SmartAssistant\Services;
|
||||||
|
|
||||||
|
use Espo\Core\Exceptions\Error;
|
||||||
|
use Espo\Core\Exceptions\NotFound;
|
||||||
|
use Espo\Core\InjectableFactory;
|
||||||
|
use Espo\Core\Utils\Log;
|
||||||
|
use Espo\ORM\EntityManager;
|
||||||
|
use Espo\Entities\User;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Services\DocumentTemplateService;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Services\NetworkDocumentService;
|
||||||
|
use Espo\Modules\NetworkStorageIntegration\Classes\LocalFilesystemClient;
|
||||||
|
use PhpOffice\PhpWord\TemplateProcessor;
|
||||||
|
|
||||||
|
class GenericTemplateGenerator
|
||||||
|
{
|
||||||
|
private const TEMP_DIR = 'data/tmp/';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManager $entityManager,
|
||||||
|
private InjectableFactory $injectableFactory,
|
||||||
|
private Log $log,
|
||||||
|
private User $user
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function generate(string $templateId, string $caseId, ?string $documentSubject = null, array $customPlaceholders = []): array
|
||||||
|
{
|
||||||
|
$template = $this->entityManager->getEntityById('DocumentTemplate', $templateId);
|
||||||
|
if (!$template) {
|
||||||
|
throw new NotFound("Template {$templateId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$template->get('isActive')) {
|
||||||
|
throw new Error("Template is not active.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$templatePath = $template->get('templatePath');
|
||||||
|
if (!$templatePath) {
|
||||||
|
throw new Error("Template path is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$case = $this->entityManager->getEntityById('Case', $caseId);
|
||||||
|
if (!$case) {
|
||||||
|
throw new NotFound("Case {$caseId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$templateName = $template->get('name') ?? 'document';
|
||||||
|
|
||||||
|
// Step 1: Download template from WebDAV
|
||||||
|
$localTemplatePath = $this->downloadTemplate($templatePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 2: Build placeholder data from entity fields
|
||||||
|
$templateService = $this->injectableFactory->create(DocumentTemplateService::class);
|
||||||
|
$placeholderData = $templateService->buildPlaceholderData($case);
|
||||||
|
|
||||||
|
// Step 3: Merge custom placeholders (override entity data)
|
||||||
|
foreach ($customPlaceholders as $key => $value) {
|
||||||
|
$placeholderData[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Process template
|
||||||
|
$outputPath = self::TEMP_DIR . uniqid('gen_') . '.docx';
|
||||||
|
if (!is_dir(self::TEMP_DIR)) {
|
||||||
|
mkdir(self::TEMP_DIR, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->processTemplate($localTemplatePath, $outputPath, $placeholderData);
|
||||||
|
|
||||||
|
// Step 5: Read generated content
|
||||||
|
$content = file_get_contents($outputPath);
|
||||||
|
@unlink($outputPath);
|
||||||
|
@unlink($localTemplatePath);
|
||||||
|
|
||||||
|
// Step 6: Build subject-only document name (rule N1) — no contact,
|
||||||
|
// no em-dash. Defaults to the template name when no subject given.
|
||||||
|
$subject = trim((string) ($documentSubject ?? ''));
|
||||||
|
if ($subject === '') {
|
||||||
|
$subject = (string) $templateName;
|
||||||
|
}
|
||||||
|
$docName = $subject;
|
||||||
|
$baseName = LocalFilesystemClient::sanitizeFileName($subject);
|
||||||
|
if ($baseName === '') {
|
||||||
|
$baseName = 'document';
|
||||||
|
}
|
||||||
|
$fileName = $baseName . '.docx';
|
||||||
|
|
||||||
|
// Step 7: Create Attachment + Document
|
||||||
|
$attachment = $this->entityManager->getNewEntity('Attachment');
|
||||||
|
$attachment->set([
|
||||||
|
'name' => $fileName,
|
||||||
|
'type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'role' => 'Attachment',
|
||||||
|
'size' => strlen($content),
|
||||||
|
'relatedType' => 'Document',
|
||||||
|
]);
|
||||||
|
$this->entityManager->saveEntity($attachment);
|
||||||
|
file_put_contents('data/upload/' . $attachment->getId(), $content);
|
||||||
|
|
||||||
|
$document = $this->entityManager->getNewEntity('Document');
|
||||||
|
$document->set([
|
||||||
|
'name' => $docName,
|
||||||
|
'fileId' => $attachment->getId(),
|
||||||
|
'fileName' => $fileName,
|
||||||
|
'assignedUserId' => $this->user->getId(),
|
||||||
|
]);
|
||||||
|
// Suppress the NSI upload hook (fires before the Case link below);
|
||||||
|
// we upload explicitly with the Case so it lands in the case folder.
|
||||||
|
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true]);
|
||||||
|
|
||||||
|
// Link to Case
|
||||||
|
$this->entityManager->getRDBRepository('Case')
|
||||||
|
->getRelation($case, 'documents')
|
||||||
|
->relate($document);
|
||||||
|
|
||||||
|
// CaseFiles backend: place the Document in the case "מסמכים" folder.
|
||||||
|
$caseFilesServiceClass = 'Espo\\Modules\\CaseFilesCore\\Services\\CaseFolderService';
|
||||||
|
if (class_exists($caseFilesServiceClass)) {
|
||||||
|
try {
|
||||||
|
$cfFolder = $this->injectableFactory->create($caseFilesServiceClass)
|
||||||
|
->getOrCreateSubfolder('Case', $caseId, 'מסמכים');
|
||||||
|
$document->set('folderId', $cfFolder->getId());
|
||||||
|
$this->entityManager->saveEntity($document, ['skipNetworkUpload' => true, 'silent' => true]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->log->warning('GenericTemplateGenerator: CaseFiles folder placement failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to the case folder; align DB names to the canonical stored name.
|
||||||
|
try {
|
||||||
|
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||||
|
if ($nds->isEnabled()) {
|
||||||
|
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
|
||||||
|
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
$storedName = $result['fileName'] ?? $fileName;
|
||||||
|
$storedBase = pathinfo($storedName, PATHINFO_FILENAME) ?: $docName;
|
||||||
|
|
||||||
|
$document->set([
|
||||||
|
'name' => $storedBase,
|
||||||
|
'fileName' => $storedName,
|
||||||
|
'storageType' => 'network',
|
||||||
|
'networkStoragePath' => $result['path'] ?? null,
|
||||||
|
'networkStorageFileName' => $storedName,
|
||||||
|
'networkStorageSize' => $result['size'] ?? strlen($content),
|
||||||
|
'networkStorageModifiedAt' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
$this->entityManager->saveEntity($document, [
|
||||||
|
'skipNetworkUpload' => true,
|
||||||
|
'silent' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$attachment->set('name', $storedName);
|
||||||
|
$this->entityManager->saveEntity($attachment, ['silent' => true]);
|
||||||
|
|
||||||
|
$sourcePath = 'data/upload/' . $attachment->getSourceId();
|
||||||
|
if (file_exists($sourcePath)) {
|
||||||
|
@unlink($sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$docName = $storedBase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Document is safely persisted in Espo; the network copy is best-effort.
|
||||||
|
$this->log->warning(
|
||||||
|
"GenericTemplateGenerator: failed to copy to network storage for Case {$caseId}: " .
|
||||||
|
$e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->log->info("GenericTemplateGenerator: Created '{$docName}' from template '{$templateName}' for Case {$caseId}");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'message' => "✅ מסמך \"{$docName}\" נוצר בהצלחה מתבנית \"{$templateName}\"\n📄 המסמך צורף לתיק.",
|
||||||
|
'entityType' => 'Document',
|
||||||
|
'entityId' => $document->getId(),
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if (isset($localTemplatePath) && file_exists($localTemplatePath)) {
|
||||||
|
@unlink($localTemplatePath);
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadTemplate(string $templatePath): string
|
||||||
|
{
|
||||||
|
// Try local paths first
|
||||||
|
if (file_exists($templatePath)) {
|
||||||
|
return $templatePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$localPath = 'data/document-templates/' . ltrim($templatePath, '/');
|
||||||
|
if (file_exists($localPath)) {
|
||||||
|
return $localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download from NetworkStorage (WebDAV)
|
||||||
|
try {
|
||||||
|
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
|
||||||
|
$client = $nds->getClient();
|
||||||
|
|
||||||
|
$integration = $this->entityManager->getEntityById('Integration', 'NetworkStorage');
|
||||||
|
$templatesFolderPath = 'Templates';
|
||||||
|
if ($integration && $integration->get('enabled')) {
|
||||||
|
$data = (array) ($integration->get('data') ?? []);
|
||||||
|
$templatesFolderPath = $data['templatesFolderPath'] ?? 'Templates';
|
||||||
|
}
|
||||||
|
|
||||||
|
$storagePath = $templatesFolderPath . '/' . $templatePath;
|
||||||
|
|
||||||
|
if (!$client->exists($storagePath)) {
|
||||||
|
throw new Error("Template file not found on storage: {$storagePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $client->downloadFile($storagePath);
|
||||||
|
|
||||||
|
if (!is_dir(self::TEMP_DIR)) {
|
||||||
|
mkdir(self::TEMP_DIR, 0775, true);
|
||||||
|
}
|
||||||
|
$tmpPath = self::TEMP_DIR . 'tpl_' . uniqid() . '.docx';
|
||||||
|
file_put_contents($tmpPath, $content);
|
||||||
|
|
||||||
|
return $tmpPath;
|
||||||
|
} catch (Error $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new Error("Failed to download template: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processTemplate(string $inputPath, string $outputPath, array $data): void
|
||||||
|
{
|
||||||
|
if (!class_exists(TemplateProcessor::class, false)) {
|
||||||
|
$autoloader = 'vendor/phpoffice/phpword/src/PhpWord/Autoloader.php';
|
||||||
|
if (file_exists($autoloader)) {
|
||||||
|
require_once $autoloader;
|
||||||
|
\PhpOffice\PhpWord\Autoloader::register();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$processor = new TemplateProcessor($inputPath);
|
||||||
|
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
if ($value === null) {
|
||||||
|
$value = '';
|
||||||
|
}
|
||||||
|
if (is_array($value)) {
|
||||||
|
$value = implode(', ', $value);
|
||||||
|
} elseif (is_bool($value)) {
|
||||||
|
$value = $value ? 'כן' : 'לא';
|
||||||
|
}
|
||||||
|
$processor->setValue($key, (string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processor->saveAs($outputPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,10 +58,18 @@ class OfficeContextBuilder
|
|||||||
$contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')];
|
$contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$parentConditions = [
|
||||||
|
['parentType' => 'Case', 'parentId' => $caseId],
|
||||||
|
];
|
||||||
|
$contactIds = $this->getCaseContactIds($case);
|
||||||
|
if (!empty($contactIds)) {
|
||||||
|
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
|
||||||
|
}
|
||||||
|
|
||||||
$tasks = [];
|
$tasks = [];
|
||||||
foreach ($this->entityManager->getRDBRepository('Task')
|
foreach ($this->entityManager->getRDBRepository('Task')
|
||||||
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
|
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
|
||||||
->where(['parentId' => $caseId, 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
|
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
|
||||||
->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) {
|
->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) {
|
||||||
$tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')];
|
$tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')];
|
||||||
}
|
}
|
||||||
@@ -76,4 +84,27 @@ class OfficeContextBuilder
|
|||||||
|
|
||||||
return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes];
|
return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getCaseContactIds(\Espo\ORM\Entity $case): array
|
||||||
|
{
|
||||||
|
$contactIds = [];
|
||||||
|
$primaryContactId = $case->get('contactId');
|
||||||
|
if ($primaryContactId) {
|
||||||
|
$contactIds[] = $primaryContactId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contacts = $this->entityManager->getRDBRepository('Case')
|
||||||
|
->getRelation($case, 'contacts')
|
||||||
|
->select(['id'])
|
||||||
|
->find();
|
||||||
|
|
||||||
|
foreach ($contacts as $contact) {
|
||||||
|
$id = $contact->get('id');
|
||||||
|
if (!in_array($id, $contactIds)) {
|
||||||
|
$contactIds[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $contactIds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_';
|
||||||
@@ -122,10 +136,18 @@ class SmartAssistantService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute tools with agentic loop — read tools feed results back to AI
|
// Execute tools with agentic loop — read tools feed results back to AI
|
||||||
$loopResult = $this->executeActionsWithLoop(
|
try {
|
||||||
$message, $context, $conversationId, $conversationHistory,
|
$loopResult = $this->executeActionsWithLoop(
|
||||||
$mode, $effectiveCaseId, $userId
|
$message, $context, $conversationId, $conversationHistory,
|
||||||
);
|
$mode, $effectiveCaseId, $userId
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->log->error("SmartAssistant: Chat failed: " . $e->getMessage());
|
||||||
|
$loopResult = [
|
||||||
|
'responseText' => "⚠️ אירעה שגיאה בתקשורת עם המערכת. אנא נסה שוב בעוד מספר דקות.\n\nפרטי השגיאה: " . $e->getMessage(),
|
||||||
|
'executedActions' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$responseText = $loopResult['responseText'];
|
$responseText = $loopResult['responseText'];
|
||||||
$executedActions = $loopResult['executedActions'];
|
$executedActions = $loopResult['executedActions'];
|
||||||
@@ -466,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);
|
||||||
}
|
}
|
||||||
@@ -510,7 +539,7 @@ class SmartAssistantService
|
|||||||
CURLOPT_POSTFIELDS => $payload,
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_HTTPHEADER => $headers,
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
CURLOPT_TIMEOUT => 180,
|
CURLOPT_TIMEOUT => 600,
|
||||||
CURLOPT_CONNECTTIMEOUT => 10,
|
CURLOPT_CONNECTTIMEOUT => 10,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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.6.1",
|
"version": "2.11.0",
|
||||||
"acceptableVersions": [
|
"acceptableVersions": [
|
||||||
">=8.0.0"
|
">=8.0.0"
|
||||||
],
|
],
|
||||||
"releaseDate": "2026-04-09",
|
"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