This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shira-hermes/api/services/memory.py
T
chaim a990f527a2 fix: save_rule falls back to user profile when AssistantRule 404
The AssistantRule entity may not exist on some CRM instances. When save_rule
gets a 404, it now falls back to saving the rule in the local user profile
(update_user_profile). Also improved update_user_profile description so
Shira is more likely to use it for user preferences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:10:10 +00:00

230 lines
8.7 KiB
Python

"""Per-lawyer and per-case memory store — bounded markdown files with atomic writes."""
from __future__ import annotations
import asyncio
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger("shira.memory")
DATA_DIR = os.environ.get("SHIRA_DATA_DIR", "/opt/data")
PROFILES_DIR = f"{DATA_DIR}/profiles"
CASES_DIR = f"{DATA_DIR}/cases"
PROFILE_MAX_CHARS = 1500
CASE_MEMORY_MAX_CHARS = 2500
ENTRY_DELIMITER = "\n§\n"
# Per-path locks for thread-safe writes
_locks: dict[str, asyncio.Lock] = {}
def _get_lock(path: str) -> asyncio.Lock:
if path not in _locks:
_locks[path] = asyncio.Lock()
return _locks[path]
def _read_file(path: str) -> str:
p = Path(path)
if not p.exists():
return ""
return p.read_text(encoding="utf-8")
def _write_file_atomic(path: str, content: str) -> None:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(p.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.rename(tmp, str(p))
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _entries(content: str) -> list[str]:
if not content.strip():
return []
return [e.strip() for e in content.split(ENTRY_DELIMITER) if e.strip()]
def _join_entries(entries: list[str]) -> str:
return ENTRY_DELIMITER.join(entries)
def _truncate(content: str, max_chars: int) -> str:
"""Remove oldest entries (from the top) until content fits."""
if len(content) <= max_chars:
return content
entries = _entries(content)
while entries and len(_join_entries(entries)) > max_chars:
entries.pop(0)
return _join_entries(entries)
# ---------- Public API ----------
def load_user_profile(user_id: str) -> str:
"""Load the per-lawyer profile. Returns empty string if none exists."""
return _read_file(f"{PROFILES_DIR}/{user_id}.md")
def load_case_memory(case_id: str) -> str:
"""Load the per-case memory. Returns empty string if none exists."""
return _read_file(f"{CASES_DIR}/{case_id}/memory.md")
async def update_user_profile(user_id: str, action: str, content: str, match: str = "") -> str:
"""Add, replace, or remove an entry in the lawyer's profile."""
path = f"{PROFILES_DIR}/{user_id}.md"
async with _get_lock(path):
current = _read_file(path)
entries = _entries(current)
if action == "add":
entries.append(content)
result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS)
_write_file_atomic(path, result)
return f'✅ נוסף לפרופיל: "{content[:60]}"'
elif action == "replace":
if not match:
return "❌ צריך לספק match כדי למצוא את הרשומה להחלפה."
replaced = False
for i, e in enumerate(entries):
if match in e:
entries[i] = content
replaced = True
break
if not replaced:
return f'❌ לא נמצאה רשומה עם "{match}"'
result = _truncate(_join_entries(entries), PROFILE_MAX_CHARS)
_write_file_atomic(path, result)
return f'✅ רשומה עודכנה בפרופיל.'
elif action == "remove":
if not match:
return "❌ צריך לספק match כדי למצוא את הרשומה למחיקה."
original_len = len(entries)
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))
return "✅ רשומה הוסרה מהפרופיל."
elif action == "read":
return current if current else "הפרופיל ריק."
return f"❌ פעולה לא מוכרת: {action}"
async def update_case_memory(case_id: str, action: str, content: str, match: str = "") -> str:
"""Add, replace, or remove an entry in the case memory."""
path = f"{CASES_DIR}/{case_id}/memory.md"
async with _get_lock(path):
current = _read_file(path)
entries = _entries(current)
if action == "add":
entries.append(content)
result = _truncate(_join_entries(entries), CASE_MEMORY_MAX_CHARS)
_write_file_atomic(path, result)
return f'✅ נשמר בזיכרון התיק: "{content[:60]}"'
elif action == "replace":
if not match:
return "❌ צריך לספק match כדי למצוא את הרשומה להחלפה."
replaced = False
for i, e in enumerate(entries):
if match in e:
entries[i] = content
replaced = True
break
if not replaced:
return f'❌ לא נמצאה רשומה עם "{match}"'
result = _truncate(_join_entries(entries), CASE_MEMORY_MAX_CHARS)
_write_file_atomic(path, result)
return "✅ רשומה עודכנה בזיכרון התיק."
elif action == "remove":
if not match:
return "❌ צריך לספק match כדי למצוא את הרשומה למחיקה."
original_len = len(entries)
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))
return "✅ רשומה הוסרה מזיכרון התיק."
elif action == "read":
return current if current else "זיכרון התיק ריק."
return f"❌ פעולה לא מוכרת: {action}"
def register_memory_tools(tools: dict, case_id: str | None, user_id: str | None) -> None:
"""Register memory tools into the tools dict."""
async def _update_user_profile(action: str, content: str = "", match: str = "") -> str:
if not user_id:
return "❌ לא ניתן לזהות את המשתמש."
return await update_user_profile(user_id, action, content, match)
async def _update_case_memory(action: str, content: str = "", match: str = "") -> str:
if not case_id:
return "❌ צריך להיות בתוך תיק כדי לעדכן את הזיכרון."
return await update_case_memory(case_id, action, content, match)
tools["update_user_profile"] = {
"description": (
"Manage the lawyer's personal profile — preferences, rules, communication style, common case types. "
"This persists across conversations. Use proactively when you learn about the user's preferences. "
"Also use this when the user says 'from now on always...', 'I prefer...', 'remember that I...'"
"save their preference here so you remember it in future conversations."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "replace", "remove", "read"],
"description": "add=append entry, replace=find by match and replace, remove=find by match and delete, read=show current profile",
},
"content": {"type": "string", "description": "The content to add or replace with"},
"match": {"type": "string", "description": "Substring to match when replacing or removing"},
},
"required": ["action"],
},
"handler": _update_user_profile,
}
tools["update_case_memory"] = {
"description": (
"Manage the case's persistent memory — key facts, strategy, decisions, timeline. "
"This persists across conversations about this case. Use alongside save_memory (which writes to CRM notes)."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "replace", "remove", "read"],
"description": "add=append entry, replace=find by match and replace, remove=find by match and delete, read=show current memory",
},
"content": {"type": "string", "description": "The content to add or replace with"},
"match": {"type": "string", "description": "Substring to match when replacing or removing"},
},
"required": ["action"],
},
"handler": _update_case_memory,
}