From 90c60f45e69a37bb413cf0daca2d526562389128 Mon Sep 17 00:00:00 2001 From: Chaim Date: Thu, 14 May 2026 13:03:59 +0000 Subject: [PATCH] =?UTF-8?q?feat:=209=20new=20tools=20=E2=80=94=20document?= =?UTF-8?q?=20creation,=20folder=20management,=20CRM=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .taskmaster/tasks/tasks.json | 16 +- api/services/prompt_builder.py | 3 + mcp_server/tools/crm_tools.py | 190 +++++++++++++++++++++++ mcp_server/tools/document_tools.py | 233 ++++++++++++++++++++++++++++- 4 files changed, 439 insertions(+), 3 deletions(-) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 47260b3..a418d32 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -36,12 +36,24 @@ "priority": "high", "subtasks": [], "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": { "version": "1.0.0", - "lastModified": "2026-04-25T20:39:11.905Z", - "taskCount": 3, + "lastModified": "2026-05-14T12:39:55.190Z", + "taskCount": 4, "completedCount": 1, "tags": [ "master" diff --git a/api/services/prompt_builder.py b/api/services/prompt_builder.py index 85b0d8d..4b4b801 100644 --- a/api/services/prompt_builder.py +++ b/api/services/prompt_builder.py @@ -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 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" + "- 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 — 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" diff --git a/mcp_server/tools/crm_tools.py b/mcp_server/tools/crm_tools.py index 04998bf..ea1870d 100644 --- a/mcp_server/tools/crm_tools.py +++ b/mcp_server/tools/crm_tools.py @@ -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, + } diff --git a/mcp_server/tools/document_tools.py b/mcp_server/tools/document_tools.py index eb29ee9..0c76928 100644 --- a/mcp_server/tools/document_tools.py +++ b/mcp_server/tools/document_tools.py @@ -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, + }