Mise en ligne de la version 0.2.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 46s
Build & Push Images / build (core) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m25s

This commit is contained in:
2026-04-21 14:25:17 +02:00
parent ebee8e106b
commit ba8a503b3e
300 changed files with 35329 additions and 1 deletions

0
brain/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,258 @@
"""Use case : chat conversationnel LoreMind avec Structural Context.
Construit un system prompt riche à partir de 4 contextes possibles
(Lore, Page focalisée, Campagne, entité narrative focalisée) puis délègue
au port `LLMChatProvider` pour le streaming token par token.
Ne charge PAS le contenu détaillé des pages — l'IA doit savoir ce qui
existe, pas être noyée sous le texte. Pattern "Structural Context", plus
simple que le RAG sémantique tant que les univers restent de taille humaine.
Combinaisons supportées :
- lore seul → chat Lore (page-edit / page-create)
- lore + page_context → chat Lore focalisé page
- campaign (+lore si liée) + optional narrative_entity → chat Campagne
"""
from typing import AsyncIterator
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChatMessage,
ChapterSummary,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
PageSummary,
)
from app.domain.ports import LLMChatProvider
# Température moyenne : chat conversationnel créatif mais cohérent.
# Plus élevée que le one-shot (0.4) car on veut de la variété d'idées,
# mais sans partir en délire halluciné (1.0+).
_DEFAULT_TEMPERATURE = 0.7
_BASE_SYSTEM = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu dialogues avec le MJ pour l'aider à enrichir son univers et ses campagnes.
Règles de ton :
- Réponds en français, ton chaleureux et créatif.
- Sois concis : listes à puces courtes plutôt que longs paragraphes.
- Propose des idées qui s'intègrent dans le contexte existant ci-dessous.
Règles de cohérence (IMPORTANT) :
- Tu PEUX et DOIS inventer des éléments originaux (personnages, lieux, objets, intrigues, créatures, scènes) — c'est ton rôle d'assistant créatif.
- Tu ne peux PAS faire référence à un élément du MJ (du Lore, des arcs, chapitres ou scènes) comme s'il existait déjà, SAUF s'il apparaît EXACTEMENT (même orthographe) dans l'une des sections de contexte ci-dessous.
- Si l'utilisateur mentionne un nom que tu ne vois pas dans le contexte, ne fais surtout pas semblant de le connaître : dis clairement "Je ne vois pas [nom] dans le contexte actuel, veux-tu qu'on le crée ?" plutôt que d'inventer des détails à son sujet.
- Évite les précisions inventées qu'on ne peut pas vérifier : dates exactes, chiffres de population, hiérarchies politiques complexes, généalogies détaillées. Préfère des formulations ouvertes que le MJ validera ("il y a longtemps", "de nombreux", "la haute noblesse")."""
class ChatUseCase:
"""Orchestre un tour de conversation avec le LLM + contextes structurels."""
def __init__(self, llm: LLMChatProvider) -> None:
self._llm = llm
async def stream(
self,
messages: list[ChatMessage],
*,
lore_context: LoreStructuralContext | None = None,
page_context: PageContext | None = None,
campaign_context: CampaignStructuralContext | None = None,
narrative_entity: NarrativeEntityContext | None = None,
) -> AsyncIterator[str]:
"""Streame les tokens de la réponse assistant pour le dernier message user.
Les 4 contextes sont tous optionnels, mais au moins l'un des deux
"niveaux haut" (lore_context ou campaign_context) doit être fourni
pour que le prompt ait du sens. Le controller (main.py) applique
cette règle à la frontière HTTP.
"""
system_prompt = self._build_system_prompt(
lore_context, page_context, campaign_context, narrative_entity
)
async for token in self._llm.stream_chat(
messages,
system_prompt=system_prompt,
temperature=_DEFAULT_TEMPERATURE,
):
yield token
# --- Construction du system prompt --------------------------------------
def _build_system_prompt(
self,
lore: LoreStructuralContext | None,
page: PageContext | None,
campaign: CampaignStructuralContext | None,
narrative: NarrativeEntityContext | None,
) -> str:
sections = [_BASE_SYSTEM]
if lore is not None:
sections.append(self._format_lore(lore))
if campaign is not None:
sections.append(self._format_campaign(campaign, lore_present=lore is not None))
if page is not None:
sections.append(self._format_page(page))
if narrative is not None:
sections.append(self._format_narrative_entity(narrative))
return "\n\n".join(sections)
# --- Blocs Lore ---------------------------------------------------------
@staticmethod
def _format_lore(ctx: LoreStructuralContext) -> str:
desc = f"\nDescription : {ctx.lore_description}" if ctx.lore_description else ""
folders_block = ChatUseCase._format_folders(ctx.folders)
tags_line = ", ".join(ctx.tags) if ctx.tags else "(aucun)"
return (
"--- UNIVERS (Lore) ---\n"
f"Nom : {ctx.lore_name}{desc}\n\n"
f"Organisation :\n{folders_block}\n\n"
f"Tags déjà utilisés : {tags_line}"
)
@staticmethod
def _format_folders(folders: dict[str, list[PageSummary]]) -> str:
"""Rend chaque page avec son contenu exploitable par le LLM.
Depuis b9 : affiche en plus des champs values/tags/pages liées sous
forme d'une fiche indentée par page, et seulement si l'info existe
(prompt compact quand une page est vierge).
"""
if not folders:
return "(Lore vide pour l'instant)"
lines: list[str] = []
for folder_name, pages in folders.items():
lines.append(f"- {folder_name} (dossier)")
if not pages:
lines.append(" (vide)")
continue
for ps in pages:
lines.append(f" - {ps.title} [template: {ps.template_name}]")
for field_name, value in ps.values.items():
lines.append(f" · {field_name} : {value}")
if ps.tags:
lines.append(f" · tags : {', '.join(ps.tags)}")
if ps.related_page_titles:
lines.append(
" · liée à : " + ", ".join(ps.related_page_titles)
)
return "\n".join(lines)
@staticmethod
def _format_page(pc: PageContext) -> str:
"""Bloc "PAGE EN COURS" — oriente l'IA vers la page précise éditée."""
if pc.template_fields:
fields_block = "\n".join(
f'- "{f}" : {pc.values.get(f) or "(vide)"}'
for f in pc.template_fields
)
else:
fields_block = "(aucun champ défini dans ce template)"
return (
"--- PAGE EN COURS D'ÉDITION ---\n"
f"Titre : {pc.title}\n"
f"Template : {pc.template_name}\n"
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette page. "
"Si l'utilisateur te demande de proposer des idées, elles doivent "
"concerner UNIQUEMENT les champs listés ci-dessus. Ne déborde pas "
"vers d'autres pages ou d'autres templates du Lore, même si ça te "
"semblerait pertinent."
)
# --- Blocs Campagne -----------------------------------------------------
@staticmethod
def _format_campaign(ctx: CampaignStructuralContext, *, lore_present: bool) -> str:
desc = f"\nDescription : {ctx.campaign_description}" if ctx.campaign_description else ""
arcs_block = ChatUseCase._format_arcs(ctx.arcs)
lore_note = (
"\n(Cette campagne est liée à l'univers ci-dessus : tu peux t'appuyer dessus.)"
if lore_present
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
)
return (
"--- CAMPAGNE COURANTE ---\n"
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n\n"
"Structure narrative (les flèches → indiquent des transitions de scène "
"déclenchées par un choix des joueurs) :\n"
f"{arcs_block}"
)
@staticmethod
def _format_arcs(arcs: list[ArcSummary]) -> str:
if not arcs:
return "(Aucun arc créé pour l'instant.)"
lines: list[str] = []
for arc in arcs:
lines.append(f"- {arc.name} (arc){ChatUseCase._illustration_hint(arc.illustration_count)}")
if arc.description:
lines.append(f" Synopsis : {arc.description}")
if not arc.chapters:
lines.append(" (aucun chapitre)")
continue
for chapter in arc.chapters:
lines.extend(ChatUseCase._format_chapter_block(chapter))
return "\n".join(lines)
@staticmethod
def _format_chapter_block(chapter: ChapterSummary) -> list[str]:
hint = ChatUseCase._illustration_hint(chapter.illustration_count)
block = [f" - {chapter.name} (chapitre){hint}"]
if chapter.description:
block.append(f" Synopsis : {chapter.description}")
if not chapter.scenes:
block.append(" (aucune scène)")
else:
for scene in chapter.scenes:
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
block.append(f" - {scene.name} (scène){sc_hint}")
if scene.description:
block.append(f" Description : {scene.description}")
for br in scene.branches:
cond = f" (si : {br.condition})" if br.condition else ""
block.append(
f'"{br.label}" vers {br.target_scene_name}{cond}'
)
return block
@staticmethod
def _illustration_hint(count: int) -> str:
"""Rend " [N illustrations]" si count > 0, sinon chaine vide.
Informe l'IA que l'entite a deja un support visuel. Permet de prioriser
les suggestions ecrites qui collent a l'existant visuel plutot que de
diverger.
"""
if count <= 0:
return ""
noun = "illustration" if count == 1 else "illustrations"
return f" [{count} {noun}]"
@staticmethod
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
type_label = {"arc": "ARC", "chapter": "CHAPITRE", "scene": "SCÈNE"}.get(
ne.entity_type.lower(), ne.entity_type.upper()
)
if ne.fields:
fields_block = "\n".join(
f'- "{key}" : {value or "(vide)"}'
for key, value in ne.fields.items()
)
else:
fields_block = "(aucun champ renseigné)"
return (
f"--- {type_label} EN COURS D'ÉDITION ---\n"
f"Titre : {ne.title}\n"
f"Champs et valeurs actuelles :\n{fields_block}\n\n"
"IMPORTANT : concentre-toi EXCLUSIVEMENT sur cette entité narrative. "
"Tes suggestions doivent enrichir UNIQUEMENT les champs listés ci-dessus. "
"Ne déborde pas vers d'autres arcs, chapitres ou scènes de la campagne, "
"même si ça te semblerait pertinent."
)

