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) <noreply@anthropic.com>
This commit is contained in:
@@ -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=
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
*.log
|
||||
.pytest_cache/
|
||||
+20
@@ -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"]
|
||||
+33
@@ -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)
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 "הגעתי למספר המקסימלי של פעולות. נסי לשאול שוב בצורה ממוקדת יותר."
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user