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:
@@ -36,12 +36,24 @@
|
|||||||
"priority": "high",
|
"priority": "high",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-04-25T20:39:11.905Z"
|
"updatedAt": "2026-04-25T20:39:11.905Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"title": "Add 9 tools: create_document, write/create/rename/move folder ops, mark_document_for_deletion, update_case_fields, search_entities, draft_email",
|
||||||
|
"description": "Expose new EspoCRM SmartAssistant actions through Python tools and update prompt_builder with new behavioral rules.",
|
||||||
|
"details": "Tools added in mcp_server/tools/document_tools.py: create_document, write_document_to_folder, create_subfolder, rename_item, move_item, mark_document_for_deletion. Tools added in mcp_server/tools/crm_tools.py: update_case_fields, search_entities, draft_email. Prompt updates in api/services/prompt_builder.py: FREE DOCUMENT CREATION rule, FOLDER MANAGEMENT rule, FILE DELETION rule (always mark_document_for_deletion). Pairs with SmartAssistant extension release that adds FreeDocumentGenerator + CaseFolderManager + 6 controller actions.",
|
||||||
|
"testStrategy": "End-to-end test in EspoCRM dev: ask Shira to draft a letter, create/rename/move folders, mark for deletion, update case fields, search entities, draft email.",
|
||||||
|
"priority": "medium",
|
||||||
|
"status": "in-progress",
|
||||||
|
"dependencies": [],
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-05-14T12:39:55.190Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-04-25T20:39:11.905Z",
|
"lastModified": "2026-05-14T12:39:55.190Z",
|
||||||
"taskCount": 3,
|
"taskCount": 4,
|
||||||
"completedCount": 1,
|
"completedCount": 1,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ TOOL_RULES = (
|
|||||||
"- When user asks to summarize multiple documents or the entire case folder — use read_multiple_documents with all relevant file paths.\n"
|
"- When user asks to summarize multiple documents or the entire case folder — use read_multiple_documents with all relevant file paths.\n"
|
||||||
"- When user asks to rename or organize files — use batch_rename_documents with an array of rename operations.\n"
|
"- When user asks to rename or organize files — use batch_rename_documents with an array of rename operations.\n"
|
||||||
"- TEMPLATE GENERATION: When user asks to generate/create a document from a template — use generate_from_template. The templateId comes from availableTemplates in your context.\n"
|
"- TEMPLATE GENERATION: When user asks to generate/create a document from a template — use generate_from_template. The templateId comes from availableTemplates in your context.\n"
|
||||||
|
"- FREE DOCUMENT CREATION: When the user asks for a document AND availableTemplates contains no template that matches the requested document type, use create_document(title, body, recipient?). DO NOT say 'I can't create documents' — that tool exists. Compose the full text yourself in Hebrew, organized into paragraphs. Use '#' for the section heading, '- ' for bullets, '**bold**' for emphasis. For letters to authorities (ביטוח לאומי, בתי משפט, רשויות) set documentType='letter' and put the authority name in recipient. The document will be created as a Hebrew RTL DOCX, attached to the case, and copied to the case network folder. Only fall back to a plain note (add_note) if the user explicitly asks NOT to create a real document.\n"
|
||||||
|
"- FOLDER MANAGEMENT: You have full read/write access ONLY to the current case's network folder (paths are sandboxed). Use create_subfolder to create a new folder, rename_item to rename a file or folder, move_item to move a file between subfolders, write_document_to_folder for raw uploads. ALL paths are relative to the case root — never absolute, never with '..'.\n"
|
||||||
|
"- FILE DELETION: You are NOT permitted to physically delete files. When the user asks you to delete a file, ALWAYS call mark_document_for_deletion. This moves the file to a 'מסמכים למחיקה' subfolder with a 'למחיקה - ' prefix; the lawyer reviews and deletes manually. Explain this softly to the user: 'סימנתי את הקובץ למחיקה, הוא בתיקיית מסמכים למחיקה ומחכה לאישור שלך למחיקה הפיזית.' Never claim a file was deleted when in fact it was only marked.\n"
|
||||||
"- BILLING (חיוב/חשבונית): When user asks about billable activities, charges, or invoices — start with list_unbilled_activities for a case, or list_charges for charge status views. Show the user a summary BEFORE creating an invoice.\n"
|
"- BILLING (חיוב/חשבונית): When user asks about billable activities, charges, or invoices — start with list_unbilled_activities for a case, or list_charges for charge status views. Show the user a summary BEFORE creating an invoice.\n"
|
||||||
"- BILLING — invoice creation: create_invoice_from_activities only creates a Draft. It does NOT auto-send to Green Invoice. Always show the user the draft amount and ask if they want to send it before calling send_invoice_to_green_invoice.\n"
|
"- BILLING — invoice creation: create_invoice_from_activities only creates a Draft. It does NOT auto-send to Green Invoice. Always show the user the draft amount and ask if they want to send it before calling send_invoice_to_green_invoice.\n"
|
||||||
"- BILLING — irreversible: send_invoice_to_green_invoice issues an official tax document and CANNOT be undone. Require an explicit instruction like 'תשלחי לחשבונית ירוקה', not a vague 'כן'. If unsure, ask again.\n"
|
"- BILLING — irreversible: send_invoice_to_green_invoice issues an official tax document and CANNOT be undone. Require an explicit instruction like 'תשלחי לחשבונית ירוקה', not a vague 'כן'. If unsure, ask again.\n"
|
||||||
|
|||||||
@@ -191,6 +191,137 @@ def register_crm_tools(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return fail(f"שגיאה בשמירת זיכרון: {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:
|
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 CRM first, fall back to local user profile if entity doesn't exist
|
||||||
try:
|
try:
|
||||||
@@ -361,3 +492,62 @@ def register_crm_tools(
|
|||||||
},
|
},
|
||||||
"handler": save_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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -183,6 +184,127 @@ def register_document_tools(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return fail(f"שגיאה ביצירת מסמך מתבנית: {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
|
# Register
|
||||||
tools["list_documents"] = {
|
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.",
|
"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,
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user