diff --git a/brain/app/api/__init__.py b/brain/app/api/__init__.py new file mode 100644 index 0000000..cecf072 --- /dev/null +++ b/brain/app/api/__init__.py @@ -0,0 +1,5 @@ +"""Adapter web (architecture hexagonale) : routers FastAPI, DTOs et factories DI. + +C'est la FRONTIÈRE HTTP du Brain : validation Pydantic, mapping DTO ↔ domaine, +traduction des erreurs domaine → HTTP. Aucune logique métier ici. +""" diff --git a/brain/app/api/chat_dto.py b/brain/app/api/chat_dto.py new file mode 100644 index 0000000..9ede24e --- /dev/null +++ b/brain/app/api/chat_dto.py @@ -0,0 +1,215 @@ +"""DTOs Pydantic du chat contextuel — frontière HTTP avec le Core Java. + +C'est ici (et seulement ici, avec les autres modules de `app.api`) qu'on +utilise Pydantic : le domaine ne voit que des dataclasses (voir chat_mapping). +""" +from pydantic import BaseModel, Field + + +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 PageSummaryDTO(BaseModel): + """Résumé enrichi d'une page : identité + contenu + interconnexions. + + Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON — + le Core Java ne les sérialise que s'ils sont non-vides (payload léger + pour un Lore avec beaucoup de pages vierges). + """ + + title: str + template_name: str + values: dict[str, str] = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) + related_page_titles: list[str] = Field(default_factory=list) + + +class LoreContextDTO(BaseModel): + """Carte structurelle du Lore avec contenu des pages (b9+).""" + + lore_name: str + lore_description: str | None = None + folders: dict[str, list[PageSummaryDTO]] = 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 SceneBranchHintDTO(BaseModel): + """Indice d'une branche narrative (le Core a deja resolu le nom cible).""" + + label: str + target_scene_name: str + condition: str | None = None + + +class RoomBranchHintDTO(BaseModel): + """Sortie d'une pièce vers une autre pièce du même lieu (donjon).""" + + label: str + target_room_name: str + condition: str | None = None + + +class RoomSummaryDTO(BaseModel): + """Pièce d'un lieu explorable. Omise par le Core si la scène est classique.""" + + name: str + floor: int | None = None + description: str | None = None + enemies: str | None = None + branches: list[RoomBranchHintDTO] = Field(default_factory=list) + + +class SceneSummaryDTO(BaseModel): + """Résumé d'une scène : nom + description courte (synopsis).""" + + name: str + description: str | None = None + # Optionnel : le Core Java ne serialise illustration_count QUE si > 0 + # (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent. + illustration_count: int = 0 + # Branches narratives sortantes, omises cote Core si vides. + branches: list[SceneBranchHintDTO] = Field(default_factory=list) + # Pièces du lieu explorable, omises par Core si scène classique. + rooms: list[RoomSummaryDTO] = Field(default_factory=list) + + +class ChapterSummaryDTO(BaseModel): + """Résumé d'un chapitre : nom + description courte + ses scènes.""" + + name: str + description: str | None = None + scenes: list[SceneSummaryDTO] = Field(default_factory=list) + illustration_count: int = 0 + + +class ArcSummaryDTO(BaseModel): + """Résumé d'un arc narratif : nom + description courte + ses chapitres.""" + + name: str + description: str | None = None + chapters: list[ChapterSummaryDTO] = Field(default_factory=list) + illustration_count: int = 0 + + +class CharacterSummaryDTO(BaseModel): + """Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé.""" + + name: str + snippet: str = "" + + +class NpcSummaryDTO(BaseModel): + """Résumé d'un PNJ : symétrique à CharacterSummaryDTO.""" + + name: str + snippet: str = "" + + +class CampaignContextDTO(BaseModel): + """Carte narrative enrichie : arcs → chapitres → scènes avec synopsis.""" + + campaign_name: str + campaign_description: str | None = None + arcs: list[ArcSummaryDTO] = Field(default_factory=list) + characters: list[CharacterSummaryDTO] = Field(default_factory=list) + npcs: list[NpcSummaryDTO] = Field(default_factory=list) + + +class NarrativeEntityDTO(BaseModel): + """Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel.""" + + entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$") + title: str + fields: dict[str, str] = Field(default_factory=dict) + + +class GameSystemContextDTO(BaseModel): + """Règles de JDR présélectionnées par le Core (filtrées par intent). + + Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être + vides si aucune section ne matchait l'intent de génération courant. + """ + + system_name: str + system_description: str | None = None + sections: dict[str, str] = Field(default_factory=dict) + + +class JournalEntrySummaryDTO(BaseModel): + """Une entrée du journal de session. + + `source_session_name` est présent uniquement pour les évènements issus + des sessions précédentes — sert à ancrer temporellement dans le prompt. + """ + + type: str + content: str + occurred_at: str | None = None + source_session_name: str | None = None + + +class QuestSummaryDTO(BaseModel): + """Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine.""" + + name: str + arc_name: str + description: str | None = None + + +class SessionContextDTO(BaseModel): + """Contexte d'une Session de jeu en cours (Play Context). + + Combine le journal complet (`entries`), les EVENTs des sessions précédentes + (`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes + Hub de la campagne (disponibles / en cours / verrouillées) plus les flags + narratifs actuellement actifs. + """ + + session_name: str + active: bool + started_at: str | None = None + entries: list[JournalEntrySummaryDTO] = Field(default_factory=list) + previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list) + available_quests: list[QuestSummaryDTO] = Field(default_factory=list) + in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list) + locked_quest_titles: list[str] = Field(default_factory=list) + active_flags: list[str] = Field(default_factory=list) + + +class ChatStreamRequestDTO(BaseModel): + """Requête de chat streamé : historique + contextes structurels. + + Les contextes (lore, page, campaign, narrative_entity, session) sont + optionnels, mais au moins l'un des contextes "racines" (lore_context, + campaign_context ou session_context) doit être fourni. Le validateur + `check_scope` applique cette règle à la frontière HTTP. + """ + + messages: list[ChatMessageDTO] = Field(min_length=1) + lore_context: LoreContextDTO | None = None + page_context: PageContextDTO | None = None + campaign_context: CampaignContextDTO | None = None + narrative_entity: NarrativeEntityDTO | None = None + game_system_context: GameSystemContextDTO | None = None + session_context: SessionContextDTO | None = None + + def has_scope(self) -> bool: + """Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni.""" + return ( + self.lore_context is not None + or self.campaign_context is not None + or self.session_context is not None + ) diff --git a/brain/app/api/chat_mapping.py b/brain/app/api/chat_mapping.py new file mode 100644 index 0000000..2fbb1db --- /dev/null +++ b/brain/app/api/chat_mapping.py @@ -0,0 +1,192 @@ +"""Mapping DTO → domaine (couche anti-corruption de la frontière HTTP). + +Traduit les DTOs Pydantic du chat contextuel en dataclasses du domaine : +le cœur métier ne dépend ainsi jamais de Pydantic ni du format JSON du Core. +""" +from app.api.chat_dto import ( + CampaignContextDTO, + GameSystemContextDTO, + JournalEntrySummaryDTO, + LoreContextDTO, + NarrativeEntityDTO, + PageContextDTO, + PageSummaryDTO, + QuestSummaryDTO, + SessionContextDTO, +) +from app.domain.models import ( + ArcSummary, + CampaignStructuralContext, + ChapterSummary, + CharacterSummary, + GameSystemContext, + JournalEntrySummary, + LoreStructuralContext, + NarrativeEntityContext, + NpcSummary, + PageContext, + PageSummary, + QuestSummary, + RoomBranchHint, + RoomSummary, + SceneBranchHint, + SceneSummary, + SessionContext, +) + + +def to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None: + if dto is None: + return None + return LoreStructuralContext( + lore_name=dto.lore_name, + lore_description=dto.lore_description, + folders={ + folder: [_to_page_summary(p) for p in pages] + for folder, pages in dto.folders.items() + }, + tags=dto.tags, + ) + + +def _to_page_summary(dto: PageSummaryDTO) -> PageSummary: + return PageSummary( + title=dto.title, + template_name=dto.template_name, + values=dict(dto.values), + tags=list(dto.tags), + related_page_titles=list(dto.related_page_titles), + ) + + +def to_page_context(dto: PageContextDTO | None) -> PageContext | None: + if dto is None: + return None + return PageContext( + title=dto.title, + template_name=dto.template_name, + template_fields=dto.template_fields, + values=dto.values, + ) + + +def to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None: + if dto is None: + return None + arcs = [ + ArcSummary( + name=arc.name, + description=arc.description, + illustration_count=arc.illustration_count, + chapters=[ + ChapterSummary( + name=ch.name, + description=ch.description, + illustration_count=ch.illustration_count, + scenes=[ + SceneSummary( + name=sc.name, + description=sc.description, + illustration_count=sc.illustration_count, + branches=[ + SceneBranchHint( + label=br.label, + target_scene_name=br.target_scene_name, + condition=br.condition, + ) + for br in sc.branches + ], + rooms=[ + RoomSummary( + name=room.name, + floor=room.floor, + description=room.description, + enemies=room.enemies, + branches=[ + RoomBranchHint( + label=rb.label, + target_room_name=rb.target_room_name, + condition=rb.condition, + ) + for rb in room.branches + ], + ) + for room in sc.rooms + ], + ) + for sc in ch.scenes + ], + ) + for ch in arc.chapters + ], + ) + for arc in dto.arcs + ] + characters = [ + CharacterSummary(name=c.name, snippet=c.snippet) + for c in dto.characters + ] + npcs = [ + NpcSummary(name=n.name, snippet=n.snippet) + for n in dto.npcs + ] + return CampaignStructuralContext( + campaign_name=dto.campaign_name, + campaign_description=dto.campaign_description, + arcs=arcs, + characters=characters, + npcs=npcs, + ) + + +def to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None: + if dto is None: + return None + return NarrativeEntityContext( + entity_type=dto.entity_type, + title=dto.title, + fields=dict(dto.fields), + ) + + +def to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None: + if dto is None: + return None + return GameSystemContext( + system_name=dto.system_name, + system_description=dto.system_description, + sections=dict(dto.sections), + ) + + +def to_session_context(dto: SessionContextDTO | None) -> SessionContext | None: + if dto is None: + return None + return SessionContext( + session_name=dto.session_name, + active=dto.active, + started_at=dto.started_at, + entries=[_to_journal_entry(e) for e in dto.entries], + previous_events=[_to_journal_entry(e) for e in dto.previous_events], + available_quests=[_to_quest_summary(q) for q in dto.available_quests], + in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests], + locked_quest_titles=list(dto.locked_quest_titles), + active_flags=list(dto.active_flags), + ) + + +def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary: + return QuestSummary( + name=dto.name, + arc_name=dto.arc_name, + description=dto.description, + ) + + +def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary: + return JournalEntrySummary( + type=dto.type, + content=dto.content, + occurred_at=dto.occurred_at, + source_session_name=dto.source_session_name, + ) diff --git a/brain/app/api/common.py b/brain/app/api/common.py new file mode 100644 index 0000000..f6efccb --- /dev/null +++ b/brain/app/api/common.py @@ -0,0 +1,26 @@ +"""Utilitaires partagés des routers : encodage SSE + garde-fous d'upload PDF.""" +from __future__ import annotations + +import json + +# Garde-fou taille : un livre de règles dépasse rarement quelques dizaines de Mo. +# Au-delà, on refuse (probable erreur d'upload) plutôt que d'OOM le conteneur. +MAX_PDF_BYTES = 60 * 1024 * 1024 # 60 Mo + + +def sse_event(event: str, data: dict) -> str: + """Encode un évènement Server-Sent Events (accents préservés).""" + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +def pdf_upload_error(content: bytes) -> str | None: + """Message d'erreur si l'upload PDF est invalide (vide / trop gros), sinon None. + + Utilisé par les flux SSE, où l'erreur doit partir en évènement `error` + plutôt qu'en HTTPException (le flux est déjà ouvert en 200). + """ + if not content: + return "Fichier PDF vide." + if len(content) > MAX_PDF_BYTES: + return f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo)." + return None diff --git a/brain/app/api/deps.py b/brain/app/api/deps.py new file mode 100644 index 0000000..616d1b9 --- /dev/null +++ b/brain/app/api/deps.py @@ -0,0 +1,143 @@ +"""Factories d'injection de dépendance — le point d'inversion de l'hexagone. + +C'est ICI (et seulement ici) qu'on choisit QUEL adapter concret incarne chaque +port (LLM, embeddings, extracteur PDF), en fonction des Settings — modifiables +à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les +ports et les use cases, jamais Ollama/Mistral/etc. +""" +from typing import Annotated + +from fastapi import Depends, HTTPException + +from app.application.adapt_campaign import AdaptCampaignUseCase +from app.application.chat import ChatUseCase +from app.application.embeddings import EmbeddingError +from app.application.generate_page import GeneratePageUseCase +from app.application.import_campaign import ImportCampaignUseCase +from app.application.import_rules import ImportRulesUseCase +from app.application.notebook_chat import NotebookChatUseCase +from app.application.notebook_deep import NotebookDeepUseCase +from app.application.notebook_rag import NotebookRagUseCase +from app.core.config import Settings, get_settings +from app.domain.ports import LLMProvider, LLMProviderError +from app.infrastructure.gemini_adapter import GeminiLLMProvider +from app.infrastructure.mistral_adapter import MistralLLMProvider +from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider +from app.infrastructure.ollama_adapter import OllamaLLMProvider +from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider +from app.infrastructure.onemin_adapter import OneMinAiLLMProvider +from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider +from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor + +# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût +# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module. +_PDF_EXTRACTOR = PyMuPdfTextExtractor() + + +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, en fonction du champ `llm_provider` des Settings + (modifiable a chaud depuis l'ecran Parametres de l'UI). + """ + try: + if settings.llm_provider == "onemin": + return OneMinAiLLMProvider(settings) + if settings.llm_provider == "openrouter": + return OpenRouterLLMProvider(settings) + if settings.llm_provider == "mistral": + return MistralLLMProvider(settings) + if settings.llm_provider == "gemini": + return GeminiLLMProvider(settings) + return OllamaLLMProvider(settings) + except LLMProviderError as exc: + # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 + # pour que le frontend puisse afficher un message actionnable. + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +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] + + +def get_import_rules_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> ImportRulesUseCase: + """Factory du use case d'import de règles PDF (extraction + structuration).""" + return ImportRulesUseCase( + llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) + + +def get_import_campaign_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> ImportCampaignUseCase: + """Factory du use case d'import de campagne PDF (extraction + arborescence).""" + return ImportCampaignUseCase( + llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) + + +def get_adapt_campaign_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> AdaptCampaignUseCase: + """Factory du use case d'adaptation d'un PDF à une campagne (conseils streamés).""" + # L'adapter satisfait aussi LLMChatProvider (stream_chat) par duck typing. + # Budget d'entrée = taille de morceau configurée (qui passe déjà côté provider). + return AdaptCampaignUseCase( # type: ignore[arg-type] + llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens) + + +def get_embedding_provider( + settings: Annotated[Settings, Depends(get_settings)], +): + """Factory de l'adapter d'embeddings (RAG) selon `embedding_provider`.""" + try: + if settings.embedding_provider == "mistral": + return MistralEmbeddingProvider(settings) + return OllamaEmbeddingProvider(settings) + except EmbeddingError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +def get_notebook_rag_use_case( + embedder: Annotated[object, Depends(get_embedding_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> NotebookRagUseCase: + return NotebookRagUseCase( + extractor=_PDF_EXTRACTOR, + embedder=embedder, # type: ignore[arg-type] + min_score=settings.rag_min_score, + ) + + +def get_notebook_chat_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)], +) -> NotebookChatUseCase: + return NotebookChatUseCase(rag=rag, llm=llm) # type: ignore[arg-type] + + +def get_notebook_deep_use_case( + llm: Annotated[LLMProvider, Depends(get_llm_provider)], + settings: Annotated[Settings, Depends(get_settings)], +) -> NotebookDeepUseCase: + return NotebookDeepUseCase(llm=llm, batch_tokens=settings.import_chunk_tokens) diff --git a/brain/app/api/routers/__init__.py b/brain/app/api/routers/__init__.py new file mode 100644 index 0000000..d3b792b --- /dev/null +++ b/brain/app/api/routers/__init__.py @@ -0,0 +1,5 @@ +"""Routers FastAPI du Brain, un par responsabilité métier. + +Chemins inchangés par rapport à l'ancien main.py monolithique : le Core Java +et le frontend ne voient AUCUNE différence. +""" diff --git a/brain/app/api/routers/chat.py b/brain/app/api/routers/chat.py new file mode 100644 index 0000000..f617640 --- /dev/null +++ b/brain/app/api/routers/chat.py @@ -0,0 +1,119 @@ +"""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") diff --git a/brain/app/api/routers/generation.py b/brain/app/api/routers/generation.py new file mode 100644 index 0000000..a06f5c9 --- /dev/null +++ b/brain/app/api/routers/generation.py @@ -0,0 +1,137 @@ +"""Endpoints de génération « simple » : prompt libre, page de Lore, auto-titre.""" +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from app.api.deps import get_generate_page_use_case, get_llm_provider +from app.application.generate_page import GeneratePageUseCase +from app.core.config import Settings, get_settings +from app.domain.models import PageGenerationContext +from app.domain.ports import LLMProvider, LLMProviderError + +router = APIRouter() + + +class GenerateRequest(BaseModel): + prompt: str + + +class GenerateResponse(BaseModel): + model: str + response: str + + +@router.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) + + +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] + + +@router.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) + + +# --- Auto-titre d'une conversation persistee -------------------------------- + + +class SummarizeTitleMessageDTO(BaseModel): + role: Literal["user", "assistant", "system"] + content: str + + +class SummarizeTitleRequestDTO(BaseModel): + """Premiers messages d'une conversation pour auto-generer un titre court.""" + + messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list) + + +class SummarizeTitleResponseDTO(BaseModel): + title: str + + +_TITLE_SYSTEM_PROMPT = ( + "Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la " + "conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, " + "sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre " + "en francais et capturer le sujet metier (pas 'Conversation IA')." +) + + +@router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO) +async def summarize_conversation_title( + body: SummarizeTitleRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], +) -> SummarizeTitleResponseDTO: + """Genere un titre court a partir des premiers echanges de la conversation. + + Appele par le core apres le 1er couple user/assistant, pour remplacer le + titre provisoire "Nouvelle conversation" par quelque chose de parlant. + """ + if not body.messages: + raise HTTPException(status_code=422, detail="Au moins un message requis") + + transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6]) + prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :" + try: + raw = await llm.generate(prompt) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".") + if len(title) > 80: + title = title[:80].rstrip() + if not title: + title = "Nouvelle conversation" + return SummarizeTitleResponseDTO(title=title) diff --git a/brain/app/api/routers/imports.py b/brain/app/api/routers/imports.py new file mode 100644 index 0000000..666214f --- /dev/null +++ b/brain/app/api/routers/imports.py @@ -0,0 +1,184 @@ +"""Endpoints d'import/adaptation de PDF (règles, campagne) — REST + flux SSE.""" +import json +import logging +from typing import Annotated, AsyncIterator + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.api.common import MAX_PDF_BYTES, pdf_upload_error, sse_event +from app.api.deps import ( + get_adapt_campaign_use_case, + get_import_campaign_use_case, + get_import_rules_use_case, +) +from app.application.adapt_campaign import AdaptCampaignUseCase +from app.application.import_campaign import ImportCampaignUseCase +from app.application.import_rules import ImportRulesUseCase +from app.domain.models import ChatMessage +from app.domain.ports import LLMProviderError, PdfExtractionError + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class RulesImportResponseDTO(BaseModel): + """Proposition de sections de règles extraites d'un PDF. + + `sections` = {titre → contenu markdown}. C'est une PROPOSITION : le Core + et l'UI laissent l'utilisateur réviser/éditer avant toute persistance. + `ocr_page_count` permet d'indiquer si le PDF était un scan (OCR utilisé). + """ + + sections: dict[str, str] + page_count: int + ocr_page_count: int + + +@router.post("/import/rules", response_model=RulesImportResponseDTO) +async def import_rules( + use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], + file: UploadFile = File(...), +) -> RulesImportResponseDTO: + """Import d'un PDF de règles → sections markdown structurées (proposition). + + Extrait le texte (couche texte + repli OCR par page pour les scans), découpe, + et demande au LLM de répartir les règles en sections thématiques. Ne persiste + rien : renvoie la proposition au Core, qui la présente pour révision. + """ + content = await file.read() + if not content: + raise HTTPException(status_code=422, detail="Fichier PDF vide.") + if len(content) > MAX_PDF_BYTES: + raise HTTPException( + status_code=413, + detail=f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo).", + ) + + try: + result = await use_case.execute(content) + except PdfExtractionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + return RulesImportResponseDTO( + sections=result.sections, + page_count=result.page_count, + ocr_page_count=result.ocr_page_count, + ) + + +@router.post("/import/rules/stream") +async def import_rules_stream( + use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], + file: UploadFile = File(...), +) -> StreamingResponse: + """Import streamé : émet l'avancement (SSE) puis le résultat final. + + Évènements SSE : + - `event: extracting` → data: {} (extraction en cours) + - `event: start` → data: {page_count, ocr_page_count, total} + - `event: progress` → data: {current, total, new_sections:[...]} + - `event: done` → data: {sections, page_count, ocr_page_count} + - `event: error` → data: {message} + """ + content = await file.read() + + async def event_stream() -> AsyncIterator[str]: + upload_error = pdf_upload_error(content) + if upload_error: + yield sse_event("error", {"message": upload_error}) + return + try: + async for ev in use_case.stream(content): + event_type = ev.pop("type") + yield sse_event(event_type, ev) + except PdfExtractionError as exc: + yield sse_event("error", {"message": str(exc)}) + except LLMProviderError as exc: + yield sse_event("error", {"message": str(exc)}) + except Exception as exc: # noqa: BLE001 — filet : une erreur inattendue ne doit + # PAS casser le flux SSE brutalement (sinon le Core n'a qu'un message générique + # sans détail). On la transforme en évènement `error` propre + log avec trace. + logger.exception("Import règles : erreur inattendue dans le flux.") + yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@router.post("/import/campaign/stream") +async def import_campaign_stream( + use_case: Annotated[ImportCampaignUseCase, Depends(get_import_campaign_use_case)], + file: UploadFile = File(...), +) -> StreamingResponse: + """Import streamé d'un PDF de campagne → arbre arc→chapitre→scène (SSE). + + Évènements : `extracting`, `start` {page_count, ocr_page_count, total}, + `progress` {current, total, arc_count, chapter_count, scene_count}, + `done` {arcs:[...], page_count, ocr_page_count}, `error` {message}. + """ + content = await file.read() + + async def event_stream() -> AsyncIterator[str]: + upload_error = pdf_upload_error(content) + if upload_error: + yield sse_event("error", {"message": upload_error}) + return + try: + async for ev in use_case.stream(content): + event_type = ev.pop("type") + yield sse_event(event_type, ev) + except PdfExtractionError as exc: + yield sse_event("error", {"message": str(exc)}) + except LLMProviderError as exc: + yield sse_event("error", {"message": str(exc)}) + except Exception as exc: # noqa: BLE001 — voir import règles : on ne laisse pas + # une erreur inattendue casser le flux sans détail. + logger.exception("Import campagne : erreur inattendue dans le flux.") + yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@router.post("/adapt/campaign/stream") +async def adapt_campaign_stream( + use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)], + file: UploadFile = File(...), + brief: str = Form(""), + messages: str = Form("[]"), +) -> StreamingResponse: + """Adaptation CONVERSATIONNELLE d'un PDF à une campagne (SSE markdown). + + `brief` = description de la campagne (Core). `messages` = JSON de l'échange + ([{role, content}, …]) ; vide au 1er tour. Évènements : `token`, `done`, `error`. + """ + content = await file.read() + + try: + raw_messages = json.loads(messages) if messages else [] + except json.JSONDecodeError: + raw_messages = [] + convo = [ + ChatMessage(role=str(m.get("role", "user")), content=str(m.get("content", ""))) + for m in raw_messages + if isinstance(m, dict) and str(m.get("content", "")).strip() + ] + + async def event_stream() -> AsyncIterator[str]: + upload_error = pdf_upload_error(content) + if upload_error: + yield sse_event("error", {"message": upload_error}) + return + try: + async for token in use_case.stream(content, brief, convo): + yield sse_event("token", {"token": token}) + yield sse_event("done", {}) + except PdfExtractionError as exc: + yield sse_event("error", {"message": str(exc)}) + except LLMProviderError as exc: + yield sse_event("error", {"message": str(exc)}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") diff --git a/brain/app/api/routers/models.py b/brain/app/api/routers/models.py new file mode 100644 index 0000000..e6d5285 --- /dev/null +++ b/brain/app/api/routers/models.py @@ -0,0 +1,365 @@ +"""Endpoints de catalogue de modèles (Ollama, OpenRouter, Mistral, Gemini, 1min.ai). + +Proxifie les APIs des providers pour que l'UI propose des listes de modèles ; +repli statique quand l'API est injoignable ou la clé absente (pas de 500 à l'UI). +""" +import json +from typing import Annotated, AsyncIterator + +import httpx +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.core.config import Settings, get_settings + +router = APIRouter() + + +@router.get("/models/ollama") +async def list_ollama_models( + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, list[str]]: + """Liste les modeles disponibles sur le serveur Ollama configure. + + Retourne une liste vide si Ollama est injoignable — l'UI affichera un + message plutot qu'une 500. + """ + url = f"{settings.ollama_base_url}/api/tags" + try: + async with httpx.AsyncClient(timeout=5) as client: + response = await client.get(url) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + return {"models": []} + models = [m.get("name", "") for m in data.get("models", []) if m.get("name")] + return {"models": sorted(models)} + + +class OllamaModelInfoDTO(BaseModel): + """Info utile extraite de /api/show pour un modele Ollama donne. + + `context_length` = fenetre de contexte max supportee par le modele + (extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert + pour borner le slider de num_ctx dans les Parametres. + """ + + context_length: int = 0 + + +@router.post("/models/ollama/info", response_model=OllamaModelInfoDTO) +async def get_ollama_model_info( + body: dict[str, str], + settings: Annotated[Settings, Depends(get_settings)], +) -> OllamaModelInfoDTO: + """Retourne les metadonnees d'un modele Ollama via /api/show. + + On passe par POST (et pas GET /models/ollama/{name}) parce que les noms + Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente + mal dans une URL — le body JSON evite le probleme d'escaping. + + Le champ qui nous interesse est `model_info[".context_length"]` + (ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on + scanne donc tous les champs finissant par `.context_length`. + """ + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name requis") + url = f"{settings.ollama_base_url}/api/show" + try: + async with httpx.AsyncClient(timeout=5) as client: + response = await client.post(url, json={"model": name}) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + return OllamaModelInfoDTO(context_length=0) + model_info = data.get("model_info") or {} + for key, value in model_info.items(): + if key.endswith(".context_length") and isinstance(value, int): + return OllamaModelInfoDTO(context_length=value) + return OllamaModelInfoDTO(context_length=0) + + +@router.post("/models/ollama/pull") +async def pull_ollama_model( + body: dict[str, str], + settings: Annotated[Settings, Depends(get_settings)], +) -> StreamingResponse: + """Telecharge un modele depuis Ollama et streame la progression. + + Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par + ligne (NDJSON) avec le statut de chaque etape : manifest, layers, + digest, success. On reemet ce flux tel quel au client (le front + parsera les lignes et affichera une barre de progression). + + Le timeout est intentionnellement tres long (60 min) car certains + modeles font 30+ Go. + """ + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name requis") + url = f"{settings.ollama_base_url}/api/pull" + + async def stream() -> AsyncIterator[bytes]: + # On utilise un timeout long pour la lecture (60 min) mais court pour + # la connexion (10s) — si Ollama n'est pas joignable, on echoue vite. + timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10) + try: + async with httpx.AsyncClient(timeout=timeout) as client: + async with client.stream("POST", url, json={"model": name, "stream": True}) as r: + if r.status_code != 200: + # Ollama renvoie un message JSON d'erreur. On le passe + # tel quel au client en preservant le code HTTP. + body_text = await r.aread() + yield body_text + return + async for chunk in r.aiter_bytes(): + yield chunk + except httpx.HTTPError as e: + # Erreur reseau : on emet une ligne JSON d'erreur compatible + # avec le format NDJSON d'Ollama. + err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n" + yield err.encode("utf-8") + + # application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE. + # C'est le format natif d'Ollama, le front le parsera ligne par ligne. + return StreamingResponse(stream(), media_type="application/x-ndjson") + + +@router.delete("/models/ollama/{name:path}") +async def delete_ollama_model( + name: str, + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, str]: + """Supprime un modele du serveur Ollama. + + Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`) + sans avoir besoin de URL-encoder cote client. + """ + if not name.strip(): + raise HTTPException(status_code=400, detail="name requis") + url = f"{settings.ollama_base_url}/api/delete" + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.request("DELETE", url, json={"model": name}) + if response.status_code == 404: + raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable") + response.raise_for_status() + except httpx.HTTPError as e: + raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}") + return {"status": "deleted", "name": name} + + +@router.get("/models/openrouter") +async def list_openrouter_models() -> dict[str, list[dict[str, object]]]: + """Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle). + + Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord + puis contexte decroissant. `free` = id finissant par ':free' OU prix nul. + """ + try: + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get("https://openrouter.ai/api/v1/models") + response.raise_for_status() + data = response.json() + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}") + + def _is_zero(value: object) -> bool: + try: + return float(value) == 0.0 # type: ignore[arg-type] + except (TypeError, ValueError): + return False + + models: list[dict[str, object]] = [] + for m in data.get("data", []) or []: + mid = str(m.get("id") or "") + if not mid: + continue + pricing = m.get("pricing") or {} + is_free = mid.endswith(":free") or ( + _is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion")) + ) + try: + ctx = int(m.get("context_length") or 0) + except (TypeError, ValueError): + ctx = 0 + models.append({ + "id": mid, + "name": str(m.get("name") or mid), + "context_length": ctx, + "free": is_free, + }) + + models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index] + return {"models": models} + + +# Repli statique si la cle Mistral n'est pas (encore) configuree ou si l'API est +# injoignable — l'utilisateur peut quand meme choisir un modele. Liste curee +# (juin 2026) ; pour l'extraction de PDF, prefere `large` (fidele, 128k) ou `small`. +_MISTRAL_FALLBACK_MODELS = [ + "mistral-large-latest", + "mistral-medium-latest", + "mistral-small-latest", + "open-mistral-nemo", + "ministral-8b-latest", + "ministral-3b-latest", + "magistral-medium-latest", + "magistral-small-latest", + "pixtral-large-latest", + "codestral-latest", +] + + +@router.get("/models/mistral") +async def list_mistral_models( + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, list[dict[str, object]]]: + """Catalogue des modeles Mistral. Dynamique si une cle est configuree + (GET /v1/models, qui requiert l'auth), sinon repli statique. + + Renvoie {models: [{id}]} (tous accessibles sur le tier gratuit Experiment).""" + key = settings.mistral_api_key + if not key: + return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]} + try: + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get( + "https://api.mistral.ai/v1/models", + headers={"Authorization": f"Bearer {key}"}, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + # Cle invalide / API down : on ne casse pas l'UI, on propose le repli. + return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]} + + ids = sorted({str(m.get("id")) for m in data.get("data", []) or [] if m.get("id")}) + if not ids: + ids = _MISTRAL_FALLBACK_MODELS + return {"models": [{"id": i} for i in ids]} + + +# Repli statique Gemini (juin 2026). Pour l'extraction, prefere un Flash a grand +# contexte ; `gemini-2.0-flash` a le quota gratuit le plus genereux. +_GEMINI_FALLBACK_MODELS = [ + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-2.5-pro", + "gemini-1.5-flash", + "gemini-1.5-pro", +] + + +@router.get("/models/gemini") +async def list_gemini_models( + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, list[dict[str, object]]]: + """Catalogue des modeles Gemini. Dynamique si une cle est configuree (endpoint + OpenAI-compatible /openai/models), sinon repli statique. Renvoie {models:[{id}]}.""" + key = settings.gemini_api_key + if not key: + return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]} + try: + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get( + "https://generativelanguage.googleapis.com/v1beta/openai/models", + headers={"Authorization": f"Bearer {key}"}, + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]} + + # Les ids peuvent arriver prefixes "models/" → on nettoie pour que la valeur + # selectionnee soit directement utilisable dans l'appel chat. On garde les + # modeles "gemini-*" (hors embeddings/aqa) pour ne pas noyer la liste. + ids: set[str] = set() + for m in data.get("data", []) or []: + mid = str(m.get("id") or "") + if mid.startswith("models/"): + mid = mid[len("models/"):] + if mid.startswith("gemini-"): + ids.add(mid) + clean = sorted(ids) if ids else _GEMINI_FALLBACK_MODELS + return {"models": [{"id": i} for i in clean]} + + +@router.get("/models/onemin") +def list_onemin_models() -> dict[str, list[dict[str, object]]]: + """Catalogue statique des modeles 1min.ai, groupes par fournisseur. + + Liste construite par probing direct de l'endpoint chat-with-ai avec + une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs + absents renvoient 400 UNSUPPORTED_MODEL. + + Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai + (`claude--`), pas la convention officielle Anthropic. + """ + return { + "groups": [ + { + "provider": "Anthropic", + "models": ["claude-opus-4-6", "claude-sonnet-4-6"], + }, + { + "provider": "OpenAI", + "models": [ + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o3", + "o3-pro", + "o3-mini", + "o4-mini", + ], + }, + { + "provider": "Google", + "models": ["gemini-2.5-pro", "gemini-2.5-flash"], + }, + { + "provider": "Mistral", + "models": [ + "mistral-large-latest", + "mistral-medium-latest", + "mistral-small-latest", + "open-mistral-nemo", + ], + }, + { + "provider": "DeepSeek", + "models": ["deepseek-chat", "deepseek-reasoner"], + }, + { + "provider": "xAI", + "models": ["grok-3", "grok-3-mini"], + }, + { + "provider": "Meta", + "models": [ + "meta/meta-llama-3.1-405b-instruct", + "meta/meta-llama-3-70b-instruct", + ], + }, + { + "provider": "Alibaba", + "models": ["qwen-plus", "qwen3-max"], + }, + { + "provider": "Perplexity", + "models": ["sonar", "sonar-pro"], + }, + ] + } diff --git a/brain/app/api/routers/notebooks.py b/brain/app/api/routers/notebooks.py new file mode 100644 index 0000000..4199e4c --- /dev/null +++ b/brain/app/api/routers/notebooks.py @@ -0,0 +1,125 @@ +"""Endpoints des notebooks (atelier RAG) : indexation des sources + chats ancrés.""" +import logging +from typing import Annotated, AsyncIterator + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from app.api.common import MAX_PDF_BYTES, sse_event +from app.api.deps import ( + get_notebook_chat_use_case, + get_notebook_deep_use_case, + get_notebook_rag_use_case, +) +from app.application.embeddings import EmbeddingError +from app.application.notebook_chat import NotebookChatUseCase +from app.application.notebook_deep import NotebookDeepUseCase +from app.application.notebook_rag import NotebookRagUseCase +from app.core.config import Settings, get_settings +from app.domain.models import ChatMessage +from app.domain.ports import LLMProviderError, PdfExtractionError +from app.infrastructure import vector_store + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class IndexSourceResponseDTO(BaseModel): + chunks: int + page_count: int + ocr_page_count: int + + +@router.post("/index/notebook-source", response_model=IndexSourceResponseDTO) +async def index_notebook_source( + rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)], + source_id: str = Form(...), + file: UploadFile = File(...), +) -> IndexSourceResponseDTO: + """Indexe une source PDF (extraction + embeddings + stockage vectoriel).""" + content = await file.read() + if not content: + raise HTTPException(status_code=422, detail="Fichier PDF vide.") + if len(content) > MAX_PDF_BYTES: + raise HTTPException( + status_code=413, detail=f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo).") + try: + recap = await rag.index_source(source_id, content) + except PdfExtractionError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except EmbeddingError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return IndexSourceResponseDTO(**recap) + + +@router.delete("/index/notebook-source/{source_id}") +def delete_notebook_source(source_id: str) -> dict[str, str]: + """Supprime les vecteurs d'une source (au DELETE d'une source/notebook).""" + vector_store.delete(source_id) + return {"status": "deleted", "source_id": source_id} + + +class NotebookChatMessageDTO(BaseModel): + role: str + content: str + + +class NotebookChatRequestDTO(BaseModel): + source_ids: list[str] = Field(default_factory=list) + messages: list[NotebookChatMessageDTO] = Field(default_factory=list) + context: str = Field(default="") + + +@router.post("/chat/notebook/stream") +async def chat_notebook_stream( + body: NotebookChatRequestDTO, + use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)], + settings: Annotated[Settings, Depends(get_settings)], +) -> StreamingResponse: + """Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis + streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}.""" + messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] + top_k = max(1, min(settings.rag_top_k, 200)) + + async def event_stream() -> AsyncIterator[str]: + try: + async for token in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k): + if token: + yield sse_event("token", {"token": token}) + yield sse_event("done", {}) + except (LLMProviderError, EmbeddingError) as exc: + yield sse_event("error", {"message": str(exc)}) + except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale du flux. + logger.exception("Chat notebook : erreur inattendue.") + yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +@router.post("/chat/notebook/deep/stream") +async def chat_notebook_deep_stream( + body: NotebookChatRequestDTO, + use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)], +) -> StreamingResponse: + """Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE : + `progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`.""" + messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] + question = next((m.content for m in reversed(messages) if m.role == "user"), "") + + async def event_stream() -> AsyncIterator[str]: + if not question.strip(): + yield sse_event("error", {"message": "Question vide."}) + return + try: + async for ev in use_case.stream(body.source_ids, messages, context=body.context): + ev_type = ev.pop("type") + yield sse_event(ev_type, ev) + except (LLMProviderError, EmbeddingError) as exc: + yield sse_event("error", {"message": str(exc)}) + except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale. + logger.exception("Analyse approfondie : erreur inattendue.") + yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) + + return StreamingResponse(event_stream(), media_type="text/event-stream") diff --git a/brain/app/api/routers/settings.py b/brain/app/api/routers/settings.py new file mode 100644 index 0000000..3750811 --- /dev/null +++ b/brain/app/api/routers/settings.py @@ -0,0 +1,115 @@ +"""Endpoints de paramétrage runtime (écran Paramètres de l'UI).""" +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app.core.config import Settings, get_settings +from app.core.settings_store import save_overrides + +router = APIRouter() + + +class SettingsDTO(BaseModel): + """Vue serialisable des settings modifiables depuis l'UI. + + Expose uniquement les champs que l'utilisateur peut changer a chaud. + Les secrets (onemin_api_key) sont masques en lecture. + """ + + llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] + ollama_base_url: str + llm_model: str + onemin_model: str + # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. + onemin_api_key_set: bool + openrouter_model: str + # True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee). + openrouter_api_key_set: bool + mistral_model: str + # True si une cle Mistral est deja configuree (cle elle-meme jamais renvoyee). + mistral_api_key_set: bool + gemini_model: str + # True si une cle Gemini est deja configuree (cle elle-meme jamais renvoyee). + gemini_api_key_set: bool + # Embeddings (RAG des ateliers) : provider + modeles + auto-pull Ollama. + embedding_provider: Literal["ollama", "mistral"] + ollama_embedding_model: str + mistral_embedding_model: str + auto_pull_embedding_model: bool + rag_top_k: int + # Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert + # aussi de plafond a la jauge de contexte UI. + llm_num_ctx: int + # Taille cible d'un morceau (tokens) pour l'import de PDF (regles/campagne). + import_chunk_tokens: int + # Timeout HTTP des appels LLM (s). A monter si les imports lourds expirent. + llm_timeout_seconds: int + + +class SettingsUpdateDTO(BaseModel): + """Patch partiel des settings. Tous les champs sont optionnels.""" + + llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] | None = None + ollama_base_url: str | None = None + llm_model: str | None = None + onemin_model: str | None = None + # Chaine vide => on efface la cle. None => pas de changement. + onemin_api_key: str | None = None + openrouter_model: str | None = None + openrouter_api_key: str | None = None + mistral_model: str | None = None + mistral_api_key: str | None = None + gemini_model: str | None = None + gemini_api_key: str | None = None + embedding_provider: Literal["ollama", "mistral"] | None = None + ollama_embedding_model: str | None = None + mistral_embedding_model: str | None = None + auto_pull_embedding_model: bool | None = None + rag_top_k: int | None = None + llm_num_ctx: int | None = None + import_chunk_tokens: int | None = None + llm_timeout_seconds: int | None = None + + +def _to_settings_dto(s: Settings) -> SettingsDTO: + return SettingsDTO( + llm_provider=s.llm_provider, + ollama_base_url=s.ollama_base_url, + llm_model=s.llm_model, + onemin_model=s.onemin_model, + onemin_api_key_set=bool(s.onemin_api_key), + openrouter_model=s.openrouter_model, + openrouter_api_key_set=bool(s.openrouter_api_key), + mistral_model=s.mistral_model, + mistral_api_key_set=bool(s.mistral_api_key), + gemini_model=s.gemini_model, + gemini_api_key_set=bool(s.gemini_api_key), + embedding_provider=s.embedding_provider, + ollama_embedding_model=s.ollama_embedding_model, + mistral_embedding_model=s.mistral_embedding_model, + auto_pull_embedding_model=s.auto_pull_embedding_model, + rag_top_k=s.rag_top_k, + llm_num_ctx=s.llm_num_ctx, + import_chunk_tokens=s.import_chunk_tokens, + llm_timeout_seconds=s.llm_timeout_seconds, + ) + + +@router.get("/settings", response_model=SettingsDTO) +def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO: + """Retourne la config courante (secrets masques).""" + return _to_settings_dto(settings) + + +@router.put("/settings", response_model=SettingsDTO) +def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO: + """Applique un patch partiel aux settings et persiste les overrides. + + Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache). + """ + overrides = {k: v for k, v in patch.model_dump().items() if v is not None} + if overrides: + save_overrides(overrides) + # Relit .env + overrides fusionnes pour confirmation. + return _to_settings_dto(get_settings()) diff --git a/brain/app/api/routers/tables.py b/brain/app/api/routers/tables.py new file mode 100644 index 0000000..4f5b5fc --- /dev/null +++ b/brain/app/api/routers/tables.py @@ -0,0 +1,216 @@ +"""Endpoints « outils de table » : tables aléatoires, improvisation, catalogues d'objets.""" +import re +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from app.api.deps import get_llm_provider +from app.application.llm_json import load_json_object +from app.application.llm_retry import generate_with_retry +from app.domain.ports import LLMProvider, LLMProviderError + +router = APIRouter() + +_DICE_FORMULA_RE = re.compile(r"^\s*(\d*)\s*[dD]\s*(\d+)\s*$") + + +def _dice_total_range(formula: str) -> tuple[int, int] | None: + """(min, max) des totaux possibles d'une formule NdM, ou None si invalide.""" + match = _DICE_FORMULA_RE.match(formula or "") + if not match: + return None + count = int(match.group(1)) if match.group(1) else 1 + faces = int(match.group(2)) + if count < 1 or count > 100 or faces < 2 or faces > 10000: + return None + return count, count * faces + + +class GenerateTableRequestDTO(BaseModel): + description: str + dice_formula: str = Field(default="1d20") + # Contexte libre assemblé par le Core (nom de campagne, système, ambiance…). + context: str = Field(default="") + + +class GeneratedTableEntryDTO(BaseModel): + min_roll: int + max_roll: int + label: str + detail: str = "" + + +class GenerateTableResponseDTO(BaseModel): + name: str + description: str = "" + entries: list[GeneratedTableEntryDTO] + + +@router.post("/generate/random-table", response_model=GenerateTableResponseDTO) +async def generate_random_table( + body: GenerateTableRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], +) -> GenerateTableResponseDTO: + """Génère une table aléatoire (entrées par plage) couvrant la formule de dé.""" + rng = _dice_total_range(body.dice_formula) + if rng is None: + raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).") + lo, hi = rng + context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" + prompt = ( + "Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n" + f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n" + f"Sujet : {body.description.strip()}\n" + f"{context_block}\n" + "Règles IMPÉRATIVES :\n" + "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" + '- Format : {"name": "...", "description": "...", "entries": ' + '[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n' + f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, " + "sans trou ni chevauchement, dans l'ordre croissant.\n" + "- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n" + "- En français. 'label' = résultat bref ; 'detail' = description/effet concret.\n" + "Renvoie maintenant le JSON." + ) + try: + raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + parsed, _ = load_json_object(raw) + if not isinstance(parsed, dict): + raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de table exploitable.") + + entries: list[GeneratedTableEntryDTO] = [] + for e in parsed.get("entries", []) or []: + if not isinstance(e, dict): + continue + try: + mn = int(e["min_roll"]) + mx = int(e["max_roll"]) + except (KeyError, TypeError, ValueError): + continue + label = str(e.get("label") or "").strip() + if not label: + continue + entries.append(GeneratedTableEntryDTO( + min_roll=mn, max_roll=max(mn, mx), label=label[:200], + detail=str(e.get("detail") or "").strip(), + )) + if not entries: + raise HTTPException(status_code=502, detail="Aucune entrée générée — réessaie ou reformule.") + + name = str(parsed.get("name") or body.description).strip()[:120] or "Table générée" + return GenerateTableResponseDTO( + name=name, + description=str(parsed.get("description") or "").strip(), + entries=entries, + ) + + +class ImproviseRollRequestDTO(BaseModel): + table_name: str + result_label: str + result_detail: str = Field(default="") + context: str = Field(default="") + + +class ImproviseRollResponseDTO(BaseModel): + narration: str + + +@router.post("/improvise/table-roll", response_model=ImproviseRollResponseDTO) +async def improvise_table_roll( + body: ImproviseRollRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], +) -> ImproviseRollResponseDTO: + """Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.""" + detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else "" + context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else "" + prompt = ( + "Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table " + f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}." + f"{context_block}\n\n" + "Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. " + "Pas de méta, pas d'options : juste la narration, en français." + ) + try: + raw = await llm.generate(prompt, temperature=0.8) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return ImproviseRollResponseDTO(narration=raw.strip()) + + +# --- Catalogues d'objets (boutiques) : génération IA ------------------------- + + +class GenerateCatalogRequestDTO(BaseModel): + description: str + context: str = Field(default="") + + +class GeneratedCatalogItemDTO(BaseModel): + name: str + price: str = "" + category: str = "" + description: str = "" + + +class GenerateCatalogResponseDTO(BaseModel): + name: str + description: str = "" + items: list[GeneratedCatalogItemDTO] + + +@router.post("/generate/item-catalog", response_model=GenerateCatalogResponseDTO) +async def generate_item_catalog( + body: GenerateCatalogRequestDTO, + llm: Annotated[LLMProvider, Depends(get_llm_provider)], +) -> GenerateCatalogResponseDTO: + """Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description.""" + context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" + prompt = ( + "Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n" + f"Sujet : {body.description.strip()}\n" + f"{context_block}\n" + "Règles IMPÉRATIVES :\n" + "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" + '- Format : {"name": "...", "description": "...", "items": ' + '[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n' + "- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n" + "- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; " + "'description' = effet/détails en une phrase. En français.\n" + "Renvoie maintenant le JSON." + ) + try: + raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) + except LLMProviderError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + parsed, _ = load_json_object(raw) + if not isinstance(parsed, dict): + raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de catalogue exploitable.") + + items: list[GeneratedCatalogItemDTO] = [] + for it in parsed.get("items", []) or []: + if not isinstance(it, dict): + continue + name = str(it.get("name") or "").strip() + if not name: + continue + items.append(GeneratedCatalogItemDTO( + name=name[:200], + price=str(it.get("price") or "").strip(), + category=str(it.get("category") or "").strip(), + description=str(it.get("description") or "").strip(), + )) + if not items: + raise HTTPException(status_code=502, detail="Aucun objet généré — réessaie ou reformule.") + + name = str(parsed.get("name") or body.description).strip()[:120] or "Catalogue généré" + return GenerateCatalogResponseDTO( + name=name, + description=str(parsed.get("description") or "").strip(), + items=items, + ) diff --git a/brain/app/infrastructure/ollama_model_installer.py b/brain/app/infrastructure/ollama_model_installer.py new file mode 100644 index 0000000..1fa0404 --- /dev/null +++ b/brain/app/infrastructure/ollama_model_installer.py @@ -0,0 +1,51 @@ +"""Auto-installation du modèle d'embeddings Ollama au démarrage du Brain. + +Adapter d'infrastructure : parle directement à l'API HTTP d'Ollama. Best-effort +(Ollama peut être absent / la connexion limitée) — n'empêche jamais le démarrage. +""" +from __future__ import annotations + +import asyncio +import logging + +import httpx + +logger = logging.getLogger(__name__) + + +async def ensure_ollama_embedding_model(base_url: str, model: str) -> None: + """Télécharge `model` sur le serveur Ollama s'il n'y est pas déjà. + + Attend qu'Ollama soit joignable (ordre de démarrage des conteneurs), puis + vérifie la présence du modèle avant de le tirer. + """ + for attempt in range(10): + try: + async with httpx.AsyncClient(timeout=10) as client: + tags = await client.get(f"{base_url}/api/tags") + tags.raise_for_status() + names = [m.get("name", "") for m in tags.json().get("models", [])] + if any(n == model or n.startswith(model + ":") for n in names): + logger.info("Modèle d'embedding '%s' déjà présent.", model) + return + break # Ollama joignable, modèle absent → on tire (ci-dessous) + except httpx.HTTPError: + await asyncio.sleep(min(5 * (attempt + 1), 30)) + else: + logger.warning( + "Ollama injoignable au démarrage — modèle d'embedding '%s' non auto-installé " + "(il sera tirable manuellement : ollama pull %s).", model, model) + return + + logger.info("Téléchargement automatique du modèle d'embedding '%s'…", model) + try: + async with httpx.AsyncClient(timeout=None) as client: + async with client.stream("POST", f"{base_url}/api/pull", json={"name": model}) as resp: + resp.raise_for_status() + async for _line in resp.aiter_lines(): + pass # on draine la progression NDJSON jusqu'à la fin + logger.info("Modèle d'embedding '%s' prêt.", model) + except httpx.HTTPError as exc: + logger.warning( + "Auto-installation du modèle d'embedding '%s' échouée : %s " + "(tirage manuel possible : ollama pull %s).", model, exc, model) diff --git a/brain/app/main.py b/brain/app/main.py index be063fd..a6e0a05 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -1,67 +1,27 @@ -"""Point d'entrée FastAPI du Brain LoreMind. +"""Point d'entrée FastAPI du Brain LoreMind : assemblage de l'application. -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. +Responsabilité UNIQUE : créer l'app, brancher le middleware d'auth inter-service, +le hook de démarrage et les routers (un par responsabilité, voir `app.api.routers`). +Toute la logique HTTP vit dans les routers ; la logique métier dans `app.application`. """ import asyncio -import json -import logging -from typing import Annotated, AsyncIterator, Literal - import hmac -import httpx -import tiktoken -from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import JSONResponse, StreamingResponse -from pydantic import BaseModel, Field +import logging -import re +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse -from app.application.adapt_campaign import AdaptCampaignUseCase -from app.application.chat import ChatUseCase -from app.application.generate_page import GeneratePageUseCase -from app.application.import_campaign import ImportCampaignUseCase -from app.application.import_rules import ImportRulesUseCase -from app.application.llm_json import load_json_object -from app.application.llm_retry import generate_with_retry -from app.application.notebook_rag import NotebookRagUseCase -from app.application.notebook_chat import NotebookChatUseCase -from app.application.notebook_deep import NotebookDeepUseCase -from app.application.embeddings import EmbeddingError -from app.infrastructure import vector_store -from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider -from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider -from app.core.config import Settings, get_settings -from app.core.settings_store import save_overrides -from app.domain.models import ( - ArcSummary, - CampaignStructuralContext, - ChapterSummary, - CharacterSummary, - NpcSummary, - ChatMessage, - GameSystemContext, - JournalEntrySummary, - LoreStructuralContext, - NarrativeEntityContext, - PageContext, - PageGenerationContext, - PageSummary, - QuestSummary, - RoomBranchHint, - RoomSummary, - SceneBranchHint, - SceneSummary, - SessionContext, +from app.api.routers import ( + chat, + generation, + imports, + models, + notebooks, + settings as settings_router, + tables, ) -from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError -from app.infrastructure.ollama_adapter import OllamaLLMProvider -from app.infrastructure.onemin_adapter import OneMinAiLLMProvider -from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider -from app.infrastructure.mistral_adapter import MistralLLMProvider -from app.infrastructure.gemini_adapter import GeminiLLMProvider -from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor +from app.core.config import get_settings +from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_model app = FastAPI( title="LoreMind Brain", @@ -71,24 +31,6 @@ app = FastAPI( logger = logging.getLogger(__name__) - -# 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)) - - # Chemins exemptes d'auth inter-service : healthcheck docker + introspection # FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain # n'est pas expose en dehors du reseau interne donc pas un risque). @@ -119,359 +61,6 @@ async def require_internal_secret(request: Request, call_next): return await call_next(request) -# --- 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 PageSummaryDTO(BaseModel): - """Résumé enrichi d'une page : identité + contenu + interconnexions. - - Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON — - le Core Java ne les sérialise que s'ils sont non-vides (payload léger - pour un Lore avec beaucoup de pages vierges). - """ - - title: str - template_name: str - values: dict[str, str] = Field(default_factory=dict) - tags: list[str] = Field(default_factory=list) - related_page_titles: list[str] = Field(default_factory=list) - - -class LoreContextDTO(BaseModel): - """Carte structurelle du Lore avec contenu des pages (b9+).""" - - lore_name: str - lore_description: str | None = None - folders: dict[str, list[PageSummaryDTO]] = 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 SceneBranchHintDTO(BaseModel): - """Indice d'une branche narrative (le Core a deja resolu le nom cible).""" - - label: str - target_scene_name: str - condition: str | None = None - - -class RoomBranchHintDTO(BaseModel): - """Sortie d'une pièce vers une autre pièce du même lieu (donjon).""" - - label: str - target_room_name: str - condition: str | None = None - - -class RoomSummaryDTO(BaseModel): - """Pièce d'un lieu explorable. Omise par le Core si la scène est classique.""" - - name: str - floor: int | None = None - description: str | None = None - enemies: str | None = None - branches: list[RoomBranchHintDTO] = Field(default_factory=list) - - -class SceneSummaryDTO(BaseModel): - """Résumé d'une scène : nom + description courte (synopsis).""" - - name: str - description: str | None = None - # Optionnel : le Core Java ne serialise illustration_count QUE si > 0 - # (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent. - illustration_count: int = 0 - # Branches narratives sortantes, omises cote Core si vides. - branches: list[SceneBranchHintDTO] = Field(default_factory=list) - # Pièces du lieu explorable, omises par Core si scène classique. - rooms: list[RoomSummaryDTO] = Field(default_factory=list) - - -class ChapterSummaryDTO(BaseModel): - """Résumé d'un chapitre : nom + description courte + ses scènes.""" - - name: str - description: str | None = None - scenes: list[SceneSummaryDTO] = Field(default_factory=list) - illustration_count: int = 0 - - -class ArcSummaryDTO(BaseModel): - """Résumé d'un arc narratif : nom + description courte + ses chapitres.""" - - name: str - description: str | None = None - chapters: list[ChapterSummaryDTO] = Field(default_factory=list) - illustration_count: int = 0 - - -class CharacterSummaryDTO(BaseModel): - """Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé.""" - - name: str - snippet: str = "" - - -class NpcSummaryDTO(BaseModel): - """Résumé d'un PNJ : symétrique à CharacterSummaryDTO.""" - - name: str - snippet: str = "" - - -class CampaignContextDTO(BaseModel): - """Carte narrative enrichie : arcs → chapitres → scènes avec synopsis.""" - - campaign_name: str - campaign_description: str | None = None - arcs: list[ArcSummaryDTO] = Field(default_factory=list) - characters: list[CharacterSummaryDTO] = Field(default_factory=list) - npcs: list[NpcSummaryDTO] = Field(default_factory=list) - - -class NarrativeEntityDTO(BaseModel): - """Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel.""" - - entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$") - title: str - fields: dict[str, str] = Field(default_factory=dict) - - -class GameSystemContextDTO(BaseModel): - """Règles de JDR présélectionnées par le Core (filtrées par intent). - - Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être - vides si aucune section ne matchait l'intent de génération courant. - """ - - system_name: str - system_description: str | None = None - sections: dict[str, str] = Field(default_factory=dict) - - -class JournalEntrySummaryDTO(BaseModel): - """Une entrée du journal de session. - - `source_session_name` est présent uniquement pour les évènements issus - des sessions précédentes — sert à ancrer temporellement dans le prompt. - """ - - type: str - content: str - occurred_at: str | None = None - source_session_name: str | None = None - - -class QuestSummaryDTO(BaseModel): - """Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine.""" - - name: str - arc_name: str - description: str | None = None - - -class SessionContextDTO(BaseModel): - """Contexte d'une Session de jeu en cours (Play Context). - - Combine le journal complet (`entries`), les EVENTs des sessions précédentes - (`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes - Hub de la campagne (disponibles / en cours / verrouillées) plus les flags - narratifs actuellement actifs. - """ - - session_name: str - active: bool - started_at: str | None = None - entries: list[JournalEntrySummaryDTO] = Field(default_factory=list) - previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list) - available_quests: list[QuestSummaryDTO] = Field(default_factory=list) - in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list) - locked_quest_titles: list[str] = Field(default_factory=list) - active_flags: list[str] = Field(default_factory=list) - - -class ChatStreamRequestDTO(BaseModel): - """Requête de chat streamé : historique + contextes structurels. - - Les contextes (lore, page, campaign, narrative_entity, session) sont - optionnels, mais au moins l'un des contextes "racines" (lore_context, - campaign_context ou session_context) doit être fourni. Le validateur - `check_scope` applique cette règle à la frontière HTTP. - """ - - messages: list[ChatMessageDTO] = Field(min_length=1) - lore_context: LoreContextDTO | None = None - page_context: PageContextDTO | None = None - campaign_context: CampaignContextDTO | None = None - narrative_entity: NarrativeEntityDTO | None = None - game_system_context: GameSystemContextDTO | None = None - session_context: SessionContextDTO | None = None - - def has_scope(self) -> bool: - """Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni.""" - return ( - self.lore_context is not None - or self.campaign_context is not None - or self.session_context is not 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, en fonction du champ `llm_provider` des Settings - (modifiable a chaud depuis l'ecran Parametres de l'UI). - """ - try: - if settings.llm_provider == "onemin": - return OneMinAiLLMProvider(settings) - if settings.llm_provider == "openrouter": - return OpenRouterLLMProvider(settings) - if settings.llm_provider == "mistral": - return MistralLLMProvider(settings) - if settings.llm_provider == "gemini": - return GeminiLLMProvider(settings) - return OllamaLLMProvider(settings) - except LLMProviderError as exc: - # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 - # pour que le frontend puisse afficher un message actionnable. - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -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] - - -# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût -# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module. -_PDF_EXTRACTOR = PyMuPdfTextExtractor() - - -def get_import_rules_use_case( - llm: Annotated[LLMProvider, Depends(get_llm_provider)], - settings: Annotated[Settings, Depends(get_settings)], -) -> ImportRulesUseCase: - """Factory du use case d'import de règles PDF (extraction + structuration).""" - return ImportRulesUseCase( - llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) - - -def get_import_campaign_use_case( - llm: Annotated[LLMProvider, Depends(get_llm_provider)], - settings: Annotated[Settings, Depends(get_settings)], -) -> ImportCampaignUseCase: - """Factory du use case d'import de campagne PDF (extraction + arborescence).""" - return ImportCampaignUseCase( - llm=llm, extractor=_PDF_EXTRACTOR, chunk_target_tokens=settings.import_chunk_tokens) - - -def get_adapt_campaign_use_case( - llm: Annotated[LLMProvider, Depends(get_llm_provider)], - settings: Annotated[Settings, Depends(get_settings)], -) -> AdaptCampaignUseCase: - """Factory du use case d'adaptation d'un PDF à une campagne (conseils streamés).""" - # L'adapter satisfait aussi LLMChatProvider (stream_chat) par duck typing. - # Budget d'entrée = taille de morceau configurée (qui passe déjà côté provider). - return AdaptCampaignUseCase( # type: ignore[arg-type] - llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens) - - -def get_embedding_provider( - settings: Annotated[Settings, Depends(get_settings)], -): - """Factory de l'adapter d'embeddings (RAG) selon `embedding_provider`.""" - try: - if settings.embedding_provider == "mistral": - return MistralEmbeddingProvider(settings) - return OllamaEmbeddingProvider(settings) - except EmbeddingError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - -def get_notebook_rag_use_case( - embedder: Annotated[object, Depends(get_embedding_provider)], -) -> NotebookRagUseCase: - return NotebookRagUseCase(extractor=_PDF_EXTRACTOR, embedder=embedder) # type: ignore[arg-type] - - -def get_notebook_chat_use_case( - llm: Annotated[LLMProvider, Depends(get_llm_provider)], - rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)], -) -> NotebookChatUseCase: - return NotebookChatUseCase(rag=rag, llm=llm) # type: ignore[arg-type] - - -def get_notebook_deep_use_case( - llm: Annotated[LLMProvider, Depends(get_llm_provider)], - settings: Annotated[Settings, Depends(get_settings)], -) -> NotebookDeepUseCase: - return NotebookDeepUseCase(llm=llm, batch_tokens=settings.import_chunk_tokens) - - -# --- Endpoints --- - - @app.get("/health") def health() -> dict[str, str]: """Sonde de santé — permet au Core Java de vérifier que le Brain répond.""" @@ -488,1327 +77,15 @@ async def _auto_install_embedding_model() -> None: settings = get_settings() if not settings.auto_pull_embedding_model or settings.embedding_provider != "ollama": return - asyncio.create_task(_ensure_ollama_embedding_model(settings.ollama_base_url, settings.ollama_embedding_model)) - - -async def _ensure_ollama_embedding_model(base_url: str, model: str) -> None: - # Attend qu'Ollama soit joignable (ordre de démarrage des conteneurs), puis - # vérifie la présence du modèle avant de le tirer. - for attempt in range(10): - try: - async with httpx.AsyncClient(timeout=10) as client: - tags = await client.get(f"{base_url}/api/tags") - tags.raise_for_status() - names = [m.get("name", "") for m in tags.json().get("models", [])] - if any(n == model or n.startswith(model + ":") for n in names): - logger.info("Modèle d'embedding '%s' déjà présent.", model) - return - break # Ollama joignable, modèle absent → on tire (ci-dessous) - except httpx.HTTPError: - await asyncio.sleep(min(5 * (attempt + 1), 30)) - else: - logger.warning( - "Ollama injoignable au démarrage — modèle d'embedding '%s' non auto-installé " - "(il sera tirable manuellement : ollama pull %s).", model, model) - return - - logger.info("Téléchargement automatique du modèle d'embedding '%s'…", model) - try: - async with httpx.AsyncClient(timeout=None) as client: - async with client.stream("POST", f"{base_url}/api/pull", json={"name": model}) as resp: - resp.raise_for_status() - async for _line in resp.aiter_lines(): - pass # on draine la progression NDJSON jusqu'à la fin - logger.info("Modèle d'embedding '%s' prêt.", model) - except httpx.HTTPError as exc: - logger.warning( - "Auto-installation du modèle d'embedding '%s' échouée : %s " - "(tirage manuel possible : ollama pull %s).", model, exc, model) - - -@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) - - -class RulesImportResponseDTO(BaseModel): - """Proposition de sections de règles extraites d'un PDF. - - `sections` = {titre → contenu markdown}. C'est une PROPOSITION : le Core - et l'UI laissent l'utilisateur réviser/éditer avant toute persistance. - `ocr_page_count` permet d'indiquer si le PDF était un scan (OCR utilisé). - """ - - sections: dict[str, str] - page_count: int - ocr_page_count: int - - -# Garde-fou taille : un livre de règles dépasse rarement quelques dizaines de Mo. -# Au-delà, on refuse (probable erreur d'upload) plutôt que d'OOM le conteneur. -_MAX_PDF_BYTES = 60 * 1024 * 1024 # 60 Mo - - -@app.post("/import/rules", response_model=RulesImportResponseDTO) -async def import_rules( - use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], - file: UploadFile = File(...), -) -> RulesImportResponseDTO: - """Import d'un PDF de règles → sections markdown structurées (proposition). - - Extrait le texte (couche texte + repli OCR par page pour les scans), découpe, - et demande au LLM de répartir les règles en sections thématiques. Ne persiste - rien : renvoie la proposition au Core, qui la présente pour révision. - """ - content = await file.read() - if not content: - raise HTTPException(status_code=422, detail="Fichier PDF vide.") - if len(content) > _MAX_PDF_BYTES: - raise HTTPException( - status_code=413, - detail=f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo).", - ) - - try: - result = await use_case.execute(content) - except PdfExtractionError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except LLMProviderError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - return RulesImportResponseDTO( - sections=result.sections, - page_count=result.page_count, - ocr_page_count=result.ocr_page_count, - ) - - -@app.post("/import/rules/stream") -async def import_rules_stream( - use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)], - file: UploadFile = File(...), -) -> StreamingResponse: - """Import streamé : émet l'avancement (SSE) puis le résultat final. - - Évènements SSE : - - `event: extracting` → data: {} (extraction en cours) - - `event: start` → data: {page_count, ocr_page_count, total} - - `event: progress` → data: {current, total, new_sections:[...]} - - `event: done` → data: {sections, page_count, ocr_page_count} - - `event: error` → data: {message} - """ - content = await file.read() - - def _sse(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - async def event_stream() -> AsyncIterator[str]: - if not content: - yield _sse("error", {"message": "Fichier PDF vide."}) - return - if len(content) > _MAX_PDF_BYTES: - yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."}) - return - try: - async for ev in use_case.stream(content): - event_type = ev.pop("type") - yield _sse(event_type, ev) - except PdfExtractionError as exc: - yield _sse("error", {"message": str(exc)}) - except LLMProviderError as exc: - yield _sse("error", {"message": str(exc)}) - except Exception as exc: # noqa: BLE001 — filet : une erreur inattendue ne doit - # PAS casser le flux SSE brutalement (sinon le Core n'a qu'un message générique - # sans détail). On la transforme en évènement `error` propre + log avec trace. - logger.exception("Import règles : erreur inattendue dans le flux.") - yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) - - return StreamingResponse(event_stream(), media_type="text/event-stream") - - -@app.post("/import/campaign/stream") -async def import_campaign_stream( - use_case: Annotated[ImportCampaignUseCase, Depends(get_import_campaign_use_case)], - file: UploadFile = File(...), -) -> StreamingResponse: - """Import streamé d'un PDF de campagne → arbre arc→chapitre→scène (SSE). - - Évènements : `extracting`, `start` {page_count, ocr_page_count, total}, - `progress` {current, total, arc_count, chapter_count, scene_count}, - `done` {arcs:[...], page_count, ocr_page_count}, `error` {message}. - """ - content = await file.read() - - def _sse(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - async def event_stream() -> AsyncIterator[str]: - if not content: - yield _sse("error", {"message": "Fichier PDF vide."}) - return - if len(content) > _MAX_PDF_BYTES: - yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."}) - return - try: - async for ev in use_case.stream(content): - event_type = ev.pop("type") - yield _sse(event_type, ev) - except PdfExtractionError as exc: - yield _sse("error", {"message": str(exc)}) - except LLMProviderError as exc: - yield _sse("error", {"message": str(exc)}) - except Exception as exc: # noqa: BLE001 — voir import règles : on ne laisse pas - # une erreur inattendue casser le flux sans détail. - logger.exception("Import campagne : erreur inattendue dans le flux.") - yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) - - return StreamingResponse(event_stream(), media_type="text/event-stream") - - -@app.post("/adapt/campaign/stream") -async def adapt_campaign_stream( - use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)], - file: UploadFile = File(...), - brief: str = Form(""), - messages: str = Form("[]"), -) -> StreamingResponse: - """Adaptation CONVERSATIONNELLE d'un PDF à une campagne (SSE markdown). - - `brief` = description de la campagne (Core). `messages` = JSON de l'échange - ([{role, content}, …]) ; vide au 1er tour. Évènements : `token`, `done`, `error`. - """ - content = await file.read() - - try: - raw_messages = json.loads(messages) if messages else [] - except json.JSONDecodeError: - raw_messages = [] - convo = [ - ChatMessage(role=str(m.get("role", "user")), content=str(m.get("content", ""))) - for m in raw_messages - if isinstance(m, dict) and str(m.get("content", "")).strip() - ] - - def _sse(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - async def event_stream() -> AsyncIterator[str]: - if not content: - yield _sse("error", {"message": "Fichier PDF vide."}) - return - if len(content) > _MAX_PDF_BYTES: - yield _sse("error", {"message": f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo)."}) - return - try: - async for token in use_case.stream(content, brief, convo): - yield _sse("token", {"token": token}) - yield _sse("done", {}) - except PdfExtractionError as exc: - yield _sse("error", {"message": str(exc)}) - except LLMProviderError as exc: - yield _sse("error", {"message": str(exc)}) - - return StreamingResponse(event_stream(), media_type="text/event-stream") - - -@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. - - 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") - - -# --- Auto-titre d'une conversation persistee -------------------------------- - - -class SummarizeTitleMessageDTO(BaseModel): - role: Literal["user", "assistant", "system"] - content: str - - -class SummarizeTitleRequestDTO(BaseModel): - """Premiers messages d'une conversation pour auto-generer un titre court.""" - - messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list) - - -class SummarizeTitleResponseDTO(BaseModel): - title: str - - -_TITLE_SYSTEM_PROMPT = ( - "Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la " - "conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, " - "sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre " - "en francais et capturer le sujet metier (pas 'Conversation IA')." -) - - -@app.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO) -async def summarize_conversation_title( - body: SummarizeTitleRequestDTO, - llm: Annotated[LLMProvider, Depends(get_llm_provider)], -) -> SummarizeTitleResponseDTO: - """Genere un titre court a partir des premiers echanges de la conversation. - - Appele par le core apres le 1er couple user/assistant, pour remplacer le - titre provisoire "Nouvelle conversation" par quelque chose de parlant. - """ - if not body.messages: - raise HTTPException(status_code=422, detail="Au moins un message requis") - - transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6]) - prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :" - try: - raw = await llm.generate(prompt) - except LLMProviderError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".") - if len(title) > 80: - title = title[:80].rstrip() - if not title: - title = "Nouvelle conversation" - return SummarizeTitleResponseDTO(title=title) - - -# --- Tables aléatoires : génération IA + improvisation ----------------------- - -_DICE_FORMULA_RE = re.compile(r"^\s*(\d*)\s*[dD]\s*(\d+)\s*$") - - -def _dice_total_range(formula: str) -> tuple[int, int] | None: - """(min, max) des totaux possibles d'une formule NdM, ou None si invalide.""" - match = _DICE_FORMULA_RE.match(formula or "") - if not match: - return None - count = int(match.group(1)) if match.group(1) else 1 - faces = int(match.group(2)) - if count < 1 or count > 100 or faces < 2 or faces > 10000: - return None - return count, count * faces - - -class GenerateTableRequestDTO(BaseModel): - description: str - dice_formula: str = Field(default="1d20") - # Contexte libre assemblé par le Core (nom de campagne, système, ambiance…). - context: str = Field(default="") - - -class GeneratedTableEntryDTO(BaseModel): - min_roll: int - max_roll: int - label: str - detail: str = "" - - -class GenerateTableResponseDTO(BaseModel): - name: str - description: str = "" - entries: list[GeneratedTableEntryDTO] - - -@app.post("/generate/random-table", response_model=GenerateTableResponseDTO) -async def generate_random_table( - body: GenerateTableRequestDTO, - llm: Annotated[LLMProvider, Depends(get_llm_provider)], -) -> GenerateTableResponseDTO: - """Génère une table aléatoire (entrées par plage) couvrant la formule de dé.""" - rng = _dice_total_range(body.dice_formula) - if rng is None: - raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).") - lo, hi = rng - context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" - prompt = ( - "Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n" - f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n" - f"Sujet : {body.description.strip()}\n" - f"{context_block}\n" - "Règles IMPÉRATIVES :\n" - "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" - '- Format : {"name": "...", "description": "...", "entries": ' - '[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n' - f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, " - "sans trou ni chevauchement, dans l'ordre croissant.\n" - "- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n" - "- En français. 'label' = résultat bref ; 'detail' = description/effet concret.\n" - "Renvoie maintenant le JSON." - ) - try: - raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) - except LLMProviderError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - parsed, _ = load_json_object(raw) - if not isinstance(parsed, dict): - raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de table exploitable.") - - entries: list[GeneratedTableEntryDTO] = [] - for e in parsed.get("entries", []) or []: - if not isinstance(e, dict): - continue - try: - mn = int(e["min_roll"]) - mx = int(e["max_roll"]) - except (KeyError, TypeError, ValueError): - continue - label = str(e.get("label") or "").strip() - if not label: - continue - entries.append(GeneratedTableEntryDTO( - min_roll=mn, max_roll=max(mn, mx), label=label[:200], - detail=str(e.get("detail") or "").strip(), - )) - if not entries: - raise HTTPException(status_code=502, detail="Aucune entrée générée — réessaie ou reformule.") - - name = str(parsed.get("name") or body.description).strip()[:120] or "Table générée" - return GenerateTableResponseDTO( - name=name, - description=str(parsed.get("description") or "").strip(), - entries=entries, - ) - - -class ImproviseRollRequestDTO(BaseModel): - table_name: str - result_label: str - result_detail: str = Field(default="") - context: str = Field(default="") - - -class ImproviseRollResponseDTO(BaseModel): - narration: str - - -@app.post("/improvise/table-roll", response_model=ImproviseRollResponseDTO) -async def improvise_table_roll( - body: ImproviseRollRequestDTO, - llm: Annotated[LLMProvider, Depends(get_llm_provider)], -) -> ImproviseRollResponseDTO: - """Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène.""" - detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else "" - context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else "" - prompt = ( - "Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table " - f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}." - f"{context_block}\n\n" - "Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. " - "Pas de méta, pas d'options : juste la narration, en français." - ) - try: - raw = await llm.generate(prompt, temperature=0.8) - except LLMProviderError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return ImproviseRollResponseDTO(narration=raw.strip()) - - -# --- Catalogues d'objets (boutiques) : génération IA ------------------------- - - -class GenerateCatalogRequestDTO(BaseModel): - description: str - context: str = Field(default="") - - -class GeneratedCatalogItemDTO(BaseModel): - name: str - price: str = "" - category: str = "" - description: str = "" - - -class GenerateCatalogResponseDTO(BaseModel): - name: str - description: str = "" - items: list[GeneratedCatalogItemDTO] - - -@app.post("/generate/item-catalog", response_model=GenerateCatalogResponseDTO) -async def generate_item_catalog( - body: GenerateCatalogRequestDTO, - llm: Annotated[LLMProvider, Depends(get_llm_provider)], -) -> GenerateCatalogResponseDTO: - """Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description.""" - context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else "" - prompt = ( - "Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n" - f"Sujet : {body.description.strip()}\n" - f"{context_block}\n" - "Règles IMPÉRATIVES :\n" - "- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n" - '- Format : {"name": "...", "description": "...", "items": ' - '[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n' - "- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n" - "- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; " - "'description' = effet/détails en une phrase. En français.\n" - "Renvoie maintenant le JSON." - ) - try: - raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7) - except LLMProviderError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - - parsed, _ = load_json_object(raw) - if not isinstance(parsed, dict): - raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de catalogue exploitable.") - - items: list[GeneratedCatalogItemDTO] = [] - for it in parsed.get("items", []) or []: - if not isinstance(it, dict): - continue - name = str(it.get("name") or "").strip() - if not name: - continue - items.append(GeneratedCatalogItemDTO( - name=name[:200], - price=str(it.get("price") or "").strip(), - category=str(it.get("category") or "").strip(), - description=str(it.get("description") or "").strip(), - )) - if not items: - raise HTTPException(status_code=502, detail="Aucun objet généré — réessaie ou reformule.") - - name = str(parsed.get("name") or body.description).strip()[:120] or "Catalogue généré" - return GenerateCatalogResponseDTO( - name=name, - description=str(parsed.get("description") or "").strip(), - items=items, - ) - - -# --- Notebooks (atelier RAG) : indexation des sources + chat ancré ---------- - - -class IndexSourceResponseDTO(BaseModel): - chunks: int - page_count: int - ocr_page_count: int - - -@app.post("/index/notebook-source", response_model=IndexSourceResponseDTO) -async def index_notebook_source( - rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)], - source_id: str = Form(...), - file: UploadFile = File(...), -) -> IndexSourceResponseDTO: - """Indexe une source PDF (extraction + embeddings + stockage vectoriel).""" - content = await file.read() - if not content: - raise HTTPException(status_code=422, detail="Fichier PDF vide.") - if len(content) > _MAX_PDF_BYTES: - raise HTTPException( - status_code=413, detail=f"PDF trop volumineux (> {_MAX_PDF_BYTES // (1024 * 1024)} Mo).") - try: - recap = await rag.index_source(source_id, content) - except PdfExtractionError as exc: - raise HTTPException(status_code=422, detail=str(exc)) from exc - except EmbeddingError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc - return IndexSourceResponseDTO(**recap) - - -@app.delete("/index/notebook-source/{source_id}") -def delete_notebook_source(source_id: str) -> dict[str, str]: - """Supprime les vecteurs d'une source (au DELETE d'une source/notebook).""" - vector_store.delete(source_id) - return {"status": "deleted", "source_id": source_id} - - -class NotebookChatMessageDTO(BaseModel): - role: str - content: str - - -class NotebookChatRequestDTO(BaseModel): - source_ids: list[str] = Field(default_factory=list) - messages: list[NotebookChatMessageDTO] = Field(default_factory=list) - context: str = Field(default="") - - -@app.post("/chat/notebook/stream") -async def chat_notebook_stream( - body: NotebookChatRequestDTO, - use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)], - settings: Annotated[Settings, Depends(get_settings)], -) -> StreamingResponse: - """Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis - streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}.""" - messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] - top_k = max(1, min(settings.rag_top_k, 200)) - - def _sse(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - async def event_stream() -> AsyncIterator[str]: - try: - async for token in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k): - if token: - yield _sse("token", {"token": token}) - yield _sse("done", {}) - except (LLMProviderError, EmbeddingError) as exc: - yield _sse("error", {"message": str(exc)}) - except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale du flux. - logger.exception("Chat notebook : erreur inattendue.") - yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) - - return StreamingResponse(event_stream(), media_type="text/event-stream") - - -@app.post("/chat/notebook/deep/stream") -async def chat_notebook_deep_stream( - body: NotebookChatRequestDTO, - use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)], -) -> StreamingResponse: - """Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE : - `progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`.""" - messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages] - question = next((m.content for m in reversed(messages) if m.role == "user"), "") - - def _sse(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - async def event_stream() -> AsyncIterator[str]: - if not question.strip(): - yield _sse("error", {"message": "Question vide."}) - return - try: - async for ev in use_case.stream(body.source_ids, messages, context=body.context): - ev_type = ev.pop("type") - yield _sse(ev_type, ev) - except (LLMProviderError, EmbeddingError) as exc: - yield _sse("error", {"message": str(exc)}) - except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale. - logger.exception("Analyse approfondie : erreur inattendue.") - yield _sse("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"}) - - return StreamingResponse(event_stream(), media_type="text/event-stream") - - -# --- Mapping DTO → domaine (frontière HTTP) --------------------------------- - - -def _to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None: - if dto is None: - return None - return LoreStructuralContext( - lore_name=dto.lore_name, - lore_description=dto.lore_description, - folders={ - folder: [_to_page_summary(p) for p in pages] - for folder, pages in dto.folders.items() - }, - tags=dto.tags, - ) - - -def _to_page_summary(dto: PageSummaryDTO) -> PageSummary: - return PageSummary( - title=dto.title, - template_name=dto.template_name, - values=dict(dto.values), - tags=list(dto.tags), - related_page_titles=list(dto.related_page_titles), - ) - - -def _to_page_context(dto: PageContextDTO | None) -> PageContext | None: - if dto is None: - return None - return PageContext( - title=dto.title, - template_name=dto.template_name, - template_fields=dto.template_fields, - values=dto.values, - ) - - -def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None: - if dto is None: - return None - arcs = [ - ArcSummary( - name=arc.name, - description=arc.description, - illustration_count=arc.illustration_count, - chapters=[ - ChapterSummary( - name=ch.name, - description=ch.description, - illustration_count=ch.illustration_count, - scenes=[ - SceneSummary( - name=sc.name, - description=sc.description, - illustration_count=sc.illustration_count, - branches=[ - SceneBranchHint( - label=br.label, - target_scene_name=br.target_scene_name, - condition=br.condition, - ) - for br in sc.branches - ], - rooms=[ - RoomSummary( - name=room.name, - floor=room.floor, - description=room.description, - enemies=room.enemies, - branches=[ - RoomBranchHint( - label=rb.label, - target_room_name=rb.target_room_name, - condition=rb.condition, - ) - for rb in room.branches - ], - ) - for room in sc.rooms - ], - ) - for sc in ch.scenes - ], - ) - for ch in arc.chapters - ], - ) - for arc in dto.arcs - ] - characters = [ - CharacterSummary(name=c.name, snippet=c.snippet) - for c in dto.characters - ] - npcs = [ - NpcSummary(name=n.name, snippet=n.snippet) - for n in dto.npcs - ] - return CampaignStructuralContext( - campaign_name=dto.campaign_name, - campaign_description=dto.campaign_description, - arcs=arcs, - characters=characters, - npcs=npcs, - ) - - -# --- Settings (parametrage runtime depuis l'UI) ------------------------------ - - -class SettingsDTO(BaseModel): - """Vue serialisable des settings modifiables depuis l'UI. - - Expose uniquement les champs que l'utilisateur peut changer a chaud. - Les secrets (onemin_api_key) sont masques en lecture. - """ - - llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] - ollama_base_url: str - llm_model: str - onemin_model: str - # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. - onemin_api_key_set: bool - openrouter_model: str - # True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee). - openrouter_api_key_set: bool - mistral_model: str - # True si une cle Mistral est deja configuree (cle elle-meme jamais renvoyee). - mistral_api_key_set: bool - gemini_model: str - # True si une cle Gemini est deja configuree (cle elle-meme jamais renvoyee). - gemini_api_key_set: bool - # Embeddings (RAG des ateliers) : provider + modeles + auto-pull Ollama. - embedding_provider: Literal["ollama", "mistral"] - ollama_embedding_model: str - mistral_embedding_model: str - auto_pull_embedding_model: bool - rag_top_k: int - # Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert - # aussi de plafond a la jauge de contexte UI. - llm_num_ctx: int - # Taille cible d'un morceau (tokens) pour l'import de PDF (regles/campagne). - import_chunk_tokens: int - # Timeout HTTP des appels LLM (s). A monter si les imports lourds expirent. - llm_timeout_seconds: int - - -class SettingsUpdateDTO(BaseModel): - """Patch partiel des settings. Tous les champs sont optionnels.""" - - llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] | None = None - ollama_base_url: str | None = None - llm_model: str | None = None - onemin_model: str | None = None - # Chaine vide => on efface la cle. None => pas de changement. - onemin_api_key: str | None = None - openrouter_model: str | None = None - openrouter_api_key: str | None = None - mistral_model: str | None = None - mistral_api_key: str | None = None - gemini_model: str | None = None - gemini_api_key: str | None = None - embedding_provider: Literal["ollama", "mistral"] | None = None - ollama_embedding_model: str | None = None - mistral_embedding_model: str | None = None - auto_pull_embedding_model: bool | None = None - rag_top_k: int | None = None - llm_num_ctx: int | None = None - import_chunk_tokens: int | None = None - llm_timeout_seconds: int | None = None - - -def _to_settings_dto(s: Settings) -> SettingsDTO: - return SettingsDTO( - llm_provider=s.llm_provider, - ollama_base_url=s.ollama_base_url, - llm_model=s.llm_model, - onemin_model=s.onemin_model, - onemin_api_key_set=bool(s.onemin_api_key), - openrouter_model=s.openrouter_model, - openrouter_api_key_set=bool(s.openrouter_api_key), - mistral_model=s.mistral_model, - mistral_api_key_set=bool(s.mistral_api_key), - gemini_model=s.gemini_model, - gemini_api_key_set=bool(s.gemini_api_key), - embedding_provider=s.embedding_provider, - ollama_embedding_model=s.ollama_embedding_model, - mistral_embedding_model=s.mistral_embedding_model, - auto_pull_embedding_model=s.auto_pull_embedding_model, - rag_top_k=s.rag_top_k, - llm_num_ctx=s.llm_num_ctx, - import_chunk_tokens=s.import_chunk_tokens, - llm_timeout_seconds=s.llm_timeout_seconds, - ) - - -@app.get("/settings", response_model=SettingsDTO) -def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO: - """Retourne la config courante (secrets masques).""" - return _to_settings_dto(settings) - - -@app.put("/settings", response_model=SettingsDTO) -def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO: - """Applique un patch partiel aux settings et persiste les overrides. - - Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache). - """ - overrides = {k: v for k, v in patch.model_dump().items() if v is not None} - if overrides: - save_overrides(overrides) - # Relit .env + overrides fusionnes pour confirmation. - return _to_settings_dto(get_settings()) - - -@app.get("/models/ollama") -async def list_ollama_models( - settings: Annotated[Settings, Depends(get_settings)], -) -> dict[str, list[str]]: - """Liste les modeles disponibles sur le serveur Ollama configure. - - Retourne une liste vide si Ollama est injoignable — l'UI affichera un - message plutot qu'une 500. - """ - url = f"{settings.ollama_base_url}/api/tags" - try: - async with httpx.AsyncClient(timeout=5) as client: - response = await client.get(url) - response.raise_for_status() - data = response.json() - except httpx.HTTPError: - return {"models": []} - models = [m.get("name", "") for m in data.get("models", []) if m.get("name")] - return {"models": sorted(models)} - - -class OllamaModelInfoDTO(BaseModel): - """Info utile extraite de /api/show pour un modele Ollama donne. - - `context_length` = fenetre de contexte max supportee par le modele - (extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert - pour borner le slider de num_ctx dans les Parametres. - """ - - context_length: int = 0 - - -@app.post("/models/ollama/info", response_model=OllamaModelInfoDTO) -async def get_ollama_model_info( - body: dict[str, str], - settings: Annotated[Settings, Depends(get_settings)], -) -> OllamaModelInfoDTO: - """Retourne les metadonnees d'un modele Ollama via /api/show. - - On passe par POST (et pas GET /models/ollama/{name}) parce que les noms - Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente - mal dans une URL — le body JSON evite le probleme d'escaping. - - Le champ qui nous interesse est `model_info[".context_length"]` - (ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on - scanne donc tous les champs finissant par `.context_length`. - """ - name = (body.get("name") or "").strip() - if not name: - raise HTTPException(status_code=400, detail="name requis") - url = f"{settings.ollama_base_url}/api/show" - try: - async with httpx.AsyncClient(timeout=5) as client: - response = await client.post(url, json={"model": name}) - response.raise_for_status() - data = response.json() - except httpx.HTTPError: - return OllamaModelInfoDTO(context_length=0) - model_info = data.get("model_info") or {} - for key, value in model_info.items(): - if key.endswith(".context_length") and isinstance(value, int): - return OllamaModelInfoDTO(context_length=value) - return OllamaModelInfoDTO(context_length=0) - - -@app.post("/models/ollama/pull") -async def pull_ollama_model( - body: dict[str, str], - settings: Annotated[Settings, Depends(get_settings)], -) -> StreamingResponse: - """Telecharge un modele depuis Ollama et streame la progression. - - Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par - ligne (NDJSON) avec le statut de chaque etape : manifest, layers, - digest, success. On reemet ce flux tel quel au client (le front - parsera les lignes et affichera une barre de progression). - - Le timeout est intentionnellement tres long (60 min) car certains - modeles font 30+ Go. - """ - name = (body.get("name") or "").strip() - if not name: - raise HTTPException(status_code=400, detail="name requis") - url = f"{settings.ollama_base_url}/api/pull" - - async def stream() -> AsyncIterator[bytes]: - # On utilise un timeout long pour la lecture (60 min) mais court pour - # la connexion (10s) — si Ollama n'est pas joignable, on echoue vite. - timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10) - try: - async with httpx.AsyncClient(timeout=timeout) as client: - async with client.stream("POST", url, json={"model": name, "stream": True}) as r: - if r.status_code != 200: - # Ollama renvoie un message JSON d'erreur. On le passe - # tel quel au client en preservant le code HTTP. - body_text = await r.aread() - yield body_text - return - async for chunk in r.aiter_bytes(): - yield chunk - except httpx.HTTPError as e: - # Erreur reseau : on emet une ligne JSON d'erreur compatible - # avec le format NDJSON d'Ollama. - err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n" - yield err.encode("utf-8") - - # application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE. - # C'est le format natif d'Ollama, le front le parsera ligne par ligne. - return StreamingResponse(stream(), media_type="application/x-ndjson") - - -@app.delete("/models/ollama/{name:path}") -async def delete_ollama_model( - name: str, - settings: Annotated[Settings, Depends(get_settings)], -) -> dict[str, str]: - """Supprime un modele du serveur Ollama. - - Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`) - sans avoir besoin de URL-encoder cote client. - """ - if not name.strip(): - raise HTTPException(status_code=400, detail="name requis") - url = f"{settings.ollama_base_url}/api/delete" - try: - async with httpx.AsyncClient(timeout=10) as client: - response = await client.request("DELETE", url, json={"model": name}) - if response.status_code == 404: - raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable") - response.raise_for_status() - except httpx.HTTPError as e: - raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}") - return {"status": "deleted", "name": name} - - -@app.get("/models/openrouter") -async def list_openrouter_models() -> dict[str, list[dict[str, object]]]: - """Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle). - - Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord - puis contexte decroissant. `free` = id finissant par ':free' OU prix nul. - """ - try: - async with httpx.AsyncClient(timeout=20) as client: - response = await client.get("https://openrouter.ai/api/v1/models") - response.raise_for_status() - data = response.json() - except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}") - - def _is_zero(value: object) -> bool: - try: - return float(value) == 0.0 # type: ignore[arg-type] - except (TypeError, ValueError): - return False - - models: list[dict[str, object]] = [] - for m in data.get("data", []) or []: - mid = str(m.get("id") or "") - if not mid: - continue - pricing = m.get("pricing") or {} - is_free = mid.endswith(":free") or ( - _is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion")) - ) - try: - ctx = int(m.get("context_length") or 0) - except (TypeError, ValueError): - ctx = 0 - models.append({ - "id": mid, - "name": str(m.get("name") or mid), - "context_length": ctx, - "free": is_free, - }) - - models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index] - return {"models": models} - - -# Repli statique si la cle Mistral n'est pas (encore) configuree ou si l'API est -# injoignable — l'utilisateur peut quand meme choisir un modele. Liste curee -# (juin 2026) ; pour l'extraction de PDF, prefere `large` (fidele, 128k) ou `small`. -_MISTRAL_FALLBACK_MODELS = [ - "mistral-large-latest", - "mistral-medium-latest", - "mistral-small-latest", - "open-mistral-nemo", - "ministral-8b-latest", - "ministral-3b-latest", - "magistral-medium-latest", - "magistral-small-latest", - "pixtral-large-latest", - "codestral-latest", -] - - -@app.get("/models/mistral") -async def list_mistral_models( - settings: Annotated[Settings, Depends(get_settings)], -) -> dict[str, list[dict[str, object]]]: - """Catalogue des modeles Mistral. Dynamique si une cle est configuree - (GET /v1/models, qui requiert l'auth), sinon repli statique. - - Renvoie {models: [{id}]} (tous accessibles sur le tier gratuit Experiment).""" - key = settings.mistral_api_key - if not key: - return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]} - try: - async with httpx.AsyncClient(timeout=20) as client: - response = await client.get( - "https://api.mistral.ai/v1/models", - headers={"Authorization": f"Bearer {key}"}, - ) - response.raise_for_status() - data = response.json() - except httpx.HTTPError: - # Cle invalide / API down : on ne casse pas l'UI, on propose le repli. - return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]} - - ids = sorted({str(m.get("id")) for m in data.get("data", []) or [] if m.get("id")}) - if not ids: - ids = _MISTRAL_FALLBACK_MODELS - return {"models": [{"id": i} for i in ids]} - - -# Repli statique Gemini (juin 2026). Pour l'extraction, prefere un Flash a grand -# contexte ; `gemini-2.0-flash` a le quota gratuit le plus genereux. -_GEMINI_FALLBACK_MODELS = [ - "gemini-2.0-flash", - "gemini-2.0-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - "gemini-2.5-pro", - "gemini-1.5-flash", - "gemini-1.5-pro", -] - - -@app.get("/models/gemini") -async def list_gemini_models( - settings: Annotated[Settings, Depends(get_settings)], -) -> dict[str, list[dict[str, object]]]: - """Catalogue des modeles Gemini. Dynamique si une cle est configuree (endpoint - OpenAI-compatible /openai/models), sinon repli statique. Renvoie {models:[{id}]}.""" - key = settings.gemini_api_key - if not key: - return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]} - try: - async with httpx.AsyncClient(timeout=20) as client: - response = await client.get( - "https://generativelanguage.googleapis.com/v1beta/openai/models", - headers={"Authorization": f"Bearer {key}"}, - ) - response.raise_for_status() - data = response.json() - except httpx.HTTPError: - return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]} - - # Les ids peuvent arriver prefixes "models/" → on nettoie pour que la valeur - # selectionnee soit directement utilisable dans l'appel chat. On garde les - # modeles "gemini-*" (hors embeddings/aqa) pour ne pas noyer la liste. - ids: set[str] = set() - for m in data.get("data", []) or []: - mid = str(m.get("id") or "") - if mid.startswith("models/"): - mid = mid[len("models/"):] - if mid.startswith("gemini-"): - ids.add(mid) - clean = sorted(ids) if ids else _GEMINI_FALLBACK_MODELS - return {"models": [{"id": i} for i in clean]} - - -@app.get("/models/onemin") -def list_onemin_models() -> dict[str, list[dict[str, object]]]: - """Catalogue statique des modeles 1min.ai, groupes par fournisseur. - - Liste construite par probing direct de l'endpoint chat-with-ai avec - une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs - absents renvoient 400 UNSUPPORTED_MODEL. - - Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai - (`claude--`), pas la convention officielle Anthropic. - """ - return { - "groups": [ - { - "provider": "Anthropic", - "models": ["claude-opus-4-6", "claude-sonnet-4-6"], - }, - { - "provider": "OpenAI", - "models": [ - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4.1-nano", - "gpt-4o", - "gpt-4o-mini", - "gpt-4-turbo", - "gpt-3.5-turbo", - "o3", - "o3-pro", - "o3-mini", - "o4-mini", - ], - }, - { - "provider": "Google", - "models": ["gemini-2.5-pro", "gemini-2.5-flash"], - }, - { - "provider": "Mistral", - "models": [ - "mistral-large-latest", - "mistral-medium-latest", - "mistral-small-latest", - "open-mistral-nemo", - ], - }, - { - "provider": "DeepSeek", - "models": ["deepseek-chat", "deepseek-reasoner"], - }, - { - "provider": "xAI", - "models": ["grok-3", "grok-3-mini"], - }, - { - "provider": "Meta", - "models": [ - "meta/meta-llama-3.1-405b-instruct", - "meta/meta-llama-3-70b-instruct", - ], - }, - { - "provider": "Alibaba", - "models": ["qwen-plus", "qwen3-max"], - }, - { - "provider": "Perplexity", - "models": ["sonar", "sonar-pro"], - }, - ] - } - - -def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None: - if dto is None: - return None - return NarrativeEntityContext( - entity_type=dto.entity_type, - title=dto.title, - fields=dict(dto.fields), - ) - - -def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None: - if dto is None: - return None - return GameSystemContext( - system_name=dto.system_name, - system_description=dto.system_description, - sections=dict(dto.sections), - ) - - -def _to_session_context(dto: SessionContextDTO | None) -> SessionContext | None: - if dto is None: - return None - return SessionContext( - session_name=dto.session_name, - active=dto.active, - started_at=dto.started_at, - entries=[_to_journal_entry(e) for e in dto.entries], - previous_events=[_to_journal_entry(e) for e in dto.previous_events], - available_quests=[_to_quest_summary(q) for q in dto.available_quests], - in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests], - locked_quest_titles=list(dto.locked_quest_titles), - active_flags=list(dto.active_flags), - ) - - -def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary: - return QuestSummary( - name=dto.name, - arc_name=dto.arc_name, - description=dto.description, - ) - - -def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary: - return JournalEntrySummary( - type=dto.type, - content=dto.content, - occurred_at=dto.occurred_at, - source_session_name=dto.source_session_name, - ) + asyncio.create_task(ensure_ollama_embedding_model( + settings.ollama_base_url, settings.ollama_embedding_model)) + + +# Un router par responsabilité (SRP) — chemins identiques à l'ancien monolithe. +app.include_router(generation.router) +app.include_router(chat.router) +app.include_router(tables.router) +app.include_router(imports.router) +app.include_router(notebooks.router) +app.include_router(settings_router.router) +app.include_router(models.router) diff --git a/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.html b/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.html new file mode 100644 index 0000000..dfdc5b2 --- /dev/null +++ b/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.html @@ -0,0 +1,85 @@ + +
+ + {{ errorMessage }} +
+
+ + {{ successMessage }} +
+ + +
+ +
    +
  • + {{ m }} + +
  • +
