From 72f08e5d15e43218e23020eb033c3873c807c45a Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 27 May 2026 08:34:14 +0000 Subject: [PATCH] feat: prompt sections + memory route via CRM; legacy Notes migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes paired with SmartAssistant 2.10.0: 1. prompt_builder.py — every prompt section (tool_rules, writing_style, legal_assistance, personality_case, personality_office, other_rules_case, other_rules_office) now reads from context['assistantPrompts'][key] with the existing string as a fallback default. Admins editing AssistantPrompt rows in the EspoCRM UI immediately affect the next conversation. A missing/inactive row falls back to the in-code default so deleting everything in the UI never bricks Shira. Also: the user-profile section now prefers context['userProfile'] (from the new UserProfile entity) over the explicit `user_profile` parameter, completing the move from /opt/data/profiles/*.md to the CRM. 2. crm_tools.py:save_memory — stops POSTing directly to /Note with a '🧠 category:' prefix. Routes through SmartAssistant/action/executeTool so the PHP saveMemory handler actually creates a CaseMemory row. Cause of yesterday's prod issue: CaseMemory table was empty even though Shira was "saving" to it. 3. scripts/migrate_legacy_memory_notes.py — one-shot migration that sweeps all existing '🧠 category: ...' Notes into CaseMemory rows and soft-deletes the originals. Idempotent; safe to re-run. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/services/prompt_builder.py | 104 +++++++++++------ mcp_server/tools/crm_tools.py | 30 +++-- scripts/migrate_legacy_memory_notes.py | 149 +++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 41 deletions(-) create mode 100644 scripts/migrate_legacy_memory_notes.py diff --git a/api/services/prompt_builder.py b/api/services/prompt_builder.py index 20451cd..1921e4a 100644 --- a/api/services/prompt_builder.py +++ b/api/services/prompt_builder.py @@ -37,7 +37,18 @@ def _to_local(dt_str: str | None, fallback: str = "") -> str: return dt_str -TOOL_RULES = ( +def _get_prompt(context: dict, key: str, default: str) -> str: + """Pull a prompt section override from context['assistantPrompts'], or fall back to the in-code default. + + Empty strings and missing keys both fall back. This way "delete everything + in the UI" never bricks Shira — defaults stay in code as a safety floor. + """ + overrides = (context.get("assistantPrompts") or {}) + value = overrides.get(key) + return value.strip() if isinstance(value, str) and value.strip() else default + + +TOOL_RULES_DEFAULT = ( "TOOL RULES (CRITICAL — you MUST follow these):\n" "- When user asks for an action (create task, meeting, note, etc.) — USE the tool. Do not describe what you would do — DO IT.\n" "- NEVER claim you performed an action if you did not call the tool. This is the most important rule.\n" @@ -77,17 +88,19 @@ TOOL_RULES = ( "- 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. Use legal_aid_monthly_summary to preview before create_monthly_legal_aid_invoice.\n" - "\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. Use legal_aid_monthly_summary to preview before create_monthly_legal_aid_invoice." +) + +WRITING_STYLE_DEFAULT = ( "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.\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." ) -LEGAL_ASSISTANCE_PROMPT = ( +LEGAL_ASSISTANCE_PROMPT_DEFAULT = ( "=== סיוע משפטי — דוח גישה ישירה ===\n\n" "לתיקים עם מינוי גישה ישירה (cLegalAidType = DirectAccess) יש לך כלי מיוחד: generate_initial_report.\n\n" '## מתי להפעיל את התהליך?\n' @@ -101,6 +114,39 @@ LEGAL_ASSISTANCE_PROMPT = ( ) +PERSONALITY_CASE_DEFAULT = ( + "You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. Help lawyers manage cases.\n" + "You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through " + "encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch." +) + +PERSONALITY_OFFICE_DEFAULT = ( + "You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. " + "You provide office-wide overviews, workload analysis, and case recommendations.\n" + "You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through " + "encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch." +) + +OTHER_RULES_CASE_DEFAULT = ( + "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" +) + +OTHER_RULES_OFFICE_DEFAULT = ( + "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)" +) + + def _now() -> str: return datetime.now(ISR_TZ).strftime("%d/%m/%Y %H:%M") @@ -169,15 +215,10 @@ def build_case_prompt(context: dict, user_profile: str = "", case_memory_notes: status_section += "\nUse English key in tool calls." parts = [ - "You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. Help lawyers manage cases.\n" - "You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through " - "encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch.\n\n", - TOOL_RULES, "\n", - "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\n\n", + _get_prompt(context, "personality_case", PERSONALITY_CASE_DEFAULT) + "\n\n", + _get_prompt(context, "tool_rules", TOOL_RULES_DEFAULT) + "\n\n", + _get_prompt(context, "writing_style", WRITING_STYLE_DEFAULT) + "\n\n", + _get_prompt(context, "other_rules_case", OTHER_RULES_CASE_DEFAULT) + "\n\n", f"Current date/time: {_now()}\n\n", "=== CASE ===\n", f'Name: {case_info.get("name", "")} (#{case_info.get("number", "")})\n', @@ -210,7 +251,7 @@ def build_case_prompt(context: dict, user_profile: str = "", case_memory_notes: parts.append("\n".join(f'• [{r.get("scope", "")}] {r.get("name", "")}: {r.get("rule", "")}' for r in assistant_rules)) parts.append(f'\n\nCurrent user: {current_user.get("name", "")}') - parts.append(f"\n\n{LEGAL_ASSISTANCE_PROMPT}") + parts.append("\n\n" + _get_prompt(context, "legal_assistance", LEGAL_ASSISTANCE_PROMPT_DEFAULT)) # Skills section if available_skills: @@ -223,9 +264,12 @@ def build_case_prompt(context: dict, user_profile: str = "", case_memory_notes: parts.append("\n\n=== YOUR NOTES (from previous conversations about this case) ===\n") parts.append(case_memory_notes) - if user_profile: + # User profile: prefer context['userProfile'] from CRM, fall back to parameter + # (for callers that still pass it explicitly during migration). + profile_text = context.get("userProfile") or user_profile + if profile_text: parts.append("\n\n=== ABOUT THIS USER (learned preferences) ===\n") - parts.append(user_profile) + parts.append(profile_text) return "".join(parts) @@ -242,20 +286,11 @@ def build_office_prompt(context: dict, user_profile: str = "", available_skills: statuses = context.get("validStatuses", VALID_STATUSES) parts = [ - "You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. " - "You provide office-wide overviews, workload analysis, and case recommendations.\n" - "You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through " - "encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch.\n\n", - TOOL_RULES, "\n", - "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)\n", - f"- Current date/time: {_now()}\n\n", + _get_prompt(context, "personality_office", PERSONALITY_OFFICE_DEFAULT) + "\n\n", + _get_prompt(context, "tool_rules", TOOL_RULES_DEFAULT) + "\n\n", + _get_prompt(context, "writing_style", WRITING_STYLE_DEFAULT) + "\n\n", + _get_prompt(context, "other_rules_office", OTHER_RULES_OFFICE_DEFAULT) + "\n\n", + f"Current date/time: {_now()}\n\n", "=== OFFICE SUMMARY ===\n", f'Open Cases: {summary.get("totalOpenCases", 0)}\n', f'Overdue Tasks: {summary.get("totalOverdueTasks", 0)}\n', @@ -302,9 +337,10 @@ def build_office_prompt(context: dict, user_profile: str = "", available_skills: parts.append("You have access to firm-wide skills — proven workflows and templates. Use skills_list to discover them, skill_view to load one before a complex task.\n") parts.append("Available: " + ", ".join(s["name"] for s in available_skills)) - # User profile - if user_profile: + # User profile: prefer context['userProfile'] from CRM, fall back to parameter. + profile_text = context.get("userProfile") or user_profile + if profile_text: parts.append("\n\n=== ABOUT THIS USER (learned preferences) ===\n") - parts.append(user_profile) + parts.append(profile_text) return "".join(parts) diff --git a/mcp_server/tools/crm_tools.py b/mcp_server/tools/crm_tools.py index ea1870d..c1168c9 100644 --- a/mcp_server/tools/crm_tools.py +++ b/mcp_server/tools/crm_tools.py @@ -177,16 +177,32 @@ def register_crm_tools( except Exception as e: return fail(f"שגיאה במחיקת {label}: {e}") - async def save_memory(category: str, content: str, importance: str = "normal") -> str: + async def save_memory( + category: str, + content: str, + importance: str = "normal", + name: str | None = None, + ) -> str: if not case_id: return fail("צריך להיות בתוך תיק כדי לשמור בזיכרון") try: - await crm.post("Note", { - "type": "Post", - "post": f"🧠 {category}: {content}", - "parentType": "Case", - "parentId": case_id, - }) + result = await crm.post( + "SmartAssistant/action/executeTool", + { + "tool": "save_memory", + "caseId": case_id, + "params": { + "category": category, + "content": content, + "importance": importance, + "name": name or content[:100], + }, + }, + ) + if not result.get("success", True): + return fail( + f"שגיאה בשמירת זיכרון: {result.get('message', 'unknown error')}" + ) return ok(f'נשמר בזיכרון: "{content[:60]}"') except Exception as e: return fail(f"שגיאה בשמירת זיכרון: {e}") diff --git a/scripts/migrate_legacy_memory_notes.py b/scripts/migrate_legacy_memory_notes.py new file mode 100644 index 0000000..7b4110c --- /dev/null +++ b/scripts/migrate_legacy_memory_notes.py @@ -0,0 +1,149 @@ +"""Migrate legacy "🧠 category: content" Notes into CaseMemory rows. + +History: until SmartAssistant 2.10.0 the Python `save_memory` tool created a +Note entity with `post` prefixed by "🧠 {category}:" instead of using the +proper CaseMemory entity. This left CaseMemory empty even though the system +prompt was reading from it — so case memory never accumulated. + +This one-shot script reads every such Note in EspoCRM (across all cases), +parses out the category and content, creates a corresponding CaseMemory row +with source='assistant', and soft-deletes the original Note. + +Usage (from inside a shira-hermes container that already has env vars set): + python3 scripts/migrate_legacy_memory_notes.py [--dry-run] + +Safe to re-run: matches `post LIKE "🧠 %"` only; soft-deletes after migration. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import time +from typing import Any + +import httpx + +VALID_CATEGORIES = { + "key_facts", "strategy", "decisions", + "contacts_notes", "timeline", + "documents_notes", "billing_notes", +} + +EMOJI_PREFIX_RE = re.compile(r"^🧠\s*([a-z_]+)\s*:\s*(.+)$", re.DOTALL) + + +def parse_note_post(post: str) -> tuple[str, str] | None: + """Return (category, content) from a "🧠 category: content" string, or None.""" + if not post or not post.startswith("🧠"): + return None + m = EMOJI_PREFIX_RE.match(post.strip()) + if not m: + return None + category, content = m.group(1).strip(), m.group(2).strip() + if category not in VALID_CATEGORIES: + category = "key_facts" + return category, content + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true", help="Don't write anything; just report what would happen.") + args = parser.parse_args() + + base = os.environ.get("ESPOCRM_URL", "").rstrip("/") + key = os.environ.get("ESPOCRM_API_KEY", "") + if not base or not key: + print("ESPOCRM_URL or ESPOCRM_API_KEY missing in env. Exiting.", file=sys.stderr) + return 2 + + headers = {"X-Api-Key": key, "Content-Type": "application/json"} + + # Page through notes that look like memory entries. + offset = 0 + page_size = 100 + migrated = 0 + skipped = 0 + failed = 0 + + while True: + params = { + "where[0][type]": "startsWith", + "where[0][attribute]": "post", + "where[0][value]": "🧠", + "maxSize": page_size, + "offset": offset, + "select": "id,post,parentId,parentType,createdAt,createdById", + } + r = httpx.get(f"{base}/api/v1/Note", headers=headers, params=params, timeout=30) + if r.status_code != 200: + print(f"GET /Note failed: HTTP {r.status_code} — {r.text[:200]}", file=sys.stderr) + return 1 + + data = r.json() + rows: list[dict[str, Any]] = data.get("list", []) or [] + if not rows: + break + + for row in rows: + parent_type = row.get("parentType") + parent_id = row.get("parentId") + post = row.get("post") or "" + note_id = row.get("id") + + if parent_type != "Case" or not parent_id: + skipped += 1 + continue + + parsed = parse_note_post(post) + if not parsed: + skipped += 1 + continue + + category, content = parsed + name = content[:100] + print(f" migrate Note {note_id} [{parent_id}] → CaseMemory ({category}): {name[:60]}") + + if args.dry_run: + migrated += 1 + continue + + create_body = { + "name": name, + "caseId": parent_id, + "category": category, + "content": content, + "source": "assistant", + "importance": "normal", + } + cr = httpx.post(f"{base}/api/v1/CaseMemory", headers=headers, json=create_body, timeout=30) + if cr.status_code not in (200, 201): + print(f" CREATE CaseMemory failed: HTTP {cr.status_code} — {cr.text[:200]}", file=sys.stderr) + failed += 1 + continue + + dr = httpx.delete(f"{base}/api/v1/Note/{note_id}", headers=headers, timeout=30) + if dr.status_code not in (200, 204): + print(f" DELETE Note {note_id} failed: HTTP {dr.status_code} — {dr.text[:200]}", file=sys.stderr) + migrated += 1 + + offset += page_size + # Don't loop forever on a misbehaving total. + total = data.get("total", 0) + if offset >= total: + break + time.sleep(0.2) # gentle rate-limit + + print() + print(f"=== summary ===") + print(f" migrated: {migrated}") + print(f" skipped: {skipped}") + print(f" failed: {failed}") + print(f" mode: {'dry-run' if args.dry_run else 'live'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())