feat: prompt sections + memory route via CRM; legacy Notes migration
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user