"""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)