From 5cc9f8e9a9acbde14c4e26ed39b30db98bc782a6 Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Fri, 17 Jul 2026 02:28:41 +0200 Subject: [PATCH] feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history --- .a0/current_status.md | 65 +-- .a0/known_errors.md | 10 + .a0/next_steps.md | 17 +- .a0/worklog.md | 86 ++- backend/.coverage | Bin 69632 -> 53248 bytes backend/app/main.py | 3 +- backend/app/models/copilot.py | 123 +++++ backend/app/routers/copilot.py | 114 ++++ backend/app/schemas/copilot.py | 94 ++++ backend/app/services/copilot_service.py | 405 ++++++++++++++ backend/app/utils/copilot_actions.py | 205 ++++++++ backend/app/utils/copilot_prompt.py | 98 ++++ backend/tests/test_copilot.py | 494 ++++++++++++++++++ backend/tests/test_copilot_coverage.py | 367 +++++++++++++ frontend/app/[locale]/ki-copilot/page.tsx | 10 + frontend/components/copilot/ActionPreview.tsx | 63 +++ frontend/components/copilot/ChatHistory.tsx | 91 ++++ frontend/components/copilot/ChatInput.tsx | 51 ++ frontend/components/copilot/ChatInterface.tsx | 162 ++++++ frontend/components/copilot/MessageList.tsx | 57 ++ frontend/components/copilot/VoiceInput.tsx | 94 ++++ frontend/lib/copilot.ts | 97 ++++ frontend/tests/copilot.test.tsx | 240 +++++++++ test_report.md | 120 ++--- 24 files changed, 2878 insertions(+), 188 deletions(-) create mode 100644 .a0/known_errors.md create mode 100644 backend/app/models/copilot.py create mode 100644 backend/app/routers/copilot.py create mode 100644 backend/app/schemas/copilot.py create mode 100644 backend/app/services/copilot_service.py create mode 100644 backend/app/utils/copilot_actions.py create mode 100644 backend/app/utils/copilot_prompt.py create mode 100644 backend/tests/test_copilot.py create mode 100644 backend/tests/test_copilot_coverage.py create mode 100644 frontend/app/[locale]/ki-copilot/page.tsx create mode 100644 frontend/components/copilot/ActionPreview.tsx create mode 100644 frontend/components/copilot/ChatHistory.tsx create mode 100644 frontend/components/copilot/ChatInput.tsx create mode 100644 frontend/components/copilot/ChatInterface.tsx create mode 100644 frontend/components/copilot/MessageList.tsx create mode 100644 frontend/components/copilot/VoiceInput.tsx create mode 100644 frontend/lib/copilot.ts create mode 100644 frontend/tests/copilot.test.tsx diff --git a/.a0/current_status.md b/.a0/current_status.md index 233e8f7..8ec1b73 100644 --- a/.a0/current_status.md +++ b/.a0/current_status.md @@ -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 diff --git a/.a0/known_errors.md b/.a0/known_errors.md new file mode 100644 index 0000000..311ba98 --- /dev/null +++ b/.a0/known_errors.md @@ -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) diff --git a/.a0/next_steps.md b/.a0/next_steps.md index 5ea807c..20735c5 100644 --- a/.a0/next_steps.md +++ b/.a0/next_steps.md @@ -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 diff --git a/.a0/worklog.md b/.a0/worklog.md index 990db04..1d26464 100644 --- a/.a0/worklog.md +++ b/.a0/worklog.md @@ -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) diff --git a/backend/.coverage b/backend/.coverage index b818e024ba748e25fc32b8dbcc17c6eba2f6edb5..348a2ab0fffc8e5ebd9bc3538a16bc91ac3acb7c 100644 GIT binary patch delta 299 zcmZozz|ydQd4fD6??#0O_B^}{3=B;Cfeid>`2#l#3b^vKMKQ53G+Is$eB&aLoL`Wc zlV1{FoLW?tnVhOuP#MTLdE#ID$$mGvM8Hx&Aug~9K@9xs_=A8Zxbd?@vjFusUbLAU z@Qzag(+s9SmPU0>pas?-b2wn;Y)W8J;9%nY%fR=a_b<`6o_10Fvc_IQ|O*|8M>;Ks#UXv$3%- NaxyY){xzS?0074dauxsp literal 69632 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 diff --git a/backend/app/main.py b/backend/app/main.py index 53c38c2..7a04605 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models/copilot.py b/backend/app/models/copilot.py new file mode 100644 index 0000000..9ab2e9b --- /dev/null +++ b/backend/app/models/copilot.py @@ -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"" + + 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"" + + 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, + } diff --git a/backend/app/routers/copilot.py b/backend/app/routers/copilot.py new file mode 100644 index 0000000..b2ff6a0 --- /dev/null +++ b/backend/app/routers/copilot.py @@ -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) diff --git a/backend/app/schemas/copilot.py b/backend/app/schemas/copilot.py new file mode 100644 index 0000000..e704c86 --- /dev/null +++ b/backend/app/schemas/copilot.py @@ -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") diff --git a/backend/app/services/copilot_service.py b/backend/app/services/copilot_service.py new file mode 100644 index 0000000..d317b82 --- /dev/null +++ b/backend/app/services/copilot_service.py @@ -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"], + } diff --git a/backend/app/utils/copilot_actions.py b/backend/app/utils/copilot_actions.py new file mode 100644 index 0000000..4dc5aab --- /dev/null +++ b/backend/app/utils/copilot_actions.py @@ -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) diff --git a/backend/app/utils/copilot_prompt.py b/backend/app/utils/copilot_prompt.py new file mode 100644 index 0000000..e358b68 --- /dev/null +++ b/backend/app/utils/copilot_prompt.py @@ -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"}}}}] +}} +``` +""" diff --git a/backend/tests/test_copilot.py b/backend/tests/test_copilot.py new file mode 100644 index 0000000..32065f3 --- /dev/null +++ b/backend/tests/test_copilot.py @@ -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 diff --git a/backend/tests/test_copilot_coverage.py b/backend/tests/test_copilot_coverage.py new file mode 100644 index 0000000..8fd6178 --- /dev/null +++ b/backend/tests/test_copilot_coverage.py @@ -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" diff --git a/frontend/app/[locale]/ki-copilot/page.tsx b/frontend/app/[locale]/ki-copilot/page.tsx new file mode 100644 index 0000000..455cf81 --- /dev/null +++ b/frontend/app/[locale]/ki-copilot/page.tsx @@ -0,0 +1,10 @@ +import { ChatInterface } from '@/components/copilot/ChatInterface'; + +export default function CopilotPage() { + return ( +
+