View File

@@ -0,0 +1,99 @@
"""Use case : génération d'une page LoreMind à partir d'un contexte métier.
Couche APPLICATION — au-dessus du domaine, en-dessous de l'infra web.
Orchestre le flux : contexte → prompt → appel LLM → parsing JSON → résultat.
Ne dépend que des abstractions du domaine (port `LLMProvider`). C'est ce qui
permet de tester ce use case avec un FakeLLMProvider, sans Ollama qui tourne.
"""
import json
from app.domain.models import PageGenerationContext, PageGenerationResult
from app.domain.ports import LLMProvider, LLMProviderError
# Température basse : remplissage de champs = tâche factuelle, peu créative.
# Une valeur trop haute (par défaut Ollama = 0.8) encourage l'IA à broder
# et à inventer des références à des PNJ/lieux/événements inexistants.
_DEFAULT_TEMPERATURE = 0.4
_SYSTEM_INSTRUCTIONS = """Tu es un assistant d'écriture pour un Maître de Jeu de JDR.
Tu vas générer le contenu d'une page appartenant à un univers fictionnel.
Règles impératives de ta réponse :
- Tu réponds UNIQUEMENT par un objet JSON valide.
- Les clés du JSON correspondent EXACTEMENT aux noms de champs demandés.
- Les valeurs sont des chaînes de texte en français, riches et évocatrices.
- Aucun markdown, aucune explication, aucun commentaire autour du JSON.
Règles de cohérence (IMPORTANT) :
- Tu PEUX inventer des détails originaux pour CETTE page : apparence, traits de caractère, anecdotes, histoire personnelle.
- Tu ne dois PAS faire référence à d'autres personnages, lieux, organisations ou événements comme s'ils existaient déjà dans l'univers, sauf si le contexte ci-dessous les mentionne explicitement.
- Si un champ appelle une précision externe (date, nom d'un roi, ville voisine, guerre passée), reste volontairement vague : "il y a de nombreuses années", "un bourg voisin", "une époque troublée". Le MJ préfère combler lui-même les blancs plutôt que trouver des faits inventés contradictoires avec son univers."""
class GeneratePageUseCase:
"""Orchestre la génération d'une page LoreMind via un LLM."""
def __init__(self, llm: LLMProvider) -> None:
self._llm = llm
async def execute(
self,
context: PageGenerationContext,
) -> PageGenerationResult:
prompt = self._build_prompt(context)
raw = await self._llm.generate(
prompt,
output_format="json",
temperature=_DEFAULT_TEMPERATURE,
)
values = self._parse_values(raw, context.template_fields)
return PageGenerationResult(values=values)
@staticmethod
def _build_prompt(context: PageGenerationContext) -> str:
fields_block = "\n".join(f'- "{field}"' for field in context.template_fields)
lore_desc_line = (
f"\nDescription de l'univers : {context.lore_description}"
if context.lore_description
else ""
)
return (
f"{_SYSTEM_INSTRUCTIONS}\n\n"
f"Univers : {context.lore_name}"
f"{lore_desc_line}\n"
f"Catégorie (dossier) : {context.folder_name}\n"
f"Gabarit : {context.template_name}\n"
f"Titre de la page à créer : {context.page_title}\n\n"
f"Champs à remplir (clés JSON attendues) :\n"
f"{fields_block}\n\n"
f"Génère maintenant le JSON."
)
@staticmethod
def _parse_values(
raw: str,
expected_fields: list[str],
) -> dict[str, str]:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise LLMProviderError(
f"Réponse du LLM non parseable en JSON : {exc}"
) from exc
if not isinstance(parsed, dict):
raise LLMProviderError(
f"Le LLM a renvoyé un {type(parsed).__name__}, pas un objet JSON."
)
# Filtrage défensif : on ne garde que les champs demandés, cast en str,
# jamais None. Les champs absents de la réponse deviennent des chaînes vides
# (l'utilisateur les complètera manuellement dans page-edit).
return {
field: str(parsed.get(field, "")).strip()
for field in expected_fields
}

