diff --git a/api/services/memory.py b/api/services/memory.py index 5b31d6d..9d110d5 100644 --- a/api/services/memory.py +++ b/api/services/memory.py @@ -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 @@ -9,6 +17,8 @@ import re import tempfile from pathlib import Path +import httpx + logger = logging.getLogger("shira.memory") 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) +# ---------- 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 ---------- 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) result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS) _write_file_atomic(path, result) + await _sync_user_profile_to_crm(user_id, result) return f'✅ נוסף לפרופיל: "{content[:60]}"' elif action == "replace": @@ -117,6 +168,7 @@ async def update_user_profile(user_id: str, action: str, content: str, match: st return f'❌ לא נמצאה רשומה עם "{match}"' result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS) _write_file_atomic(path, result) + await _sync_user_profile_to_crm(user_id, result) return f'✅ רשומה עודכנה בפרופיל.' 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] if len(entries) == original_len: 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 "✅ רשומה הוסרה מהפרופיל." elif action == "read": diff --git a/api/services/skills.py b/api/services/skills.py index bc6931a..ae417f3 100644 --- a/api/services/skills.py +++ b/api/services/skills.py @@ -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".'