Mise en ligne de la version 0.2.0
This commit is contained in:
0
brain/app/__init__.py
Normal file
0
brain/app/__init__.py
Normal file
0
brain/app/application/__init__.py
Normal file
0
brain/app/application/__init__.py
Normal file
258
brain/app/application/chat.py
Normal file
258
brain/app/application/chat.py
Normal 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."
|
||||
)
|
||||
99
brain/app/application/generate_page.py
Normal file
99
brain/app/application/generate_page.py
Normal 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
|
||||
}
|
||||
0
brain/app/core/__init__.py
Normal file
0
brain/app/core/__init__.py
Normal file
59
brain/app/core/config.py
Normal file
59
brain/app/core/config.py
Normal 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())
|
||||
61
brain/app/core/settings_store.py
Normal file
61
brain/app/core/settings_store.py
Normal 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
|
||||
0
brain/app/domain/__init__.py
Normal file
0
brain/app/domain/__init__.py
Normal file
186
brain/app/domain/models.py
Normal file
186
brain/app/domain/models.py
Normal 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
86
brain/app/domain/ports.py
Normal 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.
|
||||
"""
|
||||
0
brain/app/infrastructure/__init__.py
Normal file
0
brain/app/infrastructure/__init__.py
Normal file
121
brain/app/infrastructure/ollama_adapter.py
Normal file
121
brain/app/infrastructure/ollama_adapter.py
Normal 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
|
||||
174
brain/app/infrastructure/onemin_adapter.py
Normal file
174
brain/app/infrastructure/onemin_adapter.py
Normal 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
598
brain/app/main.py
Normal 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),
|
||||
)
|
||||
Reference in New Issue
Block a user