Compare commits

...

22 Commits

Author SHA1 Message Date
chaim 68b3536084 fix(2.10.4): add missing clientDefs/AssistantRule.json
AssistantRule had no clientDefs file — the same gap that broke CaseMemory
in 2.10.2. Console showed:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:33:49 +00:00
chaim e40f3d4534 fix(2.9.2): add AssistantRule Controller — fixes silent save_rule 404
Without Controllers/AssistantRule.php, EspoCRM responds 404 "Controller
'AssistantRule' does not exist" on every POST/GET /api/v1/AssistantRule,
even though scopes/AssistantRule.json declares entity=true, object=true,
acl=true. The save_rule tool in shira-hermes calls the Record endpoint
directly, gets the 404, falls back to nothing (because the user_id branch
of the fallback rarely matches), and the LLM tells the user " כלל נשמר"
without verification — yet another instance of the hallucination pattern.

A one-line Controller class that extends Espo\Core\Controllers\Record
exposes the standard CRUD verbs and unlocks the storage path that the
rest of the stack already expected. Verified in prod: GET, POST, DELETE
all return 200 after this is in place.

Paired with shira-hermes 06b27e9 which teaches the LLM to actually read
the tool result and never report success on a  response.

Refs Task Master #6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:50:16 +00:00
chaim 89b3f5844e fix(2.9.1): extractFromDocx — drop PHPWord dependency, parse word/document.xml directly
Root cause of the Shira hallucination incident in production case 46 Friedman:
DocumentAnalyzer::extractFromDocx relied on PhpOffice\PhpWord, but on the
EspoCRM prod image the PHPWord files are present in vendor/ yet are NOT
registered in the composer PSR-4 autoload map — class_exists() silently
returned false, the method returned null, and Shira's read_document fell
back to OCR. OCR then only saw the signature image, the AI got "[signature]"
as document content and fabricated the entire CTS appeal as a knee injury.

This change rewrites extractFromDocx to use ZipArchive + a small regex
parser of word/document.xml. Independent of PHPWord, more robust on
tables/footnotes/hyperlinks (which PHPWord's element walker missed at depth
>1), and verified on prod against the same Friedman appeal: 80 paragraphs /
16633 clean chars extracted (vs 0 before).

The paired Python fix in shira-hermes (commit 7b517e1) makes the OCR
fallback also read document.xml, so even if this PHP fix regresses again,
the AI will not be fed "[signature]" as document content.

Refs Task Master #5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:01:44 +00:00
chaim 37a8b67b95 feat(2.9.0): free-form document creation + sandboxed case folder management
- FreeDocumentGenerator: build RTL Hebrew DOCX from lightweight markdown,
  attach to case, copy to network folder.
- CaseFolderManager: writeFile / createFolder / renameItem / moveItem,
  all sandboxed to the case's own root. Physical delete is never exposed;
  markForDeletion moves the file to "<caseRoot>/מסמכים למחיקה/" with a
  "למחיקה - " prefix.
- 6 new controller actions: createDocument, writeToFolder, createFolder,
  renameItem, moveItem, markForDeletion.
- AlertCalculator: silence "X days without activity" alerts for cases
  legitimately waiting on the court (PendingDecision always; PendingHearing
  when cNextHearing is in the future).
- CaseContextBuilder: fix DocumentTemplate query — was filtering on the
  wrong column (entityType vs targetEntityType), which is why Shira's
  TEMPLATES section was always empty. Now respects isActive and surfaces
  category/description.
- Document entity: new markedForDeletionAt + markedForDeletionBy audit fields.

Pairs with shira-hermes 0.4+ which exposes the 9 matching tools to Shira.

Refs Task Master #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:17:15 +00:00
chaim 39d9093bf6 feat: free-form document creation, case folder management, alert refinements
Shira can now create documents from scratch (not just from templates) and has
full sandboxed read/write access to the case's network folder, including a
soft-delete protocol so she cannot physically remove files.

New services:
- FreeDocumentGenerator: builds RTL Hebrew DOCX from lightweight markdown,
  saves as Attachment + Document, links to Case, copies to network folder.
- CaseFolderManager: writeFile/createFolder/renameItem/moveItem operations
  constrained to the case's own root via path normalization; never exposes
  a physical delete. markForDeletion moves the file into
  "<caseRoot>/מסמכים למחיקה/" with a "למחיקה - " prefix so the lawyer can
  audit and delete manually.

New controller actions: createDocument, writeToFolder, createFolder,
renameItem, moveItem, markForDeletion.