+
+ + + diff --git a/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.ts b/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.ts new file mode 100644 index 0000000..d7399e6 --- /dev/null +++ b/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.ts @@ -0,0 +1,216 @@ +import { Component, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { LucideAngularModule, Check, AlertCircle, Download, Trash2, X } from 'lucide-angular'; +import { SettingsService, OllamaPullEvent } from '../../services/settings.service'; +import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Gestion des modeles Ollama installes (composant standalone). + * + * Responsabilite unique : le CYCLE DE VIE des modeles sur le serveur Ollama — + * liste des modeles installes + suppression, et dialog de telechargement (pull) + * avec barre de progression streamee (NDJSON). + * + * Le parent (SettingsComponent) garde le CHOIX du modele actif (formulaire) ; + * il ecoute `modelsChanged` / `modelPulled` / `modelDeleted` pour rafraichir sa + * liste et corriger la selection courante si besoin. + */ +@Component({ + selector: 'app-ollama-model-manager', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './ollama-model-manager.component.html', + // Reutilise la feuille de style de l'ecran Parametres (modal, suggestions, + // progress-bar, installed-models) pour un rendu strictement identique. + styleUrls: ['../settings.component.scss'] +}) +export class OllamaModelManagerComponent implements OnDestroy { + + readonly Check = Check; + readonly AlertCircle = AlertCircle; + readonly Download = Download; + readonly Trash2 = Trash2; + readonly X = X; + + /** Modeles actuellement installes (charges par le parent). */ + @Input() models: string[] = []; + + /** La liste des modeles a change (pull termine / suppression) → recharger. */ + @Output() modelsChanged = new EventEmitter(); + /** Un modele vient d'etre telecharge avec succes (nom complet). */ + @Output() modelPulled = new EventEmitter(); + /** Un modele vient d'etre supprime (nom complet). */ + @Output() modelDeleted = new EventEmitter(); + + /** Dialog d'ajout de modele ouvert/ferme. */ + pullDialogOpen = false; + /** Nom saisi par l'utilisateur dans le dialog. */ + pullModelName = ''; + /** Suggestions courantes affichees dans le dialog. */ + readonly pullSuggestions = [ + 'gemma4:e4b', 'gemma3:4b', 'gemma3:12b', + 'llama3.2:3b', 'llama3.1:8b', + 'mistral:7b', 'qwen2.5:3b', 'qwen2.5:7b' + ]; + /** Pull en cours ; null si aucun. */ + pullInProgress = false; + /** Etape courante affichee a l'utilisateur (ex: "downloading", "verifying"). */ + pullStatus = ''; + /** Bytes telecharges sur le digest courant. */ + pullCompleted = 0; + /** Bytes totaux du digest courant. */ + pullTotal = 0; + /** Souscription au flux de pull pour pouvoir l'annuler. */ + private pullSubscription: Subscription | null = null; + /** True si on a recu un evenement {status:"success"} d'Ollama. Sans ca, + * une fermeture de stream (timeout proxy, perte reseau) ne doit PAS etre + * interpretee comme une reussite. */ + private pullSucceeded = false; + + /** Modele en cours de suppression (nom) pour disabler son bouton. */ + deletingModel: string | null = null; + + errorMessage = ''; + successMessage = ''; + + constructor( + private settingsService: SettingsService, + private confirmDialog: ConfirmDialogService + ) {} + + ngOnDestroy(): void { + if (this.pullSubscription) { + this.pullSubscription.unsubscribe(); + } + } + + openPullDialog(): void { + this.pullDialogOpen = true; + this.pullModelName = ''; + this.resetPullState(); + } + + closePullDialog(): void { + if (this.pullInProgress) return; // empêche fermeture pendant un pull + this.pullDialogOpen = false; + } + + selectSuggestion(name: string): void { + this.pullModelName = name; + } + + startPull(): void { + const name = this.pullModelName.trim(); + if (!name || this.pullInProgress) return; + this.resetPullState(); + this.pullInProgress = true; + this.pullStatus = 'connexion...'; + this.errorMessage = ''; + + this.pullSubscription = this.settingsService.pullOllamaModel(name).subscribe({ + next: (event: OllamaPullEvent) => { + if (event.error) { + this.errorMessage = `Echec : ${event.error}`; + this.pullInProgress = false; + return; + } + if (event.status) this.pullStatus = event.status; + if (event.completed != null) this.pullCompleted = event.completed; + if (event.total != null) this.pullTotal = event.total; + // Marqueur explicite : Ollama emet "success" en derniere ligne quand + // le pull est reellement complet (manifest + layers + verify). + if (event.status === 'success') this.pullSucceeded = true; + }, + error: (err) => { + this.errorMessage = this.extractError(err, `Echec du telechargement de ${name}.`); + this.pullInProgress = false; + }, + complete: () => { + this.pullInProgress = false; + if (!this.pullSucceeded) { + // Stream ferme sans 'success' final = connexion coupee + // (timeout proxy, perte reseau, ...). Le modele est probablement + // partiellement telecharge ; Ollama gardera les couches deja DL. + this.errorMessage = `Telechargement de ${name} interrompu avant la fin. Relancez pour reprendre.`; + this.modelsChanged.emit(); + return; + } + this.successMessage = `Modele ${name} telecharge.`; + this.modelsChanged.emit(); + this.modelPulled.emit(name); + // Petite tempo avant de fermer pour que le user voie "success". + setTimeout(() => this.closePullDialog(), 1200); + } + }); + } + + cancelPull(): void { + if (this.pullSubscription) { + this.pullSubscription.unsubscribe(); + this.pullSubscription = null; + } + this.pullInProgress = false; + this.pullStatus = 'annule'; + } + + private resetPullState(): void { + this.pullStatus = ''; + this.pullCompleted = 0; + this.pullTotal = 0; + this.pullSucceeded = false; + if (this.pullSubscription) { + this.pullSubscription.unsubscribe(); + this.pullSubscription = null; + } + } + + /** Pourcentage du digest courant pour la barre de progression. */ + get pullPercent(): number { + if (this.pullTotal <= 0) return 0; + return Math.min(100, Math.round((this.pullCompleted / this.pullTotal) * 100)); + } + + /** Affichage humain des octets ('1.2 GB' / '450 MB'). */ + formatBytes(b: number): string { + if (!b) return '0'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let v = b; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`; + } + + deleteModel(name: string): void { + this.confirmDialog.confirm({ + title: 'Supprimer le modele', + message: `Supprimer le modele '${name}' ?`, + details: ['L\'espace disque sera libere.'], + confirmLabel: 'Supprimer', + variant: 'danger' + }).then(ok => { + if (!ok) return; + this.deletingModel = name; + this.errorMessage = ''; + this.settingsService.deleteOllamaModel(name).subscribe({ + next: () => { + this.deletingModel = null; + this.successMessage = `Modele ${name} supprime.`; + this.modelsChanged.emit(); + this.modelDeleted.emit(name); + }, + error: (err) => { + this.deletingModel = null; + this.errorMessage = this.extractError(err, `Echec de la suppression de ${name}.`); + } + }); + }); + } + + private extractError(err: any, fallback: string): string { + if (err?.error?.detail) return String(err.error.detail); + if (err?.message) return err.message; + return fallback; + } +} diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html index 0602772..333750b 100644 --- a/web/src/app/settings/settings.component.html +++ b/web/src/app/settings/settings.component.html @@ -68,7 +68,7 @@ {{ loadingModels ? 'Chargement...' : 'Actualiser' }} - @@ -76,83 +76,16 @@

Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.

- -
- -
    -
  • - {{ m }} - -
  • -
-
+ + + - - -

Configuration 1min.ai

@@ -434,242 +367,8 @@
- -
-

Mises a jour

-

Verifie aupres du registry Docker si une nouvelle version - des conteneurs (core, brain, web) est disponible. Postgres et MinIO sont - exclus — ils sont mis a jour manuellement.

- - - - -
-

Canal stable

- -
- -
- -
- Feature non configuree (WATCHTOWER_TOKEN absent). -
- -
-
- - Une mise a jour est disponible. -
-
- - Verification impossible (baseline absente ou registry injoignable). -
-
- Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}). -
- -
- -
- -
- - {{ updateMessage }} -
-
-
- - - - -
-

- - Canal beta — reserve aux patrons -

-

- Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere. - Le tier Compagnon (7€/mois) ou superieur debloque ce canal. -

- - - -
- -
-

- Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche - et colle-le ci-dessous. -

-
- - -
-
- -
-
- - {{ licenseError }} -
-
- - - -
- - Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif. -
-
- - - Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance. - Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant". - -
-
- - - Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta. - -
-
- - Le token installe ne peut plus etre verifie. Reconnecte-toi. -
- -
    -
  • Tier : {{ tierLabel(licenseStatus.tierId) }}
  • -
  • - Validite : - jusqu'au {{ formatDate(licenseStatus.expiresAt) }} - - (renouvellement dans {{ daysUntilExpiry }} jours) - -
  • -
  • - Dernier refresh : - {{ formatDate(licenseStatus.lastRefreshAttemptAt) }} - OK - echec -
  • -
- -
- -
- -
- - -
- - -
-
Verification des images beta...
-
- Indisponible : {{ betaStatus.disabledReason }} -
-
-
- - Verification beta impossible (registry beta injoignable ou baseline absente). -
-
-
- - -
-
- Canal actuel : - - {{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }} - -
- - - - - - - - - - -
- - Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret. -
- - -
- - {{ switchError }} -
-
- - -
- - - Le sidecar de bascule n'est pas installe. Pour beneficier du switch - automatique, recupere le dernier docker-compose.yml du repo - et fais docker compose pull && docker compose up -d une - fois. Sinon, bascule manuellement en editant IMAGE_NAMESPACE - dans ton .env (igmlcreation/loremind- pour stable, - igmlcreation/loremind-beta- pour beta). - -
-
-
-
-
+ +
+
+ +
+ Feature non configuree (WATCHTOWER_TOKEN absent). +
+ +
+
+ + Une mise a jour est disponible. +
+
+ + Verification impossible (baseline absente ou registry injoignable). +
+
+ Tout est a jour (verifie le {{ updateStatus?.checkedAt | date:'short' }}). +
+ +
+ +
+ +
+ + {{ updateMessage }} +
+
+ + + + + +
+

+ + Canal beta — reserve aux patrons +

+

+ Soutiens LoreMind sur Patreon pour acceder aux nouvelles features en avant-premiere. + Le tier Compagnon (7€/mois) ou superieur debloque ce canal. +

+ + + +
+ +
+

+ Une nouvelle fenetre va s'ouvrir vers Patreon. Apres autorisation, copie le token affiche + et colle-le ci-dessous. +

+
+ + +
+
+ +
+
+ + {{ licenseError }} +
+
+ + + +
+ + Compte Patreon connecte. Tier {{ tierLabel(licenseStatus.tierId) }} actif. +
+
+ + + Connexion Patreon expiree, mais acces beta maintenu pendant la periode de tolerance. + Verifie que ton abonnement Patreon est toujours actif et clique sur "Verifier maintenant". + +
+
+ + + Connexion Patreon expiree depuis trop longtemps. Reconnecte-toi pour retrouver l'acces beta. + +
+
+ + Le token installe ne peut plus etre verifie. Reconnecte-toi. +
+ +
    +
  • Tier : {{ tierLabel(licenseStatus.tierId) }}
  • +
  • + Validite : + jusqu'au {{ formatDate(licenseStatus.expiresAt) }} + + (renouvellement dans {{ daysUntilExpiry }} jours) + +
  • +
  • + Dernier refresh : + {{ formatDate(licenseStatus.lastRefreshAttemptAt) }} + OK + echec +
  • +
+ +
+ +
+ +
+ + +
+ + +
+
Verification des images beta...
+
+ Indisponible : {{ betaStatus.disabledReason }} +
+
+
+ + Verification beta impossible (registry beta injoignable ou baseline absente). +
+
+
+ + +
+
+ Canal actuel : + + {{ channelStatus.currentChannel === 'beta' ? 'Bêta' : 'Stable' }} + +
+ + + + + + + + + + +
+ + Bascule en cours. L'application va etre indisponible 10 a 30 secondes — la page se rechargera automatiquement quand le nouveau Core sera pret. +
+ + +
+ + {{ switchError }} +
+
+ + +
+ + + Le sidecar de bascule n'est pas installe. Pour beneficier du switch + automatique, recupere le dernier docker-compose.yml du repo + et fais docker compose pull && docker compose up -d une + fois. Sinon, bascule manuellement en editant IMAGE_NAMESPACE + dans ton .env (igmlcreation/loremind- pour stable, + igmlcreation/loremind-beta- pour beta). + +
+
+
+
+ diff --git a/web/src/app/settings/updates-section/updates-section.component.ts b/web/src/app/settings/updates-section/updates-section.component.ts new file mode 100644 index 0000000..778dadf --- /dev/null +++ b/web/src/app/settings/updates-section/updates-section.component.ts @@ -0,0 +1,364 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { interval, switchMap, Subscription } from 'rxjs'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { LucideAngularModule, ArrowLeft, RefreshCw, Check, AlertCircle, Download, Heart, Link2, Unlink } from 'lucide-angular'; +import { UpdatesService, UpdateStatus } from '../../services/updates.service'; +import { ConfigService } from '../../services/config.service'; +import { LicenseService, LicenseStatusDTO, BetaStatusDTO, ChannelStatusDTO, ChannelName } from '../../services/license.service'; +import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service'; + +/** + * Section « Mises a jour » de l'ecran Parametres (composant standalone). + * + * Responsabilite unique : tout ce qui touche au CYCLE DE VIE des conteneurs — + * - canal stable : verification + application des mises a jour (Watchtower) ; + * - licence Patreon : connexion OAuth, installation du JWT, refresh, deconnexion ; + * - canal beta : activation + verification des images beta ; + * - bascule de canal stable <-> beta via le sidecar switcher (avec polling). + * + * Autonome : injecte ses propres services, aucun @Input/@Output — le parent + * (SettingsComponent) ne gere plus que le formulaire de configuration LLM. + */ +@Component({ + selector: 'app-settings-updates-section', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './updates-section.component.html', + // Reutilise la feuille de style de l'ecran Parametres : les blocs deplaces + // gardent exactement le meme rendu (cards, alerts, channel-switch, …). + styleUrls: ['../settings.component.scss'] +}) +export class UpdatesSectionComponent implements OnInit, OnDestroy { + + readonly ArrowLeft = ArrowLeft; + readonly RefreshCw = RefreshCw; + readonly Check = Check; + readonly AlertCircle = AlertCircle; + readonly Download = Download; + readonly Heart = Heart; + readonly Link2 = Link2; + readonly Unlink = Unlink; + + // --- Licence Patreon (canal beta) --- + licenseStatus: LicenseStatusDTO | null = null; + licenseLoading = false; + licenseError = ''; + /** Message de succes local a la section (connexion/deconnexion Patreon). */ + licenseSuccess = ''; + /** Token JWT colle par l'utilisateur apres OAuth. */ + licenseJwtInput = ''; + /** Etat du canal beta (digests des images privees). */ + betaStatus: BetaStatusDTO | null = null; + betaChecking = false; + + // --- Bascule de canal stable <-> beta via sidecar switcher --- + channelStatus: ChannelStatusDTO | null = null; + /** True pendant le polling apres clic. Bloque les boutons. */ + switchInFlight = false; + /** ID de la commande de switch en cours, pour ignorer les vieux resultats. */ + private switchCommandId: string | null = null; + /** Subscription du polling pour pouvoir l'arreter. */ + private switchPollSub: Subscription | null = null; + /** Erreur affichee si le switch a echoue. */ + switchError = ''; + + // --- Mises a jour conteneurs (canal stable) --- + updateStatus: UpdateStatus | null = null; + updateChecking = false; + updateApplying = false; + updateMessage = ''; + + constructor( + private updatesService: UpdatesService, + public config: ConfigService, + private licenseService: LicenseService, + private confirmDialog: ConfirmDialogService + ) {} + + ngOnInit(): void { + if (this.config.updateCheckEnabled) { + this.checkUpdates(); + } + this.loadLicense(); + this.loadChannelStatus(); + } + + ngOnDestroy(): void { + this.stopSwitchPolling(); + } + + // --- Licence Patreon --------------------------------------------------- + + loadLicense(): void { + this.licenseLoading = true; + this.licenseService.getStatus().subscribe({ + next: (s) => { + this.licenseStatus = s; + this.licenseLoading = false; + if (s?.enabled && (s.status === 'VALID' || s.status === 'GRACE') && s.betaChannelEnabled) { + this.checkBeta(); + } + }, + error: () => { this.licenseLoading = false; } + }); + } + + /** + * Ouvre la page OAuth Patreon dans une nouvelle fenetre. + * L'utilisateur copie ensuite le JWT et le colle dans l'input ci-dessous. + */ + connectPatreon(): void { + this.licenseError = ''; + this.licenseService.getConnectUrl().subscribe({ + next: (r) => { + if (!r?.url) { + this.licenseError = 'Impossible de generer l\'URL de connexion. Verifie ta config.'; + return; + } + window.open(r.url, '_blank', 'noopener'); + } + }); + } + + installLicense(): void { + const jwt = this.licenseJwtInput.trim(); + if (!jwt) { + this.licenseError = 'Colle d\'abord le token recu apres connexion Patreon.'; + return; + } + this.licenseError = ''; + this.licenseService.install(jwt).subscribe((res) => { + if ((res as any)?.error) { + this.licenseError = (res as any).error; + return; + } + this.licenseStatus = res as LicenseStatusDTO; + this.licenseJwtInput = ''; + this.licenseSuccess = 'Compte Patreon connecte. L\'acces beta est actif.'; + if (this.licenseStatus.betaChannelEnabled) { + this.checkBeta(); + } + }); + } + + refreshLicense(): void { + this.licenseLoading = true; + this.licenseService.refresh().subscribe({ + next: (s) => { + this.licenseStatus = s; + this.licenseLoading = false; + }, + error: () => { this.licenseLoading = false; } + }); + } + + disconnectPatreon(): void { + this.confirmDialog.confirm({ + title: 'Deconnecter Patreon', + message: 'Deconnecter ton compte Patreon ?', + details: ['Tu perdras l\'acces au canal beta.'], + confirmLabel: 'Deconnecter', + variant: 'warning' + }).then(ok => { + if (!ok) return; + this.licenseService.disconnect().subscribe(() => { + this.licenseStatus = null; + this.betaStatus = null; + this.licenseSuccess = 'Compte Patreon deconnecte.'; + this.loadLicense(); + }); + }); + } + + toggleBetaChannel(enabled: boolean): void { + this.licenseService.setBetaChannel(enabled).subscribe({ + next: (s) => { + if (s) this.licenseStatus = s; + if (enabled) this.checkBeta(); + else this.betaStatus = null; + } + }); + } + + checkBeta(): void { + this.betaChecking = true; + this.licenseService.checkBeta().subscribe({ + next: (s) => { + this.betaStatus = s; + this.betaChecking = false; + }, + error: () => { this.betaChecking = false; } + }); + } + + // --- Bascule de canal stable <-> beta -------------------------------------- + + loadChannelStatus(): void { + this.licenseService.getChannelStatus().subscribe({ + next: (s) => { + this.channelStatus = s; + // Si on revient sur l'ecran apres un reload (post-switch reussi), + // on affiche le dernier resultat eventuel jusqu'a interaction utilisateur. + }, + error: () => { this.channelStatus = null; } + }); + } + + /** + * Declenche un switch de canal. La sequence cote UI : + * 1. Confirm modal (action destructrice : recreate des containers) + * 2. POST /api/license/channel/switch -> 202 avec l'ID de la commande + * 3. Polling /api/license/channel toutes les 2s jusqu'a status != IN_PROGRESS + * 4. Si SUCCESS : la page va se rendre injoignable (Core recree). On affiche + * "Recharge la page dans quelques secondes" et on essaie de poll quand + * meme — au retour de Core, on detectera SUCCESS et on rechargera auto. + * 5. Si ERROR : on affiche le message d'erreur et on debloque les boutons. + */ + requestChannelSwitch(target: ChannelName): void { + const confirmMessage = target === 'beta' + ? 'Basculer LoreMind sur le canal beta ? Les containers core/brain/web vont etre recrees avec les images beta. L\'application sera indisponible 10-30 secondes.' + : 'Repasser LoreMind sur le canal stable ? Les containers core/brain/web vont etre recrees avec les images stables. L\'application sera indisponible 10-30 secondes.'; + + this.confirmDialog.confirm({ + title: target === 'beta' ? 'Passer en beta ?' : 'Repasser en stable ?', + message: confirmMessage, + details: [ + 'Les donnees (DB, images) sont preservees.', + 'Tu pourras refaire le chemin inverse a tout moment depuis cet ecran.' + ], + confirmLabel: target === 'beta' ? 'Passer en beta' : 'Repasser en stable', + variant: 'warning' + }).then(ok => { + if (!ok) return; + this.doChannelSwitch(target); + }); + } + + private doChannelSwitch(target: ChannelName): void { + this.switchInFlight = true; + this.switchError = ''; + this.licenseService.switchChannel(target).subscribe((res) => { + if ('error' in res) { + this.switchError = res.error; + this.switchInFlight = false; + return; + } + this.switchCommandId = res.id; + this.startSwitchPolling(); + }); + } + + /** + * Poll /api/license/channel toutes les 2s. S'arrete quand on detecte un + * resultat avec un ID >= a celui qu'on a soumis (le sidecar le met a jour + * a la fin de son traitement). + */ + private startSwitchPolling(): void { + this.stopSwitchPolling(); + this.switchPollSub = interval(2000).pipe( + switchMap(() => this.licenseService.getChannelStatus()) + ).subscribe((status) => { + if (!status) return; + this.channelStatus = status; + const last = status.lastSwitch; + if (!last || last.id !== this.switchCommandId) return; + if (last.status === 'SUCCESS') { + // La page va se rafraichir auto via l'update-banner qui detecte le + // restart de Core. On laisse switchInFlight a true pour bloquer + // toute autre action en attendant. + this.stopSwitchPolling(); + this.switchInFlight = false; + } else if (last.status === 'ERROR') { + this.switchError = last.message || 'Echec du switch'; + this.stopSwitchPolling(); + this.switchInFlight = false; + } + // IN_PROGRESS : on continue a poll. + }); + } + + private stopSwitchPolling(): void { + if (this.switchPollSub) { + this.switchPollSub.unsubscribe(); + this.switchPollSub = null; + } + } + + /** + * Mapping tier_id Patreon → nom lisible. Les IDs viennent du dashboard + * Patreon de LoreMind (Settings -> Tiers). Sans entree dans la map, on + * affiche l'ID brut pour rester debuggable. + * + * Si tu ajoutes un nouveau tier Patreon, complete cette map et redeploie. + * (Pas besoin de toucher au backend — c'est juste un libelle d'UI.) + */ + private static readonly TIER_LABELS: Record = { + '28448887': 'Compagnon', + // '0000000': 'Aventurier', + // '0000000': 'Heros', + }; + + /** Libelle lisible d'un tier Patreon, fallback sur l'ID brut. */ + tierLabel(tierId: string | null | undefined): string { + if (!tierId) return ''; + return UpdatesSectionComponent.TIER_LABELS[tierId] ?? tierId; + } + + /** Format human-readable des dates renvoyees par le backend. */ + formatDate(iso: string | null | undefined): string { + if (!iso) return ''; + try { return new Date(iso).toLocaleString(); } catch { return iso; } + } + + /** Nombre de jours restants avant expiration JWT (peut etre negatif). */ + get daysUntilExpiry(): number | null { + if (!this.licenseStatus?.expiresAt) return null; + const exp = new Date(this.licenseStatus.expiresAt).getTime(); + const now = Date.now(); + return Math.ceil((exp - now) / (1000 * 60 * 60 * 24)); + } + + // --- Mises a jour conteneurs (canal stable) ----------------------------- + + checkUpdates(): void { + this.updateChecking = true; + this.updateMessage = ''; + this.updatesService.checkNow().subscribe({ + next: (s) => { + this.updateStatus = s; + this.updateChecking = false; + }, + error: () => { + this.updateChecking = false; + } + }); + } + + applyUpdate(): void { + this.confirmDialog.confirm({ + title: 'Mettre a jour', + message: 'Telecharger et redemarrer les conteneurs maintenant ?', + details: ['L\'app sera indisponible quelques secondes.'], + confirmLabel: 'Mettre à jour', + variant: 'warning' + }).then(ok => { + if (!ok) return; + this.updateApplying = true; + this.updateMessage = ''; + this.updatesService.apply().subscribe({ + next: (r) => { + this.updateApplying = false; + // Le redemarrage de core peut couper la connexion avant la reponse — + // dans ce cas r vaut null (gere par catchError dans le service). + this.updateMessage = r?.message + ?? 'Mise a jour declenchee. Rechargez la page dans 30s.'; + }, + error: () => { + this.updateApplying = false; + this.updateMessage = 'Mise a jour declenchee. Rechargez la page dans 30s.'; + } + }); + }); + } +}