KI-Copilot

+ +
+ ); +} diff --git a/frontend/components/copilot/ActionPreview.tsx b/frontend/components/copilot/ActionPreview.tsx new file mode 100644 index 0000000..ea4f399 --- /dev/null +++ b/frontend/components/copilot/ActionPreview.tsx @@ -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 = { + 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 ( +
+

+ Der Copilot moechte folgende Aktionen ausfuehren: +

+ {actions.map((action, idx) => ( +
+
+

+ {ACTION_LABELS[action.type] || action.type} +

+

+ {Object.entries(action.params).map(([key, value]) => + `${key}: ${String(value)}` + ).join(', ') || 'Keine Parameter'} +

+
+
+ + +
+
+ ))} +
+ ); +} diff --git a/frontend/components/copilot/ChatHistory.tsx b/frontend/components/copilot/ChatHistory.tsx new file mode 100644 index 0000000..6a39bc9 --- /dev/null +++ b/frontend/components/copilot/ChatHistory.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadHistory = async () => { + setLoading(true); + setError(null); + try { + const result = await getChatHistory(1, 100); + const sessionMap = new Map(); + 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 ( +
+
+

Konversationen

+ +
+ {loading &&

Wird geladen...

} + {error &&

{error}

} + {!loading && !error && sessions.length === 0 && ( +

Keine Konversationen vorhanden.

+ )} + {!loading && !error && sessions.length > 0 && ( +
    + {sessions.map((session) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/components/copilot/ChatInput.tsx b/frontend/components/copilot/ChatInput.tsx new file mode 100644 index 0000000..c94f980 --- /dev/null +++ b/frontend/components/copilot/ChatInput.tsx @@ -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) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend] + ); + + return ( +
+