72f08e5d15
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>
570 lines
23 KiB
Python
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,
|
|
}
|