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
chaim 72f08e5d15 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>
2026-05-27 08:34:14 +00:00

570 lines
23 KiB
Python

"""Core CRM tools: tasks, notes, meetings, calls, status, hearings, queries, deletions, rules."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mcp_server.espocrm_client import EspoCrmClient
from mcp_server.espocrm_client import EspoCrmApiError
from mcp_server.tools._helpers import normalize_date, ok, fail
VALID_STATUSES = {
"New": "חדש",
"Assigned": "בטיפול",
"Pending": "ממתין",
"PendingHearing": "ממתין לדיון",
"PendingDecision": "ממתין להחלטה",
"PendingResponse": "ממתין לתשובה",
"Negotiation": "משא ומתן",
"Suspended": "מושהה",
"ClosedSettlement": "סגור - הסכם",
"ClosedJudgment": "סגור - פס״ד",
"Closed": "סגור",
"Rejected": "נדחה",
}
STATUS_KEYS = list(VALID_STATUSES.keys())
def register_crm_tools(
tools: dict,
crm: "EspoCrmClient",
case_id: str | None,
user_id: str | None,
context: dict,
):
"""Register all core CRM tools into the tools dict."""
async def create_task(name: str, dateEnd: str = "", priority: str = "Normal", description: str = "") -> str:
try:
body = {
"name": name,
"status": "Not Started",
"priority": priority,
"dateEnd": normalize_date(dateEnd) if dateEnd else None,
"description": description or None,
"assignedUserId": user_id,
}
if case_id:
body["parentType"] = "Case"
body["parentId"] = case_id
result = await crm.post("Task", body)
return ok(f'משימה "{name}" נוצרה בהצלחה (ID: {result["id"]})')
except Exception as e:
return fail(f"שגיאה ביצירת משימה: {e}")
async def add_note(post: str) -> str:
try:
body = {"type": "Post", "post": post}
if case_id:
body["parentType"] = "Case"
body["parentId"] = case_id
result = await crm.post("Note", body)
return ok(f'הערה נוספה בהצלחה (ID: {result["id"]})')
except Exception as e:
return fail(f"שגיאה בהוספת הערה: {e}")
async def change_status(status: str) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי לשנות סטטוס")
try:
await crm.put(f"Case/{case_id}", {"status": status})
label = VALID_STATUSES.get(status, status)
return ok(f'סטטוס שונה ל-"{label}"')
except Exception as e:
return fail(f"שגיאה בשינוי סטטוס: {e}")
async def create_meeting(name: str, dateStart: str, dateEnd: str = "", description: str = "") -> str:
try:
start = normalize_date(dateStart)
body = {
"name": name,
"status": "Planned",
"dateStart": start,
"dateEnd": normalize_date(dateEnd) if dateEnd else start,
"description": description or None,
"assignedUserId": user_id,
}
if case_id:
body["parentType"] = "Case"
body["parentId"] = case_id
result = await crm.post("Meeting", body)
return ok(f'פגישה "{name}" נקבעה בהצלחה (ID: {result["id"]})')
except Exception as e:
return fail(f"שגיאה ביצירת פגישה: {e}")
async def create_call(
name: str,
dateStart: str = "",
dateEnd: str = "",
description: str = "",
direction: str = "Outbound",
status: str = "Held",
) -> str:
try:
start = normalize_date(dateStart) if dateStart else None
body = {
"name": name,
"status": status,
"direction": direction,
"dateStart": start,
"dateEnd": normalize_date(dateEnd) if dateEnd else start,
"description": description or None,
"assignedUserId": user_id,
}
if case_id:
body["parentType"] = "Case"
body["parentId"] = case_id
result = await crm.post("Call", body)
return ok(f'שיחה "{name}" תועדה בהצלחה (ID: {result["id"]})')
except Exception as e:
return fail(f"שגיאה בתיעוד שיחה: {e}")
async def schedule_hearing(dateTime: str, court: str = "", description: str = "") -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי לתזמן דיון")
try:
dt = normalize_date(dateTime)
await crm.put(f"Case/{case_id}", {"cNextHearing": dt})
meeting_name = description or "דיון בבית המשפט"
if court:
meeting_name += f" - {court}"
body = {
"name": meeting_name,
"status": "Planned",
"dateStart": dt,
"dateEnd": dt,
"parentType": "Case",
"parentId": case_id,
"assignedUserId": user_id,
}
result = await crm.post("Meeting", body)
return ok(f"דיון נקבע ל-{dt} (ID: {result['id']})")
except Exception as e:
return fail(f"שגיאה בתזמון דיון: {e}")
async def query_info(query_type: str) -> str:
import json
case_info = context.get("case", {})
if query_type == "status":
return ok(json.dumps({
"status": VALID_STATUSES.get(case_info.get("status"), case_info.get("status")),
"court": case_info.get("cCourt"),
"judge": case_info.get("cJudge"),
"nextHearing": case_info.get("cNextHearing"),
}))
if query_type == "open_tasks":
return ok(json.dumps({"tasks": context.get("openTasks", [])}))
if query_type == "contacts":
return ok(json.dumps({"contacts": context.get("contacts", [])}))
if query_type in ("summary", "alerts", "workload", "hearings"):
return ok(json.dumps({"summary": context.get("summary", {})}))
return ok(json.dumps({"case": case_info}))
async def delete_entity(entity_type: str, entityId: str) -> str:
type_labels = {"Task": "משימה", "Meeting": "פגישה", "Call": "שיחה", "Note": "הערה"}
label = type_labels.get(entity_type, entity_type)
try:
if entity_type != "Note":
entity = await crm.get(f"{entity_type}/{entityId}")
name = entity.get("name", entityId)
else:
name = entityId
await crm.delete(f"{entity_type}/{entityId}")
return ok(f'{label} "{name}" נמחקה בהצלחה')
except Exception as e:
return fail(f"שגיאה במחיקת {label}: {e}")
async def save_memory(
category: str,
content: str,
importance: str = "normal",
name: str | None = None,
) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי לשמור בזיכרון")
try:
result = await crm.post(
"SmartAssistant/action/executeTool",
{
"tool": "save_memory",
"caseId": case_id,
"params": {
"category": category,
"content": content,
"importance": importance,
"name": name or content[:100],
},
},
)
if not result.get("success", True):
return fail(
f"שגיאה בשמירת זיכרון: {result.get('message', 'unknown error')}"
)
return ok(f'נשמר בזיכרון: "{content[:60]}"')
except Exception as e:
return fail(f"שגיאה בשמירת זיכרון: {e}")
# Allowlist of Case fields that Shira may update via update_case_fields.
_UPDATABLE_CASE_FIELDS = {
"name", "description", "caseType", "practiceArea",
"court", "judge", "opposingParty",
"cNextHearing", "nextHearingAt", "status", "priority",
"assignedUserId",
}
# Allowlist of entity types Shira may search across.
_SEARCHABLE_ENTITIES = {"Case", "Contact", "Account", "Document", "Task"}
async def update_case_fields(updates_json: str) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי לעדכן שדות")
try:
updates = updates_json if isinstance(updates_json, dict) else __import__("json").loads(updates_json)
except Exception:
return fail("updates חייב להיות JSON object")
if not isinstance(updates, dict) or not updates:
return fail("חסר dict של עדכונים")
rejected = [k for k in updates if k not in _UPDATABLE_CASE_FIELDS]
if rejected:
return fail(f"שדות לא מותרים לעדכון: {', '.join(rejected)}. שדות מותרים: {', '.join(sorted(_UPDATABLE_CASE_FIELDS))}")
safe_updates = {k: v for k, v in updates.items() if k in _UPDATABLE_CASE_FIELDS}
if not safe_updates:
return fail("אין שדות תקפים לעדכון")
try:
await crm.put(f"Case/{case_id}", safe_updates)
applied = ", ".join(f"{k}={v!r}" for k, v in safe_updates.items())
return ok(f"✅ התיק עודכן: {applied}")
except Exception as e:
return fail(f"שגיאה בעדכון תיק: {e}")
async def search_entities(
entity_type: str,
filters_json: str = "",
limit: int = 20,
) -> str:
if entity_type not in _SEARCHABLE_ENTITIES:
return fail(f"סוג ישות לא מותר: {entity_type}. מותרים: {', '.join(sorted(_SEARCHABLE_ENTITIES))}")
import json as _json
filters: dict = {}
if filters_json:
try:
filters = filters_json if isinstance(filters_json, dict) else _json.loads(filters_json)
except Exception:
return fail("filters חייב להיות JSON object")
if not isinstance(filters, dict):
return fail("filters חייב להיות JSON object")
try:
limit = max(1, min(int(limit), 50))
except Exception:
limit = 20
from urllib.parse import urlencode
query_pairs: list[tuple[str, str]] = [
("maxSize", str(limit)),
("offset", "0"),
]
idx = 0
for field, value in filters.items():
if value is None or value == "":
continue
s_value = str(value)
if "*" in s_value:
comparison = "like"
normalized = s_value.replace("*", "%")
else:
comparison = "equals"
normalized = s_value
query_pairs.append((f"where[{idx}][type]", comparison))
query_pairs.append((f"where[{idx}][attribute]", field))
query_pairs.append((f"where[{idx}][value]", normalized))
idx += 1
endpoint = f"{entity_type}?{urlencode(query_pairs)}"
try:
result = await crm.get(endpoint)
rows = result.get("list", []) if isinstance(result, dict) else []
total = result.get("total", len(rows)) if isinstance(result, dict) else len(rows)
if not rows:
return ok(f'לא נמצאו רשומות {entity_type} התואמות.')
lines = [f"נמצאו {len(rows)} מתוך {total} רשומות {entity_type}:"]
for r in rows[:limit]:
bits = [f'id={r.get("id", "?")}']
for key in ("name", "status", "phoneNumber", "emailAddress", "assignedUserName"):
if r.get(key):
bits.append(f"{key}={r[key]}")
lines.append("" + ", ".join(bits))
return ok("\n".join(lines))
except Exception as e:
return fail(f"שגיאה בחיפוש: {e}")
async def draft_email(
to: str,
subject: str,
body: str,
cc: str = "",
) -> str:
if not to or not subject or not body:
return fail("חסר to / subject / body")
try:
payload = {
"status": "Draft",
"to": to.strip(),
"subject": subject.strip(),
"body": body,
"isHtml": False,
"from": None,
}
if cc and cc.strip():
payload["cc"] = cc.strip()
if case_id:
payload["parentType"] = "Case"
payload["parentId"] = case_id
if user_id:
payload["assignedUserId"] = user_id
result = await crm.post("Email", payload)
return ok(
f'✉️ טיוטת מייל נוצרה (לא נשלחה!): "{subject.strip()}"{to.strip()}'
f'\nID: {result.get("id", "?")} — ניתן לערוך ולשלוח דרך EspoCRM.'
)
except Exception as e:
return fail(f"שגיאה ביצירת טיוטה: {e}")
async def save_rule(name: str, rule: str, scope: str = "global") -> str:
# Try CRM first, fall back to local user profile if entity doesn't exist
try:
result = await crm.post("AssistantRule", {
"name": name,
"rule": rule,
"scope": scope,
"isActive": True,
})
return ok(f'כלל חדש נשמר: "{name}" (ID: {result["id"]})')
except EspoCrmApiError as e:
if e.status_code == 404 and user_id:
# AssistantRule entity not available — save to user profile instead
from api.services.memory import update_user_profile
entry = f"כלל [{scope}] {name}: {rule}"
return await update_user_profile(user_id, "add", entry)
return fail(f"שגיאה בשמירת כלל: {e}")
except Exception as e:
return fail(f"שגיאה בשמירת כלל: {e}")
# Register all tools
tools["create_task"] = {
"description": "Create a task in the CRM. Links to case if case context is available.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Task name"},
"dateEnd": {"type": "string", "description": "Due date — any format (YYYY-MM-DD, DD/MM/YYYY)"},
"priority": {"type": "string", "enum": ["Urgent", "High", "Normal", "Low"], "description": "Priority level"},
"description": {"type": "string", "description": "Task description"},
},
"required": ["name"],
},
"handler": create_task,
}
tools["add_note"] = {
"description": "Add a note/comment to the case stream or as standalone.",
"parameters": {
"type": "object",
"properties": {
"post": {"type": "string", "description": "Note content"},
},
"required": ["post"],
},
"handler": add_note,
}
tools["change_status"] = {
"description": "Change case status. Only works when viewing a specific case.",
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": STATUS_KEYS, "description": "New status (English key)"},
},
"required": ["status"],
},
"handler": change_status,
}
tools["create_meeting"] = {
"description": "Schedule a meeting. Links to case if available.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Meeting name"},
"dateStart": {"type": "string", "description": "Start date — any format (YYYY-MM-DD HH:mm, DD/MM/YYYY)"},
"dateEnd": {"type": "string", "description": "End date"},
"description": {"type": "string", "description": "Meeting description"},
},
"required": ["name", "dateStart"],
},
"handler": create_meeting,
}
tools["create_call"] = {
"description": "Record a phone call. Use when user reports they spoke/talked/called someone (שוחחתי, דיברתי, התקשרתי). Do NOT use create_meeting for phone calls.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": 'Call subject (e.g. "שיחה עם לקוחה אדל")'},
"dateStart": {"type": "string", "description": "Call date — any format"},
"dateEnd": {"type": "string", "description": "End time"},
"description": {"type": "string", "description": "Call summary / notes"},
"direction": {"type": "string", "enum": ["Outbound", "Inbound"], "description": "Call direction"},
"status": {"type": "string", "enum": ["Planned", "Held", "Not Held"], "description": "Call status"},
},
"required": ["name"],
},
"handler": create_call,
}
tools["schedule_hearing"] = {
"description": "Schedule a court hearing. Updates case next hearing date and creates a meeting.",
"parameters": {
"type": "object",
"properties": {
"dateTime": {"type": "string", "description": "Hearing date — any format (YYYY-MM-DD HH:mm, DD/MM/YYYY)"},
"court": {"type": "string", "description": "Court name"},
"description": {"type": "string", "description": "Hearing description"},
},
"required": ["dateTime"],
},
"handler": schedule_hearing,
}
tools["query_info"] = {
"description": "Query case or office information from context.",
"parameters": {
"type": "object",
"properties": {
"query_type": {
"type": "string",
"enum": ["status", "next_hearing", "open_tasks", "contacts", "general", "summary", "alerts", "workload", "hearings"],
},
},
"required": ["query_type"],
},
"handler": query_info,
}
# Delete tools
for entity_type, tool_name, desc in [
("Task", "delete_task", "Delete a task by ID."),
("Meeting", "delete_meeting", "Delete a meeting by ID."),
("Call", "delete_call", "Delete a call record by ID."),
("Note", "delete_note", "Delete a note/comment by ID."),
]:
et = entity_type
async def _delete(entityId: str, _et=et) -> str:
return await delete_entity(_et, entityId)
tools[tool_name] = {
"description": desc,
"parameters": {
"type": "object",
"properties": {"entityId": {"type": "string", "description": f"The {entity_type.lower()} ID to delete"}},
"required": ["entityId"],
},
"handler": _delete,
}
tools["save_memory"] = {
"description": "Save an important fact, decision, or strategy to the case memory. Use PROACTIVELY when user shares key info.",
"parameters": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["key_facts", "strategy", "decisions", "contacts_notes", "timeline", "documents_notes", "billing_notes"]},
"content": {"type": "string", "description": "The information to remember"},
"importance": {"type": "string", "enum": ["low", "normal", "high", "critical"]},
},
"required": ["category", "content"],
},
"handler": save_memory,
}
tools["save_rule"] = {
"description": 'Save a behavioral rule that Shira should follow in all future conversations. Use when user says "from now on always...", "whenever I say X do Y", etc.',
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Short rule name"},
"rule": {"type": "string", "description": "The full rule text"},
"scope": {"type": "string", "enum": ["global", "case", "office"], "description": "Rule scope"},
},
"required": ["name", "rule"],
},
"handler": save_rule,
}
tools["update_case_fields"] = {
"description": (
"Update one or more allowed fields on the current case. Pass updates as a JSON "
"object string, e.g. '{\"judge\": \"כב' השופט יוסי כהן\", \"court\": \"שלום ת\\\"א\"}'. "
"Only these fields are accepted: " + ", ".join(sorted(_UPDATABLE_CASE_FIELDS)) + ". "
"Use change_status for status updates that need validated mapping; this tool is "
"for arbitrary case metadata."
),
"parameters": {
"type": "object",
"properties": {
"updates_json": {
"type": "string",
"description": "JSON object of field→value updates. Example: '{\"judge\":\"...\",\"court\":\"...\"}'.",
},
},
"required": ["updates_json"],
},
"handler": update_case_fields,
}
tools["search_entities"] = {
"description": (
"Search any of: Case, Contact, Account, Document, Task by field filters. "
"filters_json is a JSON object mapping field name to value. Values containing "
"'*' use a LIKE match (e.g. {\"name\": \"כהן*\"}); otherwise exact match. "
"Returns up to `limit` records with their key fields."
),
"parameters": {
"type": "object",
"properties": {
"entity_type": {"type": "string", "enum": sorted(_SEARCHABLE_ENTITIES), "description": "Entity type to search."},
"filters_json": {"type": "string", "description": "JSON object of field→value filters. Empty for no filters (returns first N)."},
"limit": {"type": "integer", "description": "Max records to return (default 20, max 50)."},
},
"required": ["entity_type"],
},
"handler": search_entities,
}
tools["draft_email"] = {
"description": (
"Create a Draft Email in EspoCRM linked to the current case (if in case mode). "
"Does NOT send the email — the lawyer reviews and sends from CRM. Use when the "
"user asks to 'send', 'write', or 'draft' an email."
),
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient email address."},
"subject": {"type": "string", "description": "Email subject."},
"body": {"type": "string", "description": "Email body (plain text)."},
"cc": {"type": "string", "description": "Optional CC address."},
},
"required": ["to", "subject", "body"],
},
"handler": draft_email,
}