feat: write-through sync of user profile + skills to EspoCRM entities

After SmartAssistant 2.10.0 introduced UserProfile and AssistantSkill
entities, the Python side kept writing only to the filesystem. So an
admin browsing the EspoCRM UI saw nothing — but the system prompt did
pull from CRM via context['userProfile'] / context['availableSkills'].

Result: edits made by Shira (via update_user_profile / skill_manage tool)
were invisible to admins; edits made by admins took effect immediately
because the prompt fetched from DB. Now the two sources agree.

* memory.py — added _sync_user_profile_to_crm() that upserts a UserProfile
  row whenever update_user_profile writes the file. Best-effort with
  logging; HTTP failures don't break the tool.

* skills.py — added _sync_skill_to_crm() that upserts an AssistantSkill
  row from the SKILL.md frontmatter (description, triggers, version) plus
  the full body in content. Called after every save_skill in skill_manage.

Read path is unchanged: the EspoCRM CaseContextBuilder / SmartAssistantService
populate context fields, and the Python prompt_builder consumes them with
in-code defaults as a fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 09:13:47 +00:00
parent 72f08e5d15
commit 7f2f29ebc8
2 changed files with 110 additions and 2 deletions
+54
View File
@@ -110,6 +110,58 @@ def save_skill(skill_name: str, content: str) -> None:
logger.info("Saved skill: %s", safe_name)
async def _sync_skill_to_crm(skill_name: str, content: str) -> None:
"""Write-through to AssistantSkill entity in EspoCRM. Best-effort."""
import os
import httpx
base = (os.environ.get("ESPOCRM_URL") or "").rstrip("/")
key = os.environ.get("ESPOCRM_API_KEY") or ""
if not base or not key:
return
# Parse description + triggers from frontmatter for the metadata columns.
fm, body = _parse_frontmatter(content)
description = (fm.get("description") or "")[:500]
triggers = fm.get("triggers", "")
if isinstance(triggers, list):
triggers = ", ".join(str(t) for t in triggers)
version = str(fm.get("version") or "1.0")[:20]
headers = {"X-Api-Key": key, "Content-Type": "application/json"}
try:
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.get(
f"{base}/api/v1/AssistantSkill",
headers=headers,
params={
"where[0][type]": "equals",
"where[0][attribute]": "name",
"where[0][value]": skill_name,
"maxSize": 1,
},
)
existing = (r.json().get("list", []) if r.status_code == 200 else [])
row = existing[0] if existing else None
payload = {
"description": description,
"triggers": triggers,
"content": content,
"version": version,
"isActive": True,
}
if row:
resp = await client.put(f"{base}/api/v1/AssistantSkill/{row['id']}", headers=headers, json=payload)
else:
payload["name"] = skill_name
resp = await client.post(f"{base}/api/v1/AssistantSkill", headers=headers, json=payload)
if resp.status_code >= 400:
logger.warning("AssistantSkill sync HTTP %s: %s", resp.status_code, resp.text[:200])
except Exception as e:
logger.warning("AssistantSkill sync failed: %s", e)
def register_skill_tools(tools: dict) -> None:
"""Register skill-related tools into the tools dict."""
@@ -137,6 +189,7 @@ def register_skill_tools(tools: dict) -> None:
if existing:
return f'❌ Skill "{skill_name}" כבר קיים. השתמש ב-action "update" כדי לעדכן.'
save_skill(skill_name, content)
await _sync_skill_to_crm(skill_name, content)
return f'✅ Skill "{skill_name}" נוצר בהצלחה.'
elif action == "update":
existing = view_skill(skill_name)
@@ -145,6 +198,7 @@ def register_skill_tools(tools: dict) -> None:
if not content:
return "❌ צריך לספק תוכן מעודכן."
save_skill(skill_name, content)
await _sync_skill_to_crm(skill_name, content)
return f'✅ Skill "{skill_name}" עודכן בהצלחה.'
else:
return f'❌ פעולה לא מוכרת: {action}. השתמש ב-"create" או "update".'