e947bca655
Security:
- Path traversal: validate user_id, case_id, skill_name against safe regex
- Input sanitization in memory.py and skills.py file path construction
Correctness:
- ISR timezone: replace hardcoded UTC+3 with ZoneInfo("Asia/Jerusalem") for DST
- save_rule: use EspoCrmApiError.status_code instead of fragile string matching
- Background tasks: store references to prevent GC before completion
- Lock race: use dict.setdefault() for atomic lock creation
- Version: health.py now matches main.py (0.2.0)
- Skill names: normalized to lowercase for dedup consistency
Build:
- Dockerfile: install from pyproject.toml instead of hardcoded pip list
- Remove unused mcp>=1.0.0 dependency from pyproject.toml
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
152 lines
5.0 KiB
Python
152 lines
5.0 KiB
Python
"""SmartAssistant chat endpoint — same HTTP contract as ai-gateway."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
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
|
|
from api.services.skills import list_skills
|
|
from api.services.memory import load_user_profile, load_case_memory
|
|
from api.services.learner import extract_skills
|
|
|
|
logger = logging.getLogger("shira.api")
|
|
|
|
router = APIRouter()
|
|
|
|
# Hold references to background tasks so they don't get garbage-collected
|
|
_background_tasks: set[asyncio.Task] = set()
|
|
|
|
|
|
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:
|
|
logger.warning(
|
|
"[auth] Key mismatch — expected=%s... provided=%s...",
|
|
expected[:8] if expected else "NONE",
|
|
provided[:8] if provided else "NONE",
|
|
)
|
|
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}
|
|
|
|
# Extract IDs for memory loading
|
|
case_id = (context.get("case") or {}).get("id") or (context.get("drillDown", {}).get("case", {}).get("id"))
|
|
user_id = (context.get("currentUser") or {}).get("id")
|
|
|
|
# Load cross-conversation memory and skills
|
|
user_profile = load_user_profile(user_id) if user_id else ""
|
|
case_memory_notes = load_case_memory(case_id) if case_id else ""
|
|
available_skills = list_skills()
|
|
|
|
# Build system prompt with memory and skills injected
|
|
if mode == "office":
|
|
system_prompt = build_office_prompt(
|
|
context,
|
|
user_profile=user_profile,
|
|
available_skills=available_skills,
|
|
)
|
|
else:
|
|
system_prompt = build_case_prompt(
|
|
context,
|
|
user_profile=user_profile,
|
|
case_memory_notes=case_memory_notes,
|
|
available_skills=available_skills,
|
|
)
|
|
|
|
# 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])
|
|
|
|
# Trigger self-learning in the background (non-blocking)
|
|
conversation_messages = runner.get_messages()
|
|
if conversation_messages:
|
|
task = asyncio.create_task(_learn_in_background(conversation_messages))
|
|
_background_tasks.add(task)
|
|
task.add_done_callback(_background_tasks.discard)
|
|
|
|
return {
|
|
"text": text,
|
|
"actions": [],
|
|
"executedActions": [],
|
|
"conversationId": conversation_id,
|
|
}
|
|
|
|
|
|
async def _learn_in_background(messages: list[dict]) -> None:
|
|
"""Run skill extraction in background — errors are swallowed and logged."""
|
|
try:
|
|
await extract_skills(messages)
|
|
except Exception as e:
|
|
logger.error("[learner] background learning failed: %s", e)
|