feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history

This commit is contained in:
2026-07-17 02:28:41 +02:00
parent cb89e3ff1e
commit 5cc9f8e9a9
24 changed files with 2878 additions and 188 deletions
BIN
View File
Binary file not shown.
+2 -1
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, contacts, datev, files, ocr, sales, users, vehicles
from app.routers import auth, contacts, copilot, datev, files, ocr, sales, users, vehicles
@asynccontextmanager
@@ -46,6 +46,7 @@ api_v1_router.include_router(files.router)
api_v1_router.include_router(ocr.router)
api_v1_router.include_router(sales.router)
api_v1_router.include_router(datev.router)
api_v1_router.include_router(copilot.router)
# Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"])
+123
View File
@@ -0,0 +1,123 @@
"""SQLAlchemy models for Copilot chat sessions and messages.
Stores per-user chat history with action proposals from the AI assistant.
"""
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class CopilotRole(str, enum.Enum):
"""Roles for chat messages."""
user = "user"
assistant = "assistant"
class CopilotSession(Base):
"""A chat session belonging to a user. Groups related messages."""
__tablename__ = "copilot_sessions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
title: Mapped[str] = mapped_column(
String(255), nullable=False, default="Neue Konversation",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
messages: Mapped[list["CopilotChat"]] = relationship(
back_populates="session",
cascade="all, delete-orphan",
order_by="CopilotChat.created_at.asc()",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<CopilotSession id={self.id} user_id={self.user_id} title={self.title}>"
def to_dict(self) -> dict:
"""Serialize session for API responses."""
return {
"id": str(self.id),
"user_id": str(self.user_id),
"title": self.title,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"messages": [m.to_dict() for m in (self.messages or [])],
}
class CopilotChat(Base):
"""A single chat message (user or assistant) within a session."""
__tablename__ = "copilot_chats"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("copilot_sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
role: Mapped[CopilotRole] = mapped_column(
Enum(CopilotRole, name="copilot_role"),
nullable=False,
)
content: Mapped[str] = mapped_column(Text, nullable=False)
actions: Mapped[list | None] = mapped_column(
JSONB, nullable=True, default=None,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
session: Mapped["CopilotSession"] = relationship(back_populates="messages")
def __repr__(self) -> str:
return f"<CopilotChat id={self.id} role={self.role} session_id={self.session_id}>"
def to_dict(self) -> dict:
"""Serialize chat message for API responses."""
return {
"id": str(self.id),
"session_id": str(self.session_id),
"user_id": str(self.user_id),
"role": self.role.value if isinstance(self.role, CopilotRole) else self.role,
"content": self.content,
"actions": self.actions,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
+114
View File
@@ -0,0 +1,114 @@
"""Copilot router: chat, action execution, history, and voice endpoints.
All endpoints require authentication (get_current_user).
Actions are proposed by the AI and only executed after user confirmation.
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination
from app.models.user import User
from app.schemas.copilot import (
ActionRequest,
ActionResponse,
ChatHistoryResponse,
ChatMessageItem,
ChatRequest,
ChatResponse,
VoiceRequest,
VoiceResponse,
)
from app.services import copilot_service
router = APIRouter(prefix="/copilot", tags=["copilot"])
@router.post("/chat", response_model=ChatResponse, status_code=status.HTTP_200_OK)
async def copilot_chat(
body: ChatRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Send a message to the Copilot and receive a response with action proposals."""
try:
result = await copilot_service.chat(
db,
user_id=current_user.id,
message=body.message,
session_id=body.session_id,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "COPILOT_ERROR", "message": str(exc)}},
)
return ChatResponse(**result)
@router.post("/action", response_model=ActionResponse, status_code=status.HTTP_200_OK)
async def copilot_action(
body: ActionRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Execute a user-confirmed action proposed by the Copilot."""
try:
result = await copilot_service.execute_confirmed_action(
db,
action_type=body.action,
params=body.params,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "INVALID_ACTION", "message": str(exc)}},
)
return ActionResponse(**result)
@router.get("/history", response_model=ChatHistoryResponse, status_code=status.HTTP_200_OK)
async def copilot_history(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
session_id: str | None = Query(None, description="Filter by session ID"),
current_user: User = Depends(get_current_user),
):
"""Get paginated chat history for the current user."""
messages, total = await copilot_service.get_history(
db,
user_id=current_user.id,
page=pagination["page"],
page_size=pagination["page_size"],
session_id=session_id,
)
return ChatHistoryResponse(
items=[ChatMessageItem.model_validate(m.to_dict()) for m in messages],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.post("/voice", response_model=VoiceResponse, status_code=status.HTTP_200_OK)
async def copilot_voice(
body: VoiceRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept audio, transcribe, and return a chat response with action proposals."""
try:
result = await copilot_service.voice_chat(
db,
user_id=current_user.id,
audio_b64=body.audio,
mime_type=body.mime_type,
session_id=body.session_id,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "VOICE_ERROR", "message": str(exc)}},
)
return VoiceResponse(**result)
+94
View File
@@ -0,0 +1,94 @@
"""Pydantic schemas for Copilot chat, action, voice, and history endpoints."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class ChatRequest(BaseModel):
"""Request body for POST /copilot/chat."""
message: str = Field(..., min_length=1, description="User message to the Copilot")
session_id: Optional[str] = Field(None, description="Existing session UUID to continue")
class ActionItem(BaseModel):
"""A single action proposed by the Copilot."""
type: str = Field(..., description="Action type, e.g. search_vehicles")
params: dict[str, Any] = Field(default_factory=dict, description="Action parameters")
class ChatResponse(BaseModel):
"""Response body for POST /copilot/chat."""
model_config = ConfigDict(from_attributes=True)
response: str = Field(..., description="Assistant text response")
actions: list[ActionItem] = Field(default_factory=list, description="Proposed actions")
session_id: str = Field(..., description="Session UUID")
message_id: str = Field(..., description="Assistant message UUID")
class ActionRequest(BaseModel):
"""Request body for POST /copilot/action — user confirms an action."""
action: str = Field(..., min_length=1, description="Action type to execute")
params: dict[str, Any] = Field(default_factory=dict, description="Action parameters")
session_id: Optional[str] = Field(None, description="Session context")
class ActionResponse(BaseModel):
"""Response body for POST /copilot/action."""
model_config = ConfigDict(from_attributes=True)
action: str = Field(..., description="Executed action type")
result: Any = Field(..., description="Action result data")
success: bool = Field(..., description="Whether the action succeeded")
class ChatMessageItem(BaseModel):
"""A single chat message in history."""
model_config = ConfigDict(from_attributes=True)
id: str
session_id: str
role: str
content: str
actions: Optional[list[dict[str, Any]]] = None
created_at: Optional[datetime] = None
class ChatHistoryResponse(BaseModel):
"""Paginated chat history response."""
items: list[ChatMessageItem] = Field(default_factory=list)
total: int = Field(0)
page: int = Field(1)
page_size: int = Field(20)
class VoiceRequest(BaseModel):
"""Request body for POST /copilot/voice.
Accepts base64-encoded audio data for transcription.
"""
audio: str = Field(..., min_length=1, description="Base64-encoded audio data")
mime_type: str = Field("audio/webm", description="Audio MIME type")
session_id: Optional[str] = Field(None, description="Existing session UUID")
class VoiceResponse(BaseModel):
"""Response body for POST /copilot/voice."""
model_config = ConfigDict(from_attributes=True)
transcription: str = Field(..., description="Transcribed text")
response: str = Field(..., description="Assistant text response")
actions: list[ActionItem] = Field(default_factory=list, description="Proposed actions")
session_id: str = Field(..., description="Session UUID")
message_id: str = Field(..., description="Assistant message UUID")
+405
View File
@@ -0,0 +1,405 @@
"""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"],
}
+205
View File
@@ -0,0 +1,205 @@
"""Action definitions for the Copilot action system.
Each action maps a type string to a handler that queries existing services.
Actions are PROPOSED by the AI and EXECUTED only after user confirmation.
"""
from __future__ import annotations
import logging
from typing import Any, Callable
from sqlalchemy.ext.asyncio import AsyncSession
from app.services import contact_service, vehicle_service
logger = logging.getLogger(__name__)
type ActionHandler = Callable[[AsyncSession, dict[str, Any]], Any]
async def _search_vehicles(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
"""Search vehicles by type, availability, price range, or text search."""
vehicle_type = params.get("type") or params.get("vehicle_type")
availability = params.get("availability")
min_price = params.get("min_price")
max_price = params.get("max_price")
search = params.get("search") or params.get("query")
page = params.get("page", 1)
page_size = params.get("page_size", 20)
vehicles, total = await vehicle_service.list_vehicles(
db,
page=page,
page_size=page_size,
vehicle_type=vehicle_type,
availability=availability,
min_price=min_price,
max_price=max_price,
search=search,
)
return {
"items": [v.to_dict() for v in vehicles],
"total": total,
"page": page,
"page_size": page_size,
}
async def _search_contacts(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
"""Search contacts by role, text search, EU/inland flag."""
search = params.get("search") or params.get("query")
role = params.get("role")
is_eu = params.get("is_eu")
is_private = params.get("is_private")
page = params.get("page", 1)
page_size = params.get("page_size", 20)
contacts, total = await contact_service.list_contacts(
db,
page=page,
page_size=page_size,
search=search,
role=role,
is_eu=is_eu,
is_private=is_private,
)
return {
"items": [c.to_dict() for c in contacts],
"total": total,
"page": page,
"page_size": page_size,
}
async def _get_sale_overview(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
"""Get an overview of sales, optionally filtered by status or date range."""
from app.models.sale import Sale
from sqlalchemy import func, select
status_filter = params.get("status")
page = params.get("page", 1)
page_size = params.get("page_size", 20)
count_stmt = select(func.count(Sale.id))
data_stmt = select(Sale)
if status_filter:
count_stmt = count_stmt.where(Sale.status == status_filter)
data_stmt = data_stmt.where(Sale.status == status_filter)
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
offset = (page - 1) * page_size
data_stmt = data_stmt.offset(offset).limit(page_size).order_by(Sale.created_at.desc())
result = await db.execute(data_stmt)
sales = list(result.scalars().all())
return {
"items": [s.to_dict() for s in sales],
"total": total,
"page": page,
"page_size": page_size,
}
async def _create_vehicle(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
"""Create a new vehicle. Requires make, model, fin, price, vehicle_type."""
required = ["make", "model", "fin", "price", "vehicle_type"]
missing = [f for f in required if not params.get(f)]
if missing:
raise ValueError(f"Missing required fields for create_vehicle: {', '.join(missing)}")
vehicle = await vehicle_service.create_vehicle(db, params)
return vehicle.to_dict()
async def _create_contact(db: AsyncSession, params: dict[str, Any]) -> dict[str, Any]:
"""Create a new contact. Requires company_name, role, address_country."""
required = ["company_name", "role"]
missing = [f for f in required if not params.get(f)]
if missing:
raise ValueError(f"Missing required fields for create_contact: {', '.join(missing)}")
if "address_country" not in params:
params["address_country"] = "DE"
contact = await contact_service.create_contact(db, params)
return contact.to_dict()
ACTION_REGISTRY: dict[str, ActionHandler] = {
"search_vehicles": _search_vehicles,
"search_contacts": _search_contacts,
"get_sale_overview": _get_sale_overview,
"create_vehicle": _create_vehicle,
"create_contact": _create_contact,
}
def get_available_actions() -> list[dict[str, Any]]:
"""Return action definitions for the system prompt."""
return [
{
"type": "search_vehicles",
"description": "Fahrzeuge durchsuchen — nach Typ, Verfügbarkeit, Preis oder Text.",
"params": {
"type": "optional: lkw, pkw, baumaschine, stapler, transporter",
"availability": "optional: available, reserved, sold",
"min_price": "optional: float",
"max_price": "optional: float",
"search": "optional: text search in make, model, fin, location",
},
},
{
"type": "search_contacts",
"description": "Kontakte durchsuchen — nach Rolle, Text, EU/Inland.",
"params": {
"search": "optional: text search in company_name, city, email, vat_id",
"role": "optional: kaeufer, verkaeufer, beide",
"is_eu": "optional: bool",
"is_private": "optional: bool",
},
},
{
"type": "get_sale_overview",
"description": "Verkaufsübersicht abrufen — nach Status filterbar.",
"params": {
"status": "optional: draft, completed, cancelled",
},
},
{
"type": "create_vehicle",
"description": "Neues Fahrzeug anlegen. Erfordert make, model, fin, price, vehicle_type.",
"params": {
"make": "required: string",
"model": "required: string",
"fin": "required: 17-char VIN",
"price": "required: decimal",
"vehicle_type": "required: lkw, pkw, baumaschine, stapler, transporter",
},
},
{
"type": "create_contact",
"description": "Neuen Kontakt anlegen. Erfordert company_name, role.",
"params": {
"company_name": "required: string",
"role": "required: kaeufer, verkaeufer, beide",
"address_country": "optional: 2-letter ISO code, default DE",
},
},
]
async def execute_action(db: AsyncSession, action_type: str, params: dict[str, Any]) -> Any:
"""Execute a registered action by type.
Raises ValueError if the action type is not registered.
"""
handler = ACTION_REGISTRY.get(action_type)
if handler is None:
raise ValueError(f"Unknown action type: {action_type}")
logger.info("Executing copilot action: %s with params: %s", action_type, params)
return await handler(db, params)
+98
View File
@@ -0,0 +1,98 @@
"""System prompt for the KI-Copilot with ERP context and available actions.
The prompt instructs the AI to:
1. Understand ERP context (vehicles, contacts, sales)
2. Propose actions as structured JSON
3. Never execute actions directly — only propose them
4. Respond in the user's language (default German)
"""
import json
from app.utils.copilot_actions import get_available_actions
def build_system_prompt() -> str:
"""Build the system prompt with ERP context and action definitions."""
actions = get_available_actions()
actions_json = json.dumps(actions, ensure_ascii=False, indent=2)
return f"""Du bist der KI-Copilot für das ERP-System Nutzfahrzeuge. Du hilfst Verkäufern und Buchhaltern bei der Arbeit mit Fahrzeugen, Kontakten und Verkäufen.
## ERP-Kontext
### Fahrzeugtypen
- **LKW**: Sattelzugmaschinen, Lkw, Verteiler
- **PKW**: Personenwagen
- **Baumaschine**: Bagger, Radlader, Kräne
- **Stapler**: Gabelstapler, Hubwagen
- **Transporter**: Transporter, Kleintransporter
### Kontakttypen
- **Käufer (kaeufer)**: Kunden die Fahrzeuge kaufen
- **Verkäufer (verkaeufer)**: Lieferanten die Fahrzeuge verkaufen
- **Beide**: Kontakte die sowohl kaufen als auch verkaufen
- **EU vs Inland**: address_country DE = Inland, alles andere = EU
- **Privat**: is_private flag für Privatpersonen
### Verkaufsworkflow
1. **draft**: Verkauf begonnen, noch nicht abgeschlossen
2. **completed**: Verkauf abgeschlossen, Vertrag generiert
3. **cancelled**: Verkauf storniert
## Verfügbare Aktionen
Du kannst folgende Aktionen VORSCHLAGEN (nicht selbst ausführen):
{actions_json}
## Antwortformat
Antworte IMMER in folgendem JSON-Format:
```json
{{
"response": "Deine Text-Antwort an den Nutzer",
"actions": [
{{
"type": "action_type",
"params": {{}}
}}
]
}}
```
### Regeln:
1. **response**: Ein natürlicher Text, der den Nutzer informiert was du gefunden hast oder vorschlägst.
2. **actions**: Eine Liste von Aktionsvorschlägen. Der Nutzer muss jede Aktion bestätigen bevor sie ausgeführt wird.
3. Wenn keine Aktion nötig ist, gib einen leeren actions-Array zurück.
4. Führe NIEMALS Aktionen selbst aus — du kannst sie nur vorschlagen.
5. Antworte in der Sprache des Nutzers (Standard: Deutsch).
6. Wenn du unklar bist, frage nach.
### Beispiele:
Nutzer: "Zeige alle LKWs"
```json
{{
"response": "Ich suche nach allen LKWs im Bestand für dich.",
"actions": [{{"type": "search_vehicles", "params": {{"type": "lkw"}}}}]
}}
```
Nutzer: "Suche Kontakt Müller"
```json
{{
"response": "Ich suche nach Kontakten mit dem Namen Müller.",
"actions": [{{"type": "search_contacts", "params": {{"search": "Müller"}}}}]
}}
```
Nutzer: "Wie viele Verkäufe wurden abgeschlossen?"
```json
{{
"response": "Ich rufe eine Übersicht der abgeschlossenen Verkäufe auf.",
"actions": [{{"type": "get_sale_overview", "params": {{"status": "completed"}}}}]
}}
```
"""
+494
View File
@@ -0,0 +1,494 @@
"""Tests for Copilot chat, action, history, and voice endpoints with mocked OpenRouter."""
import base64
import json
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.database import Base, get_db
from app.main import app
from app.models.copilot import CopilotChat, CopilotRole, CopilotSession
from app.models.user import User, UserRole
from app.services.auth_service import hash_password
@pytest_asyncio.fixture
async def sample_vehicle_data():
"""Valid vehicle data for creation."""
return {
"make": "Mercedes-Benz",
"model": "Actros",
"fin": "WDB9066351L123456",
"year": 2020,
"power_kw": 300,
"fuel_type": "Diesel",
"condition": "used",
"availability": "available",
"price": 45000.00,
"vehicle_type": "lkw",
"lkw_type": "sattelzugmaschine",
"mileage_km": 120000,
}
def _mock_chat_json(response_text: str, actions: list | None = None) -> str:
"""Build a JSON response string as the AI would return."""
return json.dumps({
"response": response_text,
"actions": actions or [],
})
def _patch_openrouter_chat(content: str):
"""Patch _call_openrouter_chat to return fixed content.
Usage:
with _patch_openrouter_chat(content):
response = await client.post(...)
"""
return patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
return_value=content,
)
class TestCopilotChat:
"""POST /api/v1/copilot/chat tests."""
@pytest.mark.asyncio
async def test_chat_returns_200_with_response_and_actions(self, admin_client):
"""POST /copilot/chat with valid message returns 200 + response + actions."""
mock_content = _mock_chat_json(
"Ich suche nach allen LKWs im Bestand.",
[{"type": "search_vehicles", "params": {"type": "lkw"}}],
)
with _patch_openrouter_chat(mock_content):
response = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Zeige alle LKWs"},
)
assert response.status_code == 200, response.text
data = response.json()
assert "response" in data
assert "actions" in data
assert "session_id" in data
assert "message_id" in data
assert isinstance(data["response"], str)
assert len(data["response"]) > 0
assert len(data["actions"]) == 1
assert data["actions"][0]["type"] == "search_vehicles"
assert data["actions"][0]["params"]["type"] == "lkw"
@pytest.mark.asyncio
async def test_chat_empty_message_returns_422(self, admin_client):
"""POST /copilot/chat with empty message returns 422."""
response = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": ""},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_chat_no_actions_returns_empty_list(self, admin_client):
"""POST /copilot/chat with a general question returns empty actions list."""
mock_content = _mock_chat_json("Hallo! Wie kann ich helfen?", [])
with _patch_openrouter_chat(mock_content):
response = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Hallo"},
)
assert response.status_code == 200
data = response.json()
assert data["actions"] == []
@pytest.mark.asyncio
async def test_chat_persists_messages_to_db(self, admin_client, db_session):
"""Chat messages are persisted to the database."""
mock_content = _mock_chat_json("Test response", [])
with _patch_openrouter_chat(mock_content):
response = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Test message"},
)
assert response.status_code == 200
session_id = response.json()["session_id"]
# Verify messages in DB via a fresh query
from sqlalchemy import select
stmt = select(CopilotChat).where(
CopilotChat.session_id == uuid.UUID(session_id)
)
result = await db_session.execute(stmt)
messages = list(result.scalars().all())
assert len(messages) == 2 # user + assistant
roles = [m.role.value for m in messages]
assert "user" in roles
assert "assistant" in roles
@pytest.mark.asyncio
async def test_chat_contact_search_action(self, admin_client):
"""Copilot can propose a contact search action."""
mock_content = _mock_chat_json(
"Ich suche nach Kontakten mit dem Namen Müller.",
[{"type": "search_contacts", "params": {"search": "Müller"}}],
)
with _patch_openrouter_chat(mock_content):
response = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Suche Kontakt Müller"},
)
assert response.status_code == 200
data = response.json()
assert len(data["actions"]) == 1
assert data["actions"][0]["type"] == "search_contacts"
assert data["actions"][0]["params"]["search"] == "Müller"
@pytest.mark.asyncio
async def test_chat_requires_auth(self, client):
"""POST /copilot/chat without auth returns 401."""
response = await client.post(
"/api/v1/copilot/chat",
json={"message": "test"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_chat_continues_existing_session(self, admin_client):
"""POST /copilot/chat with session_id continues the same session."""
mock_content1 = _mock_chat_json("Erste Antwort", [])
with _patch_openrouter_chat(mock_content1):
resp1 = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Erste Nachricht"},
)
assert resp1.status_code == 200
session_id = resp1.json()["session_id"]
mock_content2 = _mock_chat_json("Zweite Antwort", [])
with _patch_openrouter_chat(mock_content2):
resp2 = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Zweite Nachricht", "session_id": session_id},
)
assert resp2.status_code == 200
assert resp2.json()["session_id"] == session_id
class TestCopilotAction:
"""POST /api/v1/copilot/action tests."""
@pytest.mark.asyncio
async def test_action_search_vehicles_returns_200(self, admin_client, sample_vehicle_data):
"""POST /copilot/action with search_vehicles returns 200 + results."""
# First create a vehicle
await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
response = await admin_client.post(
"/api/v1/copilot/action",
json={"action": "search_vehicles", "params": {"type": "lkw"}},
)
assert response.status_code == 200, response.text
data = response.json()
assert data["action"] == "search_vehicles"
assert data["success"] is True
assert "result" in data
assert "items" in data["result"]
assert data["result"]["total"] >= 1
@pytest.mark.asyncio
async def test_action_search_contacts_returns_200(self, admin_client):
"""POST /copilot/action with search_contacts returns 200 + results."""
# First create a contact
await admin_client.post(
"/api/v1/contacts/",
json={
"company_name": "Test GmbH",
"role": "kaeufer",
"address_country": "DE",
},
)
response = await admin_client.post(
"/api/v1/copilot/action",
json={"action": "search_contacts", "params": {"search": "Test"}},
)
assert response.status_code == 200, response.text
data = response.json()
assert data["action"] == "search_contacts"
assert data["success"] is True
assert data["result"]["total"] >= 1
@pytest.mark.asyncio
async def test_action_invalid_action_returns_400(self, admin_client):
"""POST /copilot/action with invalid action returns 400."""
response = await admin_client.post(
"/api/v1/copilot/action",
json={"action": "invalid_action", "params": {}},
)
assert response.status_code == 400
data = response.json()
assert "error" in data["detail"]
assert data["detail"]["error"]["code"] == "INVALID_ACTION"
@pytest.mark.asyncio
async def test_action_get_sale_overview_returns_200(self, admin_client):
"""POST /copilot/action with get_sale_overview returns 200."""
response = await admin_client.post(
"/api/v1/copilot/action",
json={"action": "get_sale_overview", "params": {}},
)
assert response.status_code == 200, response.text
data = response.json()
assert data["action"] == "get_sale_overview"
assert data["success"] is True
assert "items" in data["result"]
@pytest.mark.asyncio
async def test_action_requires_auth(self, client):
"""POST /copilot/action without auth returns 401."""
response = await client.post(
"/api/v1/copilot/action",
json={"action": "search_vehicles", "params": {}},
)
assert response.status_code == 401
class TestCopilotHistory:
"""GET /api/v1/copilot/history tests."""
@pytest.mark.asyncio
async def test_history_returns_200_with_pagination(self, admin_client):
"""GET /copilot/history returns 200 with paginated history."""
# First create a chat message
mock_content = _mock_chat_json("Test", [])
with _patch_openrouter_chat(mock_content):
await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Test message"},
)
response = await admin_client.get("/api/v1/copilot/history?page=1&page_size=20")
assert response.status_code == 200, response.text
data = response.json()
assert "items" in data
assert "total" in data
assert "page" in data
assert "page_size" in data
assert data["total"] >= 2 # user + assistant message
assert len(data["items"]) >= 2
@pytest.mark.asyncio
async def test_history_empty_returns_200(self, admin_client):
"""GET /copilot/history with no messages returns 200 + empty list."""
response = await admin_client.get("/api/v1/copilot/history")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
@pytest.mark.asyncio
async def test_history_requires_auth(self, client):
"""GET /copilot/history without auth returns 401."""
response = await client.get("/api/v1/copilot/history")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_history_filtered_by_session(self, admin_client):
"""GET /copilot/history?session_id=... returns only that session's messages."""
mock_content = _mock_chat_json("Test", [])
with _patch_openrouter_chat(mock_content):
resp1 = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Session 1 message"},
)
resp2 = await admin_client.post(
"/api/v1/copilot/chat",
json={"message": "Session 2 message"},
)
session1_id = resp1.json()["session_id"]
response = await admin_client.get(
f"/api/v1/copilot/history?session_id={session1_id}"
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 2 # only session 1's user + assistant
for item in data["items"]:
assert item["session_id"] == session1_id
class TestCopilotVoice:
"""POST /api/v1/copilot/voice tests."""
@pytest.mark.asyncio
async def test_voice_returns_200_with_transcription_and_response(self, admin_client):
"""POST /copilot/voice returns 200 with transcription + chat response."""
mock_content = _mock_chat_json("Ich helfe dir bei der Suche.", [])
audio_b64 = base64.b64encode(b"fake-audio-data").decode("utf-8")
with patch(
"app.services.copilot_service.transcribe_audio",
new_callable=AsyncMock,
return_value="Zeige alle LKWs",
), _patch_openrouter_chat(mock_content):
response = await admin_client.post(
"/api/v1/copilot/voice",
json={"audio": audio_b64, "mime_type": "audio/webm"},
)
assert response.status_code == 200, response.text
data = response.json()
assert "transcription" in data
assert "response" in data
assert "actions" in data
assert "session_id" in data
assert "message_id" in data
assert isinstance(data["transcription"], str)
assert len(data["transcription"]) > 0
assert data["transcription"] == "Zeige alle LKWs"
@pytest.mark.asyncio
async def test_voice_empty_audio_returns_422(self, admin_client):
"""POST /copilot/voice with empty audio returns 422."""
response = await admin_client.post(
"/api/v1/copilot/voice",
json={"audio": ""},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_voice_requires_auth(self, client):
"""POST /copilot/voice without auth returns 401."""
audio_b64 = base64.b64encode(b"fake").decode("utf-8")
response = await client.post(
"/api/v1/copilot/voice",
json={"audio": audio_b64},
)
assert response.status_code == 401
class TestCopilotServiceUnit:
"""Unit tests for copilot_service internal functions."""
def test_parse_ai_response_valid_json(self):
"""_parse_ai_response correctly parses valid JSON."""
from app.services.copilot_service import _parse_ai_response
raw = json.dumps({
"response": "Ich suche LKWs.",
"actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}],
})
result = _parse_ai_response(raw)
assert result["response"] == "Ich suche LKWs."
assert len(result["actions"]) == 1
assert result["actions"][0]["type"] == "search_vehicles"
def test_parse_ai_response_with_code_fence(self):
"""_parse_ai_response handles markdown code fences."""
from app.services.copilot_service import _parse_ai_response
raw = "```json\n" + json.dumps({
"response": "Test",
"actions": [],
}) + "\n```"
result = _parse_ai_response(raw)
assert result["response"] == "Test"
assert result["actions"] == []
def test_parse_ai_response_fallback_to_raw_text(self):
"""_parse_ai_response falls back to raw text when no JSON found."""
from app.services.copilot_service import _parse_ai_response
raw = "Das ist keine JSON-Antwort."
result = _parse_ai_response(raw)
assert result["response"] == raw
assert result["actions"] == []
def test_parse_ai_response_invalid_actions_filtered(self):
"""_parse_ai_response filters out invalid action structures."""
from app.services.copilot_service import _parse_ai_response
raw = json.dumps({
"response": "Test",
"actions": [
{"type": "search_vehicles", "params": {}},
{"invalid": "no type"},
"not a dict",
],
})
result = _parse_ai_response(raw)
assert len(result["actions"]) == 1
assert result["actions"][0]["type"] == "search_vehicles"
def test_system_prompt_contains_erp_context(self):
"""System prompt includes ERP context and available actions."""
from app.utils.copilot_prompt import build_system_prompt
prompt = build_system_prompt()
assert "ERP" in prompt
assert "Fahrzeugtypen" in prompt
assert "Kontakttypen" in prompt
assert "Verkaufsworkflow" in prompt
assert "search_vehicles" in prompt
assert "search_contacts" in prompt
assert "get_sale_overview" in prompt
assert "create_vehicle" in prompt
assert "create_contact" in prompt
assert "actions" in prompt
assert "response" in prompt
def test_action_registry_has_all_actions(self):
"""Action registry contains all 5 defined actions."""
from app.utils.copilot_actions import ACTION_REGISTRY
assert len(ACTION_REGISTRY) == 5
assert "search_vehicles" in ACTION_REGISTRY
assert "search_contacts" in ACTION_REGISTRY
assert "get_sale_overview" in ACTION_REGISTRY
assert "create_vehicle" in ACTION_REGISTRY
assert "create_contact" in ACTION_REGISTRY
@pytest.mark.asyncio
async def test_execute_action_unknown_raises_value_error(self, db_session):
"""execute_action raises ValueError for unknown action type."""
from app.utils.copilot_actions import execute_action
with pytest.raises(ValueError, match="Unknown action type"):
await execute_action(db_session, "nonexistent_action", {})
@pytest.mark.asyncio
async def test_get_or_create_session_creates_new(self, db_session, admin_user):
"""_get_or_create_session creates a new session when no session_id given."""
from app.services.copilot_service import _get_or_create_session
session = await _get_or_create_session(
db_session, admin_user.id, first_message="Test message"
)
assert session.title == "Test message"
assert session.user_id == admin_user.id
@pytest.mark.asyncio
async def test_get_or_create_session_returns_existing(self, db_session, admin_user):
"""_get_or_create_session returns existing session when valid session_id given."""
from app.services.copilot_service import _get_or_create_session
session1 = await _get_or_create_session(
db_session, admin_user.id, first_message="First"
)
await db_session.flush()
session2 = await _get_or_create_session(
db_session, admin_user.id, session_id=str(session1.id)
)
assert session2.id == session1.id
+367
View File
@@ -0,0 +1,367 @@
"""Additional tests to improve coverage for copilot_service functions."""
import json
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from app.models.copilot import CopilotChat, CopilotRole, CopilotSession
from app.services import copilot_service
class TestCopilotServiceCoverage:
"""Direct service-level tests for coverage improvement."""
@pytest.mark.asyncio
async def test_chat_calls_openrouter_and_persists(self, db_session, admin_user):
"""chat() calls _call_openrouter_chat and persists both messages."""
mock_content = json.dumps({
"response": "Ich suche LKWs.",
"actions": [{"type": "search_vehicles", "params": {"type": "lkw"}}],
})
with patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
return_value=mock_content,
) as mock_chat:
result = await copilot_service.chat(
db_session, admin_user.id, "Zeige LKWs"
)
assert mock_chat.call_count == 1
assert result["response"] == "Ich suche LKWs."
assert len(result["actions"]) == 1
assert result["session_id"] is not None
assert result["message_id"] is not None
@pytest.mark.asyncio
async def test_chat_with_existing_session(self, db_session, admin_user):
"""chat() with session_id reuses existing session."""
session = CopilotSession(user_id=admin_user.id, title="Test")
db_session.add(session)
await db_session.flush()
mock_content = json.dumps({"response": "OK", "actions": []})
with patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
return_value=mock_content,
):
result = await copilot_service.chat(
db_session, admin_user.id, "Follow up", session_id=str(session.id)
)
assert result["session_id"] == str(session.id)
@pytest.mark.asyncio
async def test_chat_with_invalid_session_id_creates_new(self, db_session, admin_user):
"""chat() with invalid session_id creates a new session."""
mock_content = json.dumps({"response": "OK", "actions": []})
with patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
return_value=mock_content,
):
result = await copilot_service.chat(
db_session, admin_user.id, "Test", session_id="invalid-uuid"
)
assert result["session_id"] != "invalid-uuid"
@pytest.mark.asyncio
async def test_chat_with_raw_text_response(self, db_session, admin_user):
"""chat() handles non-JSON AI response gracefully."""
with patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
return_value="Das ist kein JSON.",
):
result = await copilot_service.chat(
db_session, admin_user.id, "Hallo"
)
assert result["response"] == "Das ist kein JSON."
assert result["actions"] == []
@pytest.mark.asyncio
async def test_chat_with_code_fence_response(self, db_session, admin_user):
"""chat() handles markdown code-fenced JSON response."""
content = "```json\n" + json.dumps({
"response": "Test",
"actions": [{"type": "search_contacts", "params": {"search": "Mueller"}}],
}) + "\n```"
with patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
return_value=content,
):
result = await copilot_service.chat(
db_session, admin_user.id, "Suche Mueller"
)
assert result["response"] == "Test"
assert len(result["actions"]) == 1
@pytest.mark.asyncio
async def test_get_history_returns_user_messages(self, db_session, admin_user):
"""get_history() returns only messages for the given user."""
session = CopilotSession(user_id=admin_user.id, title="Test")
db_session.add(session)
await db_session.flush()
for i in range(3):
msg = CopilotChat(
session_id=session.id,
user_id=admin_user.id,
role=CopilotRole.user if i % 2 == 0 else CopilotRole.assistant,
content=f"Message {i}",
)
db_session.add(msg)
await db_session.flush()
messages, total = await copilot_service.get_history(
db_session, admin_user.id, page=1, page_size=20
)
assert total == 3
assert len(messages) == 3
@pytest.mark.asyncio
async def test_get_history_filtered_by_session(self, db_session, admin_user):
"""get_history() filters by session_id."""
session1 = CopilotSession(user_id=admin_user.id, title="S1")
session2 = CopilotSession(user_id=admin_user.id, title="S2")
db_session.add_all([session1, session2])
await db_session.flush()
for session in [session1, session2]:
for i in range(2):
msg = CopilotChat(
session_id=session.id,
user_id=admin_user.id,
role=CopilotRole.user,
content=f"Msg {i}",
)
db_session.add(msg)
await db_session.flush()
messages, total = await copilot_service.get_history(
db_session, admin_user.id, page=1, page_size=20, session_id=str(session1.id)
)
assert total == 2
for msg in messages:
assert msg.session_id == session1.id
@pytest.mark.asyncio
async def test_get_history_with_invalid_session_id_ignores_filter(self, db_session, admin_user):
"""get_history() ignores invalid session_id and returns all."""
session = CopilotSession(user_id=admin_user.id, title="Test")
db_session.add(session)
await db_session.flush()
msg = CopilotChat(
session_id=session.id,
user_id=admin_user.id,
role=CopilotRole.user,
content="Test",
)
db_session.add(msg)
await db_session.flush()
messages, total = await copilot_service.get_history(
db_session, admin_user.id, page=1, page_size=20, session_id="invalid"
)
assert total == 1
@pytest.mark.asyncio
async def test_get_history_pagination(self, db_session, admin_user):
"""get_history() respects pagination parameters."""
session = CopilotSession(user_id=admin_user.id, title="Test")
db_session.add(session)
await db_session.flush()
for i in range(5):
msg = CopilotChat(
session_id=session.id,
user_id=admin_user.id,
role=CopilotRole.user,
content=f"Message {i}",
)
db_session.add(msg)
await db_session.flush()
messages, total = await copilot_service.get_history(
db_session, admin_user.id, page=1, page_size=2
)
assert total == 5
assert len(messages) == 2
messages2, _ = await copilot_service.get_history(
db_session, admin_user.id, page=2, page_size=2
)
assert len(messages2) == 2
@pytest.mark.asyncio
async def test_get_sessions_returns_user_sessions(self, db_session, admin_user):
"""get_sessions() returns sessions for the given user."""
for i in range(3):
session = CopilotSession(user_id=admin_user.id, title=f"Session {i}")
db_session.add(session)
await db_session.flush()
sessions, total = await copilot_service.get_sessions(
db_session, admin_user.id, page=1, page_size=20
)
assert total == 3
assert len(sessions) == 3
@pytest.mark.asyncio
async def test_get_sessions_pagination(self, db_session, admin_user):
"""get_sessions() respects pagination."""
for i in range(5):
session = CopilotSession(user_id=admin_user.id, title=f"S{i}")
db_session.add(session)
await db_session.flush()
sessions, total = await copilot_service.get_sessions(
db_session, admin_user.id, page=1, page_size=2
)
assert total == 5
assert len(sessions) == 2
@pytest.mark.asyncio
async def test_execute_confirmed_action_success(self, db_session, admin_user):
"""execute_confirmed_action returns success result."""
from app.models.vehicle import Vehicle
from decimal import Decimal
vehicle = Vehicle(
make="Test",
model="Model",
fin="WDB9066351L123456",
price=Decimal("10000"),
vehicle_type="lkw",
)
db_session.add(vehicle)
await db_session.flush()
result = await copilot_service.execute_confirmed_action(
db_session, "search_vehicles", {"type": "lkw"}
)
assert result["success"] is True
assert result["action"] == "search_vehicles"
assert "items" in result["result"]
@pytest.mark.asyncio
async def test_execute_confirmed_action_unknown_raises(self, db_session):
"""execute_confirmed_action raises ValueError for unknown action."""
with pytest.raises(ValueError, match="Unknown action type"):
await copilot_service.execute_confirmed_action(
db_session, "nonexistent", {}
)
@pytest.mark.asyncio
async def test_execute_confirmed_action_create_vehicle_missing_fields(self, db_session):
"""execute_confirmed_action raises ValueError for missing required fields."""
with pytest.raises(ValueError, match="Missing required fields"):
await copilot_service.execute_confirmed_action(
db_session, "create_vehicle", {"make": "Test"}
)
@pytest.mark.asyncio
async def test_transcribe_audio_no_api_key_returns_stub(self):
"""transcribe_audio returns stub message when no API key configured."""
with patch("app.services.copilot_service.settings") as mock_settings:
mock_settings.OPENROUTER_API_KEY = ""
result = await copilot_service.transcribe_audio(b"fake-audio")
assert "nicht verfuegbar" in result or "OPENROUTER_API_KEY" in result
@pytest.mark.asyncio
async def test_transcribe_audio_with_api_key_calls_openrouter(self):
"""transcribe_audio calls OpenRouter when API key is set."""
with patch("app.services.copilot_service.settings") as mock_settings, \
patch("app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, return_value=" Transkribierter Text ") as mock_chat:
mock_settings.OPENROUTER_API_KEY = "test-key"
result = await copilot_service.transcribe_audio(b"fake-audio", "audio/webm")
assert result == "Transkribierter Text"
assert mock_chat.call_count == 1
@pytest.mark.asyncio
async def test_transcribe_audio_api_error_returns_error_message(self):
"""transcribe_audio returns error message on API failure."""
with patch("app.services.copilot_service.settings") as mock_settings, \
patch("app.services.copilot_service._call_openrouter_chat", new_callable=AsyncMock, side_effect=Exception("API error")):
mock_settings.OPENROUTER_API_KEY = "test-key"
result = await copilot_service.transcribe_audio(b"fake-audio")
assert "Transkription fehlgeschlagen" in result
@pytest.mark.asyncio
async def test_voice_chat_full_flow(self, db_session, admin_user):
"""voice_chat transcribes and then chats."""
import base64
audio_b64 = base64.b64encode(b"fake-audio").decode("utf-8")
mock_content = json.dumps({"response": "Antwort", "actions": []})
with patch(
"app.services.copilot_service.transcribe_audio",
new_callable=AsyncMock,
return_value="Transkription",
), patch(
"app.services.copilot_service._call_openrouter_chat",
new_callable=AsyncMock,
return_value=mock_content,
):
result = await copilot_service.voice_chat(
db_session, admin_user.id, audio_b64
)
assert result["transcription"] == "Transkription"
assert result["response"] == "Antwort"
assert result["actions"] == []
assert "session_id" in result
assert "message_id" in result
@pytest.mark.asyncio
async def test_call_openrouter_chat_no_api_key_raises(self):
"""_call_openrouter_chat raises ValueError when no API key."""
with patch("app.services.copilot_service.settings") as mock_settings:
mock_settings.OPENROUTER_API_KEY = ""
with pytest.raises(ValueError, match="OPENROUTER_API_KEY is not configured"):
await copilot_service._call_openrouter_chat([])
@pytest.mark.asyncio
async def test_call_openrouter_chat_success(self):
"""_call_openrouter_chat calls httpx and returns content."""
mock_response = MagicMock()
mock_response.json.return_value = {
"choices": [{"message": {"content": "Test response"}}]
}
mock_response.raise_for_status = MagicMock()
with patch("app.services.copilot_service.settings") as mock_settings, \
patch("app.services.copilot_service.httpx.AsyncClient") as mock_client_cls:
mock_settings.OPENROUTER_API_KEY = "test-key"
mock_settings.OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await copilot_service._call_openrouter_chat(
[{"role": "user", "content": "test"}]
)
assert result == "Test response"