From 65faf99e11be6782571d470bce0525b2cc07c23f Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 27 May 2026 08:33:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(2.10.0):=20Shira=20management=20UI=20?= =?UTF-8?q?=E2=80=94=20prompts,=20rules,=20memory,=20skills=20in=20EspoCRM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/views/fields/prompt-editor.js | 32 ++++++++ .../Controllers/AssistantPrompt.php | 10 +++ .../Controllers/AssistantSkill.php | 10 +++ .../Controllers/UserProfile.php | 13 +++ .../Resources/data/default-prompts.json | 58 +++++++++++++ .../Resources/i18n/fa_IR/AssistantPrompt.json | 30 +++++++ .../Resources/i18n/fa_IR/AssistantSkill.json | 21 +++++ .../Resources/i18n/fa_IR/Global.json | 6 ++ .../Resources/i18n/fa_IR/UserProfile.json | 18 +++++ .../Resources/metadata/app/adminPanel.json | 37 +++++++++ .../metadata/clientDefs/AssistantPrompt.json | 5 ++ .../metadata/clientDefs/AssistantSkill.json | 5 ++ .../metadata/clientDefs/UserProfile.json | 7 ++ .../metadata/entityDefs/AssistantPrompt.json | 76 +++++++++++++++++ .../metadata/entityDefs/AssistantSkill.json | 62 ++++++++++++++ .../metadata/entityDefs/UserProfile.json | 63 +++++++++++++++ .../layouts/AssistantPrompt/detail.json | 17 ++++ .../layouts/AssistantPrompt/list.json | 8 ++ .../layouts/AssistantSkill/detail.json | 17 ++++ .../metadata/layouts/AssistantSkill/list.json | 7 ++ .../metadata/layouts/UserProfile/detail.json | 15 ++++ .../metadata/layouts/UserProfile/list.json | 6 ++ .../metadata/scopes/AssistantPrompt.json | 13 +++ .../metadata/scopes/AssistantRule.json | 2 +- .../metadata/scopes/AssistantSkill.json | 13 +++ .../Resources/metadata/scopes/CaseMemory.json | 2 +- .../metadata/scopes/UserProfile.json | 13 +++ .../AssistantPromptContextProvider.php | 52 ++++++++++++ .../AssistantSkillContextProvider.php | 45 +++++++++++ .../Services/SmartAssistantService.php | 12 +++ .../Services/UserProfileContextProvider.php | 46 +++++++++++ manifest.json | 2 +- scripts/AfterInstall.php | 81 +++++++++++++++++++ 33 files changed, 801 insertions(+), 3 deletions(-) create mode 100644 files/client/custom/modules/smart-assistant/src/views/fields/prompt-editor.js create mode 100644 files/custom/Espo/Modules/SmartAssistant/Controllers/AssistantPrompt.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Controllers/AssistantSkill.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Controllers/UserProfile.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/data/default-prompts.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/AssistantPrompt.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/AssistantSkill.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/UserProfile.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/app/adminPanel.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/AssistantPrompt.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/AssistantSkill.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/UserProfile.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/AssistantPrompt.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/AssistantSkill.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/UserProfile.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantPrompt/detail.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantPrompt/list.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantSkill/detail.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantSkill/list.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/UserProfile/detail.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/UserProfile/list.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantPrompt.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantSkill.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/UserProfile.json create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/AssistantPromptContextProvider.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/AssistantSkillContextProvider.php create mode 100644 files/custom/Espo/Modules/SmartAssistant/Services/UserProfileContextProvider.php create mode 100644 scripts/AfterInstall.php diff --git a/files/client/custom/modules/smart-assistant/src/views/fields/prompt-editor.js b/files/client/custom/modules/smart-assistant/src/views/fields/prompt-editor.js new file mode 100644 index 0000000..41d6a60 --- /dev/null +++ b/files/client/custom/modules/smart-assistant/src/views/fields/prompt-editor.js @@ -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', + }); + } + } + }, + }); +}); diff --git a/files/custom/Espo/Modules/SmartAssistant/Controllers/AssistantPrompt.php b/files/custom/Espo/Modules/SmartAssistant/Controllers/AssistantPrompt.php new file mode 100644 index 0000000..ca32a63 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Controllers/AssistantPrompt.php @@ -0,0 +1,10 @@ +) to search storage, pick the best match, then call set_case_folder_path(path=...) to persist it, then list_documents(refresh=true). Use browse_folder(path=...) only as a manual override when find_case_folder doesn't surface the right match.\n- SUBFOLDER SEARCH (mandatory when user names a specific file):\n * If the user names a file (e.g. 'הודעה לבית הדין-...docx') and list_documents shows root subfolders but the file isn't listed at the top level, you MUST recurse into EVERY subfolder via browse_folder before reporting 'not found'. Cases have standard subfolders like מסמכים / התכתבויות / כללי / אסמכתאות — search all of them.\n * NEVER permanently set_case_folder_path to a subfolder just because that's where one file lives. The root must stay at the case root so other documents remain reachable. If you must inspect a subfolder, use browse_folder(path=...), not set_case_folder_path.\n- NEVER tell the user 'no documents' without first running find_case_folder — the folder might just be mis-mapped.\n- When user asks to summarize multiple documents or the entire case folder — use read_multiple_documents with all relevant file paths.\n- When user asks to rename or organize files — use batch_rename_documents with an array of rename operations.\n- TEMPLATE GENERATION: When user asks to generate/create a document from a template — use generate_from_template. The templateId comes from availableTemplates in your context.\n- FREE DOCUMENT CREATION: When the user asks for a document AND availableTemplates contains no template that matches the requested document type, use create_document(title, body, recipient?). DO NOT say 'I can't create documents' — that tool exists. Compose the full text yourself in Hebrew, organized into paragraphs. Use '#' for the section heading, '- ' for bullets, '**bold**' for emphasis. For letters to authorities (ביטוח לאומי, בתי משפט, רשויות) set documentType='letter' and put the authority name in recipient. The document will be created as a Hebrew RTL DOCX, attached to the case, and copied to the case network folder. Only fall back to a plain note (add_note) if the user explicitly asks NOT to create a real document.\n- FOLDER MANAGEMENT: You have full read/write access ONLY to the current case's network folder (paths are sandboxed). Use create_subfolder to create a new folder, rename_item to rename a file or folder, move_item to move a file between subfolders, write_document_to_folder for raw uploads. ALL paths are relative to the case root — never absolute, never with '..'.\n- FILE DELETION: You are NOT permitted to physically delete files. When the user asks you to delete a file, ALWAYS call mark_document_for_deletion.\n- BILLING (חיוב/חשבונית): When user asks about billable activities, charges, or invoices — start with list_unbilled_activities for a case, or list_charges for charge status views. Show the user a summary BEFORE creating an invoice.\n- BILLING — invoice creation: create_invoice_from_activities only creates a Draft. It does NOT auto-send to Green Invoice. Always show the user the draft amount and ask if they want to send it before calling send_invoice_to_green_invoice.\n- BILLING — irreversible: send_invoice_to_green_invoice issues an official tax document and CANNOT be undone. Require an explicit instruction like 'תשלחי לחשבונית ירוקה', not a vague 'כן'. If unsure, ask again.\n- BILLING — charges: A Charge moves Pending → Approved → (invoiced via create_invoice_from_activities). Cancel only if user explicitly asks. Never approve in bulk without listing the IDs and amounts first.\n- LEGAL AID (סיוע משפטי): the flow is recorded charges → create_legal_aid_proforma → user submits manually at the official site → mark_legal_aid_submitted with the request number → payment report ingestion (automated) → create_monthly_legal_aid_invoice for the month. NEVER call mark_legal_aid_submitted without an explicit request number provided by the user." + }, + { + "key": "writing_style", + "name": "סגנון כתיבה", + "description": "איסור מקפים רחבים ו-tells של AI בכתיבה בעברית/אנגלית.", + "mode": "both", + "displayOrder": 30, + "content": "WRITING STYLE (mandatory — the user actively dislikes AI-coded text):\n- Never use the em-dash character '—' (U+2014, מקף רחב). Forbidden in every Hebrew or English response.\n- Never use the en-dash character '–' (U+2013) either.\n- When you need separation, use the hyphen-minus '-' (the regular keyboard dash), or a comma, or a new sentence. Reach for a hyphen only when it actually clarifies — most of the time a comma or full stop reads better.\n- Don't pile up dashes either: 'משפט - תיאור - הסבר' is a tell. Prefer commas, periods, or breaking into two sentences.\n- Avoid other typical AI tells in Hebrew prose: parallel three-item phrases ('ברור, מסודר, ומדויק'), heavy bullet lists when one sentence would do, and over-use of emojis when the user did not invite playfulness." + }, + { + "key": "other_rules_case", + "name": "כללים נוספים — מצב תיק", + "description": "כללי שפה, פורמט תאריכים, save_memory יזום, הצעת עדכון סטטוס.", + "mode": "case", + "displayOrder": 40, + "content": "OTHER RULES:\n- Always respond in Hebrew, in feminine form (לשון נקבה) — you are שירה\n- Dates: DD/MM/YYYY format in responses to user\n- When user shares important facts/decisions/strategy, PROACTIVELY save them using save_memory without asking permission\n- When user describes case situation, suggest updating status" + }, + { + "key": "other_rules_office", + "name": "כללים נוספים — מצב משרד", + "description": "כללים לסיכומים תמציתיים, התראות, drill-down.", + "mode": "office", + "displayOrder": 40, + "content": "OTHER RULES:\n- Always respond in Hebrew, in feminine form (לשון נקבה) — you are שירה\n- Provide concise, actionable summaries\n- Prioritize critical alerts\n- When user mentions a specific case and asks to create a task/meeting/note, DO IT using the tools\n- If a case is identified in the DRILL-DOWN section below, use its context for actions\n- Tools that require case context (change_status, save_memory, list_documents) — if no case is identified, tell the user to navigate to the case first\n- For create_task, add_note, create_meeting — these work even without a case (standalone)" + }, + { + "key": "legal_assistance", + "name": "סיוע משפטי — דוח גישה ישירה", + "description": "תהליך דוח גישה ישירה: מתי להפעיל את generate_initial_report.", + "mode": "case", + "displayOrder": 90, + "content": "=== סיוע משפטי — דוח גישה ישירה ===\n\nלתיקים עם מינוי גישה ישירה (cLegalAidType = DirectAccess) יש לך כלי מיוחד: generate_initial_report.\n\n## מתי להפעיל את התהליך?\n- כשהמשתמש מזכיר: \"גישה ישירה\", \"דוח גישה ישירה\", \"דיווח ראשוני\", \"דוח דיווח ראשוני\", \"מינוי גישה ישירה\"\n- כשיש תיק עם cLegalAidType = DirectAccess\n- כשהמשתמש שואל על תהליך הדיווח או המינוי\n\nכשמזהים שהתיק הוא גישה ישירה, הסבר למשתמש שיש לך כלי ייעודי ליצירת דוח גישה ישירה, ושאל אם הוא רוצה להתחיל.\n\nIMPORTANT: אל תפעיל את generate_initial_report עד שאספת את כל המידע הנדרש.\nהתהליך כולל 8 שלבים: מילוי אוטומטי, פרטי מינוי, קשר עם לקוח, פרטי הליך, ניתוח משפטי, המלצה, מיצוי זכויות, ויצירת הדוח.\nשאל בסגנון שיחה טבעי, לא כטופס. קבץ 2-3 שאלות קשורות ביחד." + } +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/AssistantPrompt.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/AssistantPrompt.json new file mode 100644 index 0000000..b2f5c82 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/AssistantPrompt.json @@ -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 מהקוד." + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/AssistantSkill.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/AssistantSkill.json new file mode 100644 index 0000000..ed04148 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/AssistantSkill.json @@ -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 כשהיא צריכה אותו." + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Global.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Global.json index 470d2c0..2a2cabf 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Global.json +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/Global.json @@ -2,11 +2,17 @@ "scopeNames": { "CaseMemory": "זיכרון תיק", "AssistantRule": "כלל התנהגות", + "UserProfile": "פרופיל משתמש", + "AssistantSkill": "מיומנות שירה", + "AssistantPrompt": "פרומפט שירה", "SmartAssistant": "עוזר חכם" }, "scopeNamesPlural": { "CaseMemory": "זיכרונות תיקים", "AssistantRule": "כללי התנהגות", + "UserProfile": "פרופילי משתמשים", + "AssistantSkill": "מיומנויות שירה", + "AssistantPrompt": "פרומפטים של שירה", "SmartAssistant": "עוזר חכם" } } diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/UserProfile.json b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/UserProfile.json new file mode 100644 index 0000000..883aa0e --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/i18n/fa_IR/UserProfile.json @@ -0,0 +1,18 @@ +{ + "labels": { + "Create UserProfile": "צור פרופיל משתמש" + }, + "fields": { + "name": "שם", + "user": "משתמש", + "content": "תוכן הפרופיל", + "isActive": "פעיל", + "createdAt": "נוצר", + "modifiedAt": "עודכן", + "createdBy": "נוצר על ידי", + "modifiedBy": "עודכן על ידי" + }, + "tooltips": { + "content": "טקסט חופשי שמוזרק ל-prompt של שירה תחת '=== ABOUT THIS USER ===' לכל שיחה של המשתמש הזה. שמור עובדות מפתח: תפקיד, תחומי התמחות, העדפות תקשורת, נושאי טיק שמטופלים." + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/app/adminPanel.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/app/adminPanel.json new file mode 100644 index 0000000..13436c3 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/app/adminPanel.json @@ -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": "כל רשומות הזיכרון מכל התיקים. גישה ישירה לחיפוש/עריכה — לצד כניסה רגילה מתוך כל תיק." + } + ] + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/AssistantPrompt.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/AssistantPrompt.json new file mode 100644 index 0000000..d981040 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/AssistantPrompt.json @@ -0,0 +1,5 @@ +{ + "controller": "controllers/record", + "color": "#d99848", + "iconClass": "fas fa-file-code" +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/AssistantSkill.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/AssistantSkill.json new file mode 100644 index 0000000..772d5c3 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/AssistantSkill.json @@ -0,0 +1,5 @@ +{ + "controller": "controllers/record", + "color": "#aa72b3", + "iconClass": "fas fa-magic" +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/UserProfile.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/UserProfile.json new file mode 100644 index 0000000..99d4732 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/clientDefs/UserProfile.json @@ -0,0 +1,7 @@ +{ + "controller": "controllers/record", + "boolFilterList": ["onlyMy"], + "filterList": [{"name": "active"}, {"name": "inactive"}], + "color": "#7c8caf", + "iconClass": "fas fa-user-cog" +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/AssistantPrompt.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/AssistantPrompt.json new file mode 100644 index 0000000..c185112 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/AssistantPrompt.json @@ -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"]} + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/AssistantSkill.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/AssistantSkill.json new file mode 100644 index 0000000..76cdeea --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/AssistantSkill.json @@ -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"]} + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/UserProfile.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/UserProfile.json new file mode 100644 index 0000000..c29b43f --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/entityDefs/UserProfile.json @@ -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 + } + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantPrompt/detail.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantPrompt/detail.json new file mode 100644 index 0000000..f8d29c5 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantPrompt/detail.json @@ -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}] + ] + } +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantPrompt/list.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantPrompt/list.json new file mode 100644 index 0000000..fe20e54 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantPrompt/list.json @@ -0,0 +1,8 @@ +[ + {"name": "name", "link": true}, + {"name": "key"}, + {"name": "mode"}, + {"name": "displayOrder"}, + {"name": "isActive"}, + {"name": "modifiedAt"} +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantSkill/detail.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantSkill/detail.json new file mode 100644 index 0000000..5f73c4d --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantSkill/detail.json @@ -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}] + ] + } +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantSkill/list.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantSkill/list.json new file mode 100644 index 0000000..1ce52a4 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/AssistantSkill/list.json @@ -0,0 +1,7 @@ +[ + {"name": "name", "link": true}, + {"name": "description"}, + {"name": "isActive"}, + {"name": "version"}, + {"name": "modifiedAt"} +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/UserProfile/detail.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/UserProfile/detail.json new file mode 100644 index 0000000..da9c2e8 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/UserProfile/detail.json @@ -0,0 +1,15 @@ +[ + { + "label": "Overview", + "rows": [ + [{"name": "name"}, {"name": "user"}], + [{"name": "isActive"}, false] + ] + }, + { + "label": "Profile", + "rows": [ + [{"name": "content", "fullWidth": true}] + ] + } +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/UserProfile/list.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/UserProfile/list.json new file mode 100644 index 0000000..9d9a855 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/layouts/UserProfile/list.json @@ -0,0 +1,6 @@ +[ + {"name": "name", "link": true}, + {"name": "user", "link": true}, + {"name": "isActive"}, + {"name": "modifiedAt"} +] diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantPrompt.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantPrompt.json new file mode 100644 index 0000000..e5e6d76 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantPrompt.json @@ -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 +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantRule.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantRule.json index f1d929f..3c99bf1 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantRule.json +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantRule.json @@ -6,6 +6,6 @@ "aclActionList": ["create", "read", "edit", "delete"], "aclLevelList": ["all", "team", "own", "no"], "stream": false, - "tab": false, + "tab": true, "disabled": false } diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantSkill.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantSkill.json new file mode 100644 index 0000000..e5e6d76 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/AssistantSkill.json @@ -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 +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/CaseMemory.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/CaseMemory.json index 3107b65..aa62e6a 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/CaseMemory.json +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/CaseMemory.json @@ -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, diff --git a/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/UserProfile.json b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/UserProfile.json new file mode 100644 index 0000000..07e88da --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Resources/metadata/scopes/UserProfile.json @@ -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 +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/AssistantPromptContextProvider.php b/files/custom/Espo/Modules/SmartAssistant/Services/AssistantPromptContextProvider.php new file mode 100644 index 0000000..b164a12 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/AssistantPromptContextProvider.php @@ -0,0 +1,52 @@ + "...", "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 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; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/AssistantSkillContextProvider.php b/files/custom/Espo/Modules/SmartAssistant/Services/AssistantSkillContextProvider.php new file mode 100644 index 0000000..1b060b5 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/AssistantSkillContextProvider.php @@ -0,0 +1,45 @@ +entityManager = $entityManager; + $this->log = $log; + } + + /** + * @return array + */ + 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; + } +} diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php b/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php index 15318f4..4d8d683 100644 --- a/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php +++ b/files/custom/Espo/Modules/SmartAssistant/Services/SmartAssistantService.php @@ -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_'; diff --git a/files/custom/Espo/Modules/SmartAssistant/Services/UserProfileContextProvider.php b/files/custom/Espo/Modules/SmartAssistant/Services/UserProfileContextProvider.php new file mode 100644 index 0000000..a4601d9 --- /dev/null +++ b/files/custom/Espo/Modules/SmartAssistant/Services/UserProfileContextProvider.php @@ -0,0 +1,46 @@ +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') ?? ''); + } +} diff --git a/manifest.json b/manifest.json index 2b7cc24..25ec462 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "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.2", + "version": "2.10.0", "acceptableVersions": [ ">=8.0.0" ], diff --git a/scripts/AfterInstall.php b/scripts/AfterInstall.php new file mode 100644 index 0000000..6a8e87d --- /dev/null +++ b/scripts/AfterInstall.php @@ -0,0 +1,81 @@ +get('entityManager'); + $log = $container->get('log'); + + $resourceFile = __DIR__ . '/../files/custom/Espo/Modules/SmartAssistant/Resources/data/default-prompts.json'; + + if (!is_file($resourceFile)) { + // Fall back to the module Resources path when the extension is + // installed (Resources/ is the canonical location at runtime). + $resourceFile = dirname(__DIR__) . '/files/custom/Espo/Modules/SmartAssistant/Resources/data/default-prompts.json'; + } + + if (!is_file($resourceFile)) { + // Final fallback: the module's installed Resources path. + $resourceFile = 'custom/Espo/Modules/SmartAssistant/Resources/data/default-prompts.json'; + } + + if (!is_file($resourceFile)) { + $log->warning("[SmartAssistant] AfterInstall: default-prompts.json not found, skipping seed"); + return; + } + + $json = file_get_contents($resourceFile); + $defaults = json_decode($json, true); + + if (!is_array($defaults)) { + $log->warning("[SmartAssistant] AfterInstall: default-prompts.json invalid JSON"); + 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, + ]); + $entityManager->saveEntity($entity); + $created++; + } + + $log->info("[SmartAssistant] AfterInstall: seeded $created AssistantPrompt rows, $skipped already existed"); + } +}