View File

59
brain/app/core/config.py Normal file
View File

@@ -0,0 +1,59 @@
"""Configuration applicative centralisée (principe 12-factor : config via env).
Équivalent Python du `application.properties` Spring Boot, avec validation
Pydantic : une variable manquante/invalide = crash au démarrage, pas une
NullPointerException surprise à la 3ème requête.
Depuis l'ecran Parametres (UI) : certains champs sont surchargeables a chaud
via `settings_store` (fichier JSON). A chaque Depends(get_settings), on relit
.env + overrides fusionnes. Pas de cache : le cout d'un read JSON local est
negligeable face a un appel LLM.
"""
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.core.settings_store import load_overrides
class Settings(BaseSettings):
"""Settings chargés depuis .env ou variables d'environnement."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
llm_provider: Literal["ollama", "onemin"] = "ollama"
ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b"
llm_timeout_seconds: int = 120
# Fenêtre de contexte (num_ctx Ollama). Défaut Ollama = 2048, trop étroit
# dès que le Structural Context du Lore dépasse ~10 pages (b9). On monte
# à 16384 pour tenir ~100 pages enrichies. Coût VRAM : ~600 MB de KV cache
# supplémentaire (vs 2048) pour le modèle gemma 2B. Surchargeable via
# LLM_NUM_CTX dans .env si besoin (ex: VRAM limitée → 8192).
llm_num_ctx: int = 16384
# 1min.ai (etage 2) — la cle et le modele sont stockes via settings_store
# (modifiables depuis l'UI). Les defauts ici sont juste des placeholders.
onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini"
# Secret partage entre le Core Spring et le Brain. Le Brain n'accepte une
# requete que si l'entete X-Internal-Secret correspond. Volontairement
# non-surchargeable via settings_store (securite critique, .env-only).
internal_shared_secret: str = ""
def get_settings() -> Settings:
"""Fabrique des Settings merges (.env -> overrides runtime).
Relu a chaque requete HTTP (via Depends). Permet a l'UI de changer
le modele / provider sans redemarrer le Brain.
"""
return Settings(**load_overrides())

View File

@@ -0,0 +1,61 @@
"""Overrides runtime persistés sur disque pour les Settings.
Les Settings par defaut viennent de .env (12-factor). L'utilisateur peut
surcharger certains champs depuis l'UI (ex: modele Ollama choisi) — ces
overrides sont stockes dans un fichier JSON local, relus a chaque requete.
Thread-safe via un lock simple : suffisant pour un deploiement mono-process
(usage local). Si un jour on passe en multi-worker, migrer vers SQLite.
"""
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any
_LOCK = threading.Lock()
_OVERRIDES_PATH = Path("data/settings.json")
# Allow-list stricte des cles persistables via l'API. Toute autre cle est
# silencieusement ignoree — empeche un appelant de polluer settings.json
# avec des champs arbitraires (ex: `internal_shared_secret`) ou d'exposer
# un vecteur SSRF/credential-swap via un champ non-documente.
_ALLOWED_KEYS = frozenset({
"llm_provider",
"ollama_base_url",
"llm_model",
"llm_timeout_seconds",
"llm_num_ctx",
"onemin_api_key",
"onemin_model",
})
def load_overrides() -> dict[str, Any]:
"""Retourne le dict d'overrides, ou {} si le fichier n'existe pas / est corrompu."""
if not _OVERRIDES_PATH.exists():
return {}
try:
raw = json.loads(_OVERRIDES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(raw, dict):
return {}
# Defense en profondeur au chargement : si settings.json contient des
# cles hors allow-list (heritage d'un ancien binaire), on les ignore.
return {k: v for k, v in raw.items() if k in _ALLOWED_KEYS}
def save_overrides(patch: dict[str, Any]) -> dict[str, Any]:
"""Fusionne `patch` (cles allow-listees uniquement) et persiste."""
filtered = {k: v for k, v in patch.items() if k in _ALLOWED_KEYS}
with _LOCK:
current = load_overrides()
current.update(filtered)
_OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
_OVERRIDES_PATH.write_text(
json.dumps(current, indent=2, ensure_ascii=False),
encoding="utf-8",
)
return current

View File

186
brain/app/domain/models.py Normal file
View File

@@ -0,0 +1,186 @@
"""Modèles de domaine pour le cas d'usage de génération de page LoreMind.
On utilise @dataclass (pas Pydantic) pour garder le domaine exempt de toute
dépendance framework. Pydantic apparaît uniquement aux frontières : DTOs HTTP
dans `main.py`, Settings dans `core/config.py`.
"""
from dataclasses import dataclass, field
@dataclass(frozen=True)
class PageGenerationContext:
"""Contexte métier à fournir au LLM pour générer une page LoreMind.
Les champs correspondent aux entités du Lore Context côté Core Java :
- lore_* : l'univers (Lore)
- folder_name : le dossier (LoreNode) qui catégorise la page
- template_* : le gabarit qui liste les champs à remplir
- page_title : le titre de la page à créer
"""
lore_name: str
folder_name: str
template_name: str
template_fields: list[str]
page_title: str
lore_description: str | None = None
@dataclass(frozen=True)
class PageGenerationResult:
"""Résultat métier : une valeur textuelle générée par champ du template.
La clé du dict est le nom du champ (ex: "apparence"), la valeur est
le contenu généré par le LLM. Cohérent avec la structure
`Page.values: Map<String,String>` côté Core Java.
"""
values: dict[str, str]
@dataclass(frozen=True)
class ChatMessage:
"""Message d'une conversation — rôle + contenu textuel.
Rôles possibles (OpenAI/Ollama compatibles) :
- "system" : prompt système (contexte, instructions)
- "user" : message de l'utilisateur
- "assistant" : réponse précédente du LLM
"""
role: str
content: str
@dataclass(frozen=True)
class PageSummary:
"""Résumé enrichi d'une page du Lore, projeté pour alimenter le prompt.
Depuis b9 : on ne se contente plus du nom + template, on embarque aussi
les valeurs des champs dynamiques (tronquées côté Core Java à 500 car.),
les tags, et les titres des pages liées (les IDs techniques sont déjà
résolus en titres lisibles côté Java — voir LoreStructuralContextBuilder).
Les notes privées du MJ restent volontairement absentes ici (confinées
à leur page d'édition via PageContext quand l'utilisateur y travaille).
"""
title: str
template_name: str
values: dict[str, str]
tags: list[str]
related_page_titles: list[str]
@dataclass(frozen=True)
class LoreStructuralContext:
"""Carte structurelle enrichie d'un Lore pour nourrir l'IA.
Depuis b9 : chaque page expose son contenu (values, tags, liens) via
PageSummary. Le prompt n'est plus qu'une table des matières — c'est
une encyclopédie condensée que le LLM peut directement citer.
Le dict `folders` est indexé par nom de dossier et mappe vers la liste
des pages qu'il contient (PageSummary).
"""
lore_name: str
lore_description: str | None
folders: dict[str, list[PageSummary]]
tags: list[str]
@dataclass(frozen=True)
class PageContext:
"""Contexte d'une page spécifique en cours d'édition.
Injecté dans le system prompt pour focaliser le chat sur CETTE page
précise : son template, ses champs, ses valeurs actuelles. Permet à
l'IA d'éviter de parler d'autres pages du Lore par mégarde.
Complémentaire de `LoreStructuralContext` : l'un donne la carte
générale (toutes les pages existantes), l'autre zoome sur la page
en cours de discussion.
"""
title: str
template_name: str
template_fields: list[str]
values: dict[str, str]
@dataclass(frozen=True)
class SceneBranchHint:
"""Indice d'une branche narrative vers une autre scène du même chapitre.
Le Core Java résout déjà `targetSceneId` en nom humain avant l'envoi :
l'IA ne voit donc jamais d'UUID, seulement des noms qu'elle peut citer.
"""
label: str
target_scene_name: str
condition: str | None = None
@dataclass(frozen=True)
class SceneSummary:
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
name: str
description: str | None
# Depuis l'etape 6 : permet a l'IA de savoir qu'une scene a des illustrations
# attachees. 0 par defaut pour retrocompat si le Core n'envoie rien.
illustration_count: int = 0
# Connexions narratives sortantes (livre dont vous etes le heros).
branches: list[SceneBranchHint] = field(default_factory=list)
@dataclass(frozen=True)
class ChapterSummary:
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
name: str
description: str | None
scenes: list[SceneSummary]
illustration_count: int = 0
@dataclass(frozen=True)
class ArcSummary:
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
name: str
description: str | None
chapters: list[ChapterSummary]
illustration_count: int = 0
@dataclass(frozen=True)
class CampaignStructuralContext:
"""Carte narrative enrichie d'une Campagne pour nourrir l'IA.
Jumeau de LoreStructuralContext côté Campaign. On décrit l'arbre
arcs → chapitres → scènes en donnant le NOM + une DESCRIPTION courte
(synopsis) à chaque niveau. Les champs longs (notes MJ, narration
joueur, combat) restent réservés à l'entité focus via
NarrativeEntityContext. Ordre narratif préservé dans la liste `arcs`.
"""
campaign_name: str
campaign_description: str | None
arcs: list[ArcSummary]
@dataclass(frozen=True)
class NarrativeEntityContext:
"""Contexte d'une entité narrative précise en cours d'édition.
Équivalent de PageContext côté Campaign. Focalise l'IA sur un Arc,
Chapter ou Scene en particulier. `entity_type` ∈ {"arc","chapter","scene"}.
Les `fields` sont une map ordonnée nomChamp → valeurActuelle (chaîne
vide si non renseigné).
"""
entity_type: str
title: str
fields: dict[str, str]

86
brain/app/domain/ports.py Normal file
View File

@@ -0,0 +1,86 @@
"""Ports (contrats) du domaine du Brain LoreMind.
Un Port est une INTERFACE abstraite exposée par le domaine vers le monde
extérieur. Le domaine définit CE QU'IL ATTEND, pas COMMENT c'est implémenté.
En Python moderne on privilégie Protocol (PEP 544) sur ABC pour bénéficier
du duck typing structurel : toute classe qui possède les bonnes méthodes
satisfait le contrat, sans héritage explicite.
"""
from typing import AsyncIterator, Protocol
class LLMProvider(Protocol):
"""Port sortant — contrat pour un fournisseur de modèle de langage.
Toute implémentation (Ollama, OpenAI, Claude, faux-mock de test) doit
exposer au minimum cette méthode `generate`.
"""
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""Génère une réponse textuelle à partir d'un prompt donné.
Args:
prompt: le texte envoyé au modèle.
output_format: contrainte de format optionnelle. Exemple : "json"
pour forcer le modèle à renvoyer du JSON valide. Les
fournisseurs qui ne supportent pas une valeur donnée doivent
l'ignorer silencieusement ou la traduire au mieux.
temperature: créativité du modèle, 0.0 (déterministe/factuel) à
1.0+ (très créatif, hallucine plus facilement). None =
valeur par défaut de l'adapter. Recommandation LoreMind :
~0.4 pour du remplissage factuel, ~0.7 pour du chat créatif.
Raises:
LLMProviderError: si le fournisseur sous-jacent a échoué.
"""
...
class LLMChatProvider(Protocol):
"""Port sortant — fournisseur de chat streamé (conversation multi-tours).
Distinct de LLMProvider par Interface Segregation Principle : le chat
streamé est une capacité séparée (messages structurés, flux de tokens)
qui mérite son propre contrat. Un même adapter concret (ex: Ollama)
peut satisfaire les deux protocoles simultanément grâce au duck typing.
"""
async def stream_chat(
self,
messages: list["ChatMessage"], # forward ref, évite import circulaire
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
"""Streame la réponse du LLM token par token.
Args:
messages: historique de la conversation (chronologique, le dernier
message étant typiquement celui de l'utilisateur en attente
de réponse).
system_prompt: instructions système optionnelles (contexte global,
règles de comportement). Prefixe la conversation si fourni.
temperature: créativité du modèle (voir `LLMProvider.generate`).
Yields:
Fragments de texte (tokens) au fur et à mesure de la génération.
Raises:
LLMProviderError: si le fournisseur sous-jacent a échoué.
"""
...
class LLMProviderError(Exception):
"""Erreur du domaine signalant qu'un LLMProvider n'a pas pu générer.
Définie dans le domaine (pas dans l'infra) pour que les couches
supérieures puissent l'attraper sans connaître l'adapter concret.
"""

View File

View File

@@ -0,0 +1,121 @@
"""Adapter Ollama — implémentation concrète des ports LLMProvider et LLMChatProvider.
Isole le reste de l'application des spécificités du protocole Ollama
(URL /api/generate, /api/chat, payload, parsing). Pour swap vers OpenAI
demain, on écrit un nouvel adapter sans toucher au reste du code.
"""
import json
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
class OllamaLLMProvider:
"""Implémentation des ports LLM — appelle un serveur Ollama via HTTP.
Satisfait implicitement (duck typing) à la fois `LLMProvider` (endpoint
/api/generate, appel unique) et `LLMChatProvider` (endpoint /api/chat,
streaming token par token).
"""
def __init__(self, settings: Settings) -> None:
self._base_url = settings.ollama_base_url
self._model = settings.llm_model
self._timeout = settings.llm_timeout_seconds
self._num_ctx = settings.llm_num_ctx
def _build_options(self, temperature: float | None) -> dict[str, object]:
"""Construit le dict `options` attendu par Ollama (hyperparamètres).
`num_ctx` est TOUJOURS envoyé — sinon Ollama retombe sur son défaut
2048 et tronque silencieusement les gros prompts (Structural Context
du Lore enrichi depuis b9). `temperature` n'est ajoutée que si
fournie par le use case (sinon Ollama utilise son défaut).
"""
options: dict[str, object] = {"num_ctx": self._num_ctx}
if temperature is not None:
options["temperature"] = temperature
return options
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
url = f"{self._base_url}/api/generate"
payload: dict[str, object] = {
"model": self._model,
"prompt": prompt,
"stream": False,
"options": self._build_options(temperature),
}
if output_format is not None:
payload["format"] = output_format
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(url, json=payload)
response.raise_for_status()
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors de l'appel à Ollama : {exc}"
) from exc
return response.json()["response"]
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
"""Streame depuis Ollama /api/chat. Parse le NDJSON ligne par ligne.
Ollama renvoie un JSON par ligne au fil de la génération :
- étapes intermédiaires : `{"message": {"content": "token"}, "done": false}`
- étape finale : `{"done": true, ...}`
On yield chaque token non-vide au consommateur, qui se charge du
formatage SSE (c'est la responsabilité du controller HTTP, pas
de l'adapter LLM).
"""
url = f"{self._base_url}/api/chat"
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(
{"role": m.role, "content": m.content} for m in messages
)
payload: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
"options": self._build_options(temperature),
}
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream("POST", url, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.strip():
continue
chunk = json.loads(line)
if chunk.get("done"):
break
token = chunk.get("message", {}).get("content", "")
if token:
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors du streaming Ollama : {exc}"
) from exc

