Compare commits

..

17 Commits

Author SHA1 Message Date
chaim f400f2fec7 feat: add getDocumentBytes endpoint for OCR + SignatureRequest context
- New POST /api/v1/SmartAssistant/action/getDocumentBytes returns
  {success, fileName, mimeType, sizeBytes, base64} for use by
  shira-hermes Claude Vision OCR fallback.
- DocumentAnalyzer: new getDocumentBytes() method + guessMimeType helper.
- CaseContextBuilder: expose signatureRequests in case context (for
  DigitalSignature integration — gracefully empty if entity absent).
- README: sync version badge.
- Version bump 2.7.3 → 2.8.0 (new public endpoint).

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

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

Refs Task Master #1

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

SmartAssistant v2.5.2

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:09:50 +00:00
24 changed files with 1863 additions and 97 deletions
+2
View File
@@ -0,0 +1,2 @@
*.zip
build/
+46
View File
@@ -0,0 +1,46 @@
{
"models": {
"main": {
"provider": "anthropic",
"modelId": "claude-sonnet-4-20250514",
"maxTokens": 64000,
"temperature": 0.2,
"id": "claude-sonnet-4-20250514"
},
"research": {
"provider": "anthropic",
"modelId": "sonar",
"maxTokens": 8700,
"temperature": 0.1,
"id": "claude-sonnet-4-20250514"
},
"fallback": {
"provider": "anthropic",
"modelId": "claude-3-7-sonnet-20250219",
"maxTokens": 120000,
"temperature": 0.2
}
},
"global": {
"logLevel": "info",
"debug": false,
"defaultNumTasks": 10,
"defaultSubtasks": 5,
"defaultPriority": "medium",
"projectName": "Task Master",
"ollamaBaseURL": "http://localhost:11434/api",
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
"responseLanguage": "English",
"enableCodebaseAnalysis": true,
"enableProxy": false,
"anonymousTelemetry": true,
"userId": "1234567890"
},
"claudeCode": {},
"codexCli": {},
"grokCli": {
"timeout": 120000,
"workingDirectory": null,
"defaultModel": "grok-4-latest"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"currentTag": "master",
"lastSwitched": "2026-04-14T06:05:06.044Z",
"branchTagMapping": {},
"migrationNoticeShown": true
}
+39
View File
@@ -0,0 +1,39 @@
{
"master": {
"tasks": [
{
"id": "1",
"title": "fix: broaden task detection to include Contact-linked tasks",
"description": "AlertCalculator, CaseContextBuilder, and OfficeContextBuilder only queried tasks with parentType='Case', missing tasks linked to case contacts (parentType='Contact'). This caused false 'no preparation task' alerts in Shira's daily standup.",
"status": "done",
"priority": "high",
"dependencies": [],
"details": "Root cause confirmed via production API: preparation tasks had parentType='Contact' because Shira created them linked to the contact entity. Fix broadens all task queries to include both Case and Contact parent types, plus NhActivity-linked tasks.",
"testStrategy": "Deploy to staging, trigger standup for a case with Contact-linked tasks, verify no false alert.",
"subtasks": [],
"updatedAt": "2026-04-14T06:08:19.494Z"
},
{
"id": "2",
"title": "Network-storage diagnostics + folder discovery",
"description": "Expose diagnostics when a case folder can't be listed and add endpoints (findCaseFolder, setCaseFolderPath) so Shira can locate and persist the correct network-storage path.",
"details": "",
"testStrategy": "",
"status": "in-progress",
"dependencies": [],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-04-19T06:11:17.422Z"
}
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-04-19T06:11:17.423Z",
"taskCount": 2,
"completedCount": 1,
"tags": [
"master"
]
}
}
}
+454
View File
@@ -0,0 +1,454 @@
# SmartAssistant v1.0.3 — סיכום מנהלים לפריסה בפיתוח
**תאריך:** 2026-03-31
**גרסה:** 1.0.3 | **תאריך שחרור:** 2026-03-29
**סביבת יעד:** EspoCRM Development (https://espocrm.dev.marcus-law.co.il)
**מחבר:** klear | **דרישות:** EspoCRM >= 8.0.0, PHP >= 8.1
---
## 1. תיאור כללי
SmartAssistant הוא מודול עוזר AI מאוחד למשרד עורכי דין.
המודול מספק:
- **ממשק צ'אט צף** (Floating Action Button) בכל דפי המערכת
- **מצב משרד** — סקירה כללית, התראות, סטטיסטיקות עומס עבודה
- **מצב תיק** — סיוע מעמיק בתיק ספציפי עם הקשר מלא
- **מערכת זיכרון תיק** (CaseMemory) — מאגר ידע מובנה לכל תיק ב-7 קטגוריות
- **ביצוע פעולות באישור** — יצירת משימות, פתקים, דיונים, שינוי סטטוס
- **אינטגרציה עם Stream** — כל אינטראקציה מופיעה בציר הזמן של התיק
- **התראות בסרגל ניווט** — תיקים לא פעילים, משימות באיחור, דיונים קרובים
---
## 2. מה בוצע
### 2.1 Backend (PHP) — 16 קבצים
| רכיב | קובץ | תפקיד |
|-------|------|--------|
| Controller | `Controllers/SmartAssistant.php` | 10 נקודות קצה API (chat, execute, summary, alerts, history, conversations, conversationMessages, searchHistory, caseMemory, saveMemory) |
| שירות ראשי | `Services/SmartAssistantService.php` | תזמור מלא: בניית הקשר → קריאת webhook → טיפול בפעולות → שמירת שיחה |
| בונה הקשר תיק | `Services/CaseContextBuilder.php` | אוסף: נתוני תיק, אנשי קשר, משימות פתוחות, דיונים קרובים, פתקים אחרונים, זיכרון |
| בונה הקשר משרד | `Services/OfficeContextBuilder.php` | סטטיסטיקות כלליות, התראות, עומס עבודה לפי משתמש, drill-down לתיקים |
| מחשבון התראות | `Services/AlertCalculator.php` | 6 סוגי התראות: תיקים לא פעילים (14/30 יום), משימות באיחור, דיונים קרובים, תיקים ללא שיוך, משימות בעדיפות גבוהה |
| מבצע פעולות | `Services/ActionExecutor.php` | 8 כלים: create_task, add_note, change_status, create_meeting, schedule_hearing, list_documents, analyze_document, save_memory, rename_document |
| שירות זיכרון | `Services/CaseMemoryService.php` | CRUD מלא לאנטיטי CaseMemory |
| ספק הקשר זיכרון | `Services/CaseMemoryContextProvider.php` | הזרקת זיכרון מוצמד/חשוב להקשר AI |
| מאגר שיחות | `Services/ConversationRepository.php` | שמירה ואחזור שיחות ב-AssistantConversation |
| מנתח מסמכים | `Services/DocumentAnalyzer.php` | סריקת מסמכים דרך FileStorage |
| Hook - שינוי תיק | `Hooks/Case/CaseFieldChangeMemory.php` | זיכרון אוטומטי בשינוי: סטטוס, שופט, בית משפט, מועד דיון |
| Hook - דיון | `Hooks/Meeting/HearingScheduledMemory.php` | זיכרון אוטומטי בקביעת דיון חדש |
| FileStorage | `Classes/FileStorage/*` | 4 קבצים: Interface, Factory, WebDav, NextCloud |
### 2.2 Frontend (JavaScript) — 9 קבצים
| רכיב | תפקיד |
|-------|--------|
| `floating-chat.js` | ממשק צ'אט צף (~1500 שורות): שליחת הודעות, היסטוריה, זיכרון, אישור/דחיית פעולות, עיצוב RTL, Material Design |
| `site/navbar.js` | אייקון התראות בסרגל ניווט עם badge מספרי |
| `dashlets/smart-assistant.js` | דשלט לדשבורד: סיכום מדדים + התראות אחרונות |
| `dashlets/options/smart-assistant.js` | הגדרות דשלט |
| `case/modals/add-memory.js` | מודל להוספת זיכרון ידנית לתיק |
| `stream/notes/smart-request.js` | תצוגת הודעת משתמש ב-Stream |
| `stream/notes/smart-response.js` | תצוגת תשובת עוזר ב-Stream |
| `stream/notes/smart-action.js` | תצוגת פעולה שבוצעה ב-Stream |
### 2.3 מטא-נתונים וקונפיגורציה — 17 קבצי JSON
- **routes.json** — 10 נתיבי API
- **module.json** — רישום מודול עם clientModule
- **entityDefs/** — CaseMemory (אנטיטי חדש, 15 שדות, 4 אינדקסים), Case (שדה cNextHearing), Note (סוגי stream חדשים)
- **scopes/CaseMemory.json** — הגדרות scope
- **layouts/CaseMemory/** — 4 layouts (list, detail, listSmall, detailSmall)
- **dashlets/SmartAssistant.json** — הגדרת דשלט
- **integrations/SmartAssistant.json** — 6 שדות הגדרה (webhookUrl, apiKey, maxMessagesPerHour, inactivityWarningDays, inactivityCriticalDays, upcomingHearingDays)
- **clientDefs/App.json** — רישום navbar
### 2.4 תרגומים — 10 קבצים
- **en_US** — 5 קבצי תרגום (SmartAssistant, CaseMemory, Case, Global, Integration)
- **fa_IR** — 5 קבצי תרגום מקבילים (עברית/פרסית)
### 2.5 בנייה והפצה
- **build.sh** — סקריפט בנייה: קורא גרסה מ-manifest.json, מעדכן README, יוצר ZIP
- **composer.json** — הגדרת חבילה ל-Composer Registry
- **4 גרסאות ZIP** — 1.0.0, 1.0.1, 1.0.2, 1.0.3
---
## 3. היסטוריית גרסאות ותיקוני באגים
### v1.0.0 — `a148e0f`
- **פיצ'ר ראשוני** — מודול SmartAssistant מלא עם case memory
### v1.0.0 → v1.0.1 — תיקונים
| Commit | תקלה | תיקון |
|--------|-------|--------|
| `0cdbf6d` | דשלט ירש מ-DashletView שלא קיים בגרסאות חדשות | שונה ל-base View + הוספת getTitle() |
| `ffd966a` | לא היה composer.json לפרסום אוטומטי | נוסף composer.json |
| `bd1cd17` | גרסה ב-README לא מתעדכנת אוטומטית | build.sh מעדכן README |
| `95ca8a0` | חסר תיעוד מקיף | נוסף README מלא בעברית |
| `fbe4f82` | נתיבי client לא תואמים לפורמט מודולים של EspoCRM | מיגרציה לנתיבי מודול תקניים |
### v1.0.1 → v1.0.2 — `f380542`
| תקלה | תיקון |
|-------|--------|
| נתיבי CSS ו-script שגויים ב-app/client.json metadata | תיקון הנתיבים ל-format הנכון |
### v1.0.2 → v1.0.3 — `d5c8a60`
| תקלה | תיקון |
|-------|--------|
| EspoCRM לא מצליח לרזולב templates בצד לקוח כי חסר clientModule | הוספת `clientModule` ל-module.json |
### סיכום תקלות שנמצאו ותוקנו
| # | תקלה | חומרה | סטטוס |
|---|-------|--------|--------|
| 1 | דשלט ירש מ-class לא קיים | גבוהה | תוקן v1.0.1 |
| 2 | נתיבי client לא תקניים לפורמט מודולים | גבוהה | תוקן v1.0.1 |
| 3 | נתיבי CSS/script שגויים ב-metadata | בינונית | תוקן v1.0.2 |
| 4 | חסר clientModule לרזולוציית templates | גבוהה | תוקן v1.0.3 |
---
## 4. מה נבדק
### נבדק ידנית (על סמך תיקוני הבאגים)
- רישום מודול וטעינת client-side assets
- תצוגת דשלט בדשבורד
- רזולוציית templates בצד לקוח
- נתיבי metadata (CSS, scripts)
### לא נבדק (אין קבצי טסט בפרויקט)
- **אין בדיקות יחידה (unit tests)** לקוד PHP
- **אין בדיקות אינטגרציה** ל-API endpoints
- **אין בדיקות E2E** לממשק הצ'אט
- **אין CI/CD pipeline** (אין GitHub Actions / Gitea Actions)
---
## 5. דרישות קדם לפריסה בפיתוח
| דרישה | סטטוס | פירוט |
|--------|--------|--------|
| EspoCRM >= 8.0.0 | לאמת | לוודא גרסה בסביבת dev |
| PHP >= 8.1 | לאמת | לוודא גרסה בשרת |
| Webhook URL | **נדרש הגדרה** | יש להגדיר n8n workflow שיקבל את הפניות |
| API Key | אופציונלי | לאימות webhook |
| AssistantConversation entity | לאמת | נדרש שהאנטיטי קיים |
| WebDAV/NextCloud | אופציונלי | רק לפיצ'ר ניתוח מסמכים |
---
## 6. מה נשאר לעשות
### קריטי לפני פריסה
| # | משימה | עדיפות |
|---|--------|---------|
| 1 | **הגדרת n8n workflow** לקבלת webhook מהעוזר (chat endpoint) | קריטי |
| 2 | **בדיקת תאימות** עם גרסת EspoCRM בסביבת dev | קריטי |
| 3 | **לוודא שאין שאריות** של מודולים ישנים בסביבת dev | קריטי |
| 4 | **הגדרת Integration** — webhookUrl + apiKey ב-Admin > Integrations | קריטי |
| 5 | **בדיקה שאנטיטי AssistantConversation קיים** או שצריך ליצור אותו | קריטי |
### חשוב אחרי פריסה
| # | משימה | עדיפות |
|---|--------|---------|
| 6 | **בדיקות ידניות מקיפות** — צ'אט במצב משרד ומצב תיק | גבוהה |
| 7 | **בדיקת פעולות** — create_task, add_note, schedule_hearing, change_status | גבוהה |
| 8 | **בדיקת hooks** — שינוי סטטוס תיק → זיכרון אוטומטי | גבוהה |
| 9 | **בדיקת התראות** — navbar badge + דשלט | בינונית |
| 10 | **בדיקת היסטוריה** — שמירה ואחזור שיחות, חיפוש | בינונית |
### שיפורים עתידיים
| # | משימה | עדיפות |
|---|--------|---------|
| 11 | כתיבת בדיקות יחידה ל-PHP | בינונית |
| 12 | הוספת CI/CD pipeline (Gitea Actions) | בינונית |
| 13 | הוספת תרגום עברי (he_IL) — כרגע en_US + fa_IR בלבד | בינונית |
| 14 | Rate limiting בצד client (כרגע רק בצד server) | נמוכה |
| 15 | הצפנת שיחות | נמוכה |
---
## 7. תיעוד מפורט של הקוד והקבצים
### 7.1 ארכיטקטורה כללית
```
┌─────────────────────────────────────────────────┐
│ Frontend (JS) │
│ floating-chat.js ←→ navbar.js ←→ dashlet.js │
│ │ │ │ │
│ [צ'אט צף] [התראות navbar] [דשלט דשבורד] │
└───────────────────────┬─────────────────────────┘
│ REST API (10 endpoints)
┌───────────────────────┴─────────────────────────┐
│ Controller/SmartAssistant.php │
│ chat | execute | summary | alerts | history ... │
└───────────────────────┬─────────────────────────┘
┌───────────────────────┴─────────────────────────┐
│ SmartAssistantService.php │
│ תזמור: הקשר → webhook → פעולות → שמירה │
├──────────┬──────────┬──────────┬─────────────────┤
│ Context │ Action │ Memory │ Conversation │
│ Builders │ Executor │ Service │ Repository │
├──────────┴──────────┴──────────┴─────────────────┤
│ CaseContext │ OfficeContext │ AlertCalculator │
│ Builder │ Builder │ │
└───────────────┴──────────────┴───────────────────┘
┌─────────┴──────────┐
│ External n8n │
│ Webhook (AI) │
└────────────────────┘
```
### 7.2 זרימת צ'אט (Chat Flow)
```
1. משתמש שולח הודעה ← floating-chat.js
2. POST /SmartAssistant/action/chat { message, caseId?, conversationId }
3. Controller → SmartAssistantService::chat()
4. בניית הקשר:
├─ מצב תיק → CaseContextBuilder (נתוני תיק + אנשי קשר + משימות + דיונים + זיכרון)
└─ מצב משרד → OfficeContextBuilder (סטטיסטיקות + התראות)
5. קריאת webhook חיצוני (n8n) עם: message + context + history
6. קבלת תשובה: { text, actions[] }
7. פעולות מיידיות (list_documents, save_memory) → ביצוע אוטומטי
8. פעולות הדורשות אישור → שמירה + החזרה למשתמש
9. שמירת שיחה ב-ConversationRepository
10. שמירת הודעות ב-Stream (smart-request + smart-response notes)
```
### 7.3 פירוט קבצי Backend
#### `Controllers/SmartAssistant.php` (168 שורות)
- **actionChat()** — מקבל message, caseId, conversationId. בודק ACL ל-Case. קורא ל-SmartAssistantService::chat()
- **actionExecute()** — מקבל actionId, conversationId. מבצע פעולה שאושרה
- **actionSummary()** — מחזיר סיכום משרדי + התראות
- **actionAlerts()** — רשימת התראות מלאה
- **actionHistory()** — היסטוריית שיחות (לפי caseId או אחרונה)
- **actionConversations()** — רשימת שיחות אחרונות
- **actionConversationMessages()** — הודעות שיחה ספציפית
- **actionSearchHistory()** — חיפוש חופשי בשיחות
- **actionCaseMemory()** — זיכרון תיק לפי קטגוריה
- **actionSaveMemory()** — שמירת זיכרון ידנית
#### `Services/SmartAssistantService.php` (~500 שורות)
הקובץ המרכזי ביותר. אחראי על:
- **chat()** — הזרימה המלאה: בניית הקשר, קריאת webhook, עיבוד פעולות, שמירת שיחה
- **callWebhook()** — קריאת CURL ל-n8n עם timeout, headers (X-Api-Key), JSON payload
- **processActions()** — מיון פעולות: מיידיות vs. דורשות אישור
- **executeAction()** — ביצוע פעולה שאושרה דרך ActionExecutor
- **getSummary()** / **getAlerts()** — מצב משרד
- **getConversations()** / **getHistory()** / **searchHistory()** — ניהול שיחות
#### `Services/CaseContextBuilder.php` (~150 שורות)
בונה הקשר עשיר לתיק:
```php
[
'case' => [...], // שדות תיק מרכזיים
'contacts' => [...], // אנשי קשר מקושרים
'openTasks' => [...], // משימות פתוחות (עד 10)
'upcomingMeetings' => [...], // דיונים קרובים (עד 5)
'recentNotes' => [...], // פתקים אחרונים (עד 10)
'memories' => [...], // זיכרון מוצמד + חשוב
'stats' => [...] // מספרי סיכום
]
```
#### `Services/OfficeContextBuilder.php` (~80 שורות)
בונה סיכום משרדי:
```php
[
'totalActiveCases' => int,
'casesByStatus' => [...],
'overdueTasks' => int,
'upcomingHearings' => int,
'userWorkload' => [...], // עומס לפי משתמש
'alerts' => [...]
]
```
#### `Services/ActionExecutor.php` (~250 שורות)
מבצע 8 סוגי פעולות:
| כלי | פרמטרים | פעולה |
|------|----------|--------|
| `create_task` | name, dateEnd, priority, description | יצירת Task מקושר לתיק |
| `add_note` | text | הוספת Post ב-Stream של התיק |
| `change_status` | status | שינוי סטטוס (עם validation מול enum) |
| `create_meeting` | name, dateStart, dateEnd, description | יצירת Meeting |
| `schedule_hearing` | date, description | עדכון cNextHearing + יצירת Meeting |
| `list_documents` | — | רשימת מסמכים (read-only, ללא אישור) |
| `analyze_document` | documentId | ניתוח מסמך דרך DocumentAnalyzer |
| `save_memory` | name, content, category, importance | שמירת CaseMemory (ללא אישור) |
| `rename_document` | documentId, newName | שינוי שם קובץ |
#### `Services/AlertCalculator.php` (~200 שורות)
6 סוגי התראות:
| סוג | לוגיקה | חומרה |
|------|--------|--------|
| תיק לא פעיל (אזהרה) | modifiedAt < now - 14 יום | warning |
| תיק לא פעיל (קריטי) | modifiedAt < now - 30 יום | critical |
| משימה באיחור | dateEnd < now, status != Completed | critical |
| דיון קרוב | dateStart < now + 7 ימים | warning |
| תיק ללא שיוך | status = New, assignedUserId = null | warning |
| משימה בעדיפות גבוהה | priority = Urgent, dateEnd < now + 5 ימים | warning |
#### `Services/CaseMemoryService.php` (106 שורות)
CRUD פשוט:
- **getMemories()** — לפי caseId, עם סינון אופציונלי לפי category
- **saveMemory()** — יצירת רשומת CaseMemory
- **updateMemory()** — עדכון
- **deleteMemory()** — מחיקה רכה
#### `Services/CaseMemoryContextProvider.php`
מזריק זיכרון להקשר AI:
- זיכרון מוצמד (isPinned = true)
- זיכרון בחשיבות high/critical
- ממוין לפי importance DESC, modifiedAt DESC
#### `Services/ConversationRepository.php` (~120 שורות)
- **save()** — שמירת שיחה באנטיטי AssistantConversation
- **getHistory()** — אחזור לפי caseId או אחרון
- **getConversations()** — רשימה ממוינת (עד limit)
- **getMessages()** — הודעות שיחה ספציפית
- **search()** — חיפוש חופשי בתוכן
#### Hooks
- **CaseFieldChangeMemory** — afterSave hook על Case. בודק אם שדות key השתנו (status, cJudge, cCourt, cNextHearing). אם כן, יוצר CaseMemory אוטומטי מסוג `auto` בקטגוריה המתאימה.
- **HearingScheduledMemory** — afterSave hook על Meeting. אם סוג Meeting = "Hearing", יוצר CaseMemory בקטגוריה timeline.
### 7.4 פירוט קבצי Frontend
#### `floating-chat.js` (~1500 שורות)
הקובץ הגדול ביותר. View יחיד שמנהל:
- **תצוגת צ'אט** — קלט הודעה, בועות הודעות (משתמש/עוזר), פעולות בהמתנה
- **תצוגת היסטוריה** — רשימת שיחות קודמות, טעינת שיחה
- **תצוגת זיכרון** — tabs לפי קטגוריה, הוספה/מחיקה/הצמדה
- **RTL layout** — כל ה-CSS מותאם לעברית
- **עיצוב Material Design** — צבע ראשי #5c6bc0 (אינדיגו), אנימציות slide-up
- **רספונסיבי** — breakpoint ב-480px למובייל
- **Inline CSS** — כל הסגנונות מוטמעים בקובץ (לא CSS חיצוני)
#### `site/navbar.js`
- מרחיב את navbar הרגיל של EspoCRM
- מוסיף אייקון התראות עם badge מספרי
- קורא ל-GET /SmartAssistant/action/alerts ומציג ספירה
- רענון אוטומטי כל X דקות
#### `dashlets/smart-assistant.js`
- דשלט לדשבורד הראשי
- מציג: סה"כ תיקים פעילים, משימות באיחור, דיונים קרובים
- רשימת 5 התראות אחרונות
- ACL scope = Case
#### Stream Notes (3 קבצים)
- **smart-request.js** — מציג הודעת משתמש עם אייקון שאלה
- **smart-response.js** — מציג תשובת עוזר עם markdown rendering
- **smart-action.js** — מציג פעולה שבוצעה עם סטטוס (approved/rejected/executed)
#### `case/modals/add-memory.js`
- מודל Espo.Views.Modal סטנדרטי
- שדות: name, category (dropdown), content (textarea), importance (dropdown)
- POST ל-/SmartAssistant/action/saveMemory
### 7.5 אנטיטי CaseMemory — מבנה מלא
```
שדות:
├── name (varchar, required) — כותרת הזיכרון
├── case (link → Case, required) — תיק משויך
├── category (enum) — key_facts | strategy | decisions | contacts_notes | timeline | documents_notes | billing_notes
├── content (text, required) — תוכן מלא
├── source (enum, readOnly) — manual | assistant | auto
├── importance (enum) — low | normal | high | critical
├── isPinned (bool) — הצמדה לגישה מהירה
├── isArchived (bool) — הסתרת רשומות ישנות
├── sourceEntityType (varchar, readOnly) — סוג מקור (למעקב)
├── sourceEntityId (varchar, readOnly) — ID מקור
├── assignedUser (link → User)
├── teams (linkMultiple → Team)
├── createdAt, modifiedAt (datetime)
└── createdBy, modifiedBy (link → User)
אינדקסים:
├── caseId + deleted
├── caseId + category + deleted
├── caseId + isPinned + deleted
└── importance + deleted
```
### 7.6 הגדרות אינטגרציה
**Admin > Integrations > Smart Assistant:**
| שדה | סוג | ברירת מחדל | תיאור |
|------|------|------------|--------|
| webhookUrl | url | — | כתובת webhook (n8n) |
| apiKey | varchar(255) | — | מפתח אימות, נשלח כ-X-Api-Key header |
| maxMessagesPerHour | int | 30 | הגבלת קצב |
| inactivityWarningDays | int | 14 | סף אזהרה לחוסר פעילות |
| inactivityCriticalDays | int | 30 | סף קריטי |
| upcomingHearingDays | int | 7 | חלון דיונים קרובים |
### 7.7 פורמט Webhook
**Request (נשלח ל-n8n):**
```json
{
"message": "מה המצב של תיק כהן?",
"context": {
"case": { "name": "כהן נ' לוי", "status": "Active", ... },
"contacts": [...],
"openTasks": [...],
"memories": [...]
},
"conversationId": "conv_abc123",
"conversationHistory": [
{ "role": "user", "content": "..." },
{ "role": "assistant", "content": "..." }
],
"mode": "case"
}
```
**Response (נדרש מ-n8n):**
```json
{
"text": "תיק כהן פעיל. יש 3 משימות פתוחות ודיון ב-15 באפריל.",
"actions": [
{
"tool": "create_task",
"params": { "name": "הכנת סיכומים לדיון", "dateEnd": "2026-04-14" },
"actionId": "action_xyz789",
"displayText": "יצירת משימה: הכנת סיכומים לדיון"
}
]
}
```
---
## 8. סיכום מוכנות לפריסה
| קריטריון | סטטוס | הערות |
|-----------|--------|--------|
| קוד backend | מוכן | 16 קבצי PHP, ארכיטקטורה נקייה |
| קוד frontend | מוכן | 9 קבצי JS, עיצוב RTL |
| מטא-נתונים | מוכן | תוקן ב-v1.0.1-1.0.3 |
| תרגומים | חלקי | en_US + fa_IR, חסר he_IL ייעודי |
| קובץ התקנה | מוכן | SmartAssistant-1.0.3.zip |
| n8n workflow | **חסר** | צריך לבנות workflow שיטפל ב-chat |
| בדיקות אוטומטיות | **אין** | אין unit/integration/E2E tests |
| CI/CD | **אין** | אין pipeline |
| תיעוד | מוכן | README מקיף בעברית |
**המלצה:** המודול מוכן טכנית לפריסה בסביבת פיתוח. יש להגדיר webhook URL (n8n workflow) לפני שהצ'אט יהיה פונקציונלי. מומלץ לבצע בדיקות ידניות מקיפות לאחר ההתקנה.
+1 -1
View File
@@ -1,6 +1,6 @@
# SmartAssistant - עוזר חכם
**גרסה:** 1.1.7 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
**גרסה:** 2.7.3 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -4
View File
@@ -4,13 +4,16 @@ set -euo pipefail
VERSION=$(python3 -c "import json; print(json.load(open('manifest.json'))['version'])")
MODULE=$(python3 -c "import json; m=json.load(open('manifest.json')); print(m.get('module', m['name']))")
ZIPNAME="${MODULE}-${VERSION}.zip"
BUILDDIR="build"
# Update version in README.md if it exists
if [[ -f README.md ]]; then
sed -i "s/\*\*גרסה:\*\* [^ |]*/\*\*גרסה:\*\* ${VERSION}/" README.md
fi
mkdir -p "$BUILDDIR"
echo "Building $ZIPNAME..."
rm -f "$ZIPNAME"
zip -r "$ZIPNAME" manifest.json files/ \
-x "*.DS_Store" "*__MACOSX*" "*.zip"
echo "✓ Built: $ZIPNAME ($(du -h "$ZIPNAME" | cut -f1))"
rm -f "$BUILDDIR/$ZIPNAME"
zip -r "$BUILDDIR/$ZIPNAME" manifest.json files/ \
-x "*.DS_Store" "*__MACOSX*"
echo "✓ Built: $BUILDDIR/$ZIPNAME ($(du -h "$BUILDDIR/$ZIPNAME" | cut -f1))"
@@ -24,7 +24,7 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
afterRender: function () {
this._alerts = [];
this._summary = {};
this._activeType = null;
this._activeType = 'attention';
var self = this;
Espo.Ajax.getRequest('SmartAssistant/action/status').then(function (result) {
@@ -54,10 +54,10 @@ define('modules/smart-assistant/views/dashlets/smart-assistant', ['view'], funct
renderCards: function (summary) {
var cards = [
{type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
{type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
{type: 'overdue', label: 'משימות באיחור', value: summary.totalOverdueTasks || 0, color: '#c62828', bg: '#ffcdd2', icon: 'fa-exclamation-circle'},
{type: 'hearings', label: 'דיונים השבוע', value: summary.upcomingHearings7d || 0, color: '#e65100', bg: '#fff3e0', icon: 'fa-gavel'},
{type: 'attention', label: 'דורשים תשומת לב', value: summary.casesNeedingAttention || 0, color: '#f9a825', bg: '#fff9c4', icon: 'fa-bell'},
{type: 'open', label: 'תיקים פתוחים', value: summary.totalOpenCases || 0, color: '#1565c0', bg: '#e3f2fd', icon: 'fa-folder-open'},
];
var self = this;
@@ -474,7 +474,7 @@ define('modules/smart-assistant/views/floating-chat', ['view'], function (View)
payload.caseId = this.currentCaseId;
}
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload).then(function (response) {
Espo.Ajax.postRequest('SmartAssistant/action/chat', payload, {timeout: 180000}).then(function (response) {
self.conversationId = response.conversationId;
$loading.remove();
@@ -9,17 +9,23 @@ use Espo\Core\Exceptions\Forbidden;
use Espo\Core\InjectableFactory;
use Espo\Core\Acl;
use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
use Espo\Modules\SmartAssistant\Services\ActionExecutor;
use Espo\Modules\SmartAssistant\Services\CaseMemoryService;
use Espo\Modules\SmartAssistant\Services\DocumentAnalyzer;
use Espo\Modules\SmartAssistant\Services\GenericTemplateGenerator;
use Espo\Entities\User;
class SmartAssistant
{
private InjectableFactory $injectableFactory;
private Acl $acl;
private User $user;
public function __construct(InjectableFactory $injectableFactory, Acl $acl)
public function __construct(InjectableFactory $injectableFactory, Acl $acl, User $user)
{
$this->injectableFactory = $injectableFactory;
$this->acl = $acl;
$this->user = $user;
}
private function getService(): SmartAssistantService
@@ -169,4 +175,213 @@ class SmartAssistant
'id' => $entry->get('id'),
];
}
public function postActionExecuteTool(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
if (empty($data->tool)) {
throw new BadRequest('tool is required.');
}
$tool = $data->tool;
$params = json_decode(json_encode($data->params ?? []), true) ?? [];
$caseId = $data->caseId ?? null;
$executor = $this->injectableFactory->create(ActionExecutor::class);
return $executor->execute($tool, $params, $caseId, $this->user->getId());
}
public function postActionReadDocument(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePath = $data->filePath ?? null;
if (empty($filePath)) {
throw new BadRequest('filePath is required.');
}
$maxLength = isset($data->maxLength) ? (int) $data->maxLength : null;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$text = $analyzer->extractFullText($filePath, $maxLength);
return [
'success' => $text !== null,
'text' => $text,
'fileName' => basename($filePath),
'charCount' => $text !== null ? mb_strlen($text) : 0,
];
}
public function postActionGetDocumentBytes(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePath = $data->filePath ?? null;
if (empty($filePath)) {
throw new BadRequest('filePath is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->getDocumentBytes($filePath);
}
public function postActionReadMultipleDocuments(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$filePaths = $data->filePaths ?? [];
if (empty($filePaths)) {
throw new BadRequest('filePaths is required (array of file paths).');
}
$maxPerFile = isset($data->maxCharsPerFile) ? (int) $data->maxCharsPerFile : 50000;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = $analyzer->extractMultipleDocuments((array) $filePaths, $maxPerFile);
return [
'success' => true,
'documents' => $results,
];
}
public function postActionBatchRename(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$renames = $data->renames ?? [];
if (empty($renames)) {
throw new BadRequest('renames is required (array of {sourcePath, newName}).');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = [];
$successCount = 0;
foreach ($renames as $rename) {
$rename = (array) $rename;
$sourcePath = $rename['sourcePath'] ?? null;
$newName = $rename['newName'] ?? null;
if (!$sourcePath || !$newName) {
$results[] = ['success' => false, 'error' => 'Missing sourcePath or newName'];
continue;
}
try {
$result = $analyzer->renameDocument($sourcePath, $newName);
$results[] = ['success' => true, 'oldName' => $result['oldName'], 'newName' => $result['newName']];
$successCount++;
} catch (\Exception $e) {
$results[] = ['success' => false, 'oldName' => basename($sourcePath), 'error' => $e->getMessage()];
}
}
return [
'success' => true,
'totalCount' => count($renames),
'successCount' => $successCount,
'results' => $results,
];
}
public function postActionListDocuments(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->listDocumentsRecursive($caseId);
}
public function postActionBrowseFolder(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$path = $data->path ?? null;
if ($path === null) {
throw new BadRequest('path is required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return [
'path' => $path,
'entries' => $analyzer->browseFolder((string) $path),
];
}
public function postActionFindCaseFolder(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$query = $data->query ?? null;
if (empty($query)) {
throw new BadRequest('query is required.');
}
$limit = isset($data->limit) ? max(1, min((int) $data->limit, 50)) : 20;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return [
'query' => $query,
'matches' => $analyzer->findCaseFolder((string) $query, $limit),
];
}
public function postActionSetCaseFolderPath(Request $request, Response $response): array
{
$this->checkAccess();
if (!$this->acl->checkScope('Case', 'edit')) {
throw new Forbidden('No edit access to Case.');
}
$data = $request->getParsedBody();
$caseId = $data->caseId ?? null;
$path = $data->path ?? null;
if (empty($caseId) || empty($path)) {
throw new BadRequest('caseId and path are required.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->setCaseFolderPath((string) $caseId, (string) $path);
}
public function postActionGenerateFromTemplate(Request $request, Response $response): array
{
$this->checkAccess();
$data = $request->getParsedBody();
$templateId = $data->templateId ?? null;
$caseId = $data->caseId ?? null;
if (empty($templateId)) {
throw new BadRequest('templateId is required.');
}
if (empty($caseId)) {
throw new BadRequest('caseId is required.');
}
$documentSubject = $data->documentSubject ?? null;
$customPlaceholders = (array) ($data->customPlaceholders ?? []);
$generator = $this->injectableFactory->create(GenericTemplateGenerator::class);
return $generator->generate($templateId, $caseId, $documentSubject, $customPlaceholders);
}
}
@@ -27,6 +27,18 @@
"upcomingHearingDays": {
"type": "int",
"default": 7
},
"maxDocumentChars": {
"type": "int",
"default": 100000
},
"agenticLoopEnabled": {
"type": "bool",
"default": true
},
"maxAgenticLoops": {
"type": "int",
"default": 3
}
},
"allowUserAccess": false,
@@ -86,5 +86,13 @@
"controller": "SmartAssistant",
"action": "saveMemory"
}
},
{
"route": "/SmartAssistant/action/executeTool",
"method": "post",
"params": {
"controller": "SmartAssistant",
"action": "executeTool"
}
}
]
@@ -44,11 +44,15 @@ class ActionExecutor
'create_call' => $this->createCall($params, $caseId, $userId),
'delete_meeting' => $this->deleteMeeting($params),
'delete_call' => $this->deleteCall($params),
'delete_task' => $this->deleteTask($params),
'delete_note' => $this->deleteNote($params),
'schedule_hearing' => $this->scheduleHearing($params, $caseId, $userId),
'list_documents' => $this->listDocuments($caseId),
'analyze_document' => $this->analyzeDocument($params, $caseId),
'read_document' => $this->readDocument($params, $caseId),
'read_multiple_documents' => $this->readMultipleDocuments($params, $caseId),
'rename_document' => $this->renameDocument($params),
'batch_rename_documents' => $this->batchRenameDocuments($params),
'upload_document' => $this->uploadDocument($params, $caseId),
'generate_document' => $this->generateDocument($params, $caseId),
'merge_documents' => $this->mergeDocuments($params, $caseId),
@@ -288,6 +292,29 @@ class ActionExecutor
];
}
private function deleteTask(array $params): array
{
$entityId = $params['entityId'] ?? null;
if (!$entityId) {
throw new Error('entityId parameter is required.');
}
$entity = $this->entityManager->getEntityById('Task', $entityId);
if (!$entity) {
throw new NotFound("Task {$entityId} not found.");
}
$name = $entity->get('name') ?? '';
$this->entityManager->removeEntity($entity);
return [
'success' => true,
'message' => "✅ משימה \"{$name}\" נמחקה בהצלחה",
'entityType' => 'Task',
'entityId' => $entityId,
];
}
private function scheduleHearing(array $params, ?string $caseId, string $userId): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
@@ -359,6 +386,65 @@ class ActionExecutor
return ['success' => true, 'message' => implode("\n", $lines), 'data' => ['filePath' => $filePath, 'hasTextContent' => $text !== null]];
}
private function readDocument(array $params, ?string $caseId): array
{
$filePath = $params['filePath'] ?? null;
if (!$filePath) throw new Error('filePath parameter is required.');
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$text = $analyzer->extractFullText($filePath);
$fileName = basename($filePath);
if ($text === null) {
return ['success' => false, 'message' => "לא ניתן לחלץ טקסט מ-\"{$fileName}\""];
}
return [
'success' => true,
'message' => $text,
'data' => [
'filePath' => $filePath,
'fileName' => $fileName,
'charCount' => mb_strlen($text),
],
];
}
private function readMultipleDocuments(array $params, ?string $caseId): array
{
$filePaths = $params['filePaths'] ?? [];
if (empty($filePaths)) throw new Error('filePaths parameter is required (array of file paths).');
$maxPerFile = $params['maxCharsPerFile'] ?? 50000;
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = $analyzer->extractMultipleDocuments($filePaths, $maxPerFile);
$lines = [];
$successCount = 0;
foreach ($results as $r) {
$lines[] = "=== {$r['fileName']} ===";
if (!empty($r['error'])) {
$lines[] = "שגיאה: {$r['error']}";
} elseif ($r['text'] === null) {
$lines[] = "לא ניתן לחלץ טקסט";
} else {
$lines[] = $r['text'];
$successCount++;
}
$lines[] = '';
}
return [
'success' => true,
'message' => implode("\n", $lines),
'data' => [
'totalFiles' => count($filePaths),
'successCount' => $successCount,
],
];
}
private function renameDocument(array $params): array
{
$sourcePath = $params['sourcePath'] ?? null;
@@ -371,6 +457,37 @@ class ActionExecutor
return ['success' => true, 'message' => "המסמך שונה מ-\"{$result['oldName']}\" ל-\"{$result['newName']}\"", 'data' => $result];
}
private function batchRenameDocuments(array $params): array
{
$renames = $params['renames'] ?? [];
if (empty($renames)) throw new Error('renames parameter is required (array of {sourcePath, newName}).');
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$results = [];
$successCount = 0;
foreach ($renames as $rename) {
$sourcePath = $rename['sourcePath'] ?? null;
$newName = $rename['newName'] ?? null;
if (!$sourcePath || !$newName) {
$results[] = "שגיאה: חסר sourcePath או newName";
continue;
}
try {
$result = $analyzer->renameDocument($sourcePath, $newName);
$results[] = "\"{$result['oldName']}\"\"{$result['newName']}\"";
$successCount++;
} catch (\Exception $e) {
$results[] = "שגיאה בשינוי שם \"" . basename($sourcePath) . "\": " . $e->getMessage();
}
}
$msg = "שינוי שמות: {$successCount}/" . count($renames) . " הצליחו\n" . implode("\n", $results);
return ['success' => true, 'message' => $msg];
}
private function uploadDocument(array $params, ?string $caseId): array
{
if (!$caseId) {
@@ -46,7 +46,7 @@ class AlertCalculator
->where(['status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
$totalOverdueTasks = $this->entityManager->getRDBRepository('Task')
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])->count();
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
$upcomingHearings = $this->entityManager->getRDBRepository('Case')
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $weekEnd, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])->count();
@@ -106,7 +106,7 @@ class AlertCalculator
$tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'status', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => 'Case'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd<' => $today, 'dateEnd!=' => null, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
->order('dateEnd', 'ASC')->limit(0, 50)->find();
foreach ($tasks as $task) {
@@ -132,7 +132,7 @@ class AlertCalculator
$recentTaskThreshold = date('Y-m-d H:i:s', strtotime('-7 days'));
$cases = $this->entityManager->getRDBRepository('Case')
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt'])
->select(['id', 'name', 'cNextHearing', 'assignedUserName', 'cLastActivityAt', 'contactId'])
->where(['cNextHearing>=' => $today, 'cNextHearing<=' => $threshold, 'status!=' => self::CLOSED_STATUSES, 'deleted' => false])
->order('cNextHearing', 'ASC')->find();
@@ -140,13 +140,7 @@ class AlertCalculator
$lastActivity = $case->get('cLastActivityAt');
if ($lastActivity && $lastActivity >= $recentActivityThreshold) continue;
$openTaskCount = $this->entityManager->getRDBRepository('Task')
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false])->count();
if ($openTaskCount > 0) continue;
$recentCompleted = $this->entityManager->getRDBRepository('Task')
->where(['parentId' => $case->get('id'), 'parentType' => 'Case', 'status' => 'Completed', 'modifiedAt>=' => $recentTaskThreshold, 'deleted' => false])->count();
if ($recentCompleted > 0) continue;
if ($this->caseHasPreparation($case, $recentTaskThreshold)) continue;
$daysUntil = (new \DateTime())->diff(new \DateTime($case->get('cNextHearing')))->days;
$severity = ($daysUntil <= 1) ? 'critical' : 'warning';
@@ -162,6 +156,104 @@ class AlertCalculator
return $alerts;
}
/**
* Check if a case has any evidence of hearing preparation:
* 1. Tasks linked directly to the case (parentType='Case')
* 2. Tasks linked to the case's contacts (parentType='Contact')
* 3. Tasks linked through NhActivity records for this case
*/
private function caseHasPreparation(\Espo\ORM\Entity $case, string $recentTaskThreshold): bool
{
$caseId = $case->get('id');
// Collect all parentId+parentType pairs that relate to this case
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
// Also check tasks linked to the case's contacts
$contactIds = $this->getCaseContactIds($case);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
// Check 1: Any open tasks linked to the case or its contacts
$openTaskCount = $this->entityManager->getRDBRepository('Task')
->where([
'OR' => $parentConditions,
'status!=' => ['Completed', 'Canceled', 'Deferred'],
'deleted' => false,
])->count();
if ($openTaskCount > 0) return true;
// Check 2: Any recently completed tasks linked to the case or its contacts
$recentCompleted = $this->entityManager->getRDBRepository('Task')
->where([
'OR' => $parentConditions,
'status' => 'Completed',
'modifiedAt>=' => $recentTaskThreshold,
'deleted' => false,
])->count();
if ($recentCompleted > 0) return true;
// Check 3: Tasks linked through NhActivity records for this case
$nhActivityTaskCount = $this->entityManager->getRDBRepository('NhActivity')
->where([
'caseId' => $caseId,
'taskId!=' => null,
'deleted' => false,
])->count();
if ($nhActivityTaskCount > 0) {
$nhActivities = $this->entityManager->getRDBRepository('NhActivity')
->select(['taskId'])
->where([
'caseId' => $caseId,
'taskId!=' => null,
'deleted' => false,
])->find();
$taskIds = [];
foreach ($nhActivities as $nha) {
$taskIds[] = $nha->get('taskId');
}
$activeNhTasks = $this->entityManager->getRDBRepository('Task')
->where([
'id' => $taskIds,
'status!=' => ['Canceled'],
'deleted' => false,
])->count();
if ($activeNhTasks > 0) return true;
}
return false;
}
private function getCaseContactIds(\Espo\ORM\Entity $case): array
{
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
private function findUnassignedNewCases(): array
{
$alerts = [];
@@ -191,7 +283,7 @@ class AlertCalculator
$tasks = $this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'dateEnd', 'parentId', 'parentType', 'parentName', 'assignedUserName'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => 'Case'])
->where(['status!=' => ['Completed', 'Canceled', 'Deferred'], 'dateEnd>=' => $today, 'dateEnd<=' => $threshold, 'deleted' => false, 'parentType' => ['Case', 'Contact']])
->order('dateEnd', 'ASC')->limit(0, 30)->find();
foreach ($tasks as $task) {
@@ -237,7 +329,7 @@ class AlertCalculator
foreach (array_keys($userCounts) as $uid) {
$userCounts[$uid]['openTasks'] = $this->entityManager->getRDBRepository('Task')
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => 'Case'])->count();
->where(['assignedUserId' => $uid, 'status!=' => ['Completed', 'Canceled', 'Deferred'], 'deleted' => false, 'parentType' => ['Case', 'Contact']])->count();
}
return array_values($userCounts);
@@ -33,6 +33,7 @@ class CaseContextBuilder
'recentNotes' => $this->getRecentNotes($caseId),
'availableTemplates' => $this->getAvailableTemplates(),
'documents' => $this->getDocumentListing($caseId),
'signatureRequests' => $this->getSignatureRequests($caseId),
'currentUser' => $this->getUserData($userId),
'validStatuses' => ActionExecutor::VALID_STATUSES,
];
@@ -82,9 +83,18 @@ class CaseContextBuilder
private function getOpenTasks(string $caseId): array
{
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
$contactIds = $this->getCaseContactIds($caseId);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
$tasks = [];
$collection = $this->entityManager->getRDBRepository('Task')
->where(['parentType' => 'Case', 'parentId' => $caseId, 'status!=' => ['Completed', 'Canceled']])
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled']])
->order('dateEnd', 'ASC')->limit(0, 10)->find();
foreach ($collection as $t) {
@@ -97,6 +107,32 @@ class CaseContextBuilder
return $tasks;
}
private function getCaseContactIds(string $caseId): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return [];
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
private function getUpcomingMeetings(string $caseId): array
{
$meetings = [];
@@ -167,13 +203,51 @@ class CaseContextBuilder
return ['id' => $user->get('id'), 'name' => $user->get('name')];
}
private function getSignatureRequests(string $caseId): array
{
try {
$collection = $this->entityManager->getRDBRepository('SignatureRequest')
->where([
'caseId' => $caseId,
'status!=' => ['Completed', 'Voided'],
])
->order('createdAt', 'DESC')
->limit(0, 10)
->find();
$requests = [];
foreach ($collection as $sr) {
$requests[] = [
'id' => $sr->get('id'),
'name' => $sr->get('name'),
'status' => $sr->get('status'),
'sentAt' => $sr->get('sentAt'),
];
}
return $requests;
} catch (\Exception $e) {
// SignatureRequest entity might not exist if DigitalSignature not installed
return [];
}
}
private function getDocumentListing(string $caseId): array
{
try {
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
$result = $analyzer->listDocumentsRecursive($caseId);
$simplified = ['totalFiles' => $result['totalFiles'], 'folders' => [], 'rootFiles' => []];
$simplified = [
'totalFiles' => $result['totalFiles'],
'folders' => [],
'rootFiles' => [],
'resolvedPath' => $result['resolvedPath'] ?? null,
'pathSource' => $result['pathSource'] ?? null,
'folderExists' => $result['folderExists'] ?? null,
'diagnostic' => $result['reason'] ?? null,
];
foreach ($result['folders'] as $folder) {
$files = [];
@@ -11,13 +11,14 @@ use Espo\Modules\NetworkStorageIntegration\Classes\StorageClientInterface;
class DocumentAnalyzer
{
private const MAX_TEXT_LENGTH = 2000;
private const DEFAULT_MAX_TEXT_LENGTH = 100000;
private const MAX_FILE_SIZE = 10 * 1024 * 1024;
private const TMP_DIR = 'data/tmp/assistant-docs';
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
private Log $log;
private ?int $maxTextLength = null;
private const CATEGORY_PATTERNS = [
'כתבי_טענות' => ['כתב_תביעה', 'כתב תביעה', 'כתב_הגנה', 'כתב הגנה', 'סיכומים', 'תביעה', 'הגנה', 'כתב_ערעור', 'כתב ערעור', 'בקשה', 'תגובה', 'תשובה'],
@@ -40,11 +41,67 @@ class DocumentAnalyzer
public function listDocumentsRecursive(string $caseId): array
{
$casePath = $this->getCaseFolderPath($caseId);
if (!$casePath) return ['casePath' => null, 'totalFiles' => 0, 'folders' => [], 'rootFiles' => []];
$description = $this->describeCaseFolderPath($caseId);
$casePath = $description['path'];
if (!$casePath) {
return [
'casePath' => null,
'totalFiles' => 0,
'folders' => [],
'rootFiles' => [],
'resolvedPath' => null,
'pathSource' => $description['source'],
'folderExists' => false,
'reason' => $description['error'] ?? 'Case has no networkStorageFolderPath and no default could be computed.',
];
}
$client = $this->getStorageClient();
$topLevel = $client->listFolder($casePath);
try {
$folderExists = $client->exists($casePath);
} catch (\Exception $e) {
return [
'casePath' => $casePath,
'totalFiles' => 0,
'folders' => [],
'rootFiles' => [],
'resolvedPath' => $casePath,
'pathSource' => $description['source'],
'folderExists' => false,
'reason' => "Storage client failed while checking '{$casePath}': " . $e->getMessage(),
];
}
if (!$folderExists) {
return [
'casePath' => $casePath,
'totalFiles' => 0,
'folders' => [],
'rootFiles' => [],
'resolvedPath' => $casePath,
'pathSource' => $description['source'],
'folderExists' => false,
'reason' => "Folder '{$casePath}' (source: {$description['source']}) does not exist on storage.",
];
}
try {
$topLevel = $client->listFolder($casePath);
} catch (\Exception $e) {
return [
'casePath' => $casePath,
'totalFiles' => 0,
'folders' => [],
'rootFiles' => [],
'resolvedPath' => $casePath,
'pathSource' => $description['source'],
'folderExists' => true,
'reason' => "Failed to list folder '{$casePath}': " . $e->getMessage(),
];
}
$folders = []; $rootFiles = []; $totalFiles = 0;
foreach ($topLevel as $item) {
@@ -69,10 +126,26 @@ 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
{
$maxLen = $this->getMaxTextLength();
$text = $this->extractFullText($filePath, $maxLen);
return $text;
}
public function extractFullText(string $filePath, ?int $maxLength = null): ?string
{
$client = $this->getStorageClient();
$filename = basename($filePath);
@@ -98,8 +171,120 @@ class DocumentAnalyzer
if (file_exists($tmpFile)) unlink($tmpFile);
if ($text === null) return null;
if (mb_strlen($text) > self::MAX_TEXT_LENGTH) $text = mb_substr($text, 0, self::MAX_TEXT_LENGTH) . '...';
return trim($text);
$text = trim($text);
if ($maxLength !== null && mb_strlen($text) > $maxLength) {
$text = mb_substr($text, 0, $maxLength) . '...';
}
return $text;
}
/**
* Fetch raw file bytes + metadata for OCR/vision fallback.
*
* @return array{success: bool, fileName: string, mimeType: string, sizeBytes: int, base64: ?string, error: ?string}
*/
public function getDocumentBytes(string $filePath): array
{
$fileName = basename($filePath);
$client = $this->getStorageClient();
try {
$content = $client->downloadFile($filePath);
} catch (\Exception $e) {
return [
'success' => false,
'fileName' => $fileName,
'mimeType' => '',
'sizeBytes' => 0,
'base64' => null,
'error' => 'Failed to download: ' . $e->getMessage(),
];
}
$sizeBytes = strlen($content);
if ($sizeBytes > self::MAX_FILE_SIZE) {
return [
'success' => false,
'fileName' => $fileName,
'mimeType' => '',
'sizeBytes' => $sizeBytes,
'base64' => null,
'error' => 'File too large (max 10MB).',
];
}
$mimeType = $this->guessMimeType($fileName, $content);
return [
'success' => true,
'fileName' => $fileName,
'mimeType' => $mimeType,
'sizeBytes' => $sizeBytes,
'base64' => base64_encode($content),
'error' => null,
];
}
private function guessMimeType(string $fileName, string $content): string
{
$ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
$map = [
'pdf' => 'application/pdf',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'doc' => 'application/msword',
'txt' => 'text/plain',
'csv' => 'text/csv',
'log' => 'text/plain',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'tiff' => 'image/tiff',
'tif' => 'image/tiff',
'webp' => 'image/webp',
];
if (isset($map[$ext])) {
return $map[$ext];
}
// Fallback: finfo
if (function_exists('finfo_buffer')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_buffer($finfo, $content);
finfo_close($finfo);
if ($mime) return $mime;
}
return 'application/octet-stream';
}
public function extractMultipleDocuments(array $filePaths, int $maxPerFile = 50000): array
{
$results = [];
foreach ($filePaths as $filePath) {
$fileName = basename($filePath);
try {
$text = $this->extractFullText($filePath, $maxPerFile);
$results[] = [
'path' => $filePath,
'fileName' => $fileName,
'text' => $text,
'charCount' => $text !== null ? mb_strlen($text) : 0,
];
} catch (\Exception $e) {
$results[] = [
'path' => $filePath,
'fileName' => $fileName,
'text' => null,
'charCount' => 0,
'error' => $e->getMessage(),
];
}
}
return $results;
}
public function renameDocument(string $sourcePath, string $newName): array
@@ -151,24 +336,181 @@ class DocumentAnalyzer
}
public function getCaseFolderPath(string $caseId): ?string
{
return $this->describeCaseFolderPath($caseId)['path'];
}
/**
* Resolve the case folder path with diagnostic metadata.
*
* @return array{path: ?string, source: string, error: ?string, caseExists: bool}
* source: 'stored' | 'computed' | 'none'
*/
public function describeCaseFolderPath(string $caseId): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) return null;
if (!$case) {
return ['path' => null, 'source' => 'none', 'error' => "Case '{$caseId}' not found.", 'caseExists' => false];
}
// Try stored path first
$storedPath = $case->get('networkStorageFolderPath') ?: null;
if ($storedPath) return $storedPath;
if ($storedPath) {
return ['path' => $storedPath, 'source' => 'stored', 'error' => null, 'caseExists' => true];
}
// Fall back to computed path from NetworkDocumentService
try {
$nds = $this->getNetworkDocumentService();
return $nds->getEntityFolderPath('Case', $caseId);
$computed = $nds->getEntityFolderPath('Case', $caseId);
if ($computed) {
return ['path' => $computed, 'source' => 'computed', 'error' => null, 'caseExists' => true];
}
return ['path' => null, 'source' => 'none', 'error' => 'No stored path, and default path computation returned null.', 'caseExists' => true];
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Failed to get case folder path: " . $e->getMessage());
return null;
$this->log->warning("SmartAssistant: Failed to compute case folder path: " . $e->getMessage());
return ['path' => null, 'source' => 'none', 'error' => $e->getMessage(), 'caseExists' => true];
}
}
/**
* List the immediate contents of an arbitrary folder path on network storage.
* Manual override for when auto-resolution of a case folder fails.
*
* @return array<int, array{name: string, path: string, type: string, size: ?int, mimeType: ?string, modified: ?string}>
*/
public function browseFolder(string $path): array
{
$client = $this->getStorageClient();
$entries = [];
foreach ($client->listFolder($path) as $item) {
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
$entries[] = [
'name' => $item['name'] ?? '',
'path' => $item['path'] ?? '',
'type' => $isFolder ? 'folder' : 'file',
'size' => $item['size'] ?? null,
'mimeType' => $item['mimeType'] ?? null,
'modified' => $item['modified'] ?? null,
];
}
return $entries;
}
/**
* Search the storage for folders matching a query (case-insensitive substring).
* Scans root and one level deep. Returns sorted matches.
*
* @return array<int, array{path: string, name: string, level: int, score: int}>
*/
public function findCaseFolder(string $query, int $limit = 20): array
{
$normalized = trim($query);
if ($normalized === '') return [];
$client = $this->getStorageClient();
$needle = mb_strtolower($normalized);
$matches = [];
try {
$roots = $client->listFolder('');
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: findCaseFolder root listing failed: " . $e->getMessage());
return [];
}
foreach ($roots as $item) {
$isFolder = ($item['type'] ?? null) === 'folder' || !empty($item['isFolder']);
if (!$isFolder) continue;
$name = (string) ($item['name'] ?? '');
$path = (string) ($item['path'] ?? $name);
$score = $this->fuzzyScore($name, $needle);
if ($score > 0) {
$matches[] = ['path' => $path, 'name' => $name, 'level' => 0, 'score' => $score];
}
// One level deeper, but only inspect folder names — don't list files to keep it cheap.
try {
foreach ($client->listFolder($path) as $sub) {
$subIsFolder = ($sub['type'] ?? null) === 'folder' || !empty($sub['isFolder']);
if (!$subIsFolder) continue;
$subName = (string) ($sub['name'] ?? '');
$subPath = (string) ($sub['path'] ?? ($path . '/' . $subName));
$subScore = $this->fuzzyScore($subName, $needle);
if ($subScore > 0) {
$matches[] = ['path' => $subPath, 'name' => $subName, 'level' => 1, 'score' => $subScore];
}
}
} catch (\Exception $e) {
// Non-fatal: skip unreadable subfolders silently.
}
}
usort($matches, fn($a, $b) => $b['score'] <=> $a['score']);
return array_slice($matches, 0, $limit);
}
/**
* Persist the resolved folder path on the Case so future calls skip discovery.
*/
public function setCaseFolderPath(string $caseId, string $path): array
{
$case = $this->entityManager->getEntityById('Case', $caseId);
if (!$case) {
throw new Error("Case '{$caseId}' not found.");
}
$client = $this->getStorageClient();
if (!$client->exists($path)) {
throw new Error("Path '{$path}' does not exist on storage.");
}
$case->set('networkStorageFolderPath', $path);
$this->entityManager->saveEntity($case);
return ['success' => true, 'caseId' => $caseId, 'path' => $path];
}
private function fuzzyScore(string $haystack, string $needleLower): int
{
$hay = mb_strtolower($haystack);
if ($hay === $needleLower) return 100;
if (mb_strpos($hay, $needleLower) !== false) return 80;
// Token overlap (whitespace/dash/underscore).
$hayTokens = preg_split('/[\s\-_]+/u', $hay) ?: [];
$needleTokens = preg_split('/[\s\-_]+/u', $needleLower) ?: [];
$hits = 0;
foreach ($needleTokens as $nt) {
if ($nt === '') continue;
foreach ($hayTokens as $ht) {
if ($ht !== '' && mb_strpos($ht, $nt) !== false) { $hits++; break; }
}
}
return $hits > 0 ? 40 + $hits * 5 : 0;
}
private function getMaxTextLength(): int
{
if ($this->maxTextLength !== null) {
return $this->maxTextLength;
}
try {
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
$this->maxTextLength = (int) ($data['maxDocumentChars'] ?? self::DEFAULT_MAX_TEXT_LENGTH);
return $this->maxTextLength;
}
} catch (\Exception $e) {
// Ignore
}
$this->maxTextLength = self::DEFAULT_MAX_TEXT_LENGTH;
return $this->maxTextLength;
}
private function getStorageClient(): StorageClientInterface
{
return $this->getNetworkDocumentService()->getClient();
@@ -0,0 +1,213 @@
<?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 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 document name
$contactName = $this->getContactName($case);
$docName = $documentSubject ?? "{$templateName}{$contactName}";
// Step 7: Create Attachment + Document
$attachment = $this->entityManager->getNewEntity('Attachment');
$attachment->set([
'name' => $docName . '.docx',
'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' => $docName . '.docx',
'assignedUserId' => $this->user->getId(),
]);
$this->entityManager->saveEntity($document);
// Link to Case
$this->entityManager->getRDBRepository('Case')
->getRelation($case, 'documents')
->relate($document);
$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);
}
private function getContactName($case): string
{
$contactId = $case->get('contactId');
if (!$contactId) {
$ids = $case->getLinkMultipleIdList('contacts');
if (!empty($ids)) {
$contactId = $ids[0];
}
}
if ($contactId) {
$contact = $this->entityManager->getEntityById('Contact', $contactId);
if ($contact) {
return trim($contact->get('firstName') . ' ' . $contact->get('lastName'));
}
}
return $case->get('name') ?? '';
}
}
@@ -58,10 +58,18 @@ class OfficeContextBuilder
$contacts[] = ['name' => $c->get('name'), 'phone' => $c->get('phoneNumber'), 'email' => $c->get('emailAddress')];
}
$parentConditions = [
['parentType' => 'Case', 'parentId' => $caseId],
];
$contactIds = $this->getCaseContactIds($case);
if (!empty($contactIds)) {
$parentConditions[] = ['parentType' => 'Contact', 'parentId' => $contactIds];
}
$tasks = [];
foreach ($this->entityManager->getRDBRepository('Task')
->select(['id', 'name', 'status', 'dateEnd', 'assignedUserName'])
->where(['parentId' => $caseId, 'parentType' => 'Case', 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
->where(['OR' => $parentConditions, 'status!=' => ['Completed', 'Canceled'], 'deleted' => false])
->order('dateEnd', 'ASC')->limit(0, 20)->find() as $t) {
$tasks[] = ['name' => $t->get('name'), 'status' => $t->get('status'), 'dateEnd' => $t->get('dateEnd'), 'assignedUser' => $t->get('assignedUserName')];
}
@@ -76,4 +84,27 @@ class OfficeContextBuilder
return ['case' => $caseData, 'contacts' => $contacts, 'openTasks' => $tasks, 'recentNotes' => $notes];
}
private function getCaseContactIds(\Espo\ORM\Entity $case): array
{
$contactIds = [];
$primaryContactId = $case->get('contactId');
if ($primaryContactId) {
$contactIds[] = $primaryContactId;
}
$contacts = $this->entityManager->getRDBRepository('Case')
->getRelation($case, 'contacts')
->select(['id'])
->find();
foreach ($contacts as $contact) {
$id = $contact->get('id');
if (!in_array($id, $contactIds)) {
$contactIds[] = $id;
}
}
return $contactIds;
}
}
@@ -18,7 +18,17 @@ class SmartAssistantService
public const NOTE_TYPE_ACTION = 'SmartAction';
// All tools execute inline — Shira has full permissions
private const TOOLS_REQUIRING_CASE = ['change_status', 'schedule_hearing', 'list_documents', 'analyze_document', 'upload_document', 'generate_document', 'merge_documents', 'save_memory'];
private const TOOLS_REQUIRING_CASE = [
'change_status', 'schedule_hearing', 'list_documents', 'analyze_document',
'read_document', 'read_multiple_documents', 'upload_document',
'generate_document', 'merge_documents', 'save_memory',
'batch_rename_documents',
];
// Tools whose output is fed back to the AI for a follow-up webhook call
private const AGENTIC_TOOLS = ['analyze_document', 'read_document', 'read_multiple_documents', 'list_documents'];
private const DEFAULT_MAX_AGENTIC_LOOPS = 3;
private EntityManager $entityManager;
private InjectableFactory $injectableFactory;
@@ -105,68 +115,28 @@ class SmartAssistantService
// Ignore
}
// Call webhook
$response = $this->callWebhook($message, $context, $conversationId, $conversationHistory, $mode);
// Process actions — execute all inline (Shira has full permissions)
$actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? '';
$inlineResults = [];
$executedActions = [];
// Resolve effective caseId: explicit case mode, or drill-down detection in office mode
$effectiveCaseId = $caseId;
if (!$effectiveCaseId && !empty($context['drillDown']['case']['id'])) {
$effectiveCaseId = $context['drillDown']['case']['id'];
}
$executor = $this->injectableFactory->create(ActionExecutor::class);
foreach ($actions as $action) {
$tool = $action['tool'] ?? '';
$actionCaseId = $effectiveCaseId;
// Resolve case from action params if not available
if (!$actionCaseId && !empty($action['params']['caseId'])) {
$actionCaseId = $action['params']['caseId'];
}
// Skip tools that require a case if no case context available
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
continue;
}
try {
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
$executedActions[] = [
'tool' => $tool,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
];
// Create stream note for executed action in case context
if ($actionCaseId) {
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
'conversationId' => $conversationId,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
]);
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
}
// Execute tools with agentic loop — read tools feed results back to AI
try {
$loopResult = $this->executeActionsWithLoop(
$message, $context, $conversationId, $conversationHistory,
$mode, $effectiveCaseId, $userId
);
} catch (\Exception $e) {
$this->log->error("SmartAssistant: Chat failed: " . $e->getMessage());
$loopResult = [
'responseText' => "⚠️ אירעה שגיאה בתקשורת עם המערכת. אנא נסה שוב בעוד מספר דקות.\n\nפרטי השגיאה: " . $e->getMessage(),
'executedActions' => [],
];
}
if (!empty($inlineResults)) {
$responseText .= "\n\n" . implode("\n\n", $inlineResults);
}
$responseText = $loopResult['responseText'];
$executedActions = $loopResult['executedActions'];
// Persist response
try {
@@ -191,6 +161,148 @@ class SmartAssistantService
];
}
private function executeActionsWithLoop(
string $message,
array $context,
string $conversationId,
array $conversationHistory,
string $mode,
?string $effectiveCaseId,
string $userId
): array {
$agenticEnabled = $this->getIntegrationSetting('agenticLoopEnabled', true);
$maxLoops = (int) $this->getIntegrationSetting('maxAgenticLoops', self::DEFAULT_MAX_AGENTIC_LOOPS);
$executor = $this->injectableFactory->create(ActionExecutor::class);
$loopCount = 0;
$allExecutedActions = [];
$currentHistory = $conversationHistory;
$finalResponseText = '';
while ($loopCount <= $maxLoops) {
// Call webhook (first call uses original message, subsequent calls use empty message)
$currentMessage = $loopCount === 0 ? $message : '';
$response = $this->callWebhook($currentMessage, $context, $conversationId, $currentHistory, $mode);
$actions = $response['actions'] ?? [];
$responseText = $response['text'] ?? '';
// If no actions, we're done
if (empty($actions)) {
$finalResponseText = $responseText;
break;
}
$agenticResults = [];
$inlineResults = [];
foreach ($actions as $action) {
$tool = $action['tool'] ?? '';
$actionCaseId = $effectiveCaseId;
if (!$actionCaseId && !empty($action['params']['caseId'])) {
$actionCaseId = $action['params']['caseId'];
}
if (!$actionCaseId && in_array($tool, self::TOOLS_REQUIRING_CASE, true)) {
$inlineResults[] = "⚠️ לא ניתן לבצע {$tool} — לא זוהה תיק. נסה מתוך מסך התיק.";
continue;
}
try {
$result = $executor->execute($tool, $action['params'] ?? [], $actionCaseId, $userId);
$allExecutedActions[] = [
'tool' => $tool,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
];
if ($actionCaseId) {
$this->createStreamNote(self::NOTE_TYPE_ACTION, $result['message'] ?? 'פעולה בוצעה', $actionCaseId, [
'conversationId' => $conversationId,
'status' => 'executed',
'entityType' => $result['entityType'] ?? null,
'entityId' => $result['entityId'] ?? null,
]);
}
// Separate agentic tools (need re-invocation) from inline tools
if ($agenticEnabled && in_array($tool, self::AGENTIC_TOOLS, true)) {
$agenticResults[] = [
'tool' => $tool,
'params' => $action['params'] ?? [],
'result' => $result['message'] ?? '',
];
} else {
if (!empty($result['message'])) {
$inlineResults[] = $result['message'];
}
}
} catch (\Exception $e) {
$this->log->warning("SmartAssistant: Execution of {$tool} failed: " . $e->getMessage());
$inlineResults[] = "שגיאה בביצוע {$tool}: " . $e->getMessage();
}
}
// If no agentic results, we're done — return response with inline results
if (empty($agenticResults)) {
$finalResponseText = $responseText;
if (!empty($inlineResults)) {
$finalResponseText .= "\n\n" . implode("\n\n", $inlineResults);
}
break;
}
// Feed agentic results back to AI for next iteration
$this->log->debug("SmartAssistant: Agentic loop iteration " . ($loopCount + 1) . "" . count($agenticResults) . " tool results to feed back");
// Add the AI response to history
$currentHistory[] = ['role' => 'assistant', 'content' => $responseText];
// Add each tool result to history
foreach ($agenticResults as $ar) {
$currentHistory[] = [
'role' => 'tool_result',
'toolName' => $ar['tool'],
'content' => $ar['result'],
];
}
// Also append any inline results
if (!empty($inlineResults)) {
$currentHistory[] = [
'role' => 'tool_result',
'toolName' => '_inline_actions',
'content' => implode("\n\n", $inlineResults),
];
}
$loopCount++;
}
// Safety: if we exhausted the loop, use the last response
if ($loopCount > $maxLoops && empty($finalResponseText)) {
$finalResponseText = $response['text'] ?? 'הגעתי למגבלת הסיבובים. אנא נסה שוב.';
}
return [
'responseText' => $finalResponseText,
'executedActions' => $allExecutedActions,
];
}
private function getIntegrationSetting(string $key, $default = null)
{
$integration = $this->entityManager->getEntityById('Integration', 'SmartAssistant');
if ($integration && $integration->get('enabled')) {
$data = (array) ($integration->get('data') ?? []);
return $data[$key] ?? $default;
}
return $default;
}
public function executeAction(string $conversationId, string $actionId, bool $approved): array
{
$conversation = $this->loadConversation($conversationId);
@@ -406,7 +518,7 @@ class SmartAssistantService
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 120,
CURLOPT_TIMEOUT => 180,
CURLOPT_CONNECTTIMEOUT => 10,
]);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear",
"version": "2.5.0",
"version": "2.8.0",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-04-06",
"releaseDate": "2026-04-19",
"php": [
">=8.1"
]