2026-07-17 02:28:41 +02:00
|
|
|
"""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")
|
2026-07-17 21:16:38 +02:00
|
|
|
lines = [line for line in lines if not line.strip().startswith("```")]
|
2026-07-17 02:28:41 +02:00
|
|
|
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"],
|
|
|
|
|
}
|