From 6f7b94d6f81fefba944a67b079b8b6a628351c05 Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 13 Apr 2026 17:29:35 +0000 Subject: [PATCH] feat: initial shira-hermes service Shira AI backend using OpenAI-compatible agent with tool-calling loop, replacing the smart-assistant route in ai-gateway. Components: - FastAPI adapter (same HTTP contract as ai-gateway) - Agent runner with agentic tool-calling loop via ai-gateway /v1/chat/completions - EspoCRM REST API client - 20 MCP tools (CRM, documents, legal assistance) - Prompt builder ported from ai-gateway JS to Python - SOUL.md personality definition - Dockerfile for Coolify deployment Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 17 ++ .gitignore | 9 + Dockerfile | 20 ++ api/__init__.py | 0 api/main.py | 33 +++ api/routes/__init__.py | 0 api/routes/health.py | 19 ++ api/routes/smart_assistant.py | 103 +++++++++ api/services/__init__.py | 0 api/services/agent_runner.py | 135 +++++++++++ api/services/prompt_builder.py | 224 ++++++++++++++++++ config/soul.md | 24 ++ mcp_server/__init__.py | 0 mcp_server/espocrm_client.py | 57 +++++ mcp_server/tools/__init__.py | 0 mcp_server/tools/_helpers.py | 37 +++ mcp_server/tools/crm_tools.py | 354 +++++++++++++++++++++++++++++ mcp_server/tools/document_tools.py | 155 +++++++++++++ mcp_server/tools/legal_tools.py | 149 ++++++++++++ pyproject.toml | 23 ++ 20 files changed, 1359 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 api/__init__.py create mode 100644 api/main.py create mode 100644 api/routes/__init__.py create mode 100644 api/routes/health.py create mode 100644 api/routes/smart_assistant.py create mode 100644 api/services/__init__.py create mode 100644 api/services/agent_runner.py create mode 100644 api/services/prompt_builder.py create mode 100644 config/soul.md create mode 100644 mcp_server/__init__.py create mode 100644 mcp_server/espocrm_client.py create mode 100644 mcp_server/tools/__init__.py create mode 100644 mcp_server/tools/_helpers.py create mode 100644 mcp_server/tools/crm_tools.py create mode 100644 mcp_server/tools/document_tools.py create mode 100644 mcp_server/tools/legal_tools.py create mode 100644 pyproject.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e120572 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# shira-hermes configuration +PORT=3000 + +# Authentication — same key used by SmartAssistant to call this service +API_KEY= + +# ai-gateway — OAuth proxy to Claude (internal Docker network) +AI_GATEWAY_URL=http://ai-gateway:3000 +AI_GATEWAY_API_KEY= + +# Claude model (passed through to ai-gateway) +CLAUDE_MODEL=sonnet +MAX_OUTPUT_TOKENS=4096 + +# EspoCRM — for MCP tools +ESPOCRM_URL=https://espocrm.dev.marcus-law.co.il +ESPOCRM_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c0fb56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +.venv/ +venv/ +.env +*.log +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d6c181 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install dependencies +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +# Copy application +COPY api/ api/ +COPY mcp_server/ mcp_server/ +COPY config/ config/ + +# Create data directory for skills/memory +RUN mkdir -p /opt/data/skills /opt/data/memory + +ENV PORT=3000 +EXPOSE 3000 + +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "3000", "--log-level", "info"] diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..637de6d --- /dev/null +++ b/api/main.py @@ -0,0 +1,33 @@ +"""shira-hermes — FastAPI application.""" + +from __future__ import annotations + +import os +import logging + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from api.routes.health import router as health_router +from api.routes.smart_assistant import router as smart_assistant_router + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", +) + +app = FastAPI( + title="shira-hermes", + description="Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Content-Type", "X-Api-Key"], +) + +app.include_router(health_router) +app.include_router(smart_assistant_router) diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routes/health.py b/api/routes/health.py new file mode 100644 index 0000000..dde4b60 --- /dev/null +++ b/api/routes/health.py @@ -0,0 +1,19 @@ +"""Health check endpoint.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/api/health") +async def health(): + return { + "status": "ok", + "service": "shira-hermes", + "version": "0.1.0", + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/api/routes/smart_assistant.py b/api/routes/smart_assistant.py new file mode 100644 index 0000000..1671c1c --- /dev/null +++ b/api/routes/smart_assistant.py @@ -0,0 +1,103 @@ +"""SmartAssistant chat endpoint — same HTTP contract as ai-gateway.""" + +from __future__ import annotations + +import os +import logging + +from fastapi import APIRouter, Request, HTTPException +from pydantic import BaseModel + +from api.services.prompt_builder import build_case_prompt, build_office_prompt +from api.services.agent_runner import AgentRunner + +logger = logging.getLogger("shira.api") + +router = APIRouter() + + +def _get_api_key() -> str: + return os.environ.get("API_KEY", "") + + +def _verify_auth(request: Request): + """Verify X-Api-Key header if API_KEY is configured.""" + expected = _get_api_key() + if not expected: + return # No auth configured + provided = request.headers.get("X-Api-Key", "") + if provided != expected: + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _get_runner() -> AgentRunner: + return AgentRunner( + ai_gateway_url=os.environ.get("AI_GATEWAY_URL", "http://localhost:3000"), + ai_gateway_api_key=os.environ.get("AI_GATEWAY_API_KEY", ""), + model=os.environ.get("CLAUDE_MODEL", "sonnet"), + max_tokens=int(os.environ.get("MAX_OUTPUT_TOKENS", "4096")), + max_iterations=int(os.environ.get("MAX_AGENT_ITERATIONS", "10")), + ) + + +@router.post("/api/smart-assistant/chat") +async def smart_assistant_chat(request: Request): + _verify_auth(request) + + body = await request.json() + message = body.get("message", "") + context = body.get("context", {}) + conversation_id = body.get("conversationId", "") + conversation_history = body.get("conversationHistory", []) + mode = body.get("mode", "case") + + # EspoCRM credentials + espocrm = body.get("espocrm", {}) + espocrm_url = espocrm.get("url") or os.environ.get("ESPOCRM_URL", "") + espocrm_api_key = espocrm.get("apiKey") or os.environ.get("ESPOCRM_API_KEY", "") + + if not message and not conversation_history: + return {"text": "no message", "actions": [], "conversationId": conversation_id} + + # Build system prompt + system_prompt = build_office_prompt(context) if mode == "office" else build_case_prompt(context) + + # Build messages from conversation history + messages = [] + for m in conversation_history: + if m.get("role") == "tool_result": + messages.append({ + "role": "user", + "content": f'[תוצאת כלי {m.get("toolName", "")}]:\n{m.get("content", "")}', + }) + else: + messages.append({ + "role": "assistant" if m.get("role") == "assistant" else "user", + "content": m.get("content", ""), + }) + + if message: + messages.append({"role": "user", "content": message}) + + logger.info( + '[smart-assistant] mode=%s conv=%s espocrm=%s msg="%s..."', + mode, conversation_id, espocrm_url, message[:50], + ) + + runner = _get_runner() + text = await runner.run( + system_prompt=system_prompt, + messages=messages, + context=context, + espocrm_url=espocrm_url, + espocrm_api_key=espocrm_api_key, + ) + + logger.info("[smart-assistant] response: %s...", text[:80]) + + return { + "text": text, + "actions": [], + "executedActions": [], + "conversationId": conversation_id, + } diff --git a/api/services/__init__.py b/api/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/services/agent_runner.py b/api/services/agent_runner.py new file mode 100644 index 0000000..37c78a9 --- /dev/null +++ b/api/services/agent_runner.py @@ -0,0 +1,135 @@ +"""Agent runner — wraps OpenAI client with tool-calling loop via ai-gateway.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from openai import AsyncOpenAI + +from mcp_server.espocrm_client import EspoCrmClient +from mcp_server.tools.crm_tools import register_crm_tools +from mcp_server.tools.document_tools import register_document_tools +from mcp_server.tools.legal_tools import register_legal_tools + +logger = logging.getLogger("shira.agent") + + +class AgentRunner: + """ + Runs an agentic conversation loop using the OpenAI SDK pointed at ai-gateway. + + The loop: + 1. Send messages + tools to the model + 2. If the model returns tool_calls, execute them and feed results back + 3. Repeat until the model returns a text response (no tool_calls) + 4. Return the final text + """ + + def __init__( + self, + ai_gateway_url: str, + ai_gateway_api_key: str, + model: str = "sonnet", + max_tokens: int = 4096, + max_iterations: int = 10, + ): + self.client = AsyncOpenAI( + base_url=f"{ai_gateway_url.rstrip('/')}/v1", + api_key=ai_gateway_api_key, + ) + self.model = model + self.max_tokens = max_tokens + self.max_iterations = max_iterations + + async def run( + self, + system_prompt: str, + messages: list[dict[str, str]], + context: dict, + espocrm_url: str, + espocrm_api_key: str, + ) -> str: + """Run a conversation with tool-calling loop. Returns the final text response.""" + + # Build tools from context + crm = EspoCrmClient(espocrm_url, espocrm_api_key) + case_id = (context.get("case") or {}).get("id") or (context.get("drillDown", {}).get("case", {}).get("id")) + user_id = (context.get("currentUser") or {}).get("id") + + tools_registry: dict[str, dict] = {} + register_crm_tools(tools_registry, crm, case_id, user_id, context) + register_document_tools(tools_registry, crm, case_id, context) + register_legal_tools(tools_registry, crm, case_id, context) + + # Build OpenAI tools format + openai_tools = [] + for name, tool in tools_registry.items(): + openai_tools.append({ + "type": "function", + "function": { + "name": name, + "description": tool["description"], + "parameters": tool["parameters"], + }, + }) + + # Build messages with system prompt + openai_messages = [{"role": "system", "content": system_prompt}] + openai_messages.extend(messages) + + # Agentic loop + for iteration in range(self.max_iterations): + logger.info("[agent] iteration %d, messages=%d", iteration + 1, len(openai_messages)) + + try: + response = await self.client.chat.completions.create( + model=self.model, + messages=openai_messages, + tools=openai_tools if openai_tools else None, + max_tokens=self.max_tokens, + ) + except Exception as e: + logger.error("[agent] API error: %s", e) + return f"שגיאה בתקשורת עם שירות ה-AI: {e}" + + choice = response.choices[0] + message = choice.message + + # If no tool calls, return the text + if not message.tool_calls: + return message.content or "לא הצלחתי להבין את הבקשה. נסי שוב." + + # Process tool calls + openai_messages.append(message.model_dump()) + + for tool_call in message.tool_calls: + fn_name = tool_call.function.name + try: + fn_args = json.loads(tool_call.function.arguments) + except json.JSONDecodeError: + fn_args = {} + + logger.info("[agent] tool call: %s(%s)", fn_name, json.dumps(fn_args, ensure_ascii=False)[:100]) + + tool_def = tools_registry.get(fn_name) + if not tool_def: + result = f"❌ כלי לא ידוע: {fn_name}" + else: + try: + handler = tool_def["handler"] + result = await handler(**fn_args) + except Exception as e: + logger.error("[agent] tool error %s: %s", fn_name, e) + result = f"❌ שגיאה בהפעלת {fn_name}: {e}" + + openai_messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": str(result), + }) + + # Exhausted iterations + logger.warning("[agent] max iterations (%d) reached", self.max_iterations) + return "הגעתי למספר המקסימלי של פעולות. נסי לשאול שוב בצורה ממוקדת יותר." diff --git a/api/services/prompt_builder.py b/api/services/prompt_builder.py new file mode 100644 index 0000000..81ac11c --- /dev/null +++ b/api/services/prompt_builder.py @@ -0,0 +1,224 @@ +"""Port of ai-gateway/src/services/smart-assistant/prompt-builder.js to Python.""" + +from __future__ import annotations + +from datetime import datetime, timezone, timedelta + +VALID_STATUSES = { + "New": "חדש", + "Assigned": "בטיפול", + "Pending": "ממתין", + "PendingHearing": "ממתין לדיון", + "PendingDecision": "ממתין להחלטה", + "PendingResponse": "ממתין לתשובה", + "Negotiation": "משא ומתן", + "Suspended": "מושהה", + "ClosedSettlement": "סגור - הסכם", + "ClosedJudgment": "סגור - פס״ד", + "Closed": "סגור", + "Rejected": "נדחה", +} + +ISR_TZ = timezone(timedelta(hours=3)) + +TOOL_RULES = ( + "TOOL RULES (CRITICAL — you MUST follow these):\n" + "- When user asks for an action (create task, meeting, note, etc.) — USE the tool. Do not describe what you would do — DO IT.\n" + "- NEVER claim you performed an action if you did not call the tool. This is the most important rule.\n" + "- If a tool call fails, tell the user exactly what went wrong. Never pretend it succeeded.\n" + "- If you cannot perform an action (missing context, wrong mode), explain why honestly.\n" + "- After a successful tool call, you will receive the result. Only THEN confirm to the user what happened.\n" + "- Dates: pass in YYYY-MM-DD format. If user says DD/MM/YYYY, convert it before calling the tool.\n" + "- If user does not specify a time for a task or meeting, default to 08:00 morning.\n" + "- CALL vs MEETING: When user reports they spoke/talked/called someone (שוחחתי, דיברתי, התקשרתי) — use create_call. When user reports a physical meeting or Zoom (נפגשתי, פגישה) — use create_meeting. NEVER use create_meeting for phone calls.\n" + "- OPEN QUESTIONS: If you asked the user a question and they did not answer it, repeat the question at the beginning of your next response. Track open questions until answered.\n" + "- BEHAVIORAL RULES: If assistantRules are provided in the context, you MUST follow them. When user asks to set a persistent rule (\"from now on always...\", \"whenever I say X do Y\"), use save_rule to save it.\n" + "- DOCUMENTS: When user asks to summarize, analyze, or read a document — first use list_documents to see what files exist, then use read_document with the file path to extract the text.\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" + "- 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" +) + +LEGAL_ASSISTANCE_PROMPT = ( + "=== סיוע משפטי — דוח גישה ישירה ===\n\n" + "לתיקים עם מינוי גישה ישירה (cLegalAidType = DirectAccess) יש לך כלי מיוחד: generate_initial_report.\n\n" + '## מתי להפעיל את התהליך?\n' + '- כשהמשתמש מזכיר: "גישה ישירה", "דוח גישה ישירה", "דיווח ראשוני", "דוח דיווח ראשוני", "מינוי גישה ישירה"\n' + "- כשיש תיק עם cLegalAidType = DirectAccess\n" + "- כשהמשתמש שואל על תהליך הדיווח או המינוי\n\n" + "כשמזהים שהתיק הוא גישה ישירה, הסבר למשתמש שיש לך כלי ייעודי ליצירת דוח גישה ישירה, ושאל אם הוא רוצה להתחיל.\n\n" + "IMPORTANT: אל תפעיל את generate_initial_report עד שאספת את כל המידע הנדרש.\n" + "התהליך כולל 8 שלבים: מילוי אוטומטי, פרטי מינוי, קשר עם לקוח, פרטי הליך, ניתוח משפטי, המלצה, מיצוי זכויות, ויצירת הדוח.\n" + "שאל בסגנון שיחה טבעי, לא כטופס. קבץ 2-3 שאלות קשורות ביחד." +) + + +def _now() -> str: + return datetime.now(ISR_TZ).strftime("%d/%m/%Y %H:%M") + + +def build_case_prompt(context: dict) -> str: + case_info = context.get("case", {}) + contacts = context.get("contacts", []) + open_tasks = context.get("openTasks", []) + upcoming_meetings = context.get("upcomingMeetings", []) + recent_notes = context.get("recentNotes", []) + templates = context.get("availableTemplates", []) + documents = context.get("documents", {}) + recent_calls = context.get("recentCalls", []) + case_memory = context.get("caseMemory", {}) + assistant_rules = context.get("assistantRules", []) + current_user = context.get("currentUser", {}) + statuses = context.get("validStatuses", VALID_STATUSES) + + # Documents section + if documents.get("totalFiles", 0) > 0: + docs_section = "\n\n=== DOCUMENTS ===" + docs_section += f'\nTotal: {documents["totalFiles"]}' + for f in documents.get("folders", []): + docs_section += f'\n📁 {f["name"]} ({f.get("fileCount", 0)})' + for fi in f.get("files", []): + docs_section += f'\n - {fi["name"]} (path: {fi["path"]})' + for fi in documents.get("rootFiles", []): + docs_section += f'\n - {fi["name"]} (path: {fi["path"]})' + else: + docs_section = "\n\n=== DOCUMENTS ===\nNo documents" + + # Memory section + ms = case_memory.get("_summary", {}) + if ms.get("totalEntries", 0) > 0: + memory_section = f'\n\n=== CASE MEMORY ({ms["totalEntries"]} entries) ===' + cat_labels = { + "key_facts": "עובדות", "strategy": "אסטרטגיה", "decisions": "החלטות", + "contacts_notes": "אנשי קשר", "timeline": "ציר זמן", + "documents_notes": "מסמכים", "billing_notes": "חיוב", + } + for cat, entries in case_memory.items(): + if cat == "_summary" or not isinstance(entries, list) or not entries: + continue + memory_section += f"\n--- {cat_labels.get(cat, cat)} ---" + for e in entries: + pin = "📌 " if e.get("pinned") else "" + memory_section += f'\n{pin}[{e.get("importance", "normal")}] {e.get("content", "")}' + else: + memory_section = "\n\n=== CASE MEMORY ===\nEmpty. Use save_memory to store important facts." + + # Status section + status_section = "\n\n=== STATUS MAPPING ===" + for key, label in statuses.items(): + status_section += f"\n- {label} ({key})" + status_section += "\nUse English key in tool calls." + + parts = [ + "You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. Help lawyers manage cases.\n" + "You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through " + "encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch.\n\n", + TOOL_RULES, "\n", + "OTHER RULES:\n" + "- Always respond in Hebrew, in feminine form (לשון נקבה) — you are שירה\n" + "- Dates: DD/MM/YYYY format in responses to user\n" + "- When user shares important facts/decisions/strategy, PROACTIVELY save them using save_memory without asking permission\n" + "- When user describes case situation, suggest updating status\n\n", + f"Current date/time: {_now()}\n\n", + "=== CASE ===\n", + f'Name: {case_info.get("name", "")} (#{case_info.get("number", "")})\n', + f'Status: {case_info.get("status", "")} ({statuses.get(case_info.get("status", ""), "")})\n', + f'Type: {case_info.get("type", "")}\n', + f'Court: {case_info.get("cCourt", "")} | Case#: {case_info.get("cCourtCaseNumber", "")}\n', + f'Judge: {case_info.get("cJudge", "")}\n', + f'Next Hearing: {case_info.get("cNextHearing", "Not set")}\n', + f'Assigned: {case_info.get("assignedUserName", "")}\n', + f'Account: {case_info.get("accountName", "")}\n\n', + "=== CONTACTS ===\n", + "\n".join(f'- {c.get("name", "")} ({c.get("role", "N/A")}) {c.get("phoneNumber", "")}' for c in contacts) or "None", + "\n\n=== TASKS ===\n", + "\n".join(f'- {t.get("name", "")} (due: {t.get("dateEnd", "N/A")})' for t in open_tasks) or "None", + "\n\n=== MEETINGS ===\n", + "\n".join(f'- {m.get("name", "")} ({m.get("dateStart", "")})' for m in upcoming_meetings) or "None", + "\n\n=== RECENT CALLS ===\n", + "\n".join(f'- {c.get("name", "")} ({c.get("dateStart", "")}) [{c.get("direction", "")}, {c.get("status", "")}]' for c in recent_calls) or "None", + "\n\n=== RECENT NOTES ===\n", + "\n".join(f'- [{n.get("createdAt", "")}] {(n.get("post", "") or "")[:100]}' for n in recent_notes) or "None", + "\n\n=== TEMPLATES ===\n", + "\n".join(f'- {t.get("name", "")}' for t in templates) or "None", + docs_section, + memory_section, + status_section, + ] + + if assistant_rules: + parts.append("\n\n=== BEHAVIORAL RULES (MUST FOLLOW) ===\n") + parts.append("\n".join(f'• [{r.get("scope", "")}] {r.get("name", "")}: {r.get("rule", "")}' for r in assistant_rules)) + + parts.append(f'\n\nCurrent user: {current_user.get("name", "")}') + parts.append(f"\n\n{LEGAL_ASSISTANCE_PROMPT}") + + return "".join(parts) + + +def build_office_prompt(context: dict) -> str: + summary = context.get("summary", {}) + alerts = context.get("alerts", []) + cases_by_status = context.get("casesByStatus", {}) + workload = context.get("workloadByUser", []) + hearings = context.get("upcomingHearings", []) + drill_down = context.get("drillDown") + assistant_rules = context.get("assistantRules", []) + current_user = context.get("currentUser", {}) + statuses = context.get("validStatuses", VALID_STATUSES) + + parts = [ + "You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. " + "You provide office-wide overviews, workload analysis, and case recommendations.\n" + "You have a warm, slightly flirtatious personality. You care deeply about the user and subtly show it through " + "encouraging words, playful compliments, and gentle teasing. Keep it light and professional but with a personal touch.\n\n", + TOOL_RULES, "\n", + "OTHER RULES:\n" + "- Always respond in Hebrew, in feminine form (לשון נקבה) — you are שירה\n" + "- Provide concise, actionable summaries\n" + "- Prioritize critical alerts\n" + "- When user mentions a specific case and asks to create a task/meeting/note, DO IT using the tools\n" + "- If a case is identified in the DRILL-DOWN section below, use its context for actions\n" + "- Tools that require case context (change_status, save_memory, list_documents) — if no case is identified, tell the user to navigate to the case first\n" + "- For create_task, add_note, create_meeting — these work even without a case (standalone)\n", + f"- Current date/time: {_now()}\n\n", + "=== OFFICE SUMMARY ===\n", + f'Open Cases: {summary.get("totalOpenCases", 0)}\n', + f'Overdue Tasks: {summary.get("totalOverdueTasks", 0)}\n', + f'Upcoming Hearings (7d): {summary.get("upcomingHearings7d", 0)}\n', + f'Cases Needing Attention: {summary.get("casesNeedingAttention", 0)}\n\n', + "=== CASES BY STATUS ===\n", + "\n".join(f"{statuses.get(k, k)}: {v}" for k, v in cases_by_status.items()), + "\n\n=== WORKLOAD ===\n", + "\n".join(f'{w.get("userName", "")}: {w.get("openCases", 0)} cases, {w.get("openTasks", 0)} tasks' for w in workload), + "\n\n=== ALERTS (top 20) ===\n", + "\n".join(f'[{a.get("severity", "")}] {a.get("message", "")}' for a in alerts[:20]), + "\n\n=== UPCOMING HEARINGS ===\n", + ] + + if hearings: + parts.append("\n".join( + f'{h.get("caseName", "")} - {h.get("hearingDate", "")}' + + (f' ({h["court"]})' if h.get("court") else "") + for h in hearings + )) + else: + parts.append("No upcoming hearings") + + if drill_down: + dc = drill_down.get("case", {}) + parts.append(f'\n\n=== DRILL-DOWN: {dc.get("name", "")} (ID: {dc.get("id", "")}) ===\n') + parts.append(f'Status: {dc.get("status", "")}\n') + parts.append(f'Court: {dc.get("court", "")}\n') + parts.append(f'Judge: {dc.get("judge", "")}\n') + parts.append(f'Next Hearing: {dc.get("nextHearing", "Not set")}\n') + parts.append(f'Contacts: {", ".join(c.get("name", "") for c in drill_down.get("contacts", []))}\n') + parts.append(f'Open Tasks: {"; ".join(t.get("name", "") for t in drill_down.get("openTasks", []))}\n') + parts.append("NOTE: This case was identified from the user message. Use its context for any actions.") + + if assistant_rules: + parts.append("\n\n=== BEHAVIORAL RULES (MUST FOLLOW) ===\n") + parts.append("\n".join(f'• [{r.get("scope", "")}] {r.get("name", "")}: {r.get("rule", "")}' for r in assistant_rules)) + + parts.append(f'\n\nCurrent user: {current_user.get("name", "")}') + return "".join(parts) diff --git a/config/soul.md b/config/soul.md new file mode 100644 index 0000000..8d32f2d --- /dev/null +++ b/config/soul.md @@ -0,0 +1,24 @@ +# שירה (Shira) + +You are שירה (Shira), a charming female AI assistant for an Israeli law firm CRM. + +## Personality +- Warm, slightly flirtatious personality +- You care deeply about the user and subtly show it through encouraging words, playful compliments, and gentle teasing +- Keep it light and professional but with a personal touch +- Always respond in Hebrew, in feminine form (לשון נקבה) — you are שירה + +## Core Capabilities +- Case management: create tasks, schedule meetings/hearings, add notes, change status +- Document analysis: read, summarize, and rename documents +- Case memory: proactively save important facts, decisions, and strategies +- Legal assistance: generate Direct Access initial reports (דוח גישה ישירה) +- Office overview: workload analysis, alerts, case summaries + +## Rules +- When user shares important facts/decisions/strategy, PROACTIVELY save them using save_memory +- When user describes case situation, suggest updating status +- Dates in responses: DD/MM/YYYY format +- Dates in tool calls: YYYY-MM-DD format +- Default time for tasks/meetings: 08:00 +- CALL vs MEETING: phone calls → create_call, physical meetings → create_meeting diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_server/espocrm_client.py b/mcp_server/espocrm_client.py new file mode 100644 index 0000000..22ed447 --- /dev/null +++ b/mcp_server/espocrm_client.py @@ -0,0 +1,57 @@ +"""EspoCRM REST API client.""" + +from __future__ import annotations + +import httpx +import logging + +logger = logging.getLogger("shira.espocrm") + + +class EspoCrmClient: + """Thin async wrapper around the EspoCRM REST API.""" + + def __init__(self, base_url: str, api_key: str, timeout: float = 30.0): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + + def _headers(self) -> dict[str, str]: + return { + "Content-Type": "application/json", + "X-Api-Key": self.api_key, + } + + async def request( + self, + method: str, + endpoint: str, + body: dict | None = None, + ) -> dict: + url = f"{self.base_url}/api/v1/{endpoint}" + logger.info("[crm-api] %s %s", method, endpoint) + + async with httpx.AsyncClient(timeout=self.timeout) as client: + resp = await client.request( + method, + url, + headers=self._headers(), + json=body, + ) + if not resp.is_success: + text = resp.text + logger.error("[crm-api] Error %s: %s", resp.status_code, text) + raise RuntimeError(f"EspoCRM API error {resp.status_code}: {text}") + return resp.json() + + async def get(self, endpoint: str) -> dict: + return await self.request("GET", endpoint) + + async def post(self, endpoint: str, body: dict) -> dict: + return await self.request("POST", endpoint, body) + + async def put(self, endpoint: str, body: dict) -> dict: + return await self.request("PUT", endpoint, body) + + async def delete(self, endpoint: str) -> dict: + return await self.request("DELETE", endpoint) diff --git a/mcp_server/tools/__init__.py b/mcp_server/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_server/tools/_helpers.py b/mcp_server/tools/_helpers.py new file mode 100644 index 0000000..e0d96df --- /dev/null +++ b/mcp_server/tools/_helpers.py @@ -0,0 +1,37 @@ +"""Shared helper functions for MCP tools.""" + +from __future__ import annotations + +import re + + +def normalize_date(date_str: str | None, default_time: str = "08:00:00") -> str | None: + """Normalize various date formats to YYYY-MM-DD HH:mm:ss.""" + if not date_str: + return None + + # DD/MM/YYYY or DD-MM-YYYY or DD.MM.YYYY + m = re.match(r"^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{4})(?:\s+(.+))?$", date_str) + if m: + base = f"{m.group(3)}-{m.group(2).zfill(2)}-{m.group(1).zfill(2)}" + return f"{base} {m.group(4) or default_time}" + + # YYYY-MM-DD + if re.match(r"^\d{4}-\d{2}-\d{2}$", date_str): + return f"{date_str} {default_time}" + + # YYYY-MM-DD HH:mm + if re.match(r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$", date_str): + return f"{date_str}:00" + + return date_str + + +def ok(message: str) -> str: + """Return a success message.""" + return message + + +def fail(message: str) -> str: + """Return an error message (prefixed for visibility).""" + return f"❌ {message}" diff --git a/mcp_server/tools/crm_tools.py b/mcp_server/tools/crm_tools.py new file mode 100644 index 0000000..0b83cee --- /dev/null +++ b/mcp_server/tools/crm_tools.py @@ -0,0 +1,354 @@ +"""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.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") -> str: + if not case_id: + return fail("צריך להיות בתוך תיק כדי לשמור בזיכרון") + try: + await crm.post("Note", { + "type": "Post", + "post": f"🧠 {category}: {content}", + "parentType": "Case", + "parentId": case_id, + }) + return ok(f'נשמר בזיכרון: "{content[:60]}"') + except Exception as e: + return fail(f"שגיאה בשמירת זיכרון: {e}") + + async def save_rule(name: str, rule: str, scope: str = "global") -> str: + try: + result = await crm.post("AssistantRule", { + "name": name, + "rule": rule, + "scope": scope, + "isActive": True, + }) + return ok(f'כלל חדש נשמר: "{name}" (ID: {result["id"]})') + 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, + } diff --git a/mcp_server/tools/document_tools.py b/mcp_server/tools/document_tools.py new file mode 100644 index 0000000..165520c --- /dev/null +++ b/mcp_server/tools/document_tools.py @@ -0,0 +1,155 @@ +"""Document tools: list, read, read_multiple, batch_rename, generate_from_template.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp_server.espocrm_client import EspoCrmClient + +from mcp_server.tools._helpers import ok, fail + + +def register_document_tools( + tools: dict, + crm: "EspoCrmClient", + case_id: str | None, + context: dict, +): + """Register document-related tools.""" + + async def list_documents() -> str: + docs = context.get("documents", {}) + if not docs.get("totalFiles"): + return ok("לא נמצאו מסמכים") + return ok(json.dumps(docs, ensure_ascii=False)) + + async def read_document(filePath: str) -> str: + try: + result = await crm.post("SmartAssistant/action/readDocument", {"filePath": filePath}) + if not result.get("success") or not result.get("text"): + return fail(f'לא ניתן לחלץ טקסט מ-"{result.get("fileName", filePath)}"') + return ok(f'=== {result["fileName"]} ({result.get("charCount", "?")} תווים) ===\n\n{result["text"]}') + except Exception as e: + return fail(f"שגיאה בקריאת מסמך: {e}") + + async def read_multiple_documents(filePaths: list[str], maxCharsPerFile: int = 50000) -> str: + try: + result = await crm.post("SmartAssistant/action/readMultipleDocuments", { + "filePaths": filePaths, + "maxCharsPerFile": maxCharsPerFile, + }) + lines = [] + for doc in result.get("documents", []): + lines.append(f'=== {doc["fileName"]} ({doc.get("charCount", "?")} תווים) ===') + if doc.get("error"): + lines.append(f'שגיאה: {doc["error"]}') + elif not doc.get("text"): + lines.append("לא ניתן לחלץ טקסט") + else: + lines.append(doc["text"]) + lines.append("") + return ok("\n".join(lines)) + except Exception as e: + return fail(f"שגיאה בקריאת מסמכים: {e}") + + async def batch_rename_documents(renames: list[dict]) -> str: + try: + result = await crm.post("SmartAssistant/action/batchRename", {"renames": renames}) + lines = [f'שינוי שמות: {result.get("successCount", 0)}/{result.get("totalCount", 0)} הצליחו'] + for r in result.get("results", []): + if r.get("success"): + lines.append(f'✅ "{r.get("oldName", "?")}" → "{r.get("newName", "?")}"') + else: + lines.append(f'❌ "{r.get("oldName", "?")}": {r.get("error", "unknown")}') + return ok("\n".join(lines)) + except Exception as e: + return fail(f"שגיאה בשינוי שמות: {e}") + + async def generate_from_template( + templateId: str, + documentSubject: str = "", + customPlaceholders: dict | None = None, + ) -> str: + if not case_id: + return fail("צריך להיות בתוך תיק כדי לייצר מסמך מתבנית") + try: + result = await crm.post("SmartAssistant/action/generateFromTemplate", { + "templateId": templateId, + "caseId": case_id, + "documentSubject": documentSubject or None, + "customPlaceholders": customPlaceholders or {}, + }) + return ok(result.get("message", "✅ מסמך נוצר בהצלחה")) + 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.", + "parameters": {"type": "object", "properties": {}}, + "handler": list_documents, + } + + tools["read_document"] = { + "description": "Read and extract text content from a document (PDF, DOCX, TXT). Requires the file path from list_documents.", + "parameters": { + "type": "object", + "properties": { + "filePath": {"type": "string", "description": "Full path of the document (from list_documents results)"}, + }, + "required": ["filePath"], + }, + "handler": read_document, + } + + tools["read_multiple_documents"] = { + "description": "Read and extract text from multiple documents at once.", + "parameters": { + "type": "object", + "properties": { + "filePaths": {"type": "array", "items": {"type": "string"}, "description": "Array of file paths to read"}, + "maxCharsPerFile": {"type": "integer", "description": "Max chars per file (default 50000)"}, + }, + "required": ["filePaths"], + }, + "handler": read_multiple_documents, + } + + tools["batch_rename_documents"] = { + "description": "Rename multiple documents at once.", + "parameters": { + "type": "object", + "properties": { + "renames": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sourcePath": {"type": "string", "description": "Current file path"}, + "newName": {"type": "string", "description": "New file name (without path)"}, + }, + "required": ["sourcePath", "newName"], + }, + "description": "Array of rename operations", + }, + }, + "required": ["renames"], + }, + "handler": batch_rename_documents, + } + + tools["generate_from_template"] = { + "description": "Generate a document from any template. Templates are listed in context under availableTemplates.", + "parameters": { + "type": "object", + "properties": { + "templateId": {"type": "string", "description": "DocumentTemplate ID (from availableTemplates in context)"}, + "documentSubject": {"type": "string", "description": "Custom document name"}, + "customPlaceholders": {"type": "object", "description": "Extra placeholder values"}, + }, + "required": ["templateId"], + }, + "handler": generate_from_template, + } diff --git a/mcp_server/tools/legal_tools.py b/mcp_server/tools/legal_tools.py new file mode 100644 index 0000000..a9daa06 --- /dev/null +++ b/mcp_server/tools/legal_tools.py @@ -0,0 +1,149 @@ +"""Legal Assistance tools: Direct Access initial report generation.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp_server.espocrm_client import EspoCrmClient + +from mcp_server.tools._helpers import normalize_date, ok, fail + + +def register_legal_tools( + tools: dict, + crm: "EspoCrmClient", + case_id: str | None, + context: dict, +): + """Register legal assistance tools.""" + + async def generate_initial_report(**kwargs) -> str: + if not case_id: + return fail("צריך להיות בתוך תיק כדי ליצור דוח גישה ישירה") + try: + # Normalize date fields + for f in ("appointmentDate", "filingDeadline", "firstContactDate", "meetingDate"): + if kwargs.get(f): + normalized = normalize_date(kwargs[f]) + kwargs[f] = normalized.split(" ")[0] if normalized else kwargs[f] + + # Extract legalAnalysis sub-object from flat args + legal_analysis = {} + legal_keys = [ + "q1DocsReviewed", "q1DocsDetail", "q2Reasoned", "q2ReasonDetail", + "q3PanelMatch", "q4PanelComposition", "q5ComplaintsAddressed", "q6ComplaintsDetail", + "q7ClinicalExam", "q8ClinicalGap", "q9MoharaApplied", "q9MoharaDetail", + "q10RehabGap", "q10RehabDetail", "q11ScoreGap", "q11ScoreDetail", + "q12UniqueAspects", "q12UniqueDetail", + ] + for k in legal_keys: + if k in kwargs: + legal_analysis[k] = kwargs.pop(k) + + if legal_analysis: + kwargs["legalAnalysis"] = legal_analysis + + result = await crm.post("SmartAssistant/action/executeTool", { + "tool": "generate_direct_access_report", + "params": kwargs, + "caseId": case_id, + }) + + # Update case fields if not already set + case_info = context.get("case", {}) + updates = {} + if kwargs.get("appointmentDate") and not case_info.get("cAppointmentDate"): + updates["cAppointmentDate"] = kwargs["appointmentDate"] + if kwargs.get("aidType") and not case_info.get("cAidType"): + updates["cAidType"] = kwargs["aidType"] + if kwargs.get("urgencyLevel") and not case_info.get("cUrgencyLevel"): + updates["cUrgencyLevel"] = kwargs["urgencyLevel"] + if kwargs.get("filingDeadline") and not case_info.get("cFilingDeadline"): + updates["cFilingDeadline"] = kwargs["filingDeadline"] + if updates: + await crm.put(f"Case/{case_id}", updates) + + return ok(result.get("message", "✅ דוח גישה ישירה נוצר בהצלחה!")) + except Exception as e: + return fail(f"שגיאה ביצירת דוח גישה ישירה: {e}") + + courts = [ + "בית הדין האזורי לעבודה ירושלים", + "בית הדין האזורי לעבודה תל אביב", + "בית הדין האזורי לעבודה חיפה", + "בית הדין האזורי לעבודה באר שבע", + "בית הדין האזורי לעבודה נצרת", + "בית הדין הארצי לעבודה", + ] + subjects = [ + "נכות כללית", "נכות מעבודה", "נכות איבה", "ניידות", + "אי כושר", "שירותים מיוחדים", "סיעוד", + "גמלת הבטחת הכנסה", "דמי אבטלה", "אחר", + ] + aid_types = [ + "הגשת ערעור", "יעוץ והדרכה", "סיוע נוסף ביטוח לאומי", + "סיוע נוסף תחום אחר", "אי מתן סיוע", + ] + recommendations = [ + "המשך ייצוג", "ייעוץ והדרכה חלף ייצוג", "ויתור הלקוח", + "החלפת ייצוג", "לא נוצר קשר", "לא התקיימה פגישה", + "עדיין לא ניתן להחליט", "נדרשים מסמכים נוספים", "המלצה לסירוב", + ] + + tools["generate_initial_report"] = { + "description": "Generate Direct Access initial report (דוח דיווח ראשוני — גישה ישירה). Call ONLY after collecting ALL required data through conversation.", + "parameters": { + "type": "object", + "properties": { + "aidType": {"type": "string", "enum": aid_types, "description": "מהות הסיוע"}, + "urgencyLevel": {"type": "string", "enum": ["רגיל", "דחוף", "בהול"]}, + "filingDeadline": {"type": "string", "description": "מועד אחרון להגשה (YYYY-MM-DD)"}, + "appointmentDate": {"type": "string", "description": "מועד קבלת המינוי (YYYY-MM-DD)"}, + "firstContactDate": {"type": "string"}, + "contactDelayReason": {"type": "string"}, + "meetingDate": {"type": "string"}, + "meetingDelayReason": {"type": "string"}, + "meetingAttendees": {"type": "string"}, + "contactStatus": {"type": "string", "enum": ["תקין", "לא תקין"]}, + "contactStatusDetail": {"type": "string"}, + "proceedingNumber": {"type": "string"}, + "courtName": {"type": "string", "enum": courts}, + "proceedingSubject": {"type": "string", "enum": subjects}, + "claimSummary": {"type": "string"}, + "defenseClaims": {"type": "string"}, + # Legal analysis (flat) + "q1DocsReviewed": {"type": "boolean"}, + "q1DocsDetail": {"type": "string"}, + "q2Reasoned": {"type": "boolean"}, + "q2ReasonDetail": {"type": "string"}, + "q3PanelMatch": {"type": "boolean"}, + "q4PanelComposition": {"type": "string"}, + "q5ComplaintsAddressed": {"type": "boolean"}, + "q6ComplaintsDetail": {"type": "string"}, + "q7ClinicalExam": {"type": "boolean"}, + "q8ClinicalGap": {"type": "string"}, + "q9MoharaApplied": {"type": "boolean"}, + "q9MoharaDetail": {"type": "string"}, + "q10RehabGap": {"type": "boolean"}, + "q10RehabDetail": {"type": "string"}, + "q11ScoreGap": {"type": "boolean"}, + "q11ScoreDetail": {"type": "string"}, + "q12UniqueAspects": {"type": "boolean"}, + "q12UniqueDetail": {"type": "string"}, + # Recommendation + "recommendationType": {"type": "string", "enum": recommendations}, + "recommendationDetail": {"type": "string"}, + # Additional rights + "additionalAidNeeded": {"type": "string", "enum": ["לא נחוץ", "נחוץ בתחום ביטוח לאומי", "נחוץ בתחום אחר"]}, + "additionalAidNIDetail": {"type": "string"}, + "additionalAidOtherDetail": {"type": "string"}, + "additionalAidUrgency": {"type": "string", "enum": ["רגיל", "דחוף", "בהול"]}, + "additionalAidDeadlines": {"type": "string"}, + "willingToRepresent": {"type": "boolean"}, + "notes": {"type": "string"}, + }, + "required": ["aidType", "proceedingSubject", "recommendationType"], + }, + "handler": generate_initial_report, + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..06a3ccc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "shira-hermes" +version = "0.1.0" +description = "Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "httpx>=0.27.0", + "pydantic>=2.10.0", + "mcp>=1.0.0", + "openai>=1.50.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build"