124 lines
5.2 KiB
Python
124 lines
5.2 KiB
Python
"""Endpoint du chat contextuel (/chat/stream) : Structural Context + jauge tokens."""
|
|
import json
|
|
from typing import Annotated, AsyncIterator
|
|
|
|
import tiktoken
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from app.api.chat_dto import ChatStreamRequestDTO
|
|
from app.api.chat_mapping import (
|
|
to_campaign_context,
|
|
to_game_system_context,
|
|
to_lore_context,
|
|
to_narrative_entity,
|
|
to_page_context,
|
|
to_session_context,
|
|
)
|
|
from app.api.deps import get_chat_use_case
|
|
from app.application.chat import ChatUseCase
|
|
from app.core.config import get_settings
|
|
from app.core.language import get_user_language
|
|
from app.domain.models import ChatMessage
|
|
from app.domain.ports import LLMProviderError
|
|
|
|
router = APIRouter()
|
|
|
|
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
|
|
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
|
|
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
|
|
# suffisant pour une jauge visuelle à l'utilisateur.
|
|
_TOKEN_ENCODER: tiktoken.Encoding | None = None
|
|
|
|
|
|
def _count_tokens(text: str | None) -> int:
|
|
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
|
|
if not text:
|
|
return 0
|
|
global _TOKEN_ENCODER
|
|
if _TOKEN_ENCODER is None:
|
|
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
|
|
return len(_TOKEN_ENCODER.encode(text))
|
|
|
|
|
|
@router.post("/chat/stream")
|
|
async def chat_stream(
|
|
body: ChatStreamRequestDTO,
|
|
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
|
|
language: Annotated[str, Depends(get_user_language)],
|
|
) -> StreamingResponse:
|
|
"""Chat streamé (Server-Sent Events) avec Structural Context.
|
|
|
|
Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne,
|
|
entité narrative focalisée). Au moins un contexte racine (Lore ou
|
|
Campagne) est requis pour que la requête ait du sens.
|
|
|
|
Format de flux :
|
|
- Chaque token : `data: {"token": "..."}\\n\\n`
|
|
- Fin normale : `event: done\\ndata: {}\\n\\n`
|
|
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
|
|
"""
|
|
if not body.has_scope():
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.",
|
|
)
|
|
|
|
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
|
|
lore_context = to_lore_context(body.lore_context)
|
|
page_context = to_page_context(body.page_context)
|
|
campaign_context = to_campaign_context(body.campaign_context)
|
|
narrative_entity = to_narrative_entity(body.narrative_entity)
|
|
game_system_context = to_game_system_context(body.game_system_context)
|
|
session_context = to_session_context(body.session_context)
|
|
|
|
# --- Comptage tokens pour la jauge de contexte frontend ---
|
|
# On construit le system prompt une fois ici pour le compter — le use case
|
|
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
|
|
# Cette duplication évite de complexifier le contrat stream() avec un
|
|
# paramètre optionnel system_prompt précalculé.
|
|
system_prompt_preview = use_case.build_system_prompt(
|
|
lore_context=lore_context,
|
|
page_context=page_context,
|
|
campaign_context=campaign_context,
|
|
narrative_entity=narrative_entity,
|
|
game_system_context=game_system_context,
|
|
session_context=session_context,
|
|
language=language,
|
|
)
|
|
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
|
current_msg = messages[-1] if messages else None
|
|
history_msgs = messages[:-1] if messages else []
|
|
settings = get_settings()
|
|
usage_payload = {
|
|
"system": _count_tokens(system_prompt_preview),
|
|
"history": sum(_count_tokens(m.content) for m in history_msgs),
|
|
"current": _count_tokens(current_msg.content) if current_msg else 0,
|
|
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
|
|
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
|
|
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
|
|
}
|
|
|
|
async def event_stream() -> AsyncIterator[str]:
|
|
# Event 'usage' émis en tout premier : le frontend peut afficher la
|
|
# jauge avant même le premier token de réponse.
|
|
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
|
|
try:
|
|
async for token in use_case.stream(
|
|
messages,
|
|
lore_context=lore_context,
|
|
page_context=page_context,
|
|
campaign_context=campaign_context,
|
|
narrative_entity=narrative_entity,
|
|
game_system_context=game_system_context,
|
|
session_context=session_context,
|
|
language=language,
|
|
):
|
|
# json.dumps avec ensure_ascii=False pour préserver les accents
|
|
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
|
yield "event: done\ndata: {}\n\n"
|
|
except LLMProviderError as exc:
|
|
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
|
|
|
|
return StreamingResponse(event_stream(), media_type="text/event-stream")
|