View File

@@ -0,0 +1,174 @@
"""Adapter 1min.ai — implementation alternative des ports LLMProvider / LLMChatProvider.
API 1min.ai (cf. https://docs.1min.ai/docs/api/chat-with-ai-api) :
- POST https://api.1min.ai/api/chat-with-ai (one-shot)
- POST https://api.1min.ai/api/chat-with-ai?isStreaming=true (SSE)
- Auth : header "API-KEY: <cle>"
- Body : {"type": "UNIFY_CHAT_WITH_AI", "model": "...",
"promptObject": {"prompt": "..."}}
Le port LoreMind expose une API "messages[]", mais 1min.ai attend un prompt
unique. On aplatit donc l'historique + system prompt en un seul bloc texte,
avec des marqueurs de role lisibles pour le modele.
"""
from __future__ import annotations
import json
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
_API_BASE = "https://api.1min.ai/api/chat-with-ai"
_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI"
class OneMinAiLLMProvider:
"""Adapter 1min.ai — satisfait LLMProvider et LLMChatProvider par duck typing."""
def __init__(self, settings: Settings) -> None:
if not settings.onemin_api_key:
raise LLMProviderError(
"Cle API 1min.ai manquante. Configure-la depuis l'ecran Parametres."
)
self._api_key = settings.onemin_api_key
self._model = settings.onemin_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {"API-KEY": self._api_key, "Content-Type": "application/json"}
def _payload(self, prompt: str) -> dict[str, object]:
return {
"type": _PAYLOAD_TYPE,
"model": self._model,
"promptObject": {"prompt": prompt},
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None, # 1min.ai ne supporte pas format=json
temperature: float | None = None, # idem, pas d'hyperparam expose ici
) -> str:
"""Appel one-shot : retourne la reponse complete sous forme de string."""
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
response = await client.post(
_API_BASE, headers=self._headers(), json=self._payload(prompt)
)
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise LLMProviderError(f"Erreur 1min.ai : {exc}") from exc
return self._extract_result(data)
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
"""Streame via SSE.
1min.ai expose deux evenements utiles :
- `event: content` → `data: {"content": "..."}`
- `event: done` → fin du stream
- `event: error` → erreur serveur
On yield le champ `content` au fil de l'arrivee.
"""
prompt = self._flatten_messages(messages, system_prompt)
url = f"{_API_BASE}?isStreaming=true"
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", url, headers=self._headers(), json=self._payload(prompt)
) as response:
response.raise_for_status()
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(
f"Erreur lors du streaming 1min.ai : {exc}"
) from exc
# --- Helpers ------------------------------------------------------------
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""Decoupe le flux SSE ligne par ligne et yield les chunks 'content'."""
current_event: str | None = None
current_data = ""
async for line in response.aiter_lines():
if line == "":
# Fin d'un evenement SSE : dispatch
if current_event == "done":
return
if current_event == "error":
raise LLMProviderError(f"1min.ai a signale une erreur : {current_data}")
if current_data and current_event in (None, "content", "message"):
token = OneMinAiLLMProvider._extract_content_chunk(current_data)
if token:
yield token
current_event = None
current_data = ""
continue
if line.startswith("event:"):
current_event = line[6:].strip()
elif line.startswith("data:"):
chunk = line[5:].lstrip()
current_data = f"{current_data}\n{chunk}" if current_data else chunk
@staticmethod
def _extract_content_chunk(data: str) -> str:
"""Extrait le champ `content` d'un data JSON, avec tolerance si format brut."""
try:
obj = json.loads(data)
except json.JSONDecodeError:
return data # filet de securite si le serveur envoie du texte brut
if isinstance(obj, dict):
return obj.get("content") or obj.get("token") or ""
return ""
@staticmethod
def _extract_result(payload: dict) -> str:
"""Extrait le texte final d'une reponse non-streamee.
Schema attendu : `aiRecord.aiRecordDetail.resultObject` (list[str]).
On concatene par securite (le serveur renvoie habituellement un seul element).
"""
record = payload.get("aiRecord") or {}
detail = record.get("aiRecordDetail") or {}
result = detail.get("resultObject") or []
if isinstance(result, list):
return "".join(str(x) for x in result)
if isinstance(result, str):
return result
raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.")
@staticmethod
def _flatten_messages(
messages: list[ChatMessage], system_prompt: str | None
) -> str:
"""Transforme [system_prompt, history] en un unique prompt textuel.
1min.ai n'accepte qu'un champ `prompt` : on serialise la conversation
avec des marqueurs explicites pour que le modele comprenne les tours.
"""
parts: list[str] = []
if system_prompt:
parts.append(f"[SYSTEM]\n{system_prompt}")
if messages:
history = "\n\n".join(
f"[{m.role.upper()}]\n{m.content}" for m in messages
)
parts.append(history)
parts.append("[ASSISTANT]") # invite le modele a continuer
return "\n\n".join(parts)

