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
+22 -43
View File
@@ -1,52 +1,31 @@
# Current Status # Current Status
## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI ## T07: KI-Copilot (Text + Sprache) + Systemsteuerung + Copilot UI
**Status:** COMPLETED **Status:** COMPLETED
### Backend ### Backend
- ✅ Sale model (app/models/sale.py) - UUID PK, vehicle_id FK, buyer_contact_id FK, seller_contact_id FK nullable, sale_price DECIMAL, sale_date, status enum, is_gwg BOOLEAN, contract_pdf_path - [x] models/copilot.py — CopilotSession, CopilotChat models
- ✅ DATEVExport model (app/models/datev_export.py) - UUID PK, start_date, end_date, file_path, total_amount, created_at - [x] schemas/copilot.py — ChatRequest, ChatResponse, ActionRequest, ActionResponse, ChatHistoryResponse, VoiceRequest, VoiceResponse
- ✅ Sale schemas (app/schemas/sale.py) - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse - [x] routers/copilot.py — POST /chat, POST /action, GET /history, POST /voice
- ✅ DATEV schemas (app/schemas/datev.py) - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse - [x] services/copilot_service.py — chat, execute_action, get_history, get_sessions, transcribe_audio, voice_chat
- ✅ Sale service (app/services/sale_service.py) - create_sale, get_sale, list_sales, update_sale, cancel_sale, delete_sale, regenerate_contract_pdf, verify_ust_id - [x] utils/copilot_actions.py — 5 actions: search_vehicles, search_contacts, get_sale_overview, create_vehicle, create_contact
- ✅ DATEV service (app/services/datev_service.py) - create_export, list_exports, get_export_csv - [x] utils/copilot_prompt.py — System prompt with ERP context and action definitions
- ✅ Contract PDF utils (app/utils/contract_pdf.py) - HTML template with WeasyPrint, GwG-Klausel, USt-IdNr. field, async via asyncio.to_thread - [x] tests/test_copilot.py — 28 endpoint+unit tests
- ✅ DATEV CSV utils (app/utils/datev.py) - Buchungsstapel format: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext - [x] tests/test_copilot_coverage.py — 20 service-level coverage tests
- ✅ Sales router (app/routers/sales.py) - GET/POST/PUT/DELETE /sales, POST/GET /sales/:id/contract, POST /sales/:id/verify-ust-id - [x] main.py — copilot router registered
- ✅ DATEV router (app/routers/datev.py) - POST /datev/export, GET /datev/exports, GET /datev/exports/:id/download
- ✅ Config updated - BZST_API_ENABLED=False, WEASYPRINT_OUTPUT_DIR
- ✅ main.py updated - sales + datev routers registered
- ✅ requirements.txt - weasyprint>=62.0 added
- ✅ models/__init__.py - all models imported for Base.metadata registration
- ✅ Backend tests (tests/test_sales.py, tests/test_datev.py) - 34 tests, all passing
### Frontend ### Frontend
- ✅ Sales lib (lib/sales.ts) - listSales, getSale, createSale, updateSale, deleteSale, regenerateContract, verifyUstId - [x] lib/copilot.ts — API client functions
- ✅ DATEV lib (lib/datev.ts) - createDatevExport, listDatevExports, getDatevDownloadUrl - [x] app/[locale]/ki-copilot/page.tsx — Copilot page
- ✅ SaleList component - Sales table with status + date range filters, pagination - [x] components/copilot/ChatInterface.tsx — Main chat interface
- ✅ SaleForm component - Sale create form with vehicle select, buyer input, price, GwG toggle - [x] components/copilot/MessageList.tsx — Message list
- ✅ ContractPreview component - PDF embed preview, regenerate button, download link - [x] components/copilot/ChatInput.tsx — Text input
- ✅ DatevExport component - Date range picker, export list table, CSV download - [x] components/copilot/VoiceInput.tsx — Voice input (Web Speech API)
- ✅ Pages: verkauf/page.tsx, verkauf/[id]/page.tsx, verkauf/neu/page.tsx - [x] components/copilot/ActionPreview.tsx — Action preview before execution
- ✅ Frontend tests (tests/sales.test.tsx, tests/datev.test.tsx) - 16 tests, all passing - [x] components/copilot/ChatHistory.tsx — Chat history sidebar
- [x] tests/copilot.test.tsx — 24 frontend tests
### Acceptance Criteria Met ### Test Results
- ✅ GET /api/v1/sales → 200 + paginated list with filter (date range, status) - Backend: 48 passed, 90% coverage (exceeds 80% requirement)
- ✅ GET /api/v1/sales/:id → 200 + sale detail with vehicle + contact nested - Frontend: 24 passed
- ✅ POST /api/v1/sales mit valid data → 201 + sale object
- ✅ POST /api/v1/sales ohne vehicle_id → 422
- ✅ POST /api/v1/sales ohne buyer_contact_id → 422
- ✅ PUT /api/v1/sales/:id → 200 + updated sale
- ✅ DELETE /api/v1/sales/:id → 200 (sale cancelled, vehicle status back to available)
- ✅ POST /api/v1/sales/:id/contract → 200 + regenerated contract PDF path
- ✅ GET /api/v1/sales/:id/contract → 200 + PDF content (application/pdf)
- ✅ Sale mit is_gwg=true und sale_price <= 800 → GwG-Klausel in contract
- ✅ POST /api/v1/sales/:id/verify-ust-id → 200 + {verified: false, message: 'BZSt API not available'}
- ✅ POST /api/v1/datev/export mit date range → 201 + DATEVExport object
- ✅ POST /api/v1/datev/export mit invalid date range → 422
- ✅ GET /api/v1/datev/exports → 200 + list of exports
- ✅ GET /api/v1/datev/exports/:id/download → 200 + CSV content (text/csv)
- ✅ DATEV CSV enthält korrekte Felder: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext
- ✅ Sale creation → Vehicle status='sold'
- ✅ Sale deletion → Vehicle status='available' (in_stock)
+10
View File
@@ -0,0 +1,10 @@
# Known Errors
## T07: KI-Copilot
No known errors. All tests pass.
### Notes
- OpenRouter API key not configured in test environment — tests mock _call_openrouter_chat
- Voice transcription falls back to stub message when OPENROUTER_API_KEY is empty
- Frontend act() warnings in ChatHistory tests are non-blocking (React state update in async useEffect)
+9 -8
View File
@@ -1,12 +1,13 @@
# Next Steps # Next Steps
## T05: COMPLETED ## T07: KI-Copilot — COMPLETED
All acceptance criteria for T05 (Dateiablage pro Fahrzeug + File UI) have been met. All acceptance criteria met. No blockers.
## Recommended Next Actions ### Potential Follow-up Tasks
1. Integrate FileUpload and FileList components into VehicleDetail page - Integration test with real OpenRouter API (requires API key configuration)
2. Add FileGallery to vehicle detail page for image gallery view - Voice transcription with real audio (currently stub when no API key)
3. Consider adding file metadata endpoint (GET /vehicles/:id/files/:fileId/meta) for frontend to fetch metadata without downloading - Frontend E2E tests with Playwright
4. Add file count to vehicle list response for quick display - Copilot settings panel (model selection, temperature)
5. Consider adding file type filtering in FileList (images only, documents only) - Action result rendering in chat (show vehicle/contact search results inline)
- Multi-language support for system prompt
+36 -50
View File
@@ -1,55 +1,41 @@
# Worklog # Worklog
## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI ## T07: KI-Copilot — 2026-07-17
**Date:** 2026-07-17 ### Implemented
**Status:** COMPLETED - Backend: CopilotSession + CopilotChat models with UUID PKs, user FK, JSONB actions
- Backend: Copilot service with OpenRouter chat completions, action proposal/execution system
- Backend: System prompt with ERP context (vehicle types, contact types, sales workflow) and 5 available actions
- Backend: Action registry — search_vehicles, search_contacts, get_sale_overview, create_vehicle, create_contact
- Backend: Voice endpoint with audio transcription (OpenRouter or stub fallback)
- Backend: Paginated chat history per user, filterable by session_id
- Backend: Router registered in main.py under /api/v1/copilot
- Frontend: Full chat interface with message list, text input, voice input, action preview, chat history sidebar
- Frontend: Web Speech API integration for voice recording via MediaRecorder
- Frontend: Action confirmation flow — Copilot proposes, user confirms/dismisses
### Files Created ### Test Evidence
- Backend: 48 tests passed, 90% coverage on copilot_service + copilot router
- Frontend: 24 tests passed covering all components
- OpenRouter fully mocked in all tests
#### Backend ### Files Changed
- backend/app/models/sale.py - Sale model with UUID PK, vehicle/buyer/seller FKs, sale_price, status, is_gwg, contract_pdf_path - backend/app/models/copilot.py (new)
- backend/app/models/datev_export.py - DATEVExport model with start_date, end_date, file_path, total_amount - backend/app/schemas/copilot.py (new)
- backend/app/schemas/sale.py - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse - backend/app/routers/copilot.py (new)
- backend/app/schemas/datev.py - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse - backend/app/services/copilot_service.py (new)
- backend/app/services/sale_service.py - Full CRUD + contract PDF + GwG logic + USt-IdNr. verification - backend/app/utils/copilot_actions.py (new)
- backend/app/services/datev_service.py - Export creation, listing, CSV download - backend/app/utils/copilot_prompt.py (new)
- backend/app/utils/contract_pdf.py - HTML template with WeasyPrint, GwG-Klausel §6 Abs. 2 EStG, USt-IdNr. field - backend/app/main.py (modified — added copilot router import + registration)
- backend/app/utils/datev.py - DATEV CSV Buchungsstapel format helper - backend/tests/test_copilot.py (new)
- backend/app/routers/sales.py - Sales CRUD + contract + USt-IdNr. endpoints - backend/tests/test_copilot_coverage.py (new)
- backend/app/routers/datev.py - DATEV export + list + download endpoints - frontend/lib/copilot.ts (new)
- backend/tests/test_sales.py - 20 tests covering CRUD, contract PDF, GwG, vehicle status, USt-IdNr. - frontend/app/[locale]/ki-copilot/page.tsx (new)
- backend/tests/test_datev.py - 14 tests covering export API, CSV format, validation - frontend/components/copilot/ChatInterface.tsx (new)
- frontend/components/copilot/MessageList.tsx (new)
#### Frontend - frontend/components/copilot/ChatInput.tsx (new)
- frontend/lib/sales.ts - Sales API client functions and types - frontend/components/copilot/VoiceInput.tsx (new)
- frontend/lib/datev.ts - DATEV API client functions and types - frontend/components/copilot/ActionPreview.tsx (new)
- frontend/components/sales/SaleList.tsx - Sales table with filters and pagination - frontend/components/copilot/ChatHistory.tsx (new)
- frontend/components/sales/SaleForm.tsx - Sale create form with GwG toggle - frontend/tests/copilot.test.tsx (new)
- frontend/components/sales/ContractPreview.tsx - PDF preview with regenerate/download - test_report.md (new)
- frontend/components/sales/DatevExport.tsx - DATEV export page with date range picker
- frontend/app/[locale]/verkauf/page.tsx - Sales list page
- frontend/app/[locale]/verkauf/[id]/page.tsx - Sale detail with contract preview
- frontend/app/[locale]/verkauf/neu/page.tsx - Sale create page
- frontend/tests/sales.test.tsx - 11 frontend tests
- frontend/tests/datev.test.tsx - 5 frontend tests
### Files Modified
- backend/app/config.py - Added BZST_API_ENABLED=False, WEASYPRINT_OUTPUT_DIR
- backend/app/main.py - Registered sales + datev routers
- backend/app/models/__init__.py - Added sale + datev_export imports for Base.metadata registration
- backend/requirements.txt - Added weasyprint>=62.0
### Test Results
- Backend: 34/34 passed (11.42s)
- Frontend: 16/16 passed (1.85s)
- Total: 50/50 passed
### Smoke Test
- Backend: All endpoints respond correctly (200/201/404/422)
- GwG logic: Clause appears when is_gwg=true AND price <= 800 EUR (§6 Abs. 2 EStG)
- Vehicle status: Sale creation → 'sold', sale cancel → 'available'
- DATEV CSV: Correct headers, comma decimal separator, DD.MM.YYYY date format
- USt-IdNr. verify: Returns 'BZSt API not available' when feature flag disabled
- WeasyPrint: Installed, async PDF generation via asyncio.to_thread
- Frontend: All components render with mocked API, test IDs present
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 fastapi.middleware.cors import CORSMiddleware
from app.config import settings 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 @asynccontextmanager
@@ -46,6 +46,7 @@ api_v1_router.include_router(files.router)
api_v1_router.include_router(ocr.router) api_v1_router.include_router(ocr.router)
api_v1_router.include_router(sales.router) api_v1_router.include_router(sales.router)
api_v1_router.include_router(datev.router) api_v1_router.include_router(datev.router)
api_v1_router.include_router(copilot.router)
# Health endpoint (no auth required) # Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"]) @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"
+10
View File
@@ -0,0 +1,10 @@
import { ChatInterface } from '@/components/copilot/ChatInterface';
export default function CopilotPage() {
return (
<div className="container mx-auto px-4 py-6">
<h1 className="text-2xl font-bold mb-4">KI-Copilot</h1>
<ChatInterface />
</div>
);
}
@@ -0,0 +1,63 @@
'use client';
import { type ActionItem } from '@/lib/copilot';
interface ActionPreviewProps {
actions: ActionItem[];
onConfirm: (action: ActionItem) => void;
onDismiss: (action: ActionItem) => void;
}
const ACTION_LABELS: Record<string, string> = {
search_vehicles: 'Fahrzeuge durchsuchen',
search_contacts: 'Kontakte durchsuchen',
get_sale_overview: 'Verkaufsuebersicht abrufen',
create_vehicle: 'Fahrzeug anlegen',
create_contact: 'Kontakt anlegen',
};
export function ActionPreview({ actions, onConfirm, onDismiss }: ActionPreviewProps) {
if (actions.length === 0) return null;
return (
<div className="border border-yellow-300 bg-yellow-50 rounded-lg p-3 space-y-2" data-testid="action-preview">
<p className="font-semibold text-yellow-800">
Der Copilot moechte folgende Aktionen ausfuehren:
</p>
{actions.map((action, idx) => (
<div
key={`${action.type}-${idx}`}
className="flex items-center justify-between bg-white rounded p-2 border border-yellow-200"
data-testid={`action-item-${idx}`}
>
<div className="flex-1">
<p className="font-medium">
{ACTION_LABELS[action.type] || action.type}
</p>
<p className="text-sm text-gray-600">
{Object.entries(action.params).map(([key, value]) =>
`${key}: ${String(value)}`
).join(', ') || 'Keine Parameter'}
</p>
</div>
<div className="flex gap-2 ml-2">
<button
onClick={() => onConfirm(action)}
className="bg-green-600 text-white px-3 py-1 rounded text-sm hover:bg-green-700"
data-testid={`confirm-action-${idx}`}
>
Bestaetigen
</button>
<button
onClick={() => onDismiss(action)}
className="bg-gray-300 text-gray-700 px-3 py-1 rounded text-sm hover:bg-gray-400"
data-testid={`dismiss-action-${idx}`}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,91 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { getChatHistory, type ChatMessage } from '@/lib/copilot';
interface ChatHistoryProps {
onSelectSession: (sessionId: string) => void;
onClose: () => void;
}
interface SessionGroup {
sessionId: string;
firstMessage: string;
messageCount: number;
lastActivity: string;
}
export function ChatHistory({ onSelectSession, onClose }: ChatHistoryProps) {
const [sessions, setSessions] = useState<SessionGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadHistory = async () => {
setLoading(true);
setError(null);
try {
const result = await getChatHistory(1, 100);
const sessionMap = new Map<string, ChatMessage[]>();
for (const msg of result.items) {
const existing = sessionMap.get(msg.session_id) || [];
existing.push(msg);
sessionMap.set(msg.session_id, existing);
}
const groups: SessionGroup[] = [];
for (const [sessionId, msgs] of sessionMap) {
const userMsg = msgs.find((m) => m.role === 'user');
groups.push({
sessionId,
firstMessage: userMsg?.content || 'Unbekannt',
messageCount: msgs.length,
lastActivity: msgs[msgs.length - 1]?.created_at || '',
});
}
groups.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
setSessions(groups);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verlauf konnte nicht geladen werden');
} finally {
setLoading(false);
}
};
loadHistory();
}, []);
const handleSelect = useCallback((sessionId: string) => {
onSelectSession(sessionId);
}, [onSelectSession]);
return (
<div className="w-64 border-r border-gray-200 pr-4" data-testid="chat-history">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-sm">Konversationen</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-sm" data-testid="close-history">
</button>
</div>
{loading && <p className="text-sm text-gray-400" data-testid="history-loading">Wird geladen...</p>}
{error && <p className="text-sm text-red-600" data-testid="history-error">{error}</p>}
{!loading && !error && sessions.length === 0 && (
<p className="text-sm text-gray-400" data-testid="history-empty">Keine Konversationen vorhanden.</p>
)}
{!loading && !error && sessions.length > 0 && (
<ul className="space-y-1">
{sessions.map((session) => (
<li key={session.sessionId}>
<button
onClick={() => handleSelect(session.sessionId)}
className="w-full text-left p-2 rounded hover:bg-gray-100 text-sm"
data-testid={`history-session-${session.sessionId}`}
>
<p className="font-medium truncate">{session.firstMessage}</p>
<p className="text-xs text-gray-400">{session.messageCount} Nachrichten</p>
</button>
</li>
))}
</ul>
)}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { useState, useCallback, type KeyboardEvent } from 'react';
interface ChatInputProps {
onSend: (text: string) => void;
disabled?: boolean;
}
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [text, setText] = useState('');
const handleSend = useCallback(() => {
if (!text.trim() || disabled) return;
onSend(text.trim());
setText('');
}, [text, disabled, onSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
return (
<div className="flex-1 flex gap-2" data-testid="chat-input-container">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="Nachricht an Copilot..."
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={1}
data-testid="chat-input"
/>
<button
onClick={handleSend}
disabled={disabled || !text.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="send-button"
>
Senden
</button>
</div>
);
}
@@ -0,0 +1,162 @@
'use client';
import { useState, useCallback } from 'react';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { VoiceInput } from './VoiceInput';
import { ActionPreview } from './ActionPreview';
import { ChatHistory } from './ChatHistory';
import {
sendChatMessage,
executeAction,
sendVoiceMessage,
type ChatMessage,
type ActionItem,
} from '@/lib/copilot';
export function ChatInterface() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [pendingActions, setPendingActions] = useState<ActionItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
const handleSend = useCallback(async (text: string) => {
if (!text.trim() || loading) return;
setLoading(true);
setError(null);
const userMsg: ChatMessage = {
id: `temp-${Date.now()}`,
session_id: sessionId || '',
role: 'user',
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
try {
const result = await sendChatMessage(text, sessionId);
setSessionId(result.session_id);
const assistantMsg: ChatMessage = {
id: result.message_id,
session_id: result.session_id,
role: 'assistant',
content: result.response,
actions: result.actions,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMsg]);
if (result.actions && result.actions.length > 0) {
setPendingActions(result.actions);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setLoading(false);
}
}, [sessionId, loading]);
const handleVoice = useCallback(async (audioBase64: string, mimeType: string) => {
if (loading) return;
setLoading(true);
setError(null);
try {
const result = await sendVoiceMessage(audioBase64, mimeType, sessionId);
setSessionId(result.session_id);
const userMsg: ChatMessage = {
id: `voice-${Date.now()}`,
session_id: result.session_id,
role: 'user',
content: `🎤 ${result.transcription}`,
created_at: new Date().toISOString(),
};
const assistantMsg: ChatMessage = {
id: result.message_id,
session_id: result.session_id,
role: 'assistant',
content: result.response,
actions: result.actions,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
if (result.actions && result.actions.length > 0) {
setPendingActions(result.actions);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Spracherkennung fehlgeschlagen');
} finally {
setLoading(false);
}
}, [sessionId, loading]);
const handleConfirmAction = useCallback(async (action: ActionItem) => {
setLoading(true);
setError(null);
try {
const result = await executeAction(action.type, action.params, sessionId);
const resultMsg: ChatMessage = {
id: `action-${Date.now()}`,
session_id: sessionId || '',
role: 'assistant',
content: `Aktion "${result.action}" ausgefuehrt. ${result.success ? 'Erfolgreich.' : 'Fehlgeschlagen.'}`,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, resultMsg]);
setPendingActions((prev) => prev.filter((a) => a !== action));
} catch (err) {
setError(err instanceof Error ? err.message : 'Aktion fehlgeschlagen');
} finally {
setLoading(false);
}
}, [sessionId]);
const handleDismissAction = useCallback((action: ActionItem) => {
setPendingActions((prev) => prev.filter((a) => a !== action));
}, []);
const handleSelectSession = useCallback((selectedSessionId: string) => {
setSessionId(selectedSessionId);
setMessages([]);
setPendingActions([]);
setShowHistory(false);
}, []);
return (
<div className="flex gap-4 h-[calc(100vh-12rem)]">
{showHistory && (
<ChatHistory
onSelectSession={handleSelectSession}
onClose={() => setShowHistory(false)}
/>
)}
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-2">
<button
onClick={() => setShowHistory(!showHistory)}
className="text-sm text-blue-600 hover:underline"
data-testid="toggle-history"
>
{showHistory ? 'Verlauf ausblenden' : 'Verlauf anzeigen'}
</button>
</div>
<MessageList messages={messages} loading={loading} />
{error && (
<div className="text-red-600 text-sm p-2" data-testid="chat-error">
{error}
</div>
)}
{pendingActions.length > 0 && (
<ActionPreview
actions={pendingActions}
onConfirm={handleConfirmAction}
onDismiss={handleDismissAction}
/>
)}
<div className="flex gap-2 items-end pt-2">
<ChatInput onSend={handleSend} disabled={loading} />
<VoiceInput onVoice={handleVoice} disabled={loading} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
'use client';
import { type ChatMessage } from '@/lib/copilot';
interface MessageListProps {
messages: ChatMessage[];
loading?: boolean;
}
export function MessageList({ messages, loading }: MessageListProps) {
return (
<div
className="flex-1 overflow-y-auto space-y-3 p-2"
data-testid="message-list"
>
{messages.length === 0 && !loading && (
<div className="flex-1 flex items-center justify-center text-gray-400">
<p>Starte eine Konversation mit dem KI-Copilot...</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
data-testid={`message-${msg.role}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
{msg.actions && msg.actions.length > 0 && (
<div className="mt-2 text-sm opacity-75">
<p className="font-semibold">Vorgeschlagene Aktionen:</p>
<ul className="list-disc list-inside">
{msg.actions.map((action, idx) => (
<li key={idx}>{action.type}</li>
))}
</ul>
</div>
)}
</div>
</div>
))}
{loading && (
<div className="flex justify-start" data-testid="loading-indicator">
<div className="bg-gray-100 rounded-lg px-4 py-2 text-gray-500">
<span className="animate-pulse">Copilot denkt nach...</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,94 @@
'use client';
import { useState, useCallback, useRef, type MouseEvent } from 'react';
import { fileToBase64 } from '@/lib/copilot';
interface VoiceInputProps {
onVoice: (audioBase64: string, mimeType: string) => void;
disabled?: boolean;
}
declare global {
interface Window {
SpeechRecognition?: new () => SpeechRecognitionLike;
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
}
}
interface SpeechRecognitionLike {
start: () => void;
stop: () => void;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: (() => void) | null;
onend: (() => void) | null;
}
interface SpeechRecognitionEventLike {
results: { length: number; [key: number]: { 0: { transcript: string } } };
}
export function VoiceInput({ onVoice, disabled }: VoiceInputProps) {
const [recording, setRecording] = useState(false);
const [supported, setSupported] = useState(true);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const handleStartRecording = useCallback(async () => {
if (disabled) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const base64 = await fileToBase64(blob);
onVoice(base64, 'audio/webm');
stream.getTracks().forEach((track) => track.stop());
};
recorder.start();
mediaRecorderRef.current = recorder;
setRecording(true);
} catch {
setSupported(false);
}
}, [disabled, onVoice]);
const handleStopRecording = useCallback(() => {
if (mediaRecorderRef.current && recording) {
mediaRecorderRef.current.stop();
setRecording(false);
}
}, [recording]);
const handleToggle = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (recording) handleStopRecording();
else handleStartRecording();
},
[recording, handleStartRecording, handleStopRecording]
);
if (!supported) {
return (
<button disabled className="bg-gray-300 text-gray-500 px-3 py-2 rounded-lg cursor-not-allowed" data-testid="voice-button">
🎤
</button>
);
}
return (
<button
onClick={handleToggle}
disabled={disabled}
className={`${recording ? 'bg-red-600 animate-pulse' : 'bg-gray-200 hover:bg-gray-300'} text-gray-800 px-3 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed`}
title={recording ? 'Aufnahme stoppen' : 'Sprachnachricht aufnehmen'}
data-testid="voice-button"
>
🎤
</button>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { apiFetch, type PaginatedResponse } from './api';
export interface ActionItem {
type: string;
params: Record<string, unknown>;
}
export interface ChatResponse {
response: string;
actions: ActionItem[];
session_id: string;
message_id: string;
}
export interface ActionResponse {
action: string;
result: unknown;
success: boolean;
}
export interface ChatMessage {
id: string;
session_id: string;
role: 'user' | 'assistant';
content: string;
actions?: ActionItem[] | null;
created_at?: string;
}
export interface VoiceResponse {
transcription: string;
response: string;
actions: ActionItem[];
session_id: string;
message_id: string;
}
export async function sendChatMessage(
message: string,
sessionId?: string
): Promise<ChatResponse> {
return apiFetch<ChatResponse>('/copilot/chat', {
method: 'POST',
body: JSON.stringify({ message, session_id: sessionId || undefined }),
});
}
export async function executeAction(
action: string,
params: Record<string, unknown>,
sessionId?: string
): Promise<ActionResponse> {
return apiFetch<ActionResponse>('/copilot/action', {
method: 'POST',
body: JSON.stringify({ action, params, session_id: sessionId || undefined }),
});
}
export async function getChatHistory(
page = 1,
pageSize = 20,
sessionId?: string
): Promise<PaginatedResponse<ChatMessage>> {
let path = `/copilot/history?page=${page}&page_size=${pageSize}`;
if (sessionId) {
path += `&session_id=${sessionId}`;
}
return apiFetch<PaginatedResponse<ChatMessage>>(path);
}
export async function sendVoiceMessage(
audioBase64: string,
mimeType = 'audio/webm',
sessionId?: string
): Promise<VoiceResponse> {
return apiFetch<VoiceResponse>('/copilot/voice', {
method: 'POST',
body: JSON.stringify({
audio: audioBase64,
mime_type: mimeType,
session_id: sessionId || undefined,
}),
});
}
export function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(',')[1] || result;
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
+240
View File
@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { ChatInterface } from '@/components/copilot/ChatInterface';
import { MessageList } from '@/components/copilot/MessageList';
import { ChatInput } from '@/components/copilot/ChatInput';
import { ActionPreview } from '@/components/copilot/ActionPreview';
import { ChatHistory } from '@/components/copilot/ChatHistory';
vi.mock('@/lib/copilot', () => ({
sendChatMessage: vi.fn(),
executeAction: vi.fn(),
getChatHistory: vi.fn(),
sendVoiceMessage: vi.fn(),
fileToBase64: vi.fn(),
}));
import { sendChatMessage, executeAction, getChatHistory } from '@/lib/copilot';
import type { ActionItem, ChatMessage } from '@/lib/copilot';
describe('ChatInput', () => {
it('renders input and send button', () => {
render(<ChatInput onSend={vi.fn()} />);
expect(screen.getByTestId('chat-input')).toBeInTheDocument();
expect(screen.getByTestId('send-button')).toBeInTheDocument();
});
it('calls onSend when send button clicked', () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} />);
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'Test message' } });
fireEvent.click(screen.getByTestId('send-button'));
expect(onSend).toHaveBeenCalledWith('Test message');
});
it('clears input after send', () => {
render(<ChatInput onSend={vi.fn()} />);
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'Test' } });
fireEvent.click(screen.getByTestId('send-button'));
expect(input.value).toBe('');
});
it('does not send empty messages', () => {
const onSend = vi.fn();
render(<ChatInput onSend={onSend} />);
fireEvent.click(screen.getByTestId('send-button'));
expect(onSend).not.toHaveBeenCalled();
});
it('is disabled when disabled prop is true', () => {
render(<ChatInput onSend={vi.fn()} disabled />);
expect(screen.getByTestId('send-button')).toBeDisabled();
});
});
describe('MessageList', () => {
it('shows placeholder when no messages', () => {
render(<MessageList messages={[]} />);
expect(screen.getByText('Starte eine Konversation mit dem KI-Copilot...')).toBeInTheDocument();
});
it('renders user and assistant messages', () => {
const messages: ChatMessage[] = [
{ id: '1', session_id: 's1', role: 'user', content: 'Hallo', created_at: '2025-01-01T00:00:00Z' },
{ id: '2', session_id: 's1', role: 'assistant', content: 'Hi there!', created_at: '2025-01-01T00:00:01Z' },
];
render(<MessageList messages={messages} />);
expect(screen.getByText('Hallo')).toBeInTheDocument();
expect(screen.getByText('Hi there!')).toBeInTheDocument();
});
it('shows loading indicator', () => {
render(<MessageList messages={[]} loading />);
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
});
it('displays action proposals in assistant messages', () => {
const messages: ChatMessage[] = [
{
id: '1', session_id: 's1', role: 'assistant', content: 'Ich suche LKWs.',
actions: [{ type: 'search_vehicles', params: { type: 'lkw' } }],
created_at: '2025-01-01T00:00:00Z',
},
];
render(<MessageList messages={messages} />);
expect(screen.getByText('Vorgeschlagene Aktionen:')).toBeInTheDocument();
expect(screen.getByText('search_vehicles')).toBeInTheDocument();
});
});
describe('ActionPreview', () => {
const mockActions: ActionItem[] = [
{ type: 'search_vehicles', params: { type: 'lkw' } },
];
it('renders action preview with confirm and dismiss buttons', () => {
render(<ActionPreview actions={mockActions} onConfirm={vi.fn()} onDismiss={vi.fn()} />);
expect(screen.getByTestId('action-preview')).toBeInTheDocument();
expect(screen.getByTestId('confirm-action-0')).toBeInTheDocument();
expect(screen.getByTestId('dismiss-action-0')).toBeInTheDocument();
});
it('calls onConfirm when confirm button clicked', () => {
const onConfirm = vi.fn();
render(<ActionPreview actions={mockActions} onConfirm={onConfirm} onDismiss={vi.fn()} />);
fireEvent.click(screen.getByTestId('confirm-action-0'));
expect(onConfirm).toHaveBeenCalledWith(mockActions[0]);
});
it('calls onDismiss when dismiss button clicked', () => {
const onDismiss = vi.fn();
render(<ActionPreview actions={mockActions} onConfirm={vi.fn()} onDismiss={onDismiss} />);
fireEvent.click(screen.getByTestId('dismiss-action-0'));
expect(onDismiss).toHaveBeenCalledWith(mockActions[0]);
});
it('returns null when no actions', () => {
const { container } = render(<ActionPreview actions={[]} onConfirm={vi.fn()} onDismiss={vi.fn()} />);
expect(container.firstChild).toBeNull();
});
it('shows action label for known action types', () => {
render(<ActionPreview actions={mockActions} onConfirm={vi.fn()} onDismiss={vi.fn()} />);
expect(screen.getByText('Fahrzeuge durchsuchen')).toBeInTheDocument();
});
});
describe('ChatHistory', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('renders history sidebar with title', async () => {
vi.mocked(getChatHistory).mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100 });
render(<ChatHistory onSelectSession={vi.fn()} onClose={vi.fn()} />);
expect(screen.getByText('Konversationen')).toBeInTheDocument();
});
it('shows empty state when no sessions', async () => {
vi.mocked(getChatHistory).mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100 });
render(<ChatHistory onSelectSession={vi.fn()} onClose={vi.fn()} />);
await waitFor(() => { expect(screen.getByTestId('history-empty')).toBeInTheDocument(); });
});
it('displays sessions grouped by session_id', async () => {
const messages: ChatMessage[] = [
{ id: '1', session_id: 's1', role: 'user', content: 'Erste Frage', created_at: '2025-01-01T00:00:00Z' },
{ id: '2', session_id: 's1', role: 'assistant', content: 'Antwort', created_at: '2025-01-01T00:00:01Z' },
{ id: '3', session_id: 's2', role: 'user', content: 'Zweite Frage', created_at: '2025-01-02T00:00:00Z' },
];
vi.mocked(getChatHistory).mockResolvedValue({ items: messages, total: 3, page: 1, page_size: 100 });
render(<ChatHistory onSelectSession={vi.fn()} onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Erste Frage')).toBeInTheDocument();
expect(screen.getByText('Zweite Frage')).toBeInTheDocument();
});
});
it('calls onSelectSession when session clicked', async () => {
const messages: ChatMessage[] = [
{ id: '1', session_id: 's1', role: 'user', content: 'Test', created_at: '2025-01-01T00:00:00Z' },
];
vi.mocked(getChatHistory).mockResolvedValue({ items: messages, total: 1, page: 1, page_size: 100 });
const onSelectSession = vi.fn();
render(<ChatHistory onSelectSession={onSelectSession} onClose={vi.fn()} />);
await waitFor(() => { expect(screen.getByText('Test')).toBeInTheDocument(); });
fireEvent.click(screen.getByTestId('history-session-s1'));
expect(onSelectSession).toHaveBeenCalledWith('s1');
});
});
describe('ChatInterface', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('renders chat interface with input and message list', () => {
render(<ChatInterface />);
expect(screen.getByTestId('chat-input')).toBeInTheDocument();
expect(screen.getByTestId('message-list')).toBeInTheDocument();
expect(screen.getByTestId('voice-button')).toBeInTheDocument();
});
it('sends message and displays response', async () => {
vi.mocked(sendChatMessage).mockResolvedValue({
response: 'Ich helfe dir!', actions: [], session_id: 'test-session', message_id: 'msg-1',
});
render(<ChatInterface />);
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'Hallo' } });
fireEvent.click(screen.getByTestId('send-button'));
await waitFor(() => { expect(screen.getByText('Ich helfe dir!')).toBeInTheDocument(); });
expect(sendChatMessage).toHaveBeenCalledWith('Hallo', undefined);
});
it('shows action preview when response has actions', async () => {
vi.mocked(sendChatMessage).mockResolvedValue({
response: 'Ich suche LKWs.',
actions: [{ type: 'search_vehicles', params: { type: 'lkw' } }],
session_id: 'test-session', message_id: 'msg-1',
});
render(<ChatInterface />);
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'Zeige LKWs' } });
fireEvent.click(screen.getByTestId('send-button'));
await waitFor(() => { expect(screen.getByTestId('action-preview')).toBeInTheDocument(); });
});
it('executes action when confirm button clicked', async () => {
vi.mocked(sendChatMessage).mockResolvedValue({
response: 'Ich suche LKWs.',
actions: [{ type: 'search_vehicles', params: { type: 'lkw' } }],
session_id: 'test-session', message_id: 'msg-1',
});
vi.mocked(executeAction).mockResolvedValue({
action: 'search_vehicles', result: { items: [], total: 0, page: 1, page_size: 20 }, success: true,
});
render(<ChatInterface />);
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'Zeige LKWs' } });
fireEvent.click(screen.getByTestId('send-button'));
await waitFor(() => { expect(screen.getByTestId('action-preview')).toBeInTheDocument(); });
fireEvent.click(screen.getByTestId('confirm-action-0'));
await waitFor(() => { expect(executeAction).toHaveBeenCalled(); });
});
it('shows error on API failure', async () => {
vi.mocked(sendChatMessage).mockRejectedValue(new Error('Network error'));
render(<ChatInterface />);
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: 'Test' } });
fireEvent.click(screen.getByTestId('send-button'));
await waitFor(() => { expect(screen.getByTestId('chat-error')).toBeInTheDocument(); });
});
it('toggles history sidebar', async () => {
vi.mocked(getChatHistory).mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100 });
render(<ChatInterface />);
expect(screen.queryByTestId('chat-history')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('toggle-history'));
await waitFor(() => { expect(screen.getByTestId('chat-history')).toBeInTheDocument(); });
});
});
+34 -86
View File
@@ -1,103 +1,51 @@
# Test Report - T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI # Test Report T07: KI-Copilot
## Backend Tests ## Backend Tests
**Command:** **Command:** `cd backend && python -m pytest tests/test_copilot.py tests/test_copilot_coverage.py --cov=app.services.copilot_service --cov=app.routers.copilot --cov-report=term-missing -v`
```
cd backend && python -m pytest tests/test_sales.py tests/test_datev.py --cov=app --cov-report=term-missing -v
```
**Result:** 34 passed in 11.42s **Result:** 48 passed, 0 failed
### Test Breakdown ### Coverage
#### test_sales.py (20 tests) | Module | Stmts | Miss | Cover |
- TestSaleCRUD::test_list_sales_empty ✅ |--------|-------|------|-------|
- TestSaleCRUD::test_create_sale ✅ | app/routers/copilot.py | 33 | 8 | 76% |
- TestSaleCRUD::test_create_sale_without_vehicle_id ✅ (422) | app/services/copilot_service.py | 149 | 10 | 93% |
- TestSaleCRUD::test_create_sale_without_buyer_contact_id ✅ (422) | **TOTAL** | **182** | **18** | **90%** |
- TestSaleCRUD::test_get_sale_by_id ✅ (nested vehicle + buyer)
- TestSaleCRUD::test_get_sale_not_found ✅ (404)
- TestSaleCRUD::test_update_sale ✅
- TestSaleCRUD::test_delete_sale_cancels_and_restores_vehicle ✅
- TestSaleCRUD::test_list_sales_with_filter ✅ (status filter)
- TestSaleCRUD::test_list_sales_with_date_filter ✅ (date range)
- TestSaleVehicleStatus::test_create_sale_sets_vehicle_sold ✅
- TestSaleVehicleStatus::test_cancel_sale_restores_vehicle_available ✅
- TestContractPDF::test_regenerate_contract ✅
- TestContractPDF::test_download_contract_not_found ✅ (404)
- TestContractPDF::test_download_contract_pdf ✅ (application/pdf)
- TestContractPDF::test_gwg_clause_in_contract_html ✅ (GwG bei <=800EUR)
- TestContractPDF::test_gwg_clause_not_in_contract_when_price_too_high ✅
- TestContractPDF::test_gwg_clause_not_in_contract_when_not_gwg ✅
- TestContractPDF::test_contract_html_contains_ust_id_field ✅
- TestUstIdVerification::test_verify_ust_id_disabled ✅ (BZSt API not available)
#### test_datev.py (14 tests) Coverage exceeds the 80% requirement.
- TestDATEVExportAPI::test_create_export ✅ (201)
- TestDATEVExportAPI::test_create_export_invalid_date_range ✅ (422)
- TestDATEVExportAPI::test_list_exports ✅
- TestDATEVExportAPI::test_download_export_csv ✅ (text/csv)
- TestDATEVExportAPI::test_download_export_not_found ✅ (404)
- TestDATEVCSVFormat::test_datev_csv_headers ✅
- TestDATEVCSVFormat::test_generate_datev_csv_empty ✅
- TestDATEVCSVFormat::test_generate_datev_csv_with_sales ✅
- TestDATEVCSVFormat::test_validate_datev_csv_invalid_headers ✅
- TestDATEVCSVFormat::test_validate_datev_csv_empty ✅
- TestDATEVCSVFormat::test_validate_datev_csv_valid ✅
- TestDATEVCSVFormat::test_datev_csv_amount_format ✅ (comma separator)
- TestDATEVExportService::test_create_export_no_sales_in_range ✅
- TestDATEVExportService::test_list_exports_pagination ✅
### Coverage (T06 modules) ### Test Categories
- app/models/sale.py: 94%
- app/models/datev_export.py: 89% - **TestCopilotChat** (7 tests): POST /copilot/chat — valid message, empty message 422, no actions, DB persistence, contact search action, auth required, session continuation
- app/schemas/sale.py: 100% - **TestCopilotAction** (5 tests): POST /copilot/action — search_vehicles, search_contacts, invalid action 400, get_sale_overview, auth required
- app/schemas/datev.py: 100% - **TestCopilotHistory** (4 tests): GET /copilot/history — pagination, empty, auth required, session filter
- app/routers/sales.py: 65% - **TestCopilotVoice** (3 tests): POST /copilot/voice — transcription+response, empty audio 422, auth required
- app/routers/datev.py: 76% - **TestCopilotServiceUnit** (9 tests): _parse_ai_response, system prompt, action registry, execute_action, _get_or_create_session
- app/services/sale_service.py: 43% - **TestCopilotServiceCoverage** (20 tests): Direct service-level tests for chat(), get_history(), get_sessions(), execute_confirmed_action(), transcribe_audio(), voice_chat(), _call_openrouter_chat()
- app/services/datev_service.py: 76%
- app/utils/contract_pdf.py: 75%
- app/utils/datev.py: 88%
## Frontend Tests ## Frontend Tests
**Command:** **Command:** `cd frontend && npx vitest run tests/copilot.test.tsx`
```
cd frontend && npx vitest run tests/sales.test.tsx tests/datev.test.tsx
```
**Result:** 16 passed in 1.85s **Result:** 24 passed, 0 failed
### Test Breakdown ### Test Categories
#### sales.test.tsx (11 tests) - **ChatInput** (5 tests): renders, sends, clears, empty prevention, disabled state
- SaleList > renders sale list with title ✅ - **MessageList** (4 tests): placeholder, user/assistant messages, loading indicator, action proposals
- SaleList > displays sales in table ✅ - **ActionPreview** (5 tests): renders, confirm, dismiss, null when empty, action labels
- SaleList > shows create button ✅ - **ChatHistory** (4 tests): title, empty state, session grouping, session selection
- SaleList > has status filter ✅ - **ChatInterface** (6 tests): renders, sends+displays, action preview, action execution, error display, history toggle
- SaleList > has date range filters ✅
- SaleForm > renders form with required fields ✅
- SaleForm > has GwG toggle checkbox ✅
- SaleForm > has submit button ✅
- ContractPreview > shows empty state when no PDF path ✅
- ContractPreview > shows regenerate button ✅
- ContractPreview > shows iframe when PDF path exists ✅
#### datev.test.tsx (5 tests)
- DatevExport > renders DATEV export page with title ✅
- DatevExport > has date range inputs ✅
- DatevExport > has create export button ✅
- DatevExport > shows empty state when no exports ✅
- DatevExport > displays exports in table ✅
## Smoke Test ## Smoke Test
- Backend: App starts, all endpoints respond correctly (200/201/404/422) - Backend import verified: `from app.main import app` succeeds
- Frontend: Components render with mocked API, all test IDs present - Copilot router registered in main.py under /api/v1/copilot
- GwG logic: Clause appears in contract HTML when is_gwg=true AND price <= 800 EUR - All endpoints accessible: POST /chat, POST /action, GET /history, POST /voice
- Vehicle status: Sale creation → vehicle.availability='sold', sale cancel → vehicle.availability='available' - OpenRouter mocked in all tests — no real API calls
- DATEV CSV: Correct headers (Datum;Konto;Gegenkonto;Betrag;Belegfeld;Buchungstext), comma decimal separator - System prompt contains ERP context (Fahrzeugtypen, Kontakttypen, Verkaufsworkflow) and all 5 actions
- USt-IdNr. verify: Returns {verified: false, message: 'BZSt API not available'} when feature flag disabled - Action system: Copilot proposes actions, user confirms via /action endpoint
- WeasyPrint: Installed and importable, async PDF generation via asyncio.to_thread - Chat history persisted to DB (CopilotSession + CopilotChat models)
- Voice endpoint accepts base64 audio, transcribes, returns chat response