feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history
This commit is contained in:
+22
-43
@@ -1,52 +1,31 @@
|
||||
# Current Status
|
||||
|
||||
## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
|
||||
## T07: KI-Copilot (Text + Sprache) + Systemsteuerung + Copilot UI
|
||||
|
||||
**Status:** COMPLETED
|
||||
|
||||
### 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
|
||||
- ✅ DATEVExport model (app/models/datev_export.py) - UUID PK, start_date, end_date, file_path, total_amount, created_at
|
||||
- ✅ Sale schemas (app/schemas/sale.py) - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse
|
||||
- ✅ DATEV schemas (app/schemas/datev.py) - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse
|
||||
- ✅ 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
|
||||
- ✅ DATEV service (app/services/datev_service.py) - create_export, list_exports, get_export_csv
|
||||
- ✅ Contract PDF utils (app/utils/contract_pdf.py) - HTML template with WeasyPrint, GwG-Klausel, USt-IdNr. field, async via asyncio.to_thread
|
||||
- ✅ DATEV CSV utils (app/utils/datev.py) - Buchungsstapel format: Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext
|
||||
- ✅ Sales router (app/routers/sales.py) - GET/POST/PUT/DELETE /sales, POST/GET /sales/:id/contract, POST /sales/:id/verify-ust-id
|
||||
- ✅ 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
|
||||
- [x] models/copilot.py — CopilotSession, CopilotChat models
|
||||
- [x] schemas/copilot.py — ChatRequest, ChatResponse, ActionRequest, ActionResponse, ChatHistoryResponse, VoiceRequest, VoiceResponse
|
||||
- [x] routers/copilot.py — POST /chat, POST /action, GET /history, POST /voice
|
||||
- [x] services/copilot_service.py — chat, execute_action, get_history, get_sessions, transcribe_audio, voice_chat
|
||||
- [x] utils/copilot_actions.py — 5 actions: search_vehicles, search_contacts, get_sale_overview, create_vehicle, create_contact
|
||||
- [x] utils/copilot_prompt.py — System prompt with ERP context and action definitions
|
||||
- [x] tests/test_copilot.py — 28 endpoint+unit tests
|
||||
- [x] tests/test_copilot_coverage.py — 20 service-level coverage tests
|
||||
- [x] main.py — copilot router registered
|
||||
|
||||
### Frontend
|
||||
- ✅ Sales lib (lib/sales.ts) - listSales, getSale, createSale, updateSale, deleteSale, regenerateContract, verifyUstId
|
||||
- ✅ DATEV lib (lib/datev.ts) - createDatevExport, listDatevExports, getDatevDownloadUrl
|
||||
- ✅ SaleList component - Sales table with status + date range filters, pagination
|
||||
- ✅ SaleForm component - Sale create form with vehicle select, buyer input, price, GwG toggle
|
||||
- ✅ ContractPreview component - PDF embed preview, regenerate button, download link
|
||||
- ✅ DatevExport component - Date range picker, export list table, CSV download
|
||||
- ✅ Pages: verkauf/page.tsx, verkauf/[id]/page.tsx, verkauf/neu/page.tsx
|
||||
- ✅ Frontend tests (tests/sales.test.tsx, tests/datev.test.tsx) - 16 tests, all passing
|
||||
- [x] lib/copilot.ts — API client functions
|
||||
- [x] app/[locale]/ki-copilot/page.tsx — Copilot page
|
||||
- [x] components/copilot/ChatInterface.tsx — Main chat interface
|
||||
- [x] components/copilot/MessageList.tsx — Message list
|
||||
- [x] components/copilot/ChatInput.tsx — Text input
|
||||
- [x] components/copilot/VoiceInput.tsx — Voice input (Web Speech API)
|
||||
- [x] components/copilot/ActionPreview.tsx — Action preview before execution
|
||||
- [x] components/copilot/ChatHistory.tsx — Chat history sidebar
|
||||
- [x] tests/copilot.test.tsx — 24 frontend tests
|
||||
|
||||
### Acceptance Criteria Met
|
||||
- ✅ GET /api/v1/sales → 200 + paginated list with filter (date range, status)
|
||||
- ✅ GET /api/v1/sales/:id → 200 + sale detail with vehicle + contact nested
|
||||
- ✅ 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)
|
||||
### Test Results
|
||||
- Backend: 48 passed, 90% coverage (exceeds 80% requirement)
|
||||
- Frontend: 24 passed
|
||||
|
||||
@@ -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
@@ -1,12 +1,13 @@
|
||||
# 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
|
||||
1. Integrate FileUpload and FileList components into VehicleDetail page
|
||||
2. Add FileGallery to vehicle detail page for image gallery view
|
||||
3. Consider adding file metadata endpoint (GET /vehicles/:id/files/:fileId/meta) for frontend to fetch metadata without downloading
|
||||
4. Add file count to vehicle list response for quick display
|
||||
5. Consider adding file type filtering in FileList (images only, documents only)
|
||||
### Potential Follow-up Tasks
|
||||
- Integration test with real OpenRouter API (requires API key configuration)
|
||||
- Voice transcription with real audio (currently stub when no API key)
|
||||
- Frontend E2E tests with Playwright
|
||||
- Copilot settings panel (model selection, temperature)
|
||||
- Action result rendering in chat (show vehicle/contact search results inline)
|
||||
- Multi-language support for system prompt
|
||||
|
||||
+36
-50
@@ -1,55 +1,41 @@
|
||||
# Worklog
|
||||
|
||||
## T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
|
||||
## T07: KI-Copilot — 2026-07-17
|
||||
|
||||
**Date:** 2026-07-17
|
||||
**Status:** COMPLETED
|
||||
### Implemented
|
||||
- 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
|
||||
- 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/datev_export.py - DATEVExport model with start_date, end_date, file_path, total_amount
|
||||
- backend/app/schemas/sale.py - SaleCreate, SaleUpdate, SaleResponse, SaleListResponse, ContractResponse, UstIdVerifyResponse
|
||||
- backend/app/schemas/datev.py - DATEVExportCreate, DATEVExportResponse, DATEVExportListResponse
|
||||
- backend/app/services/sale_service.py - Full CRUD + contract PDF + GwG logic + USt-IdNr. verification
|
||||
- backend/app/services/datev_service.py - Export creation, listing, CSV download
|
||||
- backend/app/utils/contract_pdf.py - HTML template with WeasyPrint, GwG-Klausel §6 Abs. 2 EStG, USt-IdNr. field
|
||||
- backend/app/utils/datev.py - DATEV CSV Buchungsstapel format helper
|
||||
- backend/app/routers/sales.py - Sales CRUD + contract + USt-IdNr. endpoints
|
||||
- backend/app/routers/datev.py - DATEV export + list + download endpoints
|
||||
- backend/tests/test_sales.py - 20 tests covering CRUD, contract PDF, GwG, vehicle status, USt-IdNr.
|
||||
- backend/tests/test_datev.py - 14 tests covering export API, CSV format, validation
|
||||
|
||||
#### Frontend
|
||||
- frontend/lib/sales.ts - Sales API client functions and types
|
||||
- frontend/lib/datev.ts - DATEV API client functions and types
|
||||
- frontend/components/sales/SaleList.tsx - Sales table with filters and pagination
|
||||
- frontend/components/sales/SaleForm.tsx - Sale create form with GwG toggle
|
||||
- frontend/components/sales/ContractPreview.tsx - PDF preview with regenerate/download
|
||||
- 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
|
||||
### Files Changed
|
||||
- backend/app/models/copilot.py (new)
|
||||
- backend/app/schemas/copilot.py (new)
|
||||
- backend/app/routers/copilot.py (new)
|
||||
- backend/app/services/copilot_service.py (new)
|
||||
- backend/app/utils/copilot_actions.py (new)
|
||||
- backend/app/utils/copilot_prompt.py (new)
|
||||
- backend/app/main.py (modified — added copilot router import + registration)
|
||||
- backend/tests/test_copilot.py (new)
|
||||
- backend/tests/test_copilot_coverage.py (new)
|
||||
- frontend/lib/copilot.ts (new)
|
||||
- frontend/app/[locale]/ki-copilot/page.tsx (new)
|
||||
- frontend/components/copilot/ChatInterface.tsx (new)
|
||||
- frontend/components/copilot/MessageList.tsx (new)
|
||||
- frontend/components/copilot/ChatInput.tsx (new)
|
||||
- frontend/components/copilot/VoiceInput.tsx (new)
|
||||
- frontend/components/copilot/ActionPreview.tsx (new)
|
||||
- frontend/components/copilot/ChatHistory.tsx (new)
|
||||
- frontend/tests/copilot.test.tsx (new)
|
||||
- test_report.md (new)
|
||||
|
||||
Binary file not shown.
+2
-1
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, contacts, datev, files, ocr, sales, users, vehicles
|
||||
from app.routers import auth, contacts, copilot, datev, files, ocr, sales, users, vehicles
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -46,6 +46,7 @@ api_v1_router.include_router(files.router)
|
||||
api_v1_router.include_router(ocr.router)
|
||||
api_v1_router.include_router(sales.router)
|
||||
api_v1_router.include_router(datev.router)
|
||||
api_v1_router.include_router(copilot.router)
|
||||
|
||||
# Health endpoint (no auth required)
|
||||
@api_v1_router.get("/health", tags=["health"])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"}}}}]
|
||||
}}
|
||||
```
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -1,103 +1,51 @@
|
||||
# Test Report - T06: Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
|
||||
# Test Report — T07: KI-Copilot
|
||||
|
||||
## Backend Tests
|
||||
|
||||
**Command:**
|
||||
```
|
||||
cd backend && python -m pytest tests/test_sales.py tests/test_datev.py --cov=app --cov-report=term-missing -v
|
||||
```
|
||||
**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`
|
||||
|
||||
**Result:** 34 passed in 11.42s
|
||||
**Result:** 48 passed, 0 failed
|
||||
|
||||
### Test Breakdown
|
||||
### Coverage
|
||||
|
||||
#### test_sales.py (20 tests)
|
||||
- TestSaleCRUD::test_list_sales_empty ✅
|
||||
- TestSaleCRUD::test_create_sale ✅
|
||||
- TestSaleCRUD::test_create_sale_without_vehicle_id ✅ (422)
|
||||
- TestSaleCRUD::test_create_sale_without_buyer_contact_id ✅ (422)
|
||||
- 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)
|
||||
| Module | Stmts | Miss | Cover |
|
||||
|--------|-------|------|-------|
|
||||
| app/routers/copilot.py | 33 | 8 | 76% |
|
||||
| app/services/copilot_service.py | 149 | 10 | 93% |
|
||||
| **TOTAL** | **182** | **18** | **90%** |
|
||||
|
||||
#### test_datev.py (14 tests)
|
||||
- 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 exceeds the 80% requirement.
|
||||
|
||||
### Coverage (T06 modules)
|
||||
- app/models/sale.py: 94%
|
||||
- app/models/datev_export.py: 89%
|
||||
- app/schemas/sale.py: 100%
|
||||
- app/schemas/datev.py: 100%
|
||||
- app/routers/sales.py: 65%
|
||||
- app/routers/datev.py: 76%
|
||||
- app/services/sale_service.py: 43%
|
||||
- app/services/datev_service.py: 76%
|
||||
- app/utils/contract_pdf.py: 75%
|
||||
- app/utils/datev.py: 88%
|
||||
### Test Categories
|
||||
|
||||
- **TestCopilotChat** (7 tests): POST /copilot/chat — valid message, empty message 422, no actions, DB persistence, contact search action, auth required, session continuation
|
||||
- **TestCopilotAction** (5 tests): POST /copilot/action — search_vehicles, search_contacts, invalid action 400, get_sale_overview, auth required
|
||||
- **TestCopilotHistory** (4 tests): GET /copilot/history — pagination, empty, auth required, session filter
|
||||
- **TestCopilotVoice** (3 tests): POST /copilot/voice — transcription+response, empty audio 422, auth required
|
||||
- **TestCopilotServiceUnit** (9 tests): _parse_ai_response, system prompt, action registry, execute_action, _get_or_create_session
|
||||
- **TestCopilotServiceCoverage** (20 tests): Direct service-level tests for chat(), get_history(), get_sessions(), execute_confirmed_action(), transcribe_audio(), voice_chat(), _call_openrouter_chat()
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
**Command:**
|
||||
```
|
||||
cd frontend && npx vitest run tests/sales.test.tsx tests/datev.test.tsx
|
||||
```
|
||||
**Command:** `cd frontend && npx vitest run tests/copilot.test.tsx`
|
||||
|
||||
**Result:** 16 passed in 1.85s
|
||||
**Result:** 24 passed, 0 failed
|
||||
|
||||
### Test Breakdown
|
||||
### Test Categories
|
||||
|
||||
#### sales.test.tsx (11 tests)
|
||||
- SaleList > renders sale list with title ✅
|
||||
- SaleList > displays sales in table ✅
|
||||
- SaleList > shows create button ✅
|
||||
- SaleList > has status filter ✅
|
||||
- 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 ✅
|
||||
- **ChatInput** (5 tests): renders, sends, clears, empty prevention, disabled state
|
||||
- **MessageList** (4 tests): placeholder, user/assistant messages, loading indicator, action proposals
|
||||
- **ActionPreview** (5 tests): renders, confirm, dismiss, null when empty, action labels
|
||||
- **ChatHistory** (4 tests): title, empty state, session grouping, session selection
|
||||
- **ChatInterface** (6 tests): renders, sends+displays, action preview, action execution, error display, history toggle
|
||||
|
||||
## Smoke Test
|
||||
|
||||
- Backend: App starts, all endpoints respond correctly (200/201/404/422)
|
||||
- Frontend: Components render with mocked API, all test IDs present
|
||||
- GwG logic: Clause appears in contract HTML when is_gwg=true AND price <= 800 EUR
|
||||
- Vehicle status: Sale creation → vehicle.availability='sold', sale cancel → vehicle.availability='available'
|
||||
- DATEV CSV: Correct headers (Datum;Konto;Gegenkonto;Betrag;Belegfeld;Buchungstext), comma decimal separator
|
||||
- USt-IdNr. verify: Returns {verified: false, message: 'BZSt API not available'} when feature flag disabled
|
||||
- WeasyPrint: Installed and importable, async PDF generation via asyncio.to_thread
|
||||
- Backend import verified: `from app.main import app` succeeds
|
||||
- Copilot router registered in main.py under /api/v1/copilot
|
||||
- All endpoints accessible: POST /chat, POST /action, GET /history, POST /voice
|
||||
- OpenRouter mocked in all tests — no real API calls
|
||||
- System prompt contains ERP context (Fahrzeugtypen, Kontakttypen, Verkaufsworkflow) and all 5 actions
|
||||
- Action system: Copilot proposes actions, user confirms via /action endpoint
|
||||
- Chat history persisted to DB (CopilotSession + CopilotChat models)
|
||||
- Voice endpoint accepts base64 audio, transcribes, returns chat response
|
||||
|
||||
Reference in New Issue
Block a user