Compare commits

...

7 Commits

Author SHA1 Message Date
chaim 0f9d06739e 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 09:52:13 +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
44 changed files with 913 additions and 26 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
/**
* Prompt editor field for AssistantPrompt.content, AssistantSkill.content,
* UserProfile.content. Inherits from the standard text field — adds:
* - monospace font (mixed Hebrew + English + JSON snippets read better)
* - dir="auto" so RTL/LTR works without a manual toggle
* - taller textarea (min 500px) — these prompts run to thousands of chars
*/
define('smart-assistant:views/fields/prompt-editor', ['views/fields/text'], function (Dep) {
return Dep.extend({
rowsDefault: 20,
afterRender: function () {
Dep.prototype.afterRender.call(this);
if (this.isEditMode() || this.isDetailMode()) {
var $area = this.$element;
if ($area && $area.length) {
$area.attr('dir', 'auto');
$area.css({
'font-family': "'Menlo','Consolas','DejaVu Sans Mono',monospace",
'font-size': '13px',
'line-height': '1.45',
'min-height': '500px',
'tab-size': '4',
});
}
}
},
});
});
@@ -0,0 +1,10 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Standard CRUD over /api/v1/AssistantPrompt. Pattern matches AssistantRule.
*/
class AssistantPrompt extends \Espo\Core\Controllers\Record
{
}
@@ -0,0 +1,16 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Exposes the AssistantRule entity over the standard REST API
* (GET/POST/PUT/DELETE /api/v1/AssistantRule).
*
* Without this controller class EspoCRM responds 404 "Controller does not exist"
* even when the entity, object, and acl flags in scopes/AssistantRule.json are
* set. Inheriting from Record gives us the full CRUD surface — list/search,
* read, create, update, delete — with ACL enforcement.
*/
class AssistantRule extends \Espo\Core\Controllers\Record
{
}
@@ -0,0 +1,10 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Standard CRUD over /api/v1/AssistantSkill. Pattern matches AssistantRule.
*/
class AssistantSkill extends \Espo\Core\Controllers\Record
{
}
@@ -0,0 +1,17 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Standard CRUD over /api/v1/CaseMemory.
*
* Until SmartAssistant 2.10.0 the CaseMemory entity was created via
* CaseMemoryService::createMemory and listed via Case relation panels —
* never via the bare REST endpoint. Flipping `tab: true` exposes the
* navbar list view, which uses GET /api/v1/CaseMemory and needs this
* Controller class to exist (otherwise EspoCRM returns 404 "Controller
* does not exist"). Same pattern as the AssistantRule fix shipped in 2.9.2.
*/
class CaseMemory extends \Espo\Core\Controllers\Record
{
}
@@ -0,0 +1,13 @@
<?php
namespace Espo\Modules\SmartAssistant\Controllers;
/**
* Standard CRUD over /api/v1/UserProfile.
*
* Pattern matches AssistantRule.php — without this class EspoCRM returns
* 404 "Controller does not exist" even when the entity is fully defined.
*/
class UserProfile extends \Espo\Core\Controllers\Record
{
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,30 @@
{
"labels": {
"Create AssistantPrompt": "צור פרומפט חדש"
},
"fields": {
"name": "שם תיאורי",
"key": "מפתח (key)",
"description": "תיאור — מה הסעיף הזה עושה",
"mode": "מצב",
"displayOrder": "סדר הצגה",
"content": "תוכן הפרומפט",
"isActive": "פעיל",
"createdAt": "נוצר",
"modifiedAt": "עודכן"
},
"options": {
"mode": {
"both": "תיק + משרד",
"case": "תיק בלבד",
"office": "משרד בלבד"
}
},
"tooltips": {
"key": "המפתח שדרכו shira-hermes שולפת את הסעיף הזה. שמות מוכרים: tool_rules, legal_assistance, personality_case, personality_office, writing_style, other_rules_case, other_rules_office. שינוי שם המפתח של סעיף קיים יגרום לכך ש-fallback ל-default יחזור.",
"mode": "אם 'both' — מופיע בכל סשן. 'case' — רק כשנפתח תיק. 'office' — רק במסך המשרד הכללי.",
"displayOrder": "מספר נמוך = מופיע מוקדם יותר ב-prompt. ברירת מחדל 100.",
"content": "הטקסט שיוזרק ל-system prompt. תמיכה ב-RTL אוטומטית. שורה ריקה מסמנת default — קוד ה-Python יחזור לערך המקורי.",
"isActive": "כיבוי = הסעיף מתעלם, fallback ל-default מהקוד."
}
}
@@ -0,0 +1,21 @@
{
"labels": {
"Create AssistantSkill": "צור מיומנות חדשה"
},
"fields": {
"name": "שם",
"description": "תיאור קצר",
"triggers": "מילות הפעלה",
"content": "תוכן המיומנות (Markdown)",
"version": "גרסה",
"isActive": "פעיל",
"createdAt": "נוצר",
"modifiedAt": "עודכן"
},
"tooltips": {
"name": "שם ייחודי (snake-case או kebab-case). למשל direct-access-report-flow.",
"description": "משפט קצר: מתי כדאי לשירה להפעיל את המיומנות.",
"triggers": "מילים/ביטויים שבהם המיומנות צריכה להופיע — מופרדים בפסיק.",
"content": "ה-Markdown המלא של המיומנות. שירה תקרא אותו דרך read_skill כשהיא צריכה אותו."
}
}
@@ -2,11 +2,17 @@
"scopeNames": {
"CaseMemory": "זיכרון תיק",
"AssistantRule": "כלל התנהגות",
"UserProfile": "פרופיל משתמש",
"AssistantSkill": "מיומנות שירה",
"AssistantPrompt": "פרומפט שירה",
"SmartAssistant": "עוזר חכם"
},
"scopeNamesPlural": {
"CaseMemory": "זיכרונות תיקים",
"AssistantRule": "כללי התנהגות",
"UserProfile": "פרופילי משתמשים",
"AssistantSkill": "מיומנויות שירה",
"AssistantPrompt": "פרומפטים של שירה",
"SmartAssistant": "עוזר חכם"
}
}
@@ -0,0 +1,18 @@
{
"labels": {
"Create UserProfile": "צור פרופיל משתמש"
},
"fields": {
"name": "שם",
"user": "משתמש",
"content": "תוכן הפרופיל",
"isActive": "פעיל",
"createdAt": "נוצר",
"modifiedAt": "עודכן",
"createdBy": "נוצר על ידי",
"modifiedBy": "עודכן על ידי"
},
"tooltips": {
"content": "טקסט חופשי שמוזרק ל-prompt של שירה תחת '=== ABOUT THIS USER ===' לכל שיחה של המשתמש הזה. שמור עובדות מפתח: תפקיד, תחומי התמחות, העדפות תקשורת, נושאי טיק שמטופלים."
}
}
@@ -0,0 +1,17 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "key"}],
[{"name": "mode"}, {"name": "displayOrder"}],
[{"name": "isActive"}, false],
[{"name": "description", "fullWidth": true}]
]
},
{
"label": "Prompt",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,8 @@
[
{"name": "name", "link": true},
{"name": "key"},
{"name": "mode"},
{"name": "displayOrder"},
{"name": "isActive"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,17 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "version"}],
[{"name": "description", "fullWidth": true}],
[{"name": "triggers", "fullWidth": true}],
[{"name": "isActive"}, false]
]
},
{
"label": "Skill",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,7 @@
[
{"name": "name", "link": true},
{"name": "description"},
{"name": "isActive"},
{"name": "version"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,15 @@
[
{
"label": "Overview",
"rows": [
[{"name": "name"}, {"name": "user"}],
[{"name": "isActive"}, false]
]
},
{
"label": "Profile",
"rows": [
[{"name": "content", "fullWidth": true}]
]
}
]
@@ -0,0 +1,6 @@
[
{"name": "name", "link": true},
{"name": "user", "link": true},
{"name": "isActive"},
{"name": "modifiedAt"}
]
@@ -0,0 +1,37 @@
{
"shiraManagement": {
"label": "ניהול שירה",
"itemList": [
{
"url": "#AssistantPrompt",
"label": "פרומפטים",
"iconClass": "fas fa-file-code",
"description": "שינוי הסעיפים שמוזרקים ל-system prompt של שירה. עריכה כאן מחליפה את ברירות-המחדל בקוד."
},
{
"url": "#AssistantRule",
"label": "כללי התנהגות",
"iconClass": "fas fa-list-check",
"description": "כללים גלובליים/לפי מצב שיופיעו תחת '=== BEHAVIORAL RULES (MUST FOLLOW) ===' של ה-system prompt."
},
{
"url": "#AssistantSkill",
"label": "מיומנויות (Skills)",
"iconClass": "fas fa-magic",
"description": "תהליכי עבודה מוכנים שהמודל יכול להפעיל. כל מיומנות פעילה מופיעה ב-context."
},
{
"url": "#UserProfile",
"label": "פרופילי משתמשים",
"iconClass": "fas fa-user-cog",
"description": "פרופיל אישי לכל עורך/ת דין. מופיע ב-system prompt תחת '=== ABOUT THIS USER ==='."
},
{
"url": "#CaseMemory",
"label": "זיכרון תיקים",
"iconClass": "fas fa-brain",
"description": "כל רשומות הזיכרון מכל התיקים. גישה ישירה לחיפוש/עריכה — לצד כניסה רגילה מתוך כל תיק."
}
]
}
}
@@ -0,0 +1,5 @@
{
"controller": "controllers/record",
"color": "#d99848",
"iconClass": "fas fa-file-code"
}
@@ -0,0 +1,5 @@
{
"controller": "controllers/record",
"color": "#aa72b3",
"iconClass": "fas fa-magic"
}
@@ -0,0 +1,10 @@
{
"controller": "controllers/record",
"color": "#9b59b6",
"iconClass": "fas fa-brain",
"boolFilterList": ["onlyMy"],
"filterList": [
{"name": "pinned"},
{"name": "byCategory"}
]
}
@@ -0,0 +1,7 @@
{
"controller": "controllers/record",
"boolFilterList": ["onlyMy"],
"filterList": [{"name": "active"}, {"name": "inactive"}],
"color": "#7c8caf",
"iconClass": "fas fa-user-cog"
}
@@ -0,0 +1,76 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 200,
"required": true,
"trim": true
},
"key": {
"type": "varchar",
"maxLength": 100,
"required": true,
"trim": true,
"lowerCase": true
},
"description": {
"type": "varchar",
"maxLength": 500
},
"mode": {
"type": "enum",
"options": ["both", "case", "office"],
"default": "both",
"required": true,
"style": {
"both": "primary",
"case": "info",
"office": "warning"
}
},
"displayOrder": {
"type": "int",
"default": 100,
"min": 0
},
"content": {
"type": "text",
"required": true,
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 600
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {"type": "belongsTo", "entity": "User"},
"modifiedBy": {"type": "belongsTo", "entity": "User"}
},
"collection": {
"orderBy": "displayOrder",
"order": "asc",
"textFilterFields": ["name", "key", "description", "content"]
},
"indexes": {
"key": {"columns": ["key", "deleted"], "unique": true},
"modeActive": {"columns": ["mode", "isActive", "deleted"]}
}
}
@@ -0,0 +1,62 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 100,
"required": true,
"trim": true
},
"description": {
"type": "varchar",
"maxLength": 500
},
"triggers": {
"type": "text",
"lengthOfMaxDisplay": 200
},
"content": {
"type": "text",
"required": true,
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 400
},
"version": {
"type": "varchar",
"maxLength": 20,
"default": "1.0"
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"createdBy": {"type": "belongsTo", "entity": "User"},
"modifiedBy": {"type": "belongsTo", "entity": "User"}
},
"collection": {
"orderBy": "name",
"order": "asc",
"textFilterFields": ["name", "description", "triggers", "content"]
},
"indexes": {
"name": {"columns": ["name", "deleted"], "unique": true},
"isActive": {"columns": ["isActive", "deleted"]}
}
}
@@ -0,0 +1,63 @@
{
"fields": {
"name": {
"type": "varchar",
"maxLength": 255,
"required": true
},
"user": {
"type": "link",
"required": true
},
"content": {
"type": "text",
"view": "smart-assistant:views/fields/prompt-editor",
"lengthOfMaxDisplay": 400
},
"isActive": {
"type": "bool",
"default": true
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true
},
"modifiedBy": {
"type": "link",
"readOnly": true
}
},
"links": {
"user": {
"type": "belongsTo",
"entity": "User"
},
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
}
},
"collection": {
"orderBy": "modifiedAt",
"order": "desc",
"textFilterFields": ["name", "content"]
},
"indexes": {
"userId": {
"columns": ["userId", "deleted"],
"unique": true
}
}
}
@@ -0,0 +1,13 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "no"],
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -6,6 +6,8 @@
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "team", "own", "no"],
"stream": false,
"tab": false,
"disabled": false
"tab": true,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -0,0 +1,13 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "no"],
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -5,7 +5,7 @@
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "team", "own", "no"],
"tab": false,
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
@@ -0,0 +1,13 @@
{
"entity": true,
"object": true,
"module": "SmartAssistant",
"acl": true,
"aclActionList": ["create", "read", "edit", "delete"],
"aclLevelList": ["all", "own", "no"],
"tab": true,
"stream": false,
"disabled": false,
"importable": false,
"customizable": true
}
@@ -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;
}
}
@@ -533,20 +533,37 @@ class DocumentAnalyzer
private function extractFromDocx(string $tmpFile): ?string
{
if (!class_exists(\PhpOffice\PhpWord\IOFactory::class)) return null;
$phpWord = \PhpOffice\PhpWord\IOFactory::load($tmpFile);
$text = '';
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (method_exists($element, 'getText')) $text .= $element->getText() . "\n";
elseif (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $child) {
if (method_exists($child, 'getText')) $text .= $child->getText() . "\n";
}
// PHPWord (vendor/phpoffice/phpword) ships with the EspoCRM image but
// is NOT in the composer PSR-4 autoload map — class_exists silently
// returns false. Rather than fix the autoloader (rebuild of the image)
// we parse word/document.xml directly. This is also more robust:
// PHPWord's element walker only goes one level deep, missing text in
// tables, hyperlinks, and footnotes.
$zip = new \ZipArchive();
if ($zip->open($tmpFile) !== true) {
$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
@@ -85,6 +85,18 @@ class SmartAssistantService
$ruleProvider = $this->injectableFactory->create(AssistantRuleContextProvider::class);
$context['assistantRules'] = $ruleProvider->getRulesForMode($mode === 'case' ? 'case' : 'office');
// Load editable prompt sections (DB overrides for the Python defaults).
$promptProvider = $this->injectableFactory->create(AssistantPromptContextProvider::class);
$context['assistantPrompts'] = $promptProvider->getPromptsForMode($mode === 'case' ? 'case' : 'office');
// Load per-user profile (replaces /opt/data/profiles/{uid}.md).
$profileProvider = $this->injectableFactory->create(UserProfileContextProvider::class);
$context['userProfile'] = $profileProvider->getProfileForUser($userId);
// Load active skills (metadata only; full body fetched via REST when needed).
$skillProvider = $this->injectableFactory->create(AssistantSkillContextProvider::class);
$context['availableSkills'] = $skillProvider->listSkills();
// Generate conversation ID
if (!$conversationId) {
$prefix = $mode === 'case' ? 'conv_' : 'oconv_';
@@ -0,0 +1,46 @@
<?php
namespace Espo\Modules\SmartAssistant\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
/**
* Loads the active UserProfile row for a given user into Shira's context.
*
* Returns the markdown body that becomes the "=== ABOUT THIS USER ===" block
* in the system prompt. Returns "" when no profile exists; the Python prompt
* builder treats empty string as "skip this section".
*/
class UserProfileContextProvider
{
private EntityManager $entityManager;
private Log $log;
public function __construct(EntityManager $entityManager, Log $log)
{
$this->entityManager = $entityManager;
$this->log = $log;
}
public function getProfileForUser(string $userId): string
{
if (!$userId) {
return '';
}
$entity = $this->entityManager->getRDBRepository('UserProfile')
->where([
'userId' => $userId,
'isActive' => true,
'deleted' => false,
])
->findOne();
if (!$entity) {
return '';
}
return (string) ($entity->get('content') ?? '');
}
}
+2 -2
View File
@@ -3,11 +3,11 @@
"module": "SmartAssistant",
"description": "Unified AI Assistant for Legal CRM — floating chat with case memory, office alerts, and AI Gateway integration",
"author": "klear",
"version": "2.9.0",
"version": "2.10.3",
"acceptableVersions": [
">=8.0.0"
],
"releaseDate": "2026-05-14",
"releaseDate": "2026-05-27",
"php": [
">=8.1"
]
+77
View File
@@ -0,0 +1,77 @@
<?php
/**
* AfterInstall script for SmartAssistant extension 2.10.x.
*
* Idempotently seeds the AssistantPrompt entity with 7 default prompt
* sections (tool_rules, writing_style, legal_assistance, personality_case,
* personality_office, other_rules_case, other_rules_office). If a row with
* the same `key` already exists it is left alone admin edits win.
*
* Class must be named `AfterInstall` with NO namespace that's what the
* EspoCRM extension installer looks for in scripts/AfterInstall.php.
* See application/Espo/Core/Upgrades/ExtensionManager.php scriptNames map.
*/
use Espo\Core\Container;
class AfterInstall
{
public function run(Container $container, $params = null): void
{
$entityManager = $container->get('entityManager');
$log = $container->get('log');
// The JSON file lives inside the installed module resources at runtime.
$resourceFile = 'custom/Espo/Modules/SmartAssistant/Resources/data/default-prompts.json';
if (!is_file($resourceFile)) {
$log->warning("[SmartAssistant] AfterInstall: $resourceFile not found, skipping seed");
return;
}
$json = file_get_contents($resourceFile);
$defaults = json_decode($json, true);
if (!is_array($defaults)) {
$log->warning("[SmartAssistant] AfterInstall: invalid JSON in $resourceFile");
return;
}
$created = 0;
$skipped = 0;
foreach ($defaults as $row) {
$key = $row['key'] ?? null;
if (!$key) {
continue;
}
$existing = $entityManager->getRDBRepository('AssistantPrompt')
->where(['key' => $key, 'deleted' => false])
->findOne();
if ($existing) {
$skipped++;
continue;
}
$entity = $entityManager->getNewEntity('AssistantPrompt');
$entity->set([
'key' => $key,
'name' => $row['name'] ?? $key,
'description' => $row['description'] ?? null,
'mode' => $row['mode'] ?? 'both',
'displayOrder' => $row['displayOrder'] ?? 100,
'content' => $row['content'] ?? '',
'isActive' => true,
]);
// skipAll bypasses beforeSave hooks that require a logged-in user
// service (installer runs without an HTTP session) and ACL checks
// (we're seeding firm-wide defaults; no user is implicated).
$entityManager->saveEntity($entity, ['skipAll' => true]);
$created++;
}
$log->info("[SmartAssistant] AfterInstall: seeded $created AssistantPrompt rows, $skipped already existed");
}
}