"""Point d'entrée FastAPI du Brain LoreMind. Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue au domaine via injection de dépendance (ports + use cases), et transforme les erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici. """ import json from typing import Annotated, AsyncIterator from fastapi import Depends, FastAPI, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from app.application.chat import ChatUseCase from app.application.generate_page import GeneratePageUseCase from app.core.config import Settings, get_settings from app.domain.models import ChatMessage, LoreStructuralContext, PageContext, PageGenerationContext from app.domain.ports import LLMProvider, LLMProviderError from app.infrastructure.ollama_adapter import OllamaLLMProvider app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", version="0.1.0", ) # --- DTOs HTTP (frontière, c'est ici et seulement ici qu'on utilise Pydantic) --- class GenerateRequest(BaseModel): prompt: str class GenerateResponse(BaseModel): model: str response: str class GeneratePageRequestDTO(BaseModel): """Contexte envoyé par le Core Java pour remplir une page via le LLM.""" lore_name: str folder_name: str template_name: str template_fields: list[str] = Field(min_length=1) page_title: str lore_description: str | None = None class GeneratePageResponseDTO(BaseModel): """Retour : une valeur textuelle par champ du template (clé = field name).""" values: dict[str, str] class ChatMessageDTO(BaseModel): """Un message de la conversation. Rôles acceptés : user, assistant, system.""" role: str = Field(pattern="^(user|assistant|system)$") content: str class FolderPageDTO(BaseModel): """Résumé d'une page dans un dossier (titre + nom de template).""" title: str template_name: str class LoreContextDTO(BaseModel): """Carte structurelle du Lore : on envoie des noms, pas des contenus.""" lore_name: str lore_description: str | None = None folders: dict[str, list[FolderPageDTO]] = Field(default_factory=dict) tags: list[str] = Field(default_factory=list) class PageContextDTO(BaseModel): """Contexte d'une page spécifique pour focaliser le chat (optionnel).""" title: str template_name: str template_fields: list[str] = Field(default_factory=list) values: dict[str, str] = Field(default_factory=dict) class ChatStreamRequestDTO(BaseModel): """Requête de chat streamé : historique + contexte Lore (+ page éditée).""" messages: list[ChatMessageDTO] = Field(min_length=1) lore_context: LoreContextDTO page_context: PageContextDTO | None = None # --- Factories d'injection de dépendance --- def get_llm_provider( settings: Annotated[Settings, Depends(get_settings)], ) -> LLMProvider: """Factory d'adapter — point d'inversion de dépendance. C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret incarne le port. Pour swap vers un autre fournisseur, on change cette ligne et rien d'autre. """ return OllamaLLMProvider(settings) def get_generate_page_use_case( llm: Annotated[LLMProvider, Depends(get_llm_provider)], ) -> GeneratePageUseCase: """Factory du use case — injecte le port LLMProvider sans connaître l'adapter.""" return GeneratePageUseCase(llm=llm) def get_chat_use_case( llm: Annotated[LLMProvider, Depends(get_llm_provider)], ) -> ChatUseCase: """Factory du use case chat. L'adapter OllamaLLMProvider satisfait les deux protocoles (LLMProvider et LLMChatProvider) par duck typing ; on lui passe la même instance. """ return ChatUseCase(llm=llm) # type: ignore[arg-type] # --- Endpoints --- @app.get("/health") def health() -> dict[str, str]: """Sonde de santé — permet au Core Java de vérifier que le Brain répond.""" return {"status": "ok", "service": "brain"} @app.post("/generate", response_model=GenerateResponse) async def generate( body: GenerateRequest, settings: Annotated[Settings, Depends(get_settings)], llm: Annotated[LLMProvider, Depends(get_llm_provider)], ) -> GenerateResponse: """Endpoint libre : prompt → texte brut. Utile pour debug et exploration.""" try: text = await llm.generate(body.prompt) except LLMProviderError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return GenerateResponse(model=settings.llm_model, response=text) @app.post("/generate-page", response_model=GeneratePageResponseDTO) async def generate_page( body: GeneratePageRequestDTO, use_case: Annotated[ GeneratePageUseCase, Depends(get_generate_page_use_case) ], ) -> GeneratePageResponseDTO: """Endpoint métier : contexte LoreMind → valeurs structurées par champ. Branche tout le use case `GeneratePageUseCase`. Ce controller ne fait que le mapping DTO ↔ dataclass et la traduction d'erreur domaine → HTTP. """ context = PageGenerationContext( lore_name=body.lore_name, lore_description=body.lore_description, folder_name=body.folder_name, template_name=body.template_name, template_fields=body.template_fields, page_title=body.page_title, ) try: result = await use_case.execute(context) except LLMProviderError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return GeneratePageResponseDTO(values=result.values) @app.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 du Lore. Format de flux : - Chaque token : `data: {"token": "..."}\\n\\n` - Fin normale : `event: done\\ndata: {}\\n\\n` - Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n` Le media_type `text/event-stream` déclenche le comportement SSE côté navigateur (objet EventSource) et la désactivation automatique du buffer. """ messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] context = LoreStructuralContext( lore_name=body.lore_context.lore_name, lore_description=body.lore_context.lore_description, folders={ folder: [(p.title, p.template_name) for p in pages] for folder, pages in body.lore_context.folders.items() }, tags=body.lore_context.tags, ) page_context: PageContext | None = None if body.page_context is not None: page_context = PageContext( title=body.page_context.title, template_name=body.page_context.template_name, template_fields=body.page_context.template_fields, values=body.page_context.values, ) async def event_stream() -> AsyncIterator[str]: try: async for token in use_case.stream(messages, context, page_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")