Bug fix (root cause of the empty TEMPLATES section in Shira's case prompt):
CaseContextBuilder was querying DocumentTemplate with `entityType=Case` but
the real column is `targetEntityType`. Also added `isActive=true` filter and
surfaced category/description for better LLM selection.

Alert refinement: AlertCalculator no longer raises "X days without activity"
for cases that are legitimately waiting on the court. PendingDecision is
always silenced; PendingHearing is silenced when cNextHearing is in the
future (unscheduled PendingHearing cases still alert).

Document entity: new `markedForDeletionAt` + `markedForDeletionBy` audit
fields with Hebrew labels.

Refs Task Master #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:03:27 +00:00
chaim b05e1c8488 fix(chat): extend AJAX timeout 180s→600s and surface slow-progress hint
The floating chat called Espo.Ajax.postRequest with timeout:180000, which is
shorter than the agentic loop's worst-case latency. On 2026-05-13 a prod
request to generate a direct-access report sat 227s in the first ai-gateway
iteration (large skill+context payload), so the browser fired the .catch()
handler at 180s and rendered "שגיאה בתקשורת" — even though the backend
completed the work 47s later (report attached to case 42).

Changes:
- floating-chat.js: timeout 180000 → 600000 (matches the PHP curl timeout
  already set to 600s in SmartAssistantService.php:521).
- After 60s the spinner label swaps to a "still working" hint so the user
  knows long requests are normal, not stuck.
- i18n: new SlowHint label in en_US + fa_IR (the project uses fa_IR as the
  Hebrew slot).

Refs Task Master #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:47:02 +00:00
chaim b313c96955 fix: increase curl timeout to 600s for long-running AI tasks
CURLOPT_TIMEOUT was 180s. Generating a consultation summary requires
up to 10 AI iterations at ~20s each, regularly hitting the limit and
causing a "שגיאה בתקשורת" error in the UI before Shira could respond.

Raised to 600s. The shira-hermes agent runner now also enforces a 570s
total budget so it returns a clean Hebrew message if the limit is hit,
rather than the PHP connection being cut mid-response.

Refs Task Master #2

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 17:55:48 +00:00
chaim 2a03425514 chore: sync README version to 2.8.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:19:25 +00:00
chaim f400f2fec7 feat: add getDocumentBytes endpoint for OCR + SignatureRequest context
- New POST /api/v1/SmartAssistant/action/getDocumentBytes returns
  {success, fileName, mimeType, sizeBytes, base64} for use by
  shira-hermes Claude Vision OCR fallback.
- DocumentAnalyzer: new getDocumentBytes() method + guessMimeType helper.
- CaseContextBuilder: expose signatureRequests in case context (for
  DigitalSignature integration — gracefully empty if entity absent).
- README: sync version badge.
- Version bump 2.7.3 → 2.8.0 (new public endpoint).

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

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

Refs Task Master #1

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:31:16 +00:00
93 changed files with 2700 additions and 151 deletions
+2
View File
@@ -0,0 +1,2 @@
*.zip
build/
+46
View File
@@ -0,0 +1,46 @@
{
"models": {
"main": {
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 64000,
"temperature": 0.2,
"id": "sonnet"
},
"research": {
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 8700,
"temperature": 0.1,
"id": "claude-sonnet-4-20250514"
},
"fallback": {
"provider": "claude-code",
"modelId": "sonnet",
"maxTokens": 120000,
"temperature": 0.2
}
},
"global": {
"logLevel": "info",
"debug": false,
"defaultNumTasks": 10,
"defaultSubtasks": 5,
"defaultPriority": "medium",
"projectName": "Task Master",
"ollamaBaseURL": "http://localhost:11434/api",
"bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com",
"responseLanguage": "English",
"enableCodebaseAnalysis": true,
"enableProxy": false,
"anonymousTelemetry": true,
"userId": "1234567890"
},
"claudeCode": {},
"codexCli": {},
"grokCli": {
"timeout": 120000,
"workingDirectory": null,
"defaultModel": "grok-4-latest"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"currentTag": "master",
"lastSwitched": "2026-04-14T06:05:06.044Z",
"branchTagMapping": {},
"migrationNoticeShown": true
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
# SmartAssistant - עוזר חכם # SmartAssistant - עוזר חכם
**גרסה:** 2.0.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0 **גרסה:** 2.8.0 | **מחבר:** klear | **EspoCRM:** >= 8.0.0
## תיאור ## תיאור
עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM. עוזר AI מאוחד למשרד עורכי דין. מספק ממשק צ'אט צף עם שני מצבי עבודה: **מצב משרד** (סקירה כללית, התראות, סטטיסטיקות) ו**מצב תיק** (סיוע מעמיק בתיק ספציפי). כולל מערכת זיכרון תיק מובנית, ביצוע פעולות באישור המשתמש, ואינטגרציה עם Stream של EspoCRM.
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.
-31
View File
@@ -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
תוכן הטבלה — כותרות ושורות. אם לא צוין, שאל את המשתמש מה להציג.
-62
View File
@@ -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)
+7 -4
View File
@@ -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
{
}
@@ -9,18 +9,25 @@ use Espo\Core\Exceptions\Forbidden;
use Espo\Core\InjectableFactory; use Espo\Core\InjectableFactory;
use Espo\Core\Acl; use Espo\Core\Acl;
use Espo\Modules\SmartAssistant\Services\SmartAssistantService; use Espo\Modules\SmartAssistant\Services\SmartAssistantService;
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;
class SmartAssistant class SmartAssistant
{ {
private InjectableFactory $injectableFactory; private InjectableFactory $injectableFactory;
private Acl $acl; 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->injectableFactory = $injectableFactory;
$this->acl = $acl; $this->acl = $acl;
$this->user = $user;
} }
private function getService(): SmartAssistantService private function getService(): SmartAssistantService
@@ -171,6 +178,24 @@ class SmartAssistant
]; ];
} }
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 public function postActionReadDocument(Request $request, Response $response): array
{ {
$this->checkAccess(); $this->checkAccess();
@@ -194,6 +219,20 @@ 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.');
}
$analyzer = $this->injectableFactory->create(DocumentAnalyzer::class);
return $analyzer->getDocumentBytes($filePath);
}
public function postActionReadMultipleDocuments(Request $request, Response $response): array public function postActionReadMultipleDocuments(Request $request, Response $response): array
{ {
$this->checkAccess(); $this->checkAccess();
@@ -255,4 +294,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 ===' לכל שיחה של המשתמש הזה. שמור עובדות מפתח: תפקיד, תחומי התמחות, העדפות תקשורת, נושאי טיק שמטופלים."
}
}
@@ -0,0 +1,17 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "key"}],
[{"name": "mode"}, {"name": "displayOrder"}],
[{"name": "isActive"}, false],
[{"name": "description", "fullWidth": true}]
]
},
{
"label": "Prompt",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,8 @@
[
{"name": "name", "link": true},
{"name": "key"},
{"name": "mode"},
{"name": "displayOrder"},
{"name": "isActive"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,17 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "version"}],
[{"name": "description", "fullWidth": true}],
[{"name": "triggers", "fullWidth": true}],
[{"name": "isActive"}, false]
]
},
{
"label": "Skill",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,7 @@
[
{"name": "name", "link": true},
{"name": "description"},
{"name": "isActive"},
{"name": "version"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,15 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "user"}],
[{"name": "isActive"}, false]
]
},
{
"label": "Profile",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,6 @@
[
{"name": "name", "link": true},
{"name": "user", "link": true},
{"name": "isActive"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,37 @@
{
"shiraManagement": {
"label": "ניהול שירה",
"itemList": [
{
"url": "#AssistantPrompt",
"label": "פרומפטים",
"iconClass": "fas fa-file-code",
"description": "שינוי הסעיפים שמוזרקים ל-system prompt של שירה. עריכה כאן מחליפה את ברירות-המחדל בקוד."
},
{
"url": "#AssistantRule",
"label": "כללי התנהגות",
"iconClass": "fas fa-list-check",
"description": "כללים גלובליים/לפי מצב שיופיעו תחת '=== BEHAVIORAL RULES (MUST FOLLOW) ===' של ה-system prompt."
},
{
"url": "#AssistantSkill",
"label": "מיומנויות (Skills)",
"iconClass": "fas fa-magic",
"description": "תהליכי עבודה מוכנים שהמודל יכול להפעיל. כל מיומנות פעילה מופיעה ב-context."
},
{
"url": "#UserProfile",
"label": "פרופילי משתמשים",
"iconClass": "fas fa-user-cog",
"description": "פרופיל אישי לכל עורך/ת דין. מופיע ב-system prompt תחת '=== ABOUT THIS USER ==='."
},
{
"url": "#CaseMemory",
"label": "זיכרון תיקים",
"iconClass": "fas fa-brain",
"description": "כל רשומות הזיכרון מכל התיקים. גישה ישירה לחיפוש/עריכה — לצד כניסה רגילה מתוך כל תיק."
}
]
}
}
@@ -0,0 +1,5 @@
{
"controller": "controllers/record",
"color": "#d99848",
"iconClass": "fas fa-file-code"
}
@@ -0,0 +1,10 @@
{
"controller": "controllers/record",
"color": "#5e8c61",
"iconClass": "fas fa-list-check",
"boolFilterList": ["onlyMy"],
"filterList": [
{"name": "active"},
{"name": "inactive"}
]
}
@@ -0,0 +1,5 @@
{
"controller": "controllers/record",
"color": "#aa72b3",
"iconClass": "fas fa-magic"
}
@@ -0,0 +1,10 @@
{
"controller": "controllers/record",
"color": "#9b59b6",
"iconClass": "fas fa-brain",
"boolFilterList": ["onlyMy"],
"filterList": [
{"name": "pinned"},
{"name": "byCategory"}
]
}
@@ -0,0 +1,7 @@
{
"controller": "controllers/record",
"boolFilterList": ["onlyMy"],
"filterList": [{"name": "active"}, {"name": "inactive"}],
"color": "#7c8caf",
"iconClass": "fas fa-user-cog"
}
@@ -0,0 +1,76 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 200,
"required": true,
"trim": true
},
"key": {
"type": "varchar",
"maxLength": 100,
"required": true,
"trim": true,
"lowerCase": true
},
"description": {
"type": "varchar",
"maxLength": 500
},
"mode": {
"type": "enum",
"options": ["both", "case", "office"],
"default": "both",
"required": true,
"style": {
"both": "primary",
"case": "info",
"office": "warning"
}
},
"displayOrder": {
"type": "int",
"default": 100,
"min": 0
},
"content": {
"type": "text",
"required": true,
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 600
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {"type": "belongsTo", "entity": "User"},
"modifiedBy": {"type": "belongsTo", "entity": "User"}
},
"collection": {
"orderBy": "displayOrder",
"order": "asc",
"textFilterFields": ["name", "key", "description", "content"]
},
"indexes": {
"key": {"columns": ["key", "deleted"], "unique": true},
"modeActive": {"columns": ["mode", "isActive", "deleted"]}
}
}
@@ -0,0 +1,62 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 100,
"required": true,
"trim": true
},
"description": {
"type": "varchar",
"maxLength": 500
},
"triggers": {
"type": "text",
"lengthOfMaxDisplay": 200
},
"content": {
"type": "text",
"required": true,
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 400
},
"version": {
"type": "varchar",
"maxLength": 20,
"default": "1.0"
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {"type": "belongsTo", "entity": "User"},
"modifiedBy": {"type": "belongsTo", "entity": "User"}
},
"collection": {
"orderBy": "name",
"order": "asc",
"textFilterFields": ["name", "description", "triggers", "content"]
},
"indexes": {
"name": {"columns": ["name", "deleted"], "unique": true},
"isActive": {"columns": ["isActive", "deleted"]}
}
}
@@ -0,0 +1,19 @@
{
"fields": {
"markedForDeletionAt": {
"type": "datetime",
"readOnly": true,
"tooltip": true
},
"markedForDeletionBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"markedForDeletionBy": {
"type": "belongsTo",
"entity": "User"
}
}
}
@@ -0,0 +1,63 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 255,
"required": true
},
"user": {
"type": "link",
"required": true
},
"content": {
"type": "text",
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 400
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"user": {
"type": "belongsTo",
"entity": "User"
},
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
}
},
"collection": {
"orderBy": "modifiedAt",
"order": "desc",
"textFilterFields": ["name", "content"]
},
"indexes": {
"userId": {
"columns": ["userId", "deleted"],
"unique": true
}
}
}
@@ -0,0 +1,13 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "no"],
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -6,6 +6,8 @@
"aclActionList": ["create", "read", "edit", "delete"], "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
} }
@@ -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
}
@@ -86,5 +86,13 @@
"controller": "SmartAssistant", "controller": "SmartAssistant",
"action": "saveMemory" "action": "saveMemory"
} }
},
{
"route": "/SmartAssistant/action/executeTool",
"method": "post",
"params": {
"controller": "SmartAssistant",
"action": "executeTool"
}
} }
] ]
@@ -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,334 @@
<?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;
/**
* 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
{
$absInsideStorage = $this->resolveSafePath($caseId, $relPath, requireExists: false);
$client = $this->getNds()->getClient();
$parent = $this->dirnameRel($absInsideStorage);
if ($parent !== '') {
$client->createFolderRecursive($parent);
}
$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
{
$abs = $this->resolveSafePath($caseId, $relPath, 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.');
}
$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, $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);
}
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,160 @@ 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'
*/
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 +533,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,275 @@
<?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 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);
$sanitizedTitle = $this->sanitizeFileName($title);
$today = date('Y-m-d');
$fileName = "{$today} - {$sanitizedTitle}.docx";
$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' => $title,
'fileId' => $attachment->getId(),
'fileName' => $fileName,
'assignedUserId' => $this->user->getId(),
]);
$this->entityManager->saveEntity($document);
$this->entityManager->getRDBRepository('Case')
->getRelation($case, 'documents')
->relate($document);
$networkPath = null;
try {
$nds = $this->injectableFactory->create(NetworkDocumentService::class);
if ($nds->isEnabled()) {
$result = $nds->uploadDocument($document, $attachment, 'Case', $caseId);
$networkPath = $result['path'] ?? null;
}
} 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 '{$title}' (type={$documentType}) for Case {$caseId}"
);
$message = "✅ המסמך \"{$title}\" נוצר בהצלחה וצורף לתיק";
if ($networkPath) {
$message .= " ולתיקיית הרשת";
}
$message .= ".";
return [
'success' => true,
'documentId' => $document->getId(),
'documentName' => $title,
'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}";
}
private function sanitizeFileName(string $name): string
{
$name = preg_replace('/[\\/:*?"<>|]/', '', $name);
$name = preg_replace('/\s+/', ' ', $name);
return trim(mb_substr($name, 0, 100));
}
}
@@ -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')]; $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;
}
} }
@@ -85,6 +85,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 +134,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'];
@@ -510,7 +530,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
View File
@@ -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.0", "version": "2.10.4",
"acceptableVersions": [ "acceptableVersions": [
">=8.0.0" ">=8.0.0"
], ],
"releaseDate": "2026-04-09", "releaseDate": "2026-05-27",
"php": [ "php": [
">=8.1" ">=8.1"
] ]
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* AfterInstall script for SmartAssistant extension 2.10.x.
*
* Idempotently seeds the AssistantPrompt entity with 7 default prompt
* sections (tool_rules, writing_style, legal_assistance, personality_case,
* personality_office, other_rules_case, other_rules_office). If a row with
* the same `key` already exists it is left alone — admin edits win.
*
* Class must be named `AfterInstall` with NO namespace — that's what the
* EspoCRM extension installer looks for in scripts/AfterInstall.php.
* See application/Espo/Core/Upgrades/ExtensionManager.php scriptNames map.
*/
use Espo\Core\Container;
class AfterInstall
{
public function run(Container $container, $params = null): void
{
$entityManager = $container->get('entityManager');
$log = $container->get('log');
// The JSON file lives inside the installed module resources at runtime.
$resourceFile = 'custom/Espo/Modules/SmartAssistant/Resources/data/default-prompts.json';
if (!is_file($resourceFile)) {
$log->warning("[SmartAssistant] AfterInstall: $resourceFile not found, skipping seed");
return;
}
$json = file_get_contents($resourceFile);
$defaults = json_decode($json, true);
if (!is_array($defaults)) {
$log->warning("[SmartAssistant] AfterInstall: invalid JSON in $resourceFile");
return;
}
$created = 0;
$skipped = 0;
foreach ($defaults as $row) {
$key = $row['key'] ?? null;
if (!$key) {
continue;
}
$existing = $entityManager->getRDBRepository('AssistantPrompt')
->where(['key' => $key, 'deleted' => false])
->findOne();
if ($existing) {
$skipped++;
continue;
}
$entity = $entityManager->getNewEntity('AssistantPrompt');
$entity->set([
'key' => $key,
'name' => $row['name'] ?? $key,
'description' => $row['description'] ?? null,
'mode' => $row['mode'] ?? 'both',
'displayOrder' => $row['displayOrder'] ?? 100,
'content' => $row['content'] ?? '',
'isActive' => true,
]);
// skipAll bypasses beforeSave hooks that require a logged-in user
// service (installer runs without an HTTP session) and ACL checks
// (we're seeding firm-wide defaults; no user is implicated).
$entityManager->saveEntity($entity, ['skipAll' => true]);
$created++;
}
$log->info("[SmartAssistant] AfterInstall: seeded $created AssistantPrompt rows, $skipped already existed");
}
}