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:
+56
-2
@@ -1,4 +1,12 @@
|
|||||||
"""Per-lawyer and per-case memory store — bounded markdown files with atomic writes."""
|
"""Per-lawyer and per-case memory store — bounded markdown files with atomic writes.
|
||||||
|
|
||||||
|
The filesystem files (under /opt/data/) remain the canonical write target for
|
||||||
|
update_case_memory and update_user_profile. After each successful filesystem
|
||||||
|
write, update_user_profile also syncs the result up to the EspoCRM UserProfile
|
||||||
|
entity (write-through) so an admin browsing UserProfile in the UI sees the
|
||||||
|
same content that's in the prompt. The CRM is the read source for the prompt
|
||||||
|
(context['userProfile'] populated by UserProfileContextProvider).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,6 +17,8 @@ import re
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger("shira.memory")
|
logger = logging.getLogger("shira.memory")
|
||||||
|
|
||||||
DATA_DIR = os.environ.get("SHIRA_DATA_DIR", "/opt/data")
|
DATA_DIR = os.environ.get("SHIRA_DATA_DIR", "/opt/data")
|
||||||
@@ -79,6 +89,46 @@ def _truncate(content: str, max_chars: int) -> str:
|
|||||||
return _join_entries(entries)
|
return _join_entries(entries)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- CRM write-through ----------
|
||||||
|
|
||||||
|
async def _sync_user_profile_to_crm(user_id: str, content: str, default_name: str | None = None) -> None:
|
||||||
|
"""Write-through to UserProfile entity in EspoCRM. Best-effort; logs and swallows errors."""
|
||||||
|
base = (os.environ.get("ESPOCRM_URL") or "").rstrip("/")
|
||||||
|
key = os.environ.get("ESPOCRM_API_KEY") or ""
|
||||||
|
if not base or not key:
|
||||||
|
logger.debug("ESPOCRM_URL or API key missing — skipping UserProfile sync")
|
||||||
|
return
|
||||||
|
|
||||||
|
name = (default_name or f"Profile {user_id[:8]}")[:255]
|
||||||
|
headers = {"X-Api-Key": key, "Content-Type": "application/json"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
# Find existing row for this user
|
||||||
|
r = await client.get(
|
||||||
|
f"{base}/api/v1/UserProfile",
|
||||||
|
headers=headers,
|
||||||
|
params={
|
||||||
|
"where[0][type]": "equals",
|
||||||
|
"where[0][attribute]": "userId",
|
||||||
|
"where[0][value]": user_id,
|
||||||
|
"maxSize": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
existing = (r.json().get("list", []) if r.status_code == 200 else [])
|
||||||
|
row = existing[0] if existing else None
|
||||||
|
|
||||||
|
payload = {"content": content, "isActive": True}
|
||||||
|
if row:
|
||||||
|
resp = await client.put(f"{base}/api/v1/UserProfile/{row['id']}", headers=headers, json=payload)
|
||||||
|
else:
|
||||||
|
payload.update({"userId": user_id, "name": name})
|
||||||
|
resp = await client.post(f"{base}/api/v1/UserProfile", headers=headers, json=payload)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
logger.warning("UserProfile sync HTTP %s: %s", resp.status_code, resp.text[:200])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("UserProfile sync failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
# ---------- Public API ----------
|
# ---------- Public API ----------
|
||||||
|
|
||||||
def load_user_profile(user_id: str) -> str:
|
def load_user_profile(user_id: str) -> str:
|
||||||
@@ -102,6 +152,7 @@ async def update_user_profile(user_id: str, action: str, content: str, match: st
|
|||||||
entries.append(content)
|
entries.append(content)
|
||||||
result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS)
|
result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS)
|
||||||
_write_file_atomic(path, result)
|
_write_file_atomic(path, result)
|
||||||
|
await _sync_user_profile_to_crm(user_id, result)
|
||||||
return f'✅ נוסף לפרופיל: "{content[:60]}"'
|
return f'✅ נוסף לפרופיל: "{content[:60]}"'
|
||||||
|
|
||||||
elif action == "replace":
|
elif action == "replace":
|
||||||
@@ -117,6 +168,7 @@ async def update_user_profile(user_id: str, action: str, content: str, match: st
|
|||||||
return f'❌ לא נמצאה רשומה עם "{match}"'
|
return f'❌ לא נמצאה רשומה עם "{match}"'
|
||||||
result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS)
|
result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS)
|
||||||
_write_file_atomic(path, result)
|
_write_file_atomic(path, result)
|
||||||
|
await _sync_user_profile_to_crm(user_id, result)
|
||||||
return f'✅ רשומה עודכנה בפרופיל.'
|
return f'✅ רשומה עודכנה בפרופיל.'
|
||||||
|
|
||||||
elif action == "remove":
|
elif action == "remove":
|
||||||
@@ -126,7 +178,9 @@ async def update_user_profile(user_id: str, action: str, content: str, match: st
|
|||||||
entries = [e for e in entries if match not in e]
|
entries = [e for e in entries if match not in e]
|
||||||
if len(entries) == original_len:
|
if len(entries) == original_len:
|
||||||
return f'❌ לא נמצאה רשומה עם "{match}"'
|
return f'❌ לא נמצאה רשומה עם "{match}"'
|
||||||
_write_file_atomic(path, _join_entries(entries))
|
result = _join_entries(entries)
|
||||||
|
_write_file_atomic(path, result)
|
||||||
|
await _sync_user_profile_to_crm(user_id, result)
|
||||||
return "✅ רשומה הוסרה מהפרופיל."
|
return "✅ רשומה הוסרה מהפרופיל."
|
||||||
|
|
||||||
elif action == "read":
|
elif action == "read":
|
||||||
|
|||||||
@@ -110,6 +110,58 @@ def save_skill(skill_name: str, content: str) -> None:
|
|||||||
logger.info("Saved skill: %s", safe_name)
|
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:
|
def register_skill_tools(tools: dict) -> None:
|
||||||
"""Register skill-related tools into the tools dict."""
|
"""Register skill-related tools into the tools dict."""
|
||||||
|
|
||||||
@@ -137,6 +189,7 @@ def register_skill_tools(tools: dict) -> None:
|
|||||||
if existing:
|
if existing:
|
||||||
return f'❌ Skill "{skill_name}" כבר קיים. השתמש ב-action "update" כדי לעדכן.'
|
return f'❌ Skill "{skill_name}" כבר קיים. השתמש ב-action "update" כדי לעדכן.'
|
||||||
save_skill(skill_name, content)
|
save_skill(skill_name, content)
|
||||||
|
await _sync_skill_to_crm(skill_name, content)
|
||||||
return f'✅ Skill "{skill_name}" נוצר בהצלחה.'
|
return f'✅ Skill "{skill_name}" נוצר בהצלחה.'
|
||||||
elif action == "update":
|
elif action == "update":
|
||||||
existing = view_skill(skill_name)
|
existing = view_skill(skill_name)
|
||||||
@@ -145,6 +198,7 @@ def register_skill_tools(tools: dict) -> None:
|
|||||||
if not content:
|
if not content:
|
||||||
return "❌ צריך לספק תוכן מעודכן."
|
return "❌ צריך לספק תוכן מעודכן."
|
||||||
save_skill(skill_name, content)
|
save_skill(skill_name, content)
|
||||||
|
await _sync_skill_to_crm(skill_name, content)
|
||||||
return f'✅ Skill "{skill_name}" עודכן בהצלחה.'
|
return f'✅ Skill "{skill_name}" עודכן בהצלחה.'
|
||||||
else:
|
else:
|
||||||
return f'❌ פעולה לא מוכרת: {action}. השתמש ב-"create" או "update".'
|
return f'❌ פעולה לא מוכרת: {action}. השתמש ב-"create" או "update".'
|
||||||
|
|||||||
Reference in New Issue
Block a user