feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history
This commit is contained in:
+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"}}}}]
|
||||
}}
|
||||
```
|
||||
"""
|
||||
Reference in New Issue
Block a user