feat: 9 new tools — document creation, folder management, CRM updates

Adds the Python side of the SmartAssistant 2.9 release. Each tool maps to
either a new EspoCRM action or a generic CRM endpoint.

Document creation:
- create_document: free-form RTL DOCX with markdown body; calls
  SmartAssistant/action/createDocument.

Folder management (all sandboxed to the current case's root):
- write_document_to_folder: raw upload to a path inside the case folder.
- create_subfolder: mkdir with recursive parent creation.
- rename_item / move_item: rename/move files and folders.
- mark_document_for_deletion: soft-delete — moves the file to
  "מסמכים למחיקה/" with a "למחיקה - " prefix. Shira is explicitly told
  she has NO physical-delete permission.

CRM gaps:
- update_case_fields: allow-listed PUT on Case (judge, court, caseType,
  practiceArea, opposingParty, status, priority, etc.).
- search_entities: generic GET with EspoCRM where[] params across
  Case/Contact/Account/Document/Task. Wildcard "*" maps to LIKE.
- draft_email: POST Email with status="Draft", linked to the current case.

prompt_builder additions:
- FREE DOCUMENT CREATION: don't say "I can't" — call create_document.
- FOLDER MANAGEMENT: full read/write inside the case folder only.
- FILE DELETION: always mark_document_for_deletion; never claim a physical
  delete.

Pairs with SmartAssistant extension release that adds FreeDocumentGenerator,
CaseFolderManager, and 6 controller actions.

Refs Task Master #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:03:59 +00:00
parent c697743557
commit 90c60f45e6
4 changed files with 439 additions and 3 deletions
+190
View File
@@ -191,6 +191,137 @@ def register_crm_tools(
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:
@@ -361,3 +492,62 @@ def register_crm_tools(
},
"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,
}
+232 -1
View File
@@ -1,7 +1,8 @@
"""Document tools: list, read, read_multiple, batch_rename, generate_from_template."""
"""Document tools: list, read, read_multiple, batch_rename, generate_from_template, create_document, folder management."""
from __future__ import annotations
import base64
import json
from typing import TYPE_CHECKING
@@ -183,6 +184,127 @@ def register_document_tools(
except Exception as e:
return fail(f"שגיאה ביצירת מסמך מתבנית: {e}")
async def write_document_to_folder(
relPath: str,
contentBase64: str,
) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי לכתוב לתיקייה")
if not relPath or not relPath.strip():
return fail("חסר נתיב יחסי לתיק (relPath)")
try:
result = await crm.post("SmartAssistant/action/writeToFolder", {
"caseId": case_id,
"relPath": relPath.strip(),
"contentBase64": contentBase64,
})
path = result.get("path")
size = result.get("size", 0)
return ok(f"✅ קובץ נכתב: {path} ({size} bytes).")
except Exception as e:
return fail(f"שגיאה בכתיבת קובץ: {e}")
async def create_subfolder(relPath: str) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי ליצור תיקייה")
if not relPath or not relPath.strip():
return fail("חסר נתיב לתיקייה החדשה")
try:
result = await crm.post("SmartAssistant/action/createFolder", {
"caseId": case_id,
"relPath": relPath.strip(),
})
path = result.get("path")
existed = result.get("alreadyExisted", False)
verb = "כבר קיימת" if existed else "נוצרה"
return ok(f"✅ תיקייה {verb}: {path}")
except Exception as e:
return fail(f"שגיאה ביצירת תיקייה: {e}")
async def rename_item(currentRelPath: str, newName: str) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי לשנות שם")
if not currentRelPath or not newName:
return fail("חסר currentRelPath או newName")
try:
result = await crm.post("SmartAssistant/action/renameItem", {
"caseId": case_id,
"currentRelPath": currentRelPath.strip(),
"newName": newName.strip(),
})
return ok(f'✅ שם שונה: "{result.get("oldPath")}""{result.get("newPath")}"')
except Exception as e:
return fail(f"שגיאה בשינוי שם: {e}")
async def move_item(sourceRelPath: str, targetRelPath: str) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי להעביר")
if not sourceRelPath or not targetRelPath:
return fail("חסר sourceRelPath או targetRelPath")
try:
result = await crm.post("SmartAssistant/action/moveItem", {
"caseId": case_id,
"sourceRelPath": sourceRelPath.strip(),
"targetRelPath": targetRelPath.strip(),
})
return ok(f'✅ הועבר: "{result.get("oldPath")}""{result.get("newPath")}"')
except Exception as e:
return fail(f"שגיאה בהעברה: {e}")
async def mark_document_for_deletion(fileRelPath: str) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי לסמן למחיקה")
if not fileRelPath:
return fail("חסר fileRelPath")
try:
result = await crm.post("SmartAssistant/action/markForDeletion", {
"caseId": case_id,
"fileRelPath": fileRelPath.strip(),
})
original = result.get("originalPath")
new = result.get("newPath")
return ok(
f'📌 המסמך סומן למחיקה (לא נמחק פיזית).\n'
f'הועבר ל: {new}\n'
f'(במקור: {original}). העו"ד יוכל למחוק ידנית מתיקיית "מסמכים למחיקה".'
)
except Exception as e:
return fail(f"שגיאה בסימון למחיקה: {e}")
async def create_document(
title: str,
body: str,
documentType: str = "letter",
recipient: str = "",
) -> str:
if not case_id:
return fail("צריך להיות בתוך תיק כדי ליצור מסמך")
if not title or not title.strip():
return fail("חסר שם למסמך (title)")
if not body or not body.strip():
return fail("חסר תוכן למסמך (body)")
try:
result = await crm.post("SmartAssistant/action/createDocument", {
"caseId": case_id,
"title": title.strip(),
"body": body,
"documentType": documentType or "letter",
"recipient": recipient.strip() if recipient else None,
})
msg = result.get("message", "✅ מסמך נוצר בהצלחה")
doc_id = result.get("documentId")
net_path = result.get("networkPath")
extra = []
if doc_id:
extra.append(f"documentId={doc_id}")
if net_path:
extra.append(f"path={net_path}")
if extra:
msg += "\n" + " | ".join(extra)
return ok(msg)
except Exception as e:
return fail(f"שגיאה ביצירת מסמך: {e}")
# Register
tools["list_documents"] = {
"description": "List documents in the case folder. Shows folder structure with file names and paths. When totalFiles is 0, returns a diagnostic explaining why (missing path, folder doesn't exist, etc.). Pass refresh=true to re-fetch from EspoCRM after the folder path changes.",
@@ -305,3 +427,112 @@ def register_document_tools(
},
"handler": generate_from_template,
}
tools["write_document_to_folder"] = {
"description": (
"Write a new file to a path inside the current case's network folder. "
"Operations are sandboxed to the case folder — relPath is relative to the "
"case root (e.g. 'חוזים/2026/agreement.pdf'). The content must be base64-encoded. "
"Use create_document for DOCX letters; this is for raw file uploads."
),
"parameters": {
"type": "object",
"properties": {
"relPath": {"type": "string", "description": "Path inside the case folder, including filename and extension. Subfolders are created if missing."},
"contentBase64": {"type": "string", "description": "Base64-encoded file content."},
},
"required": ["relPath", "contentBase64"],
},
"handler": write_document_to_folder,
}
tools["create_subfolder"] = {
"description": (
"Create a new subfolder inside the current case's network folder. "
"relPath is relative to the case root. Multi-level paths are supported "
"(e.g. 'חוזים/2026') and missing parents are created."
),
"parameters": {
"type": "object",
"properties": {
"relPath": {"type": "string", "description": "Folder path inside the case folder."},
},
"required": ["relPath"],
},
"handler": create_subfolder,
}
tools["rename_item"] = {
"description": (
"Rename a file or folder inside the current case's network folder. "
"Does NOT move it to another folder — use move_item for that. newName "
"must be a plain name without slashes."
),
"parameters": {
"type": "object",
"properties": {
"currentRelPath": {"type": "string", "description": "Current path of the file/folder, relative to case root."},
"newName": {"type": "string", "description": "New name (without path, without slashes)."},
},
"required": ["currentRelPath", "newName"],
},
"handler": rename_item,
}
tools["move_item"] = {
"description": (
"Move a file or folder to a different location inside the current case's "
"network folder. Both source and target must stay inside the case root. "
"Missing intermediate folders on the target side are created."
),
"parameters": {
"type": "object",
"properties": {
"sourceRelPath": {"type": "string", "description": "Current path of the file/folder."},
"targetRelPath": {"type": "string", "description": "Destination path (including the final name)."},
},
"required": ["sourceRelPath", "targetRelPath"],
},
"handler": move_item,
}
tools["mark_document_for_deletion"] = {
"description": (
"Soft-delete a document: move it to a 'מסמכים למחיקה' subfolder inside the case "
"with a 'למחיקה - ' filename prefix. Does NOT physically delete. You DO NOT have "
"permission to delete files; always call this tool when the user asks you to "
"delete a document, and explain to the user that you marked it for the lawyer to "
"delete manually."
),
"parameters": {
"type": "object",
"properties": {
"fileRelPath": {"type": "string", "description": "Path to the file inside the case folder."},
},
"required": ["fileRelPath"],
},
"handler": mark_document_for_deletion,
}
tools["create_document"] = {
"description": (
"Create a new free-form DOCX document attached to the current case. "
"Use when no suitable template exists in availableTemplates. The body accepts "
"lightweight markdown: '#'/'##'/'###' for headings, '- ' for bullets, "
"'**bold**' and '*italic*' inline. The document is created as a Hebrew RTL DOCX "
"with a David font, today's date, optional recipient line, the title centered, "
"then the body. It is saved both as an EspoCRM Document attached to the case "
"and as a file in the case's network folder."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Document title shown both in the CRM record and as the centered heading."},
"body": {"type": "string", "description": "Document body. Lightweight markdown is supported. Plain paragraphs are fine."},
"documentType": {"type": "string", "enum": ["letter", "motion", "request", "general"], "description": "Document category (default 'letter')."},
"recipient": {"type": "string", "description": "Optional recipient line shown under the date (e.g. 'המוסד לביטוח לאומי')."},
},
"required": ["title", "body"],
},
"handler": create_document,
}