598
brain/app/main.py Normal file
View File

@@ -0,0 +1,598 @@
"""Point d'entrée FastAPI du Brain LoreMind.
Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue
au domaine via injection de dépendance (ports + use cases), et transforme les
erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici.
"""
import json
from typing import Annotated, AsyncIterator, Literal
import hmac
import httpx
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from app.application.chat import ChatUseCase
from app.application.generate_page import GeneratePageUseCase
from app.core.config import Settings, get_settings
from app.core.settings_store import save_overrides
from app.domain.models import (
ArcSummary,
CampaignStructuralContext,
ChapterSummary,
ChatMessage,
LoreStructuralContext,
NarrativeEntityContext,
PageContext,
PageGenerationContext,
PageSummary,
SceneBranchHint,
SceneSummary,
)
from app.domain.ports import LLMProvider, LLMProviderError
from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI(
title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.",
version="0.2.0",
)
# 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).
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
@app.middleware("http")
async def require_internal_secret(request: Request, call_next):
"""Refuse toute requete qui ne presente pas le secret partage core<->brain.
Fail-closed : si `INTERNAL_SHARED_SECRET` n'est pas configure cote Brain,
TOUTES les requetes non-publiques sont rejetees. Force la configuration
explicite en prod et empeche un deploiement par defaut non-authentifie.
Comparaison en temps-constant via `hmac.compare_digest` pour eviter les
attaques par timing side-channel sur la validation du secret.
"""
if request.url.path in _PUBLIC_PATHS:
return await call_next(request)
expected = get_settings().internal_shared_secret
provided = request.headers.get("x-internal-secret", "")
if not expected or not hmac.compare_digest(expected, provided):
return JSONResponse(
{"detail": "Unauthorized: invalid or missing X-Internal-Secret"},
status_code=401,
)
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 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)
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 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)
class NarrativeEntityDTO(BaseModel):
"""Entité narrative (arc/chapter/scene) en cours d'édition — focus optionnel."""
entity_type: str = Field(pattern="^(arc|chapter|scene)$")
title: str
fields: dict[str, str] = Field(default_factory=dict)
class ChatStreamRequestDTO(BaseModel):
"""Requête de chat streamé : historique + contextes structurels.
Les 4 contextes (lore, page, campaign, narrative_entity) sont optionnels,
mais au moins l'un des deux "niveaux haut" (lore_context ou
campaign_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
def has_scope(self) -> bool:
"""Vrai si au moins un contexte racine (Lore ou Campagne) est fourni."""
return self.lore_context is not None or self.campaign_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)
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]
# --- Endpoints ---
@app.get("/health")
def health() -> dict[str, str]:
"""Sonde de santé — permet au Core Java de vérifier que le Brain répond."""
return {"status": "ok", "service": "brain"}
@app.post("/generate", response_model=GenerateResponse)
async def generate(
body: GenerateRequest,
settings: Annotated[Settings, Depends(get_settings)],
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
) -> GenerateResponse:
"""Endpoint libre : prompt → texte brut. Utile pour debug et exploration."""
try:
text = await llm.generate(body.prompt)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GenerateResponse(model=settings.llm_model, response=text)
@app.post("/generate-page", response_model=GeneratePageResponseDTO)
async def generate_page(
body: GeneratePageRequestDTO,
use_case: Annotated[
GeneratePageUseCase, Depends(get_generate_page_use_case)
],
) -> GeneratePageResponseDTO:
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
Branche tout le use case `GeneratePageUseCase`. Ce controller ne fait
que le mapping DTO ↔ dataclass et la traduction d'erreur domaine → HTTP.
"""
context = PageGenerationContext(
lore_name=body.lore_name,
lore_description=body.lore_description,
folder_name=body.folder_name,
template_name=body.template_name,
template_fields=body.template_fields,
page_title=body.page_title,
)
try:
result = await use_case.execute(context)
except LLMProviderError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return GeneratePageResponseDTO(values=result.values)
@app.post("/chat/stream")
async def chat_stream(
body: ChatStreamRequestDTO,
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
) -> StreamingResponse:
"""Chat streamé (Server-Sent Events) avec Structural Context.
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)
async def event_stream() -> AsyncIterator[str]:
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,
):
# 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")
# --- 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
],
)
for sc in ch.scenes
],
)
for ch in arc.chapters
],
)
for arc in dto.arcs
]
return CampaignStructuralContext(
campaign_name=dto.campaign_name,
campaign_description=dto.campaign_description,
arcs=arcs,
)
# --- 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"]
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
class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin"] | 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
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),
)
@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)}
@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-<family>-<version>`), 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),
)