"""Copilot service: chat via OpenRouter, action execution, history retrieval, voice transcription. The service: 1. Calls OpenRouter chat completions with the ERP system prompt 2. Parses the AI response for text + action proposals 3. Persists user and assistant messages to the DB 4. Executes confirmed actions via copilot_actions registry 5. Provides paginated chat history per user 6. Accepts voice audio and transcribes (stub for now, OpenRouter-compatible) """ from __future__ import annotations import base64 import json import logging import uuid from typing import Any import httpx from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.copilot import CopilotChat, CopilotRole, CopilotSession from app.utils.copilot_actions import execute_action from app.utils.copilot_prompt import build_system_prompt logger = logging.getLogger(__name__) # Model for chat completions (text-only, cheaper than vision model) CHAT_MODEL = "qwen/qwen2.5-72b-instruct" def _parse_ai_response(raw_content: str) -> dict[str, Any]: """Parse the AI response into text + actions. Handles markdown code fences and extracts the JSON object. Falls back to treating the entire content as text response with no actions. """ text = raw_content.strip() # Strip markdown code fences if present if text.startswith("```"): lines = text.split("\n") lines = [l for l in lines if not l.strip().startswith("```")] text = "\n".join(lines).strip() try: data = json.loads(text) except json.JSONDecodeError: # Try to find JSON object within the text start = text.find("{") end = text.rfind("}") if start != -1 and end != -1 and end > start: try: data = json.loads(text[start : end + 1]) except json.JSONDecodeError: logger.warning("Failed to parse AI response as JSON, using raw text") return {"response": raw_content.strip(), "actions": []} else: logger.warning("No JSON found in AI response, using raw text") return {"response": raw_content.strip(), "actions": []} response_text = data.get("response", "") actions = data.get("actions", []) if not isinstance(response_text, str): response_text = str(response_text) if not isinstance(actions, list): actions = [] # Validate action structure valid_actions = [] for action in actions: if isinstance(action, dict) and "type" in action: valid_actions.append({ "type": action["type"], "params": action.get("params", {}), }) return {"response": response_text, "actions": valid_actions} async def _call_openrouter_chat( messages: list[dict[str, Any]], api_key: str | None = None, model: str | None = None, ) -> str: """Call OpenRouter chat completions endpoint. Returns the raw content string from the model. Raises httpx.HTTPStatusError on API failure. """ key = api_key or settings.OPENROUTER_API_KEY if not key: raise ValueError("OPENROUTER_API_KEY is not configured") model_name = model or CHAT_MODEL headers = { "Authorization": f"Bearer {key}", "Content-Type": "application/json", } payload: dict[str, Any] = { "model": model_name, "messages": messages, "temperature": 0.3, "max_tokens": 2048, } base_url = settings.OPENROUTER_BASE_URL.rstrip("/") url = f"{base_url}/chat/completions" async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client: response = await client.post(url, headers=headers, json=payload) response.raise_for_status() body = response.json() content = body.get("choices", [{}])[0].get("message", {}).get("content", "") return content async def _get_or_create_session( db: AsyncSession, user_id: uuid.UUID, session_id: str | None = None, first_message: str | None = None, ) -> CopilotSession: """Get an existing session or create a new one. If creating a new session, uses the first message as title (truncated). """ if session_id: try: sid = uuid.UUID(session_id) stmt = select(CopilotSession).where( and_(CopilotSession.id == sid, CopilotSession.user_id == user_id) ) result = await db.execute(stmt) session = result.scalar_one_or_none() if session: return session except (ValueError, TypeError): pass # Invalid UUID, create new session # Create new session title = "Neue Konversation" if first_message: title = first_message[:100] if len(first_message) > 100 else first_message session = CopilotSession(user_id=user_id, title=title) db.add(session) await db.flush() return session async def chat( db: AsyncSession, user_id: uuid.UUID, message: str, session_id: str | None = None, api_key: str | None = None, ) -> dict[str, Any]: """Process a user message through the Copilot. 1. Get or create a session 2. Save the user message 3. Build conversation context from recent history 4. Call OpenRouter with system prompt 5. Parse response for text + actions 6. Save the assistant message 7. Return response with actions and IDs """ session = await _get_or_create_session(db, user_id, session_id, first_message=message) # Save user message user_msg = CopilotChat( session_id=session.id, user_id=user_id, role=CopilotRole.user, content=message, actions=None, ) db.add(user_msg) await db.flush() # Build conversation context from recent messages in this session stmt = ( select(CopilotChat) .where(CopilotChat.session_id == session.id) .order_by(CopilotChat.created_at.asc()) .limit(20) # Keep last 20 messages for context ) result = await db.execute(stmt) recent_messages = list(result.scalars().all()) # Build messages for OpenRouter system_prompt = build_system_prompt() messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] for msg in recent_messages: messages.append({"role": msg.role.value, "content": msg.content}) # Call OpenRouter raw_content = await _call_openrouter_chat(messages, api_key=api_key) parsed = _parse_ai_response(raw_content) # Save assistant message assistant_msg = CopilotChat( session_id=session.id, user_id=user_id, role=CopilotRole.assistant, content=parsed["response"], actions=parsed["actions"] if parsed["actions"] else None, ) db.add(assistant_msg) await db.flush() return { "response": parsed["response"], "actions": parsed["actions"], "session_id": str(session.id), "message_id": str(assistant_msg.id), } async def execute_confirmed_action( db: AsyncSession, action_type: str, params: dict[str, Any], ) -> dict[str, Any]: """Execute a user-confirmed action. Returns {action, result, success}. Raises ValueError for unknown actions. """ try: result = await execute_action(db, action_type, params) return { "action": action_type, "result": result, "success": True, } except ValueError: raise except Exception as exc: logger.error("Action execution failed: %s", exc) return { "action": action_type, "result": {"error": str(exc)}, "success": False, } async def get_history( db: AsyncSession, user_id: uuid.UUID, page: int = 1, page_size: int = 20, session_id: str | None = None, ) -> tuple[list[CopilotChat], int]: """Get paginated chat history for a user. Optionally filtered by session_id. Returns (messages, total_count). """ conditions = [CopilotChat.user_id == user_id] if session_id: try: sid = uuid.UUID(session_id) conditions.append(CopilotChat.session_id == sid) except (ValueError, TypeError): pass # Ignore invalid session_id # Count count_stmt = select(func.count(CopilotChat.id)).where(and_(*conditions)) total_result = await db.execute(count_stmt) total = total_result.scalar_one() # Data offset = (page - 1) * page_size data_stmt = ( select(CopilotChat) .where(and_(*conditions)) .order_by(CopilotChat.created_at.desc()) .offset(offset) .limit(page_size) ) result = await db.execute(data_stmt) messages = list(result.scalars().all()) return messages, total async def get_sessions( db: AsyncSession, user_id: uuid.UUID, page: int = 1, page_size: int = 20, ) -> tuple[list[CopilotSession], int]: """Get paginated chat sessions for a user. Returns (sessions, total_count). """ count_stmt = select(func.count(CopilotSession.id)).where( CopilotSession.user_id == user_id ) total_result = await db.execute(count_stmt) total = total_result.scalar_one() offset = (page - 1) * page_size data_stmt = ( select(CopilotSession) .where(CopilotSession.user_id == user_id) .order_by(CopilotSession.updated_at.desc()) .offset(offset) .limit(page_size) ) result = await db.execute(data_stmt) sessions = list(result.scalars().all()) return sessions, total async def transcribe_audio( audio_bytes: bytes, mime_type: str = "audio/webm", api_key: str | None = None, ) -> str: """Transcribe audio bytes to text. Uses OpenRouter with a multimodal model if available. Falls back to a simple stub that returns a placeholder. """ key = api_key or settings.OPENROUTER_API_KEY if not key: # Stub: return placeholder when no API key configured logger.warning("No OPENROUTER_API_KEY configured, using stub transcription") return "[Audio-Transkription nicht verfügbar — OPENROUTER_API_KEY fehlt]" # Encode audio as base64 data URI b64 = base64.b64encode(audio_bytes).decode("utf-8") audio_data_uri = f"data:{mime_type};base64,{b64}" messages = [ { "role": "system", "content": ( "Du bist ein Transkriptions-Assistent. Transkribiere das " "gesprochene Audio exakt wie es gesagt wurde. Gib NUR den " "transkribierten Text zurück, keine Erklärungen." ), }, { "role": "user", "content": [ { "type": "text", "text": "Bitte transkribiere dieses Audio.", }, { "type": "input_audio", "input_audio": {"data": audio_data_uri}, }, ], }, ] try: raw = await _call_openrouter_chat( messages, api_key=key, model="qwen/qwen2.5-vl-72b-instruct" ) return raw.strip() except Exception as exc: logger.error("Audio transcription failed: %s", exc) return f"[Transkription fehlgeschlagen: {exc}]" async def voice_chat( db: AsyncSession, user_id: uuid.UUID, audio_b64: str, mime_type: str = "audio/webm", session_id: str | None = None, api_key: str | None = None, ) -> dict[str, Any]: """Process a voice message: transcribe audio, then run chat. Returns transcription + chat response + actions + IDs. """ audio_bytes = base64.b64decode(audio_b64) transcription = await transcribe_audio(audio_bytes, mime_type, api_key=api_key) chat_result = await chat(db, user_id, transcription, session_id, api_key=api_key) return { "transcription": transcription, "response": chat_result["response"], "actions": chat_result["actions"], "session_id": chat_result["session_id"], "message_id": chat_result["message_id"], }