"""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.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)], ) -> 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, ) # 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, ): # 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")