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:
2026-04-13 17:29:35 +00:00
commit 6f7b94d6f8
20 changed files with 1359 additions and 0 deletions
+17
View File
@@ -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=
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.py[cod]
*.egg-info/
dist/
.venv/
venv/
.env
*.log
.pytest_cache/
+20
View File
@@ -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"]
View File
+33
View File
@@ -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)
View File
+19
View File
@@ -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(),
}
+103
View File
@@ -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,
}
View File
+135
View File
@@ -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 "הגעתי למספר המקסימלי של פעולות. נסי לשאול שוב בצורה ממוקדת יותר."
+224
View File
@@ -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)
+24
View File
@@ -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
View File
+57
View File
@@ -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)
View File
+37
View File
@@ -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}"
+354
View File
@@ -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,
}
+155
View File
@@ -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,
}
+149
View File
@@ -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,
}
+23
View File
@@ -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"