Compare commits
62 Commits
v0.8.1
...
c77c0bc994
| Author | SHA1 | Date | |
|---|---|---|---|
| c77c0bc994 | |||
| 6035df262d | |||
| 809e00ce49 | |||
| bc0cbb0f7b | |||
| 6740ed2177 | |||
| 8cc90bd24d | |||
| 14fc1c28fe | |||
| 7f519588b6 | |||
| 0799c850ec | |||
| 113df6a391 | |||
| a1f3b9b796 | |||
| cff2ceb0b9 | |||
| d1a11823bc | |||
| e1da369cfa | |||
| 177bf6e781 | |||
| 0303786aef | |||
| 7dec288829 | |||
| e26d11a99f | |||
| 3d1cf6e495 | |||
| 0e4820a2f8 | |||
| 092898e379 | |||
| 5061457f76 | |||
| c1811b0040 | |||
| 8369886f42 | |||
| e7aa67bc42 | |||
| 91069525a5 | |||
| 23878f1c63 | |||
| 6acad41672 | |||
| 05bbe64841 | |||
| d772e969ea | |||
| 04816ae9df | |||
| 49a94bb73e | |||
| b0e8fade03 | |||
| 341f6a5aae | |||
| 6c7dbff6a0 | |||
| 833280c784 | |||
| 70ec1f2fb9 | |||
| f638fdd24b | |||
| e85ab0e6b1 | |||
| ed22d9f29c | |||
| edc4434298 | |||
| 5eb15dc449 | |||
| da5b602f20 | |||
| 9ea43a1889 | |||
| 211e26dae1 | |||
| 53065c952b | |||
| 6d00543a59 | |||
| 091de0daf7 | |||
| 504c4b7b6c | |||
| e3fc96a1bc | |||
| bc05b1536d | |||
| 0e350142de | |||
| 11cb708de9 | |||
| 53343c0a75 | |||
| 43f45d68a1 | |||
| 4bbc6333fc | |||
| dc908b8d93 | |||
| ae9455c244 | |||
| e346bb4b85 | |||
| a1c9e67013 | |||
| b0edfb5aab | |||
| 117334315f |
@@ -42,19 +42,24 @@ jobs:
|
||||
username: ${{ env.GHCR_NAMESPACE }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
# Detection du canal :
|
||||
# - tag vX.Y.Z -> stable (push :latest + :version sur les repos publics)
|
||||
# - tag vX.Y.Z-beta* -> beta (push :beta + :version sur les repos GHCR prives
|
||||
# loremind-beta-<component> ; backup Gitea avec :version)
|
||||
- name: Extract version & channel
|
||||
id: meta
|
||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [[ "${VERSION}" == *-beta* ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Push vers les deux registries en un seul build (build-push-action
|
||||
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
||||
# Naming :
|
||||
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
||||
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
||||
# dans leur .env.
|
||||
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
||||
# la collision avec d'autres projets de l'org.
|
||||
- name: Build & push ${{ matrix.component }}
|
||||
# Build & push canal STABLE
|
||||
- name: Build & push ${{ matrix.component }} (stable)
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
@@ -64,3 +69,73 @@ jobs:
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Build & push canal BETA
|
||||
# GHCR : repos prives loremind-beta-<component> (gated par PAT distribue
|
||||
# via le relais Patreon aux tiers Compagnon).
|
||||
# Gitea : backup prive avec :version uniquement (pas de :latest pour ne
|
||||
# pas faire upgrader les installs branchees sur Gitea).
|
||||
- name: Build & push ${{ matrix.component }} (beta)
|
||||
if: steps.meta.outputs.channel == 'beta'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./${{ matrix.component }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:beta
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-beta-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
# Job separe pour le sidecar `switcher`.
|
||||
# Pourquoi separe : le switcher est volontairement HORS de IMAGE_NAMESPACE
|
||||
# (cf. docker-compose.yml). Il est toujours pulle depuis le repo public
|
||||
# `loremind-switcher`, quel que soit le canal de l'instance. On le build
|
||||
# donc uniquement sur les releases stables — pas la peine de re-publier
|
||||
# une variante beta du switcher, c'est une infrastructure neutre.
|
||||
build-switcher:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Detect channel
|
||||
id: meta
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [[ "${VERSION}" == *-beta* ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_PAT }}
|
||||
|
||||
- name: Login to GHCR
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ env.GHCR_NAMESPACE }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Build & push switcher (stable only)
|
||||
if: steps.meta.outputs.channel == 'stable'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./switcher
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:latest
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/switcher:${{ steps.meta.outputs.version }}
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:latest
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-switcher:${{ steps.meta.outputs.version }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -107,3 +107,5 @@ docker-compose.override.yml
|
||||
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
||||
# ============================================================================
|
||||
relay/
|
||||
scripts/bump-version.mjs
|
||||
brain/data/notebooks/5.json
|
||||
|
||||
67
README.md
67
README.md
@@ -1,37 +1,66 @@
|
||||
# LoreMind
|
||||
|
||||
> Application web auto-hébergeable pour MJ qui veulent centraliser leur univers, leurs campagnes et leurs personnages — avec un assistant IA contextuel.
|
||||
|
||||
[](LICENSE)
|
||||
[](https://loremind-docs.igmlcreation.fr/)
|
||||
[](https://loremind-demo.igmlcreation.fr/)
|
||||
[](https://www.patreon.com/c/IGMLCreation)
|
||||
[](https://discord.gg/cPpFzCjEzQ)
|
||||
|
||||
## Découvrir LoreMind en vidéo
|
||||
|
||||
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
||||
|
||||

|
||||
|
||||
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
||||
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
||||
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
||||
## Ce que ça fait
|
||||
|
||||
LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu.
|
||||
|
||||
### Lore
|
||||
|
||||
Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches.
|
||||
|
||||
### Game System
|
||||
|
||||
Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système.
|
||||
|
||||
### Campaign
|
||||
|
||||
Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu.
|
||||
|
||||
### Assistant IA
|
||||
|
||||
Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table.
|
||||
|
||||
L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir.
|
||||
|
||||
## Documentation
|
||||
|
||||
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
||||
Toute la documentation (installation, configuration, prise en main) est sur **[loremind-docs.igmlcreation.fr](https://loremind-docs.igmlcreation.fr/)**.
|
||||
|
||||
Pour l'installation, consultez le guide dans cette dernière .
|
||||
## Démo en ligne
|
||||
|
||||
## Fonctionnalités
|
||||
Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.fr](https://loremind-demo.igmlcreation.fr/)**.
|
||||
|
||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
||||
Quelques limites à connaître :
|
||||
- 10 utilisateurs maximum simultanés (instances isolées)
|
||||
- Session limitée à 20 minutes avant réinitialisation
|
||||
- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur)
|
||||
|
||||
## Démo
|
||||
## Soutenir le projet
|
||||
|
||||
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/)
|
||||
LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien :
|
||||
|
||||
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur.
|
||||
- **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs
|
||||
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
|
||||
|
||||
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser.
|
||||
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
|
||||
|
||||
## License
|
||||
## Licence
|
||||
|
||||
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
||||
|
||||
En pratique :
|
||||
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
|
||||
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
|
||||
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
||||
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer.
|
||||
- Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence.
|
||||
- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
||||
# curl : healthcheck docker.
|
||||
# tesseract-ocr (+ langues fra/eng) : repli OCR de l'import de PDF de regles
|
||||
# pour les pages sans couche texte (scans). Inutile pour les PDF born-digital
|
||||
# mais necessaire pour couvrir tous les cas.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
tesseract-ocr \
|
||||
tesseract-ocr-fra \
|
||||
tesseract-ocr-eng \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
5
brain/app/api/__init__.py
Normal file
5
brain/app/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Adapter web (architecture hexagonale) : routers FastAPI, DTOs et factories DI.
|
||||
|
||||
C'est la FRONTIÈRE HTTP du Brain : validation Pydantic, mapping DTO ↔ domaine,
|
||||
traduction des erreurs domaine → HTTP. Aucune logique métier ici.
|
||||
"""
|
||||
215
brain/app/api/chat_dto.py
Normal file
215
brain/app/api/chat_dto.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""DTOs Pydantic du chat contextuel — frontière HTTP avec le Core Java.
|
||||
|
||||
C'est ici (et seulement ici, avec les autres modules de `app.api`) qu'on
|
||||
utilise Pydantic : le domaine ne voit que des dataclasses (voir chat_mapping).
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ChatMessageDTO(BaseModel):
|
||||
"""Un message de la conversation. Rôles acceptés : user, assistant, system."""
|
||||
|
||||
role: str = Field(pattern="^(user|assistant|system)$")
|
||||
content: str
|
||||
|
||||
|
||||
class PageSummaryDTO(BaseModel):
|
||||
"""Résumé enrichi d'une page : identité + contenu + interconnexions.
|
||||
|
||||
Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON —
|
||||
le Core Java ne les sérialise que s'ils sont non-vides (payload léger
|
||||
pour un Lore avec beaucoup de pages vierges).
|
||||
"""
|
||||
|
||||
title: str
|
||||
template_name: str
|
||||
values: dict[str, str] = Field(default_factory=dict)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
related_page_titles: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LoreContextDTO(BaseModel):
|
||||
"""Carte structurelle du Lore avec contenu des pages (b9+)."""
|
||||
|
||||
lore_name: str
|
||||
lore_description: str | None = None
|
||||
folders: dict[str, list[PageSummaryDTO]] = Field(default_factory=dict)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PageContextDTO(BaseModel):
|
||||
"""Contexte d'une page spécifique pour focaliser le chat (optionnel)."""
|
||||
|
||||
title: str
|
||||
template_name: str
|
||||
template_fields: list[str] = Field(default_factory=list)
|
||||
values: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SceneBranchHintDTO(BaseModel):
|
||||
"""Indice d'une branche narrative (le Core a deja resolu le nom cible)."""
|
||||
|
||||
label: str
|
||||
target_scene_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RoomBranchHintDTO(BaseModel):
|
||||
"""Sortie d'une pièce vers une autre pièce du même lieu (donjon)."""
|
||||
|
||||
label: str
|
||||
target_room_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RoomSummaryDTO(BaseModel):
|
||||
"""Pièce d'un lieu explorable. Omise par le Core si la scène est classique."""
|
||||
|
||||
name: str
|
||||
floor: int | None = None
|
||||
description: str | None = None
|
||||
enemies: str | None = None
|
||||
branches: list[RoomBranchHintDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SceneSummaryDTO(BaseModel):
|
||||
"""Résumé d'une scène : nom + description courte (synopsis)."""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
# Optionnel : le Core Java ne serialise illustration_count QUE si > 0
|
||||
# (payload plus leger). Defaut 0 = pas d'illustrations ou champ absent.
|
||||
illustration_count: int = 0
|
||||
# Branches narratives sortantes, omises cote Core si vides.
|
||||
branches: list[SceneBranchHintDTO] = Field(default_factory=list)
|
||||
# Pièces du lieu explorable, omises par Core si scène classique.
|
||||
rooms: list[RoomSummaryDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChapterSummaryDTO(BaseModel):
|
||||
"""Résumé d'un chapitre : nom + description courte + ses scènes."""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
scenes: list[SceneSummaryDTO] = Field(default_factory=list)
|
||||
illustration_count: int = 0
|
||||
|
||||
|
||||
class ArcSummaryDTO(BaseModel):
|
||||
"""Résumé d'un arc narratif : nom + description courte + ses chapitres."""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
chapters: list[ChapterSummaryDTO] = Field(default_factory=list)
|
||||
illustration_count: int = 0
|
||||
|
||||
|
||||
class CharacterSummaryDTO(BaseModel):
|
||||
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
|
||||
|
||||
name: str
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class NpcSummaryDTO(BaseModel):
|
||||
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
|
||||
|
||||
name: str
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class CampaignContextDTO(BaseModel):
|
||||
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||
|
||||
campaign_name: str
|
||||
campaign_description: str | None = None
|
||||
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
||||
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NarrativeEntityDTO(BaseModel):
|
||||
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
||||
|
||||
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
|
||||
title: str
|
||||
fields: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GameSystemContextDTO(BaseModel):
|
||||
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
|
||||
|
||||
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
|
||||
vides si aucune section ne matchait l'intent de génération courant.
|
||||
"""
|
||||
|
||||
system_name: str
|
||||
system_description: str | None = None
|
||||
sections: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class JournalEntrySummaryDTO(BaseModel):
|
||||
"""Une entrée du journal de session.
|
||||
|
||||
`source_session_name` est présent uniquement pour les évènements issus
|
||||
des sessions précédentes — sert à ancrer temporellement dans le prompt.
|
||||
"""
|
||||
|
||||
type: str
|
||||
content: str
|
||||
occurred_at: str | None = None
|
||||
source_session_name: str | None = None
|
||||
|
||||
|
||||
class QuestSummaryDTO(BaseModel):
|
||||
"""Résumé d'une quête (Chapter dans un Arc HUB). Voir QuestSummary côté domaine."""
|
||||
|
||||
name: str
|
||||
arc_name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SessionContextDTO(BaseModel):
|
||||
"""Contexte d'une Session de jeu en cours (Play Context).
|
||||
|
||||
Combine le journal complet (`entries`), les EVENTs des sessions précédentes
|
||||
(`previous_events`), et — depuis l'ajout du mode Hub — l'état des quêtes
|
||||
Hub de la campagne (disponibles / en cours / verrouillées) plus les flags
|
||||
narratifs actuellement actifs.
|
||||
"""
|
||||
|
||||
session_name: str
|
||||
active: bool
|
||||
started_at: str | None = None
|
||||
entries: list[JournalEntrySummaryDTO] = Field(default_factory=list)
|
||||
previous_events: list[JournalEntrySummaryDTO] = Field(default_factory=list)
|
||||
available_quests: list[QuestSummaryDTO] = Field(default_factory=list)
|
||||
in_progress_quests: list[QuestSummaryDTO] = Field(default_factory=list)
|
||||
locked_quest_titles: list[str] = Field(default_factory=list)
|
||||
active_flags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChatStreamRequestDTO(BaseModel):
|
||||
"""Requête de chat streamé : historique + contextes structurels.
|
||||
|
||||
Les contextes (lore, page, campaign, narrative_entity, session) sont
|
||||
optionnels, mais au moins l'un des contextes "racines" (lore_context,
|
||||
campaign_context ou session_context) doit être fourni. Le validateur
|
||||
`check_scope` applique cette règle à la frontière HTTP.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessageDTO] = Field(min_length=1)
|
||||
lore_context: LoreContextDTO | None = None
|
||||
page_context: PageContextDTO | None = None
|
||||
campaign_context: CampaignContextDTO | None = None
|
||||
narrative_entity: NarrativeEntityDTO | None = None
|
||||
game_system_context: GameSystemContextDTO | None = None
|
||||
session_context: SessionContextDTO | None = None
|
||||
|
||||
def has_scope(self) -> bool:
|
||||
"""Vrai si au moins un contexte racine (Lore, Campagne ou Session) est fourni."""
|
||||
return (
|
||||
self.lore_context is not None
|
||||
or self.campaign_context is not None
|
||||
or self.session_context is not None
|
||||
)
|
||||
192
brain/app/api/chat_mapping.py
Normal file
192
brain/app/api/chat_mapping.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Mapping DTO → domaine (couche anti-corruption de la frontière HTTP).
|
||||
|
||||
Traduit les DTOs Pydantic du chat contextuel en dataclasses du domaine :
|
||||
le cœur métier ne dépend ainsi jamais de Pydantic ni du format JSON du Core.
|
||||
"""
|
||||
from app.api.chat_dto import (
|
||||
CampaignContextDTO,
|
||||
GameSystemContextDTO,
|
||||
JournalEntrySummaryDTO,
|
||||
LoreContextDTO,
|
||||
NarrativeEntityDTO,
|
||||
PageContextDTO,
|
||||
PageSummaryDTO,
|
||||
QuestSummaryDTO,
|
||||
SessionContextDTO,
|
||||
)
|
||||
from app.domain.models import (
|
||||
ArcSummary,
|
||||
CampaignStructuralContext,
|
||||
ChapterSummary,
|
||||
CharacterSummary,
|
||||
GameSystemContext,
|
||||
JournalEntrySummary,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
NpcSummary,
|
||||
PageContext,
|
||||
PageSummary,
|
||||
QuestSummary,
|
||||
RoomBranchHint,
|
||||
RoomSummary,
|
||||
SceneBranchHint,
|
||||
SceneSummary,
|
||||
SessionContext,
|
||||
)
|
||||
|
||||
|
||||
def to_lore_context(dto: LoreContextDTO | None) -> LoreStructuralContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return LoreStructuralContext(
|
||||
lore_name=dto.lore_name,
|
||||
lore_description=dto.lore_description,
|
||||
folders={
|
||||
folder: [_to_page_summary(p) for p in pages]
|
||||
for folder, pages in dto.folders.items()
|
||||
},
|
||||
tags=dto.tags,
|
||||
)
|
||||
|
||||
|
||||
def _to_page_summary(dto: PageSummaryDTO) -> PageSummary:
|
||||
return PageSummary(
|
||||
title=dto.title,
|
||||
template_name=dto.template_name,
|
||||
values=dict(dto.values),
|
||||
tags=list(dto.tags),
|
||||
related_page_titles=list(dto.related_page_titles),
|
||||
)
|
||||
|
||||
|
||||
def to_page_context(dto: PageContextDTO | None) -> PageContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return PageContext(
|
||||
title=dto.title,
|
||||
template_name=dto.template_name,
|
||||
template_fields=dto.template_fields,
|
||||
values=dto.values,
|
||||
)
|
||||
|
||||
|
||||
def to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
arcs = [
|
||||
ArcSummary(
|
||||
name=arc.name,
|
||||
description=arc.description,
|
||||
illustration_count=arc.illustration_count,
|
||||
chapters=[
|
||||
ChapterSummary(
|
||||
name=ch.name,
|
||||
description=ch.description,
|
||||
illustration_count=ch.illustration_count,
|
||||
scenes=[
|
||||
SceneSummary(
|
||||
name=sc.name,
|
||||
description=sc.description,
|
||||
illustration_count=sc.illustration_count,
|
||||
branches=[
|
||||
SceneBranchHint(
|
||||
label=br.label,
|
||||
target_scene_name=br.target_scene_name,
|
||||
condition=br.condition,
|
||||
)
|
||||
for br in sc.branches
|
||||
],
|
||||
rooms=[
|
||||
RoomSummary(
|
||||
name=room.name,
|
||||
floor=room.floor,
|
||||
description=room.description,
|
||||
enemies=room.enemies,
|
||||
branches=[
|
||||
RoomBranchHint(
|
||||
label=rb.label,
|
||||
target_room_name=rb.target_room_name,
|
||||
condition=rb.condition,
|
||||
)
|
||||
for rb in room.branches
|
||||
],
|
||||
)
|
||||
for room in sc.rooms
|
||||
],
|
||||
)
|
||||
for sc in ch.scenes
|
||||
],
|
||||
)
|
||||
for ch in arc.chapters
|
||||
],
|
||||
)
|
||||
for arc in dto.arcs
|
||||
]
|
||||
characters = [
|
||||
CharacterSummary(name=c.name, snippet=c.snippet)
|
||||
for c in dto.characters
|
||||
]
|
||||
npcs = [
|
||||
NpcSummary(name=n.name, snippet=n.snippet)
|
||||
for n in dto.npcs
|
||||
]
|
||||
return CampaignStructuralContext(
|
||||
campaign_name=dto.campaign_name,
|
||||
campaign_description=dto.campaign_description,
|
||||
arcs=arcs,
|
||||
characters=characters,
|
||||
npcs=npcs,
|
||||
)
|
||||
|
||||
|
||||
def to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return NarrativeEntityContext(
|
||||
entity_type=dto.entity_type,
|
||||
title=dto.title,
|
||||
fields=dict(dto.fields),
|
||||
)
|
||||
|
||||
|
||||
def to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return GameSystemContext(
|
||||
system_name=dto.system_name,
|
||||
system_description=dto.system_description,
|
||||
sections=dict(dto.sections),
|
||||
)
|
||||
|
||||
|
||||
def to_session_context(dto: SessionContextDTO | None) -> SessionContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return SessionContext(
|
||||
session_name=dto.session_name,
|
||||
active=dto.active,
|
||||
started_at=dto.started_at,
|
||||
entries=[_to_journal_entry(e) for e in dto.entries],
|
||||
previous_events=[_to_journal_entry(e) for e in dto.previous_events],
|
||||
available_quests=[_to_quest_summary(q) for q in dto.available_quests],
|
||||
in_progress_quests=[_to_quest_summary(q) for q in dto.in_progress_quests],
|
||||
locked_quest_titles=list(dto.locked_quest_titles),
|
||||
active_flags=list(dto.active_flags),
|
||||
)
|
||||
|
||||
|
||||
def _to_quest_summary(dto: QuestSummaryDTO) -> QuestSummary:
|
||||
return QuestSummary(
|
||||
name=dto.name,
|
||||
arc_name=dto.arc_name,
|
||||
description=dto.description,
|
||||
)
|
||||
|
||||
|
||||
def _to_journal_entry(dto: JournalEntrySummaryDTO) -> JournalEntrySummary:
|
||||
return JournalEntrySummary(
|
||||
type=dto.type,
|
||||
content=dto.content,
|
||||
occurred_at=dto.occurred_at,
|
||||
source_session_name=dto.source_session_name,
|
||||
)
|
||||
26
brain/app/api/common.py
Normal file
26
brain/app/api/common.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Utilitaires partagés des routers : encodage SSE + garde-fous d'upload PDF."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
# Garde-fou taille : un livre de règles dépasse rarement quelques dizaines de Mo.
|
||||
# Au-delà, on refuse (probable erreur d'upload) plutôt que d'OOM le conteneur.
|
||||
MAX_PDF_BYTES = 60 * 1024 * 1024 # 60 Mo
|
||||
|
||||
|
||||
def sse_event(event: str, data: dict) -> str:
|
||||
"""Encode un évènement Server-Sent Events (accents préservés)."""
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def pdf_upload_error(content: bytes) -> str | None:
|
||||
"""Message d'erreur si l'upload PDF est invalide (vide / trop gros), sinon None.
|
||||
|
||||
Utilisé par les flux SSE, où l'erreur doit partir en évènement `error`
|
||||
plutôt qu'en HTTPException (le flux est déjà ouvert en 200).
|
||||
"""
|
||||
if not content:
|
||||
return "Fichier PDF vide."
|
||||
if len(content) > MAX_PDF_BYTES:
|
||||
return f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo)."
|
||||
return None
|
||||
191
brain/app/api/deps.py
Normal file
191
brain/app/api/deps.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Factories d'injection de dépendance — le point d'inversion de l'hexagone.
|
||||
|
||||
C'est ICI (et seulement ici) qu'on choisit QUEL adapter concret incarne chaque
|
||||
port (LLM, embeddings, extracteur PDF), en fonction des Settings — modifiables
|
||||
à chaud depuis l'écran Paramètres de l'UI. Les routers ne connaissent que les
|
||||
ports et les use cases, jamais Ollama/Mistral/etc.
|
||||
"""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException
|
||||
|
||||
from app.application.adapt_campaign import AdaptCampaignUseCase
|
||||
from app.application.chat import ChatUseCase
|
||||
from app.application.embeddings import EmbeddingError
|
||||
from app.application.generate_page import GeneratePageUseCase
|
||||
from app.application.import_campaign import ImportCampaignUseCase
|
||||
from app.application.import_rules import ImportRulesUseCase
|
||||
from app.application.notebook_chat import NotebookChatUseCase
|
||||
from app.application.notebook_deep import NotebookDeepUseCase
|
||||
from app.application.notebook_rag import NotebookRagUseCase
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
from app.infrastructure.gemini_adapter import GeminiLLMProvider
|
||||
from app.infrastructure.mistral_adapter import MistralLLMProvider
|
||||
from app.infrastructure.mistral_embedding_adapter import MistralEmbeddingProvider
|
||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||
from app.infrastructure.ollama_embedding_adapter import OllamaEmbeddingProvider
|
||||
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
|
||||
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Extracteur PDF partagé : la détection OCR (version Tesseract) a un coût
|
||||
# (subprocess) qu'on ne veut pas payer à chaque requête → singleton module.
|
||||
_PDF_EXTRACTOR = PyMuPdfTextExtractor()
|
||||
|
||||
|
||||
def _effective_import_chunk_tokens(settings: Settings) -> int:
|
||||
"""Taille de morceau réellement utilisable pour l'import.
|
||||
|
||||
Avec Ollama, le morceau (entrée) ET sa réécriture en sections (sortie ≈ même
|
||||
taille) doivent tenir ensemble dans `num_ctx` — sinon Ollama remplit la fenêtre
|
||||
avec le prompt et la génération s'arrête après quelques tokens (JSON coupé net,
|
||||
morceau perdu). Budget : entrée×~1.3 (les morceaux sont mesurés en tokens
|
||||
cl100k, plus compacts que les tokenizers locaux) + consignes + sortie×~1.4
|
||||
≤ num_ctx → morceau ≤ (num_ctx − 800) / 2.7. On plafonne, avec un log pour
|
||||
rester transparent. Les providers cloud (gros contexte) ne sont pas plafonnés.
|
||||
"""
|
||||
requested = settings.import_chunk_tokens
|
||||
if settings.llm_provider != "ollama":
|
||||
return requested
|
||||
cap = max(1000, int((settings.llm_num_ctx - 800) / 2.7))
|
||||
if requested > cap:
|
||||
logger.warning(
|
||||
"Taille de morceau d'import réduite de %s à %s tokens : avec num_ctx=%s, "
|
||||
"un morceau plus gros ne laisserait pas la place à la sortie du modèle "
|
||||
"(génération coupée). Augmentez num_ctx pour utiliser de plus gros morceaux.",
|
||||
requested, cap, settings.llm_num_ctx,
|
||||
)
|
||||
return cap
|
||||
return requested
|
||||
|
||||
|
||||
def get_llm_provider(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> LLMProvider:
|
||||
"""Factory d'adapter — point d'inversion de dépendance.
|
||||
|
||||
C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret
|
||||
incarne le port, en fonction du champ `llm_provider` des Settings
|
||||
(modifiable a chaud depuis l'ecran Parametres de l'UI).
|
||||
"""
|
||||
try:
|
||||
if settings.llm_provider == "onemin":
|
||||
return OneMinAiLLMProvider(settings)
|
||||
if settings.llm_provider == "openrouter":
|
||||
return OpenRouterLLMProvider(settings)
|
||||
if settings.llm_provider == "mistral":
|
||||
return MistralLLMProvider(settings)
|
||||
if settings.llm_provider == "gemini":
|
||||
return GeminiLLMProvider(settings)
|
||||
return OllamaLLMProvider(settings)
|
||||
except LLMProviderError as exc:
|
||||
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
|
||||
# pour que le frontend puisse afficher un message actionnable.
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def get_generate_page_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> GeneratePageUseCase:
|
||||
"""Factory du use case — injecte le port LLMProvider sans connaître l'adapter."""
|
||||
return GeneratePageUseCase(llm=llm)
|
||||
|
||||
|
||||
def get_chat_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> ChatUseCase:
|
||||
"""Factory du use case chat.
|
||||
|
||||
L'adapter OllamaLLMProvider satisfait les deux protocoles (LLMProvider
|
||||
et LLMChatProvider) par duck typing ; on lui passe la même instance.
|
||||
"""
|
||||
return ChatUseCase(llm=llm) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def get_import_rules_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> ImportRulesUseCase:
|
||||
"""Factory du use case d'import de règles PDF (extraction + structuration)."""
|
||||
# Modèle LOCAL → mode segmentation : le LLM ne renvoie que les frontières des
|
||||
# sections (~200 tokens) et le texte original est découpé localement. Réécrire
|
||||
# tout le contenu à ~100 tokens/s prendrait des dizaines de minutes par livre.
|
||||
# Les providers cloud (rapides, grand contexte) gardent la réécriture nettoyée.
|
||||
return ImportRulesUseCase(
|
||||
llm=llm, extractor=_PDF_EXTRACTOR,
|
||||
chunk_target_tokens=_effective_import_chunk_tokens(settings),
|
||||
segment_only=settings.llm_provider == "ollama")
|
||||
|
||||
|
||||
def get_import_campaign_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> ImportCampaignUseCase:
|
||||
"""Factory du use case d'import de campagne PDF (extraction + arborescence)."""
|
||||
return ImportCampaignUseCase(
|
||||
llm=llm,
|
||||
extractor=_PDF_EXTRACTOR,
|
||||
chunk_target_tokens=_effective_import_chunk_tokens(settings),
|
||||
map_concurrency=settings.llm_map_concurrency,
|
||||
)
|
||||
|
||||
|
||||
def get_adapt_campaign_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> AdaptCampaignUseCase:
|
||||
"""Factory du use case d'adaptation d'un PDF à une campagne (conseils streamés)."""
|
||||
# L'adapter satisfait aussi LLMChatProvider (stream_chat) par duck typing.
|
||||
# Budget d'entrée = taille de morceau configurée (qui passe déjà côté provider).
|
||||
return AdaptCampaignUseCase( # type: ignore[arg-type]
|
||||
llm=llm, extractor=_PDF_EXTRACTOR, max_input_tokens=settings.import_chunk_tokens)
|
||||
|
||||
|
||||
def get_embedding_provider(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
):
|
||||
"""Factory de l'adapter d'embeddings (RAG) selon `embedding_provider`."""
|
||||
try:
|
||||
if settings.embedding_provider == "mistral":
|
||||
return MistralEmbeddingProvider(settings)
|
||||
return OllamaEmbeddingProvider(settings)
|
||||
except EmbeddingError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def get_notebook_rag_use_case(
|
||||
embedder: Annotated[object, Depends(get_embedding_provider)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> NotebookRagUseCase:
|
||||
return NotebookRagUseCase(
|
||||
extractor=_PDF_EXTRACTOR,
|
||||
embedder=embedder, # type: ignore[arg-type]
|
||||
min_score=settings.rag_min_score,
|
||||
)
|
||||
|
||||
|
||||
def get_notebook_chat_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> NotebookChatUseCase:
|
||||
return NotebookChatUseCase(
|
||||
rag=rag, llm=llm, rerank_enabled=settings.rag_rerank) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def get_notebook_deep_use_case(
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
embedder: Annotated[object, Depends(get_embedding_provider)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> NotebookDeepUseCase:
|
||||
return NotebookDeepUseCase(
|
||||
llm=llm,
|
||||
batch_tokens=settings.import_chunk_tokens,
|
||||
map_concurrency=settings.llm_map_concurrency,
|
||||
embedder=embedder,
|
||||
summary_filter=settings.deep_summary_filter,
|
||||
)
|
||||
5
brain/app/api/routers/__init__.py
Normal file
5
brain/app/api/routers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Routers FastAPI du Brain, un par responsabilité métier.
|
||||
|
||||
Chemins inchangés par rapport à l'ancien main.py monolithique : le Core Java
|
||||
et le frontend ne voient AUCUNE différence.
|
||||
"""
|
||||
119
brain/app/api/routers/chat.py
Normal file
119
brain/app/api/routers/chat.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Endpoint du chat contextuel (/chat/stream) : Structural Context + jauge tokens."""
|
||||
import json
|
||||
from typing import Annotated, AsyncIterator
|
||||
|
||||
import tiktoken
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.api.chat_dto import ChatStreamRequestDTO
|
||||
from app.api.chat_mapping import (
|
||||
to_campaign_context,
|
||||
to_game_system_context,
|
||||
to_lore_context,
|
||||
to_narrative_entity,
|
||||
to_page_context,
|
||||
to_session_context,
|
||||
)
|
||||
from app.api.deps import get_chat_use_case
|
||||
from app.application.chat import ChatUseCase
|
||||
from app.core.config import get_settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
|
||||
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
|
||||
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
|
||||
# suffisant pour une jauge visuelle à l'utilisateur.
|
||||
_TOKEN_ENCODER: tiktoken.Encoding | None = None
|
||||
|
||||
|
||||
def _count_tokens(text: str | None) -> int:
|
||||
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
|
||||
if not text:
|
||||
return 0
|
||||
global _TOKEN_ENCODER
|
||||
if _TOKEN_ENCODER is None:
|
||||
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
|
||||
return len(_TOKEN_ENCODER.encode(text))
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def chat_stream(
|
||||
body: ChatStreamRequestDTO,
|
||||
use_case: Annotated[ChatUseCase, Depends(get_chat_use_case)],
|
||||
) -> StreamingResponse:
|
||||
"""Chat streamé (Server-Sent Events) avec Structural Context.
|
||||
|
||||
Accepte jusqu'à 4 contextes optionnels (Lore, Page focalisée, Campagne,
|
||||
entité narrative focalisée). Au moins un contexte racine (Lore ou
|
||||
Campagne) est requis pour que la requête ait du sens.
|
||||
|
||||
Format de flux :
|
||||
- Chaque token : `data: {"token": "..."}\\n\\n`
|
||||
- Fin normale : `event: done\\ndata: {}\\n\\n`
|
||||
- Erreur LLM : `event: error\\ndata: {"message": "..."}\\n\\n`
|
||||
"""
|
||||
if not body.has_scope():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Au moins un des deux contextes racines (lore_context ou campaign_context) est requis.",
|
||||
)
|
||||
|
||||
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
|
||||
lore_context = to_lore_context(body.lore_context)
|
||||
page_context = to_page_context(body.page_context)
|
||||
campaign_context = to_campaign_context(body.campaign_context)
|
||||
narrative_entity = to_narrative_entity(body.narrative_entity)
|
||||
game_system_context = to_game_system_context(body.game_system_context)
|
||||
session_context = to_session_context(body.session_context)
|
||||
|
||||
# --- Comptage tokens pour la jauge de contexte frontend ---
|
||||
# On construit le system prompt une fois ici pour le compter — le use case
|
||||
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
|
||||
# Cette duplication évite de complexifier le contrat stream() avec un
|
||||
# paramètre optionnel system_prompt précalculé.
|
||||
system_prompt_preview = use_case.build_system_prompt(
|
||||
lore_context=lore_context,
|
||||
page_context=page_context,
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
session_context=session_context,
|
||||
)
|
||||
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
||||
current_msg = messages[-1] if messages else None
|
||||
history_msgs = messages[:-1] if messages else []
|
||||
settings = get_settings()
|
||||
usage_payload = {
|
||||
"system": _count_tokens(system_prompt_preview),
|
||||
"history": sum(_count_tokens(m.content) for m in history_msgs),
|
||||
"current": _count_tokens(current_msg.content) if current_msg else 0,
|
||||
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
|
||||
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
|
||||
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
|
||||
}
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
# Event 'usage' émis en tout premier : le frontend peut afficher la
|
||||
# jauge avant même le premier token de réponse.
|
||||
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
|
||||
try:
|
||||
async for token in use_case.stream(
|
||||
messages,
|
||||
lore_context=lore_context,
|
||||
page_context=page_context,
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
session_context=session_context,
|
||||
):
|
||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||
yield "event: done\ndata: {}\n\n"
|
||||
except LLMProviderError as exc:
|
||||
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
137
brain/app/api/routers/generation.py
Normal file
137
brain/app/api/routers/generation.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Endpoints de génération « simple » : prompt libre, page de Lore, auto-titre."""
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import get_generate_page_use_case, get_llm_provider
|
||||
from app.application.generate_page import GeneratePageUseCase
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import PageGenerationContext
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
model: str
|
||||
response: str
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerateResponse)
|
||||
async def generate(
|
||||
body: GenerateRequest,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> GenerateResponse:
|
||||
"""Endpoint libre : prompt → texte brut. Utile pour debug et exploration."""
|
||||
try:
|
||||
text = await llm.generate(body.prompt)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return GenerateResponse(model=settings.llm_model, response=text)
|
||||
|
||||
|
||||
class GeneratePageRequestDTO(BaseModel):
|
||||
"""Contexte envoyé par le Core Java pour remplir une page via le LLM."""
|
||||
|
||||
lore_name: str
|
||||
folder_name: str
|
||||
template_name: str
|
||||
template_fields: list[str] = Field(min_length=1)
|
||||
page_title: str
|
||||
lore_description: str | None = None
|
||||
|
||||
|
||||
class GeneratePageResponseDTO(BaseModel):
|
||||
"""Retour : une valeur textuelle par champ du template (clé = field name)."""
|
||||
|
||||
values: dict[str, str]
|
||||
|
||||
|
||||
@router.post("/generate-page", response_model=GeneratePageResponseDTO)
|
||||
async def generate_page(
|
||||
body: GeneratePageRequestDTO,
|
||||
use_case: Annotated[
|
||||
GeneratePageUseCase, Depends(get_generate_page_use_case)
|
||||
],
|
||||
) -> GeneratePageResponseDTO:
|
||||
"""Endpoint métier : contexte LoreMind → valeurs structurées par champ.
|
||||
|
||||
Branche tout le use case `GeneratePageUseCase`. Ce controller ne fait
|
||||
que le mapping DTO ↔ dataclass et la traduction d'erreur domaine → HTTP.
|
||||
"""
|
||||
context = PageGenerationContext(
|
||||
lore_name=body.lore_name,
|
||||
lore_description=body.lore_description,
|
||||
folder_name=body.folder_name,
|
||||
template_name=body.template_name,
|
||||
template_fields=body.template_fields,
|
||||
page_title=body.page_title,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await use_case.execute(context)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return GeneratePageResponseDTO(values=result.values)
|
||||
|
||||
|
||||
# --- Auto-titre d'une conversation persistee --------------------------------
|
||||
|
||||
|
||||
class SummarizeTitleMessageDTO(BaseModel):
|
||||
role: Literal["user", "assistant", "system"]
|
||||
content: str
|
||||
|
||||
|
||||
class SummarizeTitleRequestDTO(BaseModel):
|
||||
"""Premiers messages d'une conversation pour auto-generer un titre court."""
|
||||
|
||||
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SummarizeTitleResponseDTO(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
_TITLE_SYSTEM_PROMPT = (
|
||||
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
|
||||
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
|
||||
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
|
||||
"en francais et capturer le sujet metier (pas 'Conversation IA')."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
|
||||
async def summarize_conversation_title(
|
||||
body: SummarizeTitleRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> SummarizeTitleResponseDTO:
|
||||
"""Genere un titre court a partir des premiers echanges de la conversation.
|
||||
|
||||
Appele par le core apres le 1er couple user/assistant, pour remplacer le
|
||||
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
|
||||
"""
|
||||
if not body.messages:
|
||||
raise HTTPException(status_code=422, detail="Au moins un message requis")
|
||||
|
||||
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
|
||||
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :"
|
||||
try:
|
||||
raw = await llm.generate(prompt)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
|
||||
if len(title) > 80:
|
||||
title = title[:80].rstrip()
|
||||
if not title:
|
||||
title = "Nouvelle conversation"
|
||||
return SummarizeTitleResponseDTO(title=title)
|
||||
184
brain/app/api/routers/imports.py
Normal file
184
brain/app/api/routers/imports.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Endpoints d'import/adaptation de PDF (règles, campagne) — REST + flux SSE."""
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated, AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.common import MAX_PDF_BYTES, pdf_upload_error, sse_event
|
||||
from app.api.deps import (
|
||||
get_adapt_campaign_use_case,
|
||||
get_import_campaign_use_case,
|
||||
get_import_rules_use_case,
|
||||
)
|
||||
from app.application.adapt_campaign import AdaptCampaignUseCase
|
||||
from app.application.import_campaign import ImportCampaignUseCase
|
||||
from app.application.import_rules import ImportRulesUseCase
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError, PdfExtractionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class RulesImportResponseDTO(BaseModel):
|
||||
"""Proposition de sections de règles extraites d'un PDF.
|
||||
|
||||
`sections` = {titre → contenu markdown}. C'est une PROPOSITION : le Core
|
||||
et l'UI laissent l'utilisateur réviser/éditer avant toute persistance.
|
||||
`ocr_page_count` permet d'indiquer si le PDF était un scan (OCR utilisé).
|
||||
"""
|
||||
|
||||
sections: dict[str, str]
|
||||
page_count: int
|
||||
ocr_page_count: int
|
||||
|
||||
|
||||
@router.post("/import/rules", response_model=RulesImportResponseDTO)
|
||||
async def import_rules(
|
||||
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
|
||||
file: UploadFile = File(...),
|
||||
) -> RulesImportResponseDTO:
|
||||
"""Import d'un PDF de règles → sections markdown structurées (proposition).
|
||||
|
||||
Extrait le texte (couche texte + repli OCR par page pour les scans), découpe,
|
||||
et demande au LLM de répartir les règles en sections thématiques. Ne persiste
|
||||
rien : renvoie la proposition au Core, qui la présente pour révision.
|
||||
"""
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=422, detail="Fichier PDF vide.")
|
||||
if len(content) > MAX_PDF_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo).",
|
||||
)
|
||||
|
||||
try:
|
||||
result = await use_case.execute(content)
|
||||
except PdfExtractionError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
return RulesImportResponseDTO(
|
||||
sections=result.sections,
|
||||
page_count=result.page_count,
|
||||
ocr_page_count=result.ocr_page_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/rules/stream")
|
||||
async def import_rules_stream(
|
||||
use_case: Annotated[ImportRulesUseCase, Depends(get_import_rules_use_case)],
|
||||
file: UploadFile = File(...),
|
||||
) -> StreamingResponse:
|
||||
"""Import streamé : émet l'avancement (SSE) puis le résultat final.
|
||||
|
||||
Évènements SSE :
|
||||
- `event: extracting` → data: {} (extraction en cours)
|
||||
- `event: start` → data: {page_count, ocr_page_count, total}
|
||||
- `event: progress` → data: {current, total, new_sections:[...]}
|
||||
- `event: done` → data: {sections, page_count, ocr_page_count}
|
||||
- `event: error` → data: {message}
|
||||
"""
|
||||
content = await file.read()
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
upload_error = pdf_upload_error(content)
|
||||
if upload_error:
|
||||
yield sse_event("error", {"message": upload_error})
|
||||
return
|
||||
try:
|
||||
async for ev in use_case.stream(content):
|
||||
event_type = ev.pop("type")
|
||||
yield sse_event(event_type, ev)
|
||||
except PdfExtractionError as exc:
|
||||
yield sse_event("error", {"message": str(exc)})
|
||||
except LLMProviderError as exc:
|
||||
yield sse_event("error", {"message": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001 — filet : une erreur inattendue ne doit
|
||||
# PAS casser le flux SSE brutalement (sinon le Core n'a qu'un message générique
|
||||
# sans détail). On la transforme en évènement `error` propre + log avec trace.
|
||||
logger.exception("Import règles : erreur inattendue dans le flux.")
|
||||
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/import/campaign/stream")
|
||||
async def import_campaign_stream(
|
||||
use_case: Annotated[ImportCampaignUseCase, Depends(get_import_campaign_use_case)],
|
||||
file: UploadFile = File(...),
|
||||
) -> StreamingResponse:
|
||||
"""Import streamé d'un PDF de campagne → arbre arc→chapitre→scène (SSE).
|
||||
|
||||
Évènements : `extracting`, `start` {page_count, ocr_page_count, total},
|
||||
`progress` {current, total, arc_count, chapter_count, scene_count},
|
||||
`done` {arcs:[...], page_count, ocr_page_count}, `error` {message}.
|
||||
"""
|
||||
content = await file.read()
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
upload_error = pdf_upload_error(content)
|
||||
if upload_error:
|
||||
yield sse_event("error", {"message": upload_error})
|
||||
return
|
||||
try:
|
||||
async for ev in use_case.stream(content):
|
||||
event_type = ev.pop("type")
|
||||
yield sse_event(event_type, ev)
|
||||
except PdfExtractionError as exc:
|
||||
yield sse_event("error", {"message": str(exc)})
|
||||
except LLMProviderError as exc:
|
||||
yield sse_event("error", {"message": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001 — voir import règles : on ne laisse pas
|
||||
# une erreur inattendue casser le flux sans détail.
|
||||
logger.exception("Import campagne : erreur inattendue dans le flux.")
|
||||
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/adapt/campaign/stream")
|
||||
async def adapt_campaign_stream(
|
||||
use_case: Annotated[AdaptCampaignUseCase, Depends(get_adapt_campaign_use_case)],
|
||||
file: UploadFile = File(...),
|
||||
brief: str = Form(""),
|
||||
messages: str = Form("[]"),
|
||||
) -> StreamingResponse:
|
||||
"""Adaptation CONVERSATIONNELLE d'un PDF à une campagne (SSE markdown).
|
||||
|
||||
`brief` = description de la campagne (Core). `messages` = JSON de l'échange
|
||||
([{role, content}, …]) ; vide au 1er tour. Évènements : `token`, `done`, `error`.
|
||||
"""
|
||||
content = await file.read()
|
||||
|
||||
try:
|
||||
raw_messages = json.loads(messages) if messages else []
|
||||
except json.JSONDecodeError:
|
||||
raw_messages = []
|
||||
convo = [
|
||||
ChatMessage(role=str(m.get("role", "user")), content=str(m.get("content", "")))
|
||||
for m in raw_messages
|
||||
if isinstance(m, dict) and str(m.get("content", "")).strip()
|
||||
]
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
upload_error = pdf_upload_error(content)
|
||||
if upload_error:
|
||||
yield sse_event("error", {"message": upload_error})
|
||||
return
|
||||
try:
|
||||
async for token in use_case.stream(content, brief, convo):
|
||||
yield sse_event("token", {"token": token})
|
||||
yield sse_event("done", {})
|
||||
except PdfExtractionError as exc:
|
||||
yield sse_event("error", {"message": str(exc)})
|
||||
except LLMProviderError as exc:
|
||||
yield sse_event("error", {"message": str(exc)})
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
365
brain/app/api/routers/models.py
Normal file
365
brain/app/api/routers/models.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Endpoints de catalogue de modèles (Ollama, OpenRouter, Mistral, Gemini, 1min.ai).
|
||||
|
||||
Proxifie les APIs des providers pour que l'UI propose des listes de modèles ;
|
||||
repli statique quand l'API est injoignable ou la clé absente (pas de 500 à l'UI).
|
||||
"""
|
||||
import json
|
||||
from typing import Annotated, AsyncIterator
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/models/ollama")
|
||||
async def list_ollama_models(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> dict[str, list[str]]:
|
||||
"""Liste les modeles disponibles sur le serveur Ollama configure.
|
||||
|
||||
Retourne une liste vide si Ollama est injoignable — l'UI affichera un
|
||||
message plutot qu'une 500.
|
||||
"""
|
||||
url = f"{settings.ollama_base_url}/api/tags"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError:
|
||||
return {"models": []}
|
||||
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
|
||||
return {"models": sorted(models)}
|
||||
|
||||
|
||||
class OllamaModelInfoDTO(BaseModel):
|
||||
"""Info utile extraite de /api/show pour un modele Ollama donne.
|
||||
|
||||
`context_length` = fenetre de contexte max supportee par le modele
|
||||
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
|
||||
pour borner le slider de num_ctx dans les Parametres.
|
||||
"""
|
||||
|
||||
context_length: int = 0
|
||||
|
||||
|
||||
@router.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
|
||||
async def get_ollama_model_info(
|
||||
body: dict[str, str],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> OllamaModelInfoDTO:
|
||||
"""Retourne les metadonnees d'un modele Ollama via /api/show.
|
||||
|
||||
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
|
||||
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
|
||||
mal dans une URL — le body JSON evite le probleme d'escaping.
|
||||
|
||||
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
|
||||
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
|
||||
scanne donc tous les champs finissant par `.context_length`.
|
||||
"""
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/show"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.post(url, json={"model": name})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError:
|
||||
return OllamaModelInfoDTO(context_length=0)
|
||||
model_info = data.get("model_info") or {}
|
||||
for key, value in model_info.items():
|
||||
if key.endswith(".context_length") and isinstance(value, int):
|
||||
return OllamaModelInfoDTO(context_length=value)
|
||||
return OllamaModelInfoDTO(context_length=0)
|
||||
|
||||
|
||||
@router.post("/models/ollama/pull")
|
||||
async def pull_ollama_model(
|
||||
body: dict[str, str],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> StreamingResponse:
|
||||
"""Telecharge un modele depuis Ollama et streame la progression.
|
||||
|
||||
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
|
||||
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
|
||||
digest, success. On reemet ce flux tel quel au client (le front
|
||||
parsera les lignes et affichera une barre de progression).
|
||||
|
||||
Le timeout est intentionnellement tres long (60 min) car certains
|
||||
modeles font 30+ Go.
|
||||
"""
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/pull"
|
||||
|
||||
async def stream() -> AsyncIterator[bytes]:
|
||||
# On utilise un timeout long pour la lecture (60 min) mais court pour
|
||||
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
|
||||
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
|
||||
if r.status_code != 200:
|
||||
# Ollama renvoie un message JSON d'erreur. On le passe
|
||||
# tel quel au client en preservant le code HTTP.
|
||||
body_text = await r.aread()
|
||||
yield body_text
|
||||
return
|
||||
async for chunk in r.aiter_bytes():
|
||||
yield chunk
|
||||
except httpx.HTTPError as e:
|
||||
# Erreur reseau : on emet une ligne JSON d'erreur compatible
|
||||
# avec le format NDJSON d'Ollama.
|
||||
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
|
||||
yield err.encode("utf-8")
|
||||
|
||||
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
|
||||
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
|
||||
return StreamingResponse(stream(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@router.delete("/models/ollama/{name:path}")
|
||||
async def delete_ollama_model(
|
||||
name: str,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> dict[str, str]:
|
||||
"""Supprime un modele du serveur Ollama.
|
||||
|
||||
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
|
||||
sans avoir besoin de URL-encoder cote client.
|
||||
"""
|
||||
if not name.strip():
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/delete"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.request("DELETE", url, json={"model": name})
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
|
||||
return {"status": "deleted", "name": name}
|
||||
|
||||
|
||||
@router.get("/models/openrouter")
|
||||
async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
|
||||
"""Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
|
||||
|
||||
Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
|
||||
puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.get("https://openrouter.ai/api/v1/models")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
|
||||
|
||||
def _is_zero(value: object) -> bool:
|
||||
try:
|
||||
return float(value) == 0.0 # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
models: list[dict[str, object]] = []
|
||||
for m in data.get("data", []) or []:
|
||||
mid = str(m.get("id") or "")
|
||||
if not mid:
|
||||
continue
|
||||
pricing = m.get("pricing") or {}
|
||||
is_free = mid.endswith(":free") or (
|
||||
_is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
|
||||
)
|
||||
try:
|
||||
ctx = int(m.get("context_length") or 0)
|
||||
except (TypeError, ValueError):
|
||||
ctx = 0
|
||||
models.append({
|
||||
"id": mid,
|
||||
"name": str(m.get("name") or mid),
|
||||
"context_length": ctx,
|
||||
"free": is_free,
|
||||
})
|
||||
|
||||
models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
|
||||
return {"models": models}
|
||||
|
||||
|
||||
# Repli statique si la cle Mistral n'est pas (encore) configuree ou si l'API est
|
||||
# injoignable — l'utilisateur peut quand meme choisir un modele. Liste curee
|
||||
# (juin 2026) ; pour l'extraction de PDF, prefere `large` (fidele, 128k) ou `small`.
|
||||
_MISTRAL_FALLBACK_MODELS = [
|
||||
"mistral-large-latest",
|
||||
"mistral-medium-latest",
|
||||
"mistral-small-latest",
|
||||
"open-mistral-nemo",
|
||||
"ministral-8b-latest",
|
||||
"ministral-3b-latest",
|
||||
"magistral-medium-latest",
|
||||
"magistral-small-latest",
|
||||
"pixtral-large-latest",
|
||||
"codestral-latest",
|
||||
]
|
||||
|
||||
|
||||
@router.get("/models/mistral")
|
||||
async def list_mistral_models(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
"""Catalogue des modeles Mistral. Dynamique si une cle est configuree
|
||||
(GET /v1/models, qui requiert l'auth), sinon repli statique.
|
||||
|
||||
Renvoie {models: [{id}]} (tous accessibles sur le tier gratuit Experiment)."""
|
||||
key = settings.mistral_api_key
|
||||
if not key:
|
||||
return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.get(
|
||||
"https://api.mistral.ai/v1/models",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError:
|
||||
# Cle invalide / API down : on ne casse pas l'UI, on propose le repli.
|
||||
return {"models": [{"id": m} for m in _MISTRAL_FALLBACK_MODELS]}
|
||||
|
||||
ids = sorted({str(m.get("id")) for m in data.get("data", []) or [] if m.get("id")})
|
||||
if not ids:
|
||||
ids = _MISTRAL_FALLBACK_MODELS
|
||||
return {"models": [{"id": i} for i in ids]}
|
||||
|
||||
|
||||
# Repli statique Gemini (juin 2026). Pour l'extraction, prefere un Flash a grand
|
||||
# contexte ; `gemini-2.0-flash` a le quota gratuit le plus genereux.
|
||||
_GEMINI_FALLBACK_MODELS = [
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-1.5-flash",
|
||||
"gemini-1.5-pro",
|
||||
]
|
||||
|
||||
|
||||
@router.get("/models/gemini")
|
||||
async def list_gemini_models(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
"""Catalogue des modeles Gemini. Dynamique si une cle est configuree (endpoint
|
||||
OpenAI-compatible /openai/models), sinon repli statique. Renvoie {models:[{id}]}."""
|
||||
key = settings.gemini_api_key
|
||||
if not key:
|
||||
return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.get(
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/models",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError:
|
||||
return {"models": [{"id": m} for m in _GEMINI_FALLBACK_MODELS]}
|
||||
|
||||
# Les ids peuvent arriver prefixes "models/" → on nettoie pour que la valeur
|
||||
# selectionnee soit directement utilisable dans l'appel chat. On garde les
|
||||
# modeles "gemini-*" (hors embeddings/aqa) pour ne pas noyer la liste.
|
||||
ids: set[str] = set()
|
||||
for m in data.get("data", []) or []:
|
||||
mid = str(m.get("id") or "")
|
||||
if mid.startswith("models/"):
|
||||
mid = mid[len("models/"):]
|
||||
if mid.startswith("gemini-"):
|
||||
ids.add(mid)
|
||||
clean = sorted(ids) if ids else _GEMINI_FALLBACK_MODELS
|
||||
return {"models": [{"id": i} for i in clean]}
|
||||
|
||||
|
||||
@router.get("/models/onemin")
|
||||
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
||||
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
||||
|
||||
Liste construite par probing direct de l'endpoint chat-with-ai avec
|
||||
une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs
|
||||
absents renvoient 400 UNSUPPORTED_MODEL.
|
||||
|
||||
Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai
|
||||
(`claude-<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"],
|
||||
},
|
||||
]
|
||||
}
|
||||
130
brain/app/api/routers/notebooks.py
Normal file
130
brain/app/api/routers/notebooks.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Endpoints des notebooks (atelier RAG) : indexation des sources + chats ancrés."""
|
||||
import logging
|
||||
from typing import Annotated, AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.common import MAX_PDF_BYTES, sse_event
|
||||
from app.api.deps import (
|
||||
get_notebook_chat_use_case,
|
||||
get_notebook_deep_use_case,
|
||||
get_notebook_rag_use_case,
|
||||
)
|
||||
from app.application.embeddings import EmbeddingError
|
||||
from app.application.notebook_chat import NotebookChatUseCase
|
||||
from app.application.notebook_deep import NotebookDeepUseCase
|
||||
from app.application.notebook_rag import NotebookRagUseCase
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError, PdfExtractionError
|
||||
from app.infrastructure import vector_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class IndexSourceResponseDTO(BaseModel):
|
||||
chunks: int
|
||||
page_count: int
|
||||
ocr_page_count: int
|
||||
|
||||
|
||||
@router.post("/index/notebook-source", response_model=IndexSourceResponseDTO)
|
||||
async def index_notebook_source(
|
||||
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
|
||||
source_id: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
) -> IndexSourceResponseDTO:
|
||||
"""Indexe une source PDF (extraction + embeddings + stockage vectoriel)."""
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=422, detail="Fichier PDF vide.")
|
||||
if len(content) > MAX_PDF_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413, detail=f"PDF trop volumineux (> {MAX_PDF_BYTES // (1024 * 1024)} Mo).")
|
||||
try:
|
||||
recap = await rag.index_source(source_id, content)
|
||||
except PdfExtractionError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except EmbeddingError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return IndexSourceResponseDTO(**recap)
|
||||
|
||||
|
||||
@router.delete("/index/notebook-source/{source_id}")
|
||||
def delete_notebook_source(source_id: str) -> dict[str, str]:
|
||||
"""Supprime les vecteurs d'une source (au DELETE d'une source/notebook)."""
|
||||
vector_store.delete(source_id)
|
||||
return {"status": "deleted", "source_id": source_id}
|
||||
|
||||
|
||||
class NotebookChatMessageDTO(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class NotebookChatRequestDTO(BaseModel):
|
||||
source_ids: list[str] = Field(default_factory=list)
|
||||
messages: list[NotebookChatMessageDTO] = Field(default_factory=list)
|
||||
context: str = Field(default="")
|
||||
|
||||
|
||||
@router.post("/chat/notebook/stream")
|
||||
async def chat_notebook_stream(
|
||||
body: NotebookChatRequestDTO,
|
||||
use_case: Annotated[NotebookChatUseCase, Depends(get_notebook_chat_use_case)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> StreamingResponse:
|
||||
"""Chat ANCRÉ sur les sources (RAG) : récupère les passages pertinents puis
|
||||
streame la réponse. Évènements SSE : `token` {token}, `done` {}, `error` {message}."""
|
||||
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
|
||||
top_k = max(1, min(settings.rag_top_k, 200))
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
try:
|
||||
async for ev in use_case.stream(body.source_ids, messages, context=body.context, top_k=top_k):
|
||||
if ev["type"] == "token":
|
||||
if ev.get("token"):
|
||||
yield sse_event("token", {"token": ev["token"]})
|
||||
else:
|
||||
# 'sources' (et tout futur évènement typé) : relayé tel quel.
|
||||
ev_type = ev.pop("type")
|
||||
yield sse_event(ev_type, ev)
|
||||
yield sse_event("done", {})
|
||||
except (LLMProviderError, EmbeddingError) as exc:
|
||||
yield sse_event("error", {"message": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale du flux.
|
||||
logger.exception("Chat notebook : erreur inattendue.")
|
||||
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/chat/notebook/deep/stream")
|
||||
async def chat_notebook_deep_stream(
|
||||
body: NotebookChatRequestDTO,
|
||||
use_case: Annotated[NotebookDeepUseCase, Depends(get_notebook_deep_use_case)],
|
||||
) -> StreamingResponse:
|
||||
"""Analyse APPROFONDIE (map-reduce sur tout le document). Évènements SSE :
|
||||
`progress` {current,total} pendant la lecture, puis `token` {token}, puis `done`."""
|
||||
messages = [ChatMessage(role=m.role, content=m.content) for m in body.messages]
|
||||
question = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
if not question.strip():
|
||||
yield sse_event("error", {"message": "Question vide."})
|
||||
return
|
||||
try:
|
||||
async for ev in use_case.stream(body.source_ids, messages, context=body.context):
|
||||
ev_type = ev.pop("type")
|
||||
yield sse_event(ev_type, ev)
|
||||
except (LLMProviderError, EmbeddingError) as exc:
|
||||
yield sse_event("error", {"message": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001 — filet : pas de coupure brutale.
|
||||
logger.exception("Analyse approfondie : erreur inattendue.")
|
||||
yield sse_event("error", {"message": f"Erreur inattendue du Brain : {type(exc).__name__} : {exc}"})
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
115
brain/app/api/routers/settings.py
Normal file
115
brain/app/api/routers/settings.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Endpoints de paramétrage runtime (écran Paramètres de l'UI)."""
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.settings_store import save_overrides
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsDTO(BaseModel):
|
||||
"""Vue serialisable des settings modifiables depuis l'UI.
|
||||
|
||||
Expose uniquement les champs que l'utilisateur peut changer a chaud.
|
||||
Les secrets (onemin_api_key) sont masques en lecture.
|
||||
"""
|
||||
|
||||
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"]
|
||||
ollama_base_url: str
|
||||
llm_model: str
|
||||
onemin_model: str
|
||||
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
|
||||
onemin_api_key_set: bool
|
||||
openrouter_model: str
|
||||
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
|
||||
openrouter_api_key_set: bool
|
||||
mistral_model: str
|
||||
# True si une cle Mistral est deja configuree (cle elle-meme jamais renvoyee).
|
||||
mistral_api_key_set: bool
|
||||
gemini_model: str
|
||||
# True si une cle Gemini est deja configuree (cle elle-meme jamais renvoyee).
|
||||
gemini_api_key_set: bool
|
||||
# Embeddings (RAG des ateliers) : provider + modeles + auto-pull Ollama.
|
||||
embedding_provider: Literal["ollama", "mistral"]
|
||||
ollama_embedding_model: str
|
||||
mistral_embedding_model: str
|
||||
auto_pull_embedding_model: bool
|
||||
rag_top_k: int
|
||||
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
|
||||
# aussi de plafond a la jauge de contexte UI.
|
||||
llm_num_ctx: int
|
||||
# Taille cible d'un morceau (tokens) pour l'import de PDF (regles/campagne).
|
||||
import_chunk_tokens: int
|
||||
# Timeout HTTP des appels LLM (s). A monter si les imports lourds expirent.
|
||||
llm_timeout_seconds: int
|
||||
|
||||
|
||||
class SettingsUpdateDTO(BaseModel):
|
||||
"""Patch partiel des settings. Tous les champs sont optionnels."""
|
||||
|
||||
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] | None = None
|
||||
ollama_base_url: str | None = None
|
||||
llm_model: str | None = None
|
||||
onemin_model: str | None = None
|
||||
# Chaine vide => on efface la cle. None => pas de changement.
|
||||
onemin_api_key: str | None = None
|
||||
openrouter_model: str | None = None
|
||||
openrouter_api_key: str | None = None
|
||||
mistral_model: str | None = None
|
||||
mistral_api_key: str | None = None
|
||||
gemini_model: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
embedding_provider: Literal["ollama", "mistral"] | None = None
|
||||
ollama_embedding_model: str | None = None
|
||||
mistral_embedding_model: str | None = None
|
||||
auto_pull_embedding_model: bool | None = None
|
||||
rag_top_k: int | None = None
|
||||
llm_num_ctx: int | None = None
|
||||
import_chunk_tokens: int | None = None
|
||||
llm_timeout_seconds: int | None = None
|
||||
|
||||
|
||||
def _to_settings_dto(s: Settings) -> SettingsDTO:
|
||||
return SettingsDTO(
|
||||
llm_provider=s.llm_provider,
|
||||
ollama_base_url=s.ollama_base_url,
|
||||
llm_model=s.llm_model,
|
||||
onemin_model=s.onemin_model,
|
||||
onemin_api_key_set=bool(s.onemin_api_key),
|
||||
openrouter_model=s.openrouter_model,
|
||||
openrouter_api_key_set=bool(s.openrouter_api_key),
|
||||
mistral_model=s.mistral_model,
|
||||
mistral_api_key_set=bool(s.mistral_api_key),
|
||||
gemini_model=s.gemini_model,
|
||||
gemini_api_key_set=bool(s.gemini_api_key),
|
||||
embedding_provider=s.embedding_provider,
|
||||
ollama_embedding_model=s.ollama_embedding_model,
|
||||
mistral_embedding_model=s.mistral_embedding_model,
|
||||
auto_pull_embedding_model=s.auto_pull_embedding_model,
|
||||
rag_top_k=s.rag_top_k,
|
||||
llm_num_ctx=s.llm_num_ctx,
|
||||
import_chunk_tokens=s.import_chunk_tokens,
|
||||
llm_timeout_seconds=s.llm_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=SettingsDTO)
|
||||
def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO:
|
||||
"""Retourne la config courante (secrets masques)."""
|
||||
return _to_settings_dto(settings)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=SettingsDTO)
|
||||
def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO:
|
||||
"""Applique un patch partiel aux settings et persiste les overrides.
|
||||
|
||||
Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache).
|
||||
"""
|
||||
overrides = {k: v for k, v in patch.model_dump().items() if v is not None}
|
||||
if overrides:
|
||||
save_overrides(overrides)
|
||||
# Relit .env + overrides fusionnes pour confirmation.
|
||||
return _to_settings_dto(get_settings())
|
||||
216
brain/app/api/routers/tables.py
Normal file
216
brain/app/api/routers/tables.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Endpoints « outils de table » : tables aléatoires, improvisation, catalogues d'objets."""
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import get_llm_provider
|
||||
from app.application.llm_json import load_json_object
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DICE_FORMULA_RE = re.compile(r"^\s*(\d*)\s*[dD]\s*(\d+)\s*$")
|
||||
|
||||
|
||||
def _dice_total_range(formula: str) -> tuple[int, int] | None:
|
||||
"""(min, max) des totaux possibles d'une formule NdM, ou None si invalide."""
|
||||
match = _DICE_FORMULA_RE.match(formula or "")
|
||||
if not match:
|
||||
return None
|
||||
count = int(match.group(1)) if match.group(1) else 1
|
||||
faces = int(match.group(2))
|
||||
if count < 1 or count > 100 or faces < 2 or faces > 10000:
|
||||
return None
|
||||
return count, count * faces
|
||||
|
||||
|
||||
class GenerateTableRequestDTO(BaseModel):
|
||||
description: str
|
||||
dice_formula: str = Field(default="1d20")
|
||||
# Contexte libre assemblé par le Core (nom de campagne, système, ambiance…).
|
||||
context: str = Field(default="")
|
||||
|
||||
|
||||
class GeneratedTableEntryDTO(BaseModel):
|
||||
min_roll: int
|
||||
max_roll: int
|
||||
label: str
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class GenerateTableResponseDTO(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
entries: list[GeneratedTableEntryDTO]
|
||||
|
||||
|
||||
@router.post("/generate/random-table", response_model=GenerateTableResponseDTO)
|
||||
async def generate_random_table(
|
||||
body: GenerateTableRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> GenerateTableResponseDTO:
|
||||
"""Génère une table aléatoire (entrées par plage) couvrant la formule de dé."""
|
||||
rng = _dice_total_range(body.dice_formula)
|
||||
if rng is None:
|
||||
raise HTTPException(status_code=422, detail="Formule de dé invalide (ex. 1d20, 2d6, d100).")
|
||||
lo, hi = rng
|
||||
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else ""
|
||||
prompt = (
|
||||
"Tu es un assistant de jeu de rôle. Génère une TABLE ALÉATOIRE évocatrice.\n"
|
||||
f"Dé : {body.dice_formula} (résultats possibles de {lo} à {hi}).\n"
|
||||
f"Sujet : {body.description.strip()}\n"
|
||||
f"{context_block}\n"
|
||||
"Règles IMPÉRATIVES :\n"
|
||||
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
|
||||
'- Format : {"name": "...", "description": "...", "entries": '
|
||||
'[{"min_roll": N, "max_roll": M, "label": "résultat court", "detail": "1-2 phrases"}]}\n'
|
||||
f"- Les plages (min_roll..max_roll) doivent COUVRIR EXACTEMENT {lo}..{hi}, "
|
||||
"sans trou ni chevauchement, dans l'ordre croissant.\n"
|
||||
"- Des résultats variés, cohérents avec le sujet (et le contexte s'il est fourni).\n"
|
||||
"- En français. 'label' = résultat bref ; 'detail' = description/effet concret.\n"
|
||||
"Renvoie maintenant le JSON."
|
||||
)
|
||||
try:
|
||||
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
parsed, _ = load_json_object(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de table exploitable.")
|
||||
|
||||
entries: list[GeneratedTableEntryDTO] = []
|
||||
for e in parsed.get("entries", []) or []:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
try:
|
||||
mn = int(e["min_roll"])
|
||||
mx = int(e["max_roll"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
label = str(e.get("label") or "").strip()
|
||||
if not label:
|
||||
continue
|
||||
entries.append(GeneratedTableEntryDTO(
|
||||
min_roll=mn, max_roll=max(mn, mx), label=label[:200],
|
||||
detail=str(e.get("detail") or "").strip(),
|
||||
))
|
||||
if not entries:
|
||||
raise HTTPException(status_code=502, detail="Aucune entrée générée — réessaie ou reformule.")
|
||||
|
||||
name = str(parsed.get("name") or body.description).strip()[:120] or "Table générée"
|
||||
return GenerateTableResponseDTO(
|
||||
name=name,
|
||||
description=str(parsed.get("description") or "").strip(),
|
||||
entries=entries,
|
||||
)
|
||||
|
||||
|
||||
class ImproviseRollRequestDTO(BaseModel):
|
||||
table_name: str
|
||||
result_label: str
|
||||
result_detail: str = Field(default="")
|
||||
context: str = Field(default="")
|
||||
|
||||
|
||||
class ImproviseRollResponseDTO(BaseModel):
|
||||
narration: str
|
||||
|
||||
|
||||
@router.post("/improvise/table-roll", response_model=ImproviseRollResponseDTO)
|
||||
async def improvise_table_roll(
|
||||
body: ImproviseRollRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> ImproviseRollResponseDTO:
|
||||
"""Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène."""
|
||||
detail = f" ({body.result_detail.strip()})" if body.result_detail.strip() else ""
|
||||
context_block = f"\nContexte : {body.context.strip()}" if body.context.strip() else ""
|
||||
prompt = (
|
||||
"Tu es le Maître du Jeu. Les joueurs viennent de tirer sur la table "
|
||||
f"« {body.table_name.strip()} » et ont obtenu : « {body.result_label.strip()} »{detail}."
|
||||
f"{context_block}\n\n"
|
||||
"Décris en 2-3 phrases vivantes et immédiates ce qui se passe, pour lancer la scène. "
|
||||
"Pas de méta, pas d'options : juste la narration, en français."
|
||||
)
|
||||
try:
|
||||
raw = await llm.generate(prompt, temperature=0.8)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return ImproviseRollResponseDTO(narration=raw.strip())
|
||||
|
||||
|
||||
# --- Catalogues d'objets (boutiques) : génération IA -------------------------
|
||||
|
||||
|
||||
class GenerateCatalogRequestDTO(BaseModel):
|
||||
description: str
|
||||
context: str = Field(default="")
|
||||
|
||||
|
||||
class GeneratedCatalogItemDTO(BaseModel):
|
||||
name: str
|
||||
price: str = ""
|
||||
category: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
class GenerateCatalogResponseDTO(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
items: list[GeneratedCatalogItemDTO]
|
||||
|
||||
|
||||
@router.post("/generate/item-catalog", response_model=GenerateCatalogResponseDTO)
|
||||
async def generate_item_catalog(
|
||||
body: GenerateCatalogRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> GenerateCatalogResponseDTO:
|
||||
"""Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description."""
|
||||
context_block = f"\nContexte de la campagne :\n{body.context.strip()}\n" if body.context.strip() else ""
|
||||
prompt = (
|
||||
"Tu es un assistant de jeu de rôle. Génère un CATALOGUE D'OBJETS (boutique, butin, trésor…).\n"
|
||||
f"Sujet : {body.description.strip()}\n"
|
||||
f"{context_block}\n"
|
||||
"Règles IMPÉRATIVES :\n"
|
||||
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
|
||||
'- Format : {"name": "...", "description": "...", "items": '
|
||||
'[{"name": "Objet", "price": "ex. 50 po", "category": "ex. Armes", "description": "effet/détails"}]}\n'
|
||||
"- Des objets variés et cohérents avec le sujet (et le contexte s'il est fourni).\n"
|
||||
"- 'price' = prix court dans la monnaie du jeu ; 'category' = regroupement (Armes, Potions…) ; "
|
||||
"'description' = effet/détails en une phrase. En français.\n"
|
||||
"Renvoie maintenant le JSON."
|
||||
)
|
||||
try:
|
||||
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.7)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
parsed, _ = load_json_object(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise HTTPException(status_code=502, detail="Le modèle n'a pas renvoyé de catalogue exploitable.")
|
||||
|
||||
items: list[GeneratedCatalogItemDTO] = []
|
||||
for it in parsed.get("items", []) or []:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
name = str(it.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
items.append(GeneratedCatalogItemDTO(
|
||||
name=name[:200],
|
||||
price=str(it.get("price") or "").strip(),
|
||||
category=str(it.get("category") or "").strip(),
|
||||
description=str(it.get("description") or "").strip(),
|
||||
))
|
||||
if not items:
|
||||
raise HTTPException(status_code=502, detail="Aucun objet généré — réessaie ou reformule.")
|
||||
|
||||
name = str(parsed.get("name") or body.description).strip()[:120] or "Catalogue généré"
|
||||
return GenerateCatalogResponseDTO(
|
||||
name=name,
|
||||
description=str(parsed.get("description") or "").strip(),
|
||||
items=items,
|
||||
)
|
||||
125
brain/app/application/adapt_campaign.py
Normal file
125
brain/app/application/adapt_campaign.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Use case : conseils d'adaptation d'un PDF à une campagne EXISTANTE.
|
||||
|
||||
L'IA connaît la campagne de l'utilisateur (un « brief » : structure arcs/chapitres/
|
||||
scènes + PNJ + univers/lore), lit le contenu du PDF, et rédige des recommandations
|
||||
d'INTÉGRATION/ADAPTATION (où insérer, reskins de PNJ, transposition à l'univers,
|
||||
doublons à réconcilier…). Sortie en markdown, streamée token par token.
|
||||
|
||||
Contrairement à l'IMPORT (qui produit une arborescence à créer), ici on produit
|
||||
du CONSEIL libre : rien n'est créé, l'utilisateur applique à la main.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMChatProvider, PdfExtractionError, PdfTextExtractor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Plus créatif que l'import (tâche de structuration) : ici on conseille/adapte.
|
||||
_TEMPERATURE = 0.7
|
||||
|
||||
_SYSTEM_PREFIX = (
|
||||
"Tu es un assistant pour Maître de Jeu de jeu de rôle. L'utilisateur a une "
|
||||
"campagne EXISTANTE (décrite plus bas) et souhaite ADAPTER et INTÉGRER le "
|
||||
"contenu d'un PDF (aventure, donjon, supplément) à CETTE campagne précise."
|
||||
)
|
||||
|
||||
_SYSTEM_SUFFIX = (
|
||||
"Produis des CONSEILS D'ADAPTATION concrets, actionnables et en FRANÇAIS, "
|
||||
"en markdown structuré (titres ##, listes). Couvre notamment :\n"
|
||||
"- **Où l'insérer** : à quel(s) arc(s)/chapitre(s) EXISTANT(s) rattacher ce "
|
||||
"contenu, dans quel ordre, et — si l'arc est un hub — sous quelles conditions de déblocage.\n"
|
||||
"- **Reskins / liens PNJ** : quels PNJ EXISTANTS de la campagne peuvent incarner "
|
||||
"ou remplacer les personnages clés du PDF.\n"
|
||||
"- **Adaptation à l'univers** : comment transposer lieux, factions, noms propres et "
|
||||
"ton vers l'univers de l'utilisateur plutôt que le cadre d'origine du PDF.\n"
|
||||
"- **Doublons / conflits** : ce qui recoupe l'existant et comment le réconcilier.\n"
|
||||
"- **Ajustements de ton et de difficulté**.\n\n"
|
||||
"Réfère-toi TOUJOURS aux éléments existants par leur NOM. Ne réécris PAS le PDF en "
|
||||
"entier : donne des recommandations. Si une information manque, propose des options."
|
||||
)
|
||||
|
||||
|
||||
class AdaptCampaignUseCase:
|
||||
"""Génère (en streaming) des conseils d'adaptation d'un PDF à une campagne."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLMChatProvider,
|
||||
extractor: PdfTextExtractor,
|
||||
max_input_tokens: int = 10000,
|
||||
) -> None:
|
||||
self._llm = llm
|
||||
self._extractor = extractor
|
||||
# L'adaptation envoie le PDF en UNE requête (pas de découpage). On plafonne
|
||||
# donc l'entrée pour ne pas dépasser la taille de requête acceptée par le
|
||||
# provider (sinon HTTP 400). Calé sur la taille des morceaux d'import.
|
||||
self._max_input_tokens = max_input_tokens
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
pdf_bytes: bytes,
|
||||
brief: str,
|
||||
messages: list[ChatMessage],
|
||||
) -> AsyncIterator[str]:
|
||||
"""Conversationnel : le PDF + la campagne sont le CONTEXTE (system prompt),
|
||||
`messages` est l'échange (demande initiale, puis feedbacks de l'utilisateur)."""
|
||||
doc = self._extractor.extract(pdf_bytes)
|
||||
pdf_text = doc.full_text
|
||||
if not pdf_text.strip():
|
||||
raise PdfExtractionError("Aucun texte exploitable n'a été extrait du PDF.")
|
||||
|
||||
brief = brief or ""
|
||||
pdf_text, truncated = self._fit_pdf_to_budget(pdf_text, brief)
|
||||
|
||||
logger.info(
|
||||
"Adaptation campagne : %s page(s) (%s via OCR), brief %s car., PDF %s car.%s, %s message(s).",
|
||||
doc.page_count, doc.ocr_page_count, len(brief), len(pdf_text),
|
||||
" (tronqué)" if truncated else "", len(messages),
|
||||
)
|
||||
|
||||
trunc_note = (
|
||||
"\n[Note : PDF tronqué pour tenir dans une requête — base-toi sur ce début.]"
|
||||
if truncated else ""
|
||||
)
|
||||
# Concaténation (pas .format) : brief/PDF peuvent contenir des { } littéraux.
|
||||
system_prompt = (
|
||||
f"{_SYSTEM_PREFIX}\n\n"
|
||||
"--- CAMPAGNE EXISTANTE DE L'UTILISATEUR ---\n"
|
||||
f"{brief.strip() or '(campagne encore vide)'}\n\n"
|
||||
"--- CONTENU DU PDF À ADAPTER ---\n"
|
||||
f"{pdf_text}{trunc_note}\n\n"
|
||||
f"{_SYSTEM_SUFFIX}\n\n"
|
||||
"Tu es en CONVERSATION : à chaque message de l'utilisateur, ajuste, corrige "
|
||||
"ou propose des alternatives en gardant tout ce contexte à l'esprit."
|
||||
)
|
||||
|
||||
# 1er tour : si aucun message, on lance la demande initiale par défaut.
|
||||
convo = messages or [ChatMessage(
|
||||
role="user",
|
||||
content="Propose-moi comment intégrer et adapter ce PDF à ma campagne.",
|
||||
)]
|
||||
|
||||
async for token in self._llm.stream_chat(
|
||||
convo, system_prompt=system_prompt, temperature=_TEMPERATURE
|
||||
):
|
||||
yield token
|
||||
|
||||
def _fit_pdf_to_budget(self, pdf_text: str, brief: str) -> tuple[str, bool]:
|
||||
"""Tronque le texte du PDF pour que (brief + PDF) tienne dans le budget tokens.
|
||||
|
||||
Évite un HTTP 400 « requête trop grosse » côté provider. Réserve une marge
|
||||
pour le prompt système et le brief.
|
||||
"""
|
||||
import tiktoken
|
||||
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
brief_tokens = len(enc.encode(brief))
|
||||
budget = max(2000, self._max_input_tokens - brief_tokens - 1000) # 1000 = marge système
|
||||
pdf_tokens = enc.encode(pdf_text)
|
||||
if len(pdf_tokens) <= budget:
|
||||
return pdf_text, False
|
||||
return enc.decode(pdf_tokens[:budget]), True
|
||||
@@ -23,10 +23,13 @@ from app.domain.models import (
|
||||
CharacterSummary,
|
||||
NpcSummary,
|
||||
GameSystemContext,
|
||||
JournalEntrySummary,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
PageSummary,
|
||||
QuestSummary,
|
||||
SessionContext,
|
||||
)
|
||||
from app.domain.ports import LLMChatProvider
|
||||
|
||||
@@ -67,6 +70,7 @@ class ChatUseCase:
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Streame les tokens de la réponse assistant pour le dernier message user.
|
||||
|
||||
@@ -76,7 +80,7 @@ class ChatUseCase:
|
||||
cette règle à la frontière HTTP.
|
||||
"""
|
||||
system_prompt = self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
|
||||
)
|
||||
async for token in self._llm.stream_chat(
|
||||
messages,
|
||||
@@ -92,12 +96,13 @@ class ChatUseCase:
|
||||
campaign_context: CampaignStructuralContext | None = None,
|
||||
narrative_entity: NarrativeEntityContext | None = None,
|
||||
game_system_context: GameSystemContext | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> str:
|
||||
"""Version publique — utilisée par le controller HTTP pour compter
|
||||
les tokens du system prompt avant de streamer (jauge de contexte).
|
||||
"""
|
||||
return self._build_system_prompt(
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context
|
||||
lore_context, page_context, campaign_context, narrative_entity, game_system_context, session_context
|
||||
)
|
||||
|
||||
# --- Construction du system prompt --------------------------------------
|
||||
@@ -109,6 +114,7 @@ class ChatUseCase:
|
||||
campaign: CampaignStructuralContext | None,
|
||||
narrative: NarrativeEntityContext | None,
|
||||
game_system: GameSystemContext | None = None,
|
||||
session: SessionContext | None = None,
|
||||
) -> str:
|
||||
sections = [_BASE_SYSTEM]
|
||||
if lore is not None:
|
||||
@@ -121,6 +127,8 @@ class ChatUseCase:
|
||||
sections.append(self._format_page(page))
|
||||
if narrative is not None:
|
||||
sections.append(self._format_narrative_entity(narrative))
|
||||
if session is not None:
|
||||
sections.append(self._format_session(session))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
# --- Blocs Lore ---------------------------------------------------------
|
||||
@@ -288,7 +296,8 @@ class ChatUseCase:
|
||||
else:
|
||||
for scene in chapter.scenes:
|
||||
sc_hint = ChatUseCase._illustration_hint(scene.illustration_count)
|
||||
block.append(f" - {scene.name} (scène){sc_hint}")
|
||||
scene_kind = " (lieu explorable)" if scene.rooms else " (scène)"
|
||||
block.append(f" - {scene.name}{scene_kind}{sc_hint}")
|
||||
if scene.description:
|
||||
block.append(f" Description : {scene.description}")
|
||||
for br in scene.branches:
|
||||
@@ -296,6 +305,19 @@ class ChatUseCase:
|
||||
block.append(
|
||||
f' → "{br.label}" vers {br.target_scene_name}{cond}'
|
||||
)
|
||||
# Pièces du lieu explorable (mode donjon)
|
||||
for room in scene.rooms:
|
||||
floor = f" [étage {room.floor}]" if room.floor is not None else ""
|
||||
block.append(f" ◆ {room.name}{floor}")
|
||||
if room.description:
|
||||
block.append(f" {room.description}")
|
||||
if room.enemies:
|
||||
block.append(f" Ennemis : {room.enemies}")
|
||||
for rb in room.branches:
|
||||
cond = f" (si : {rb.condition})" if rb.condition else ""
|
||||
block.append(
|
||||
f' ↳ "{rb.label}" vers {rb.target_room_name}{cond}'
|
||||
)
|
||||
return block
|
||||
|
||||
@staticmethod
|
||||
@@ -342,6 +364,141 @@ class ChatUseCase:
|
||||
f"{sections_block}"
|
||||
)
|
||||
|
||||
# --- Bloc Session de jeu (Play Context) ---------------------------------
|
||||
|
||||
_ENTRY_TYPE_LABELS = {
|
||||
"NOTE": "Note du MJ",
|
||||
"EVENT": "Évènement",
|
||||
"DICE_ROLL": "Jet de dés",
|
||||
"PLAYER_ACTION": "Action joueur",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_session(sc: SessionContext) -> str:
|
||||
"""Bloc journal de la session en cours + résumé des sessions précédentes.
|
||||
|
||||
Fournit à l'IA le contexte temporel : ce qui s'est passé jusqu'ici,
|
||||
dans l'ordre chronologique. Permet de référencer un PNJ rencontré,
|
||||
rappeler un évènement antérieur, ou rebondir sur une action joueur.
|
||||
|
||||
Pour les sessions PRÉCÉDENTES de la même campagne, on ne remonte que
|
||||
les EVENTs (les moments marquants) pour préserver le contexte LLM.
|
||||
"""
|
||||
status = "EN COURS" if sc.active else "TERMINÉE"
|
||||
started = f" — démarrée {sc.started_at}" if sc.started_at else ""
|
||||
|
||||
previous_block = ChatUseCase._format_previous_events(sc.previous_events)
|
||||
hub_block = ChatUseCase._format_hub_status(sc)
|
||||
|
||||
if not sc.entries:
|
||||
current_block = "(Aucune entrée dans le journal pour l'instant — la session vient de commencer.)"
|
||||
else:
|
||||
lines: list[str] = []
|
||||
for e in sc.entries:
|
||||
label = ChatUseCase._ENTRY_TYPE_LABELS.get(e.type, e.type)
|
||||
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
|
||||
content = e.content.replace("\n", "\n ")
|
||||
lines.append(f"- {label}{ts} : {content}")
|
||||
current_block = "\n".join(lines)
|
||||
|
||||
return (
|
||||
"--- SESSION DE JEU EN COURS ---\n"
|
||||
f"Nom : {sc.session_name}\n"
|
||||
f"Statut : {status}{started}\n"
|
||||
f"{hub_block}"
|
||||
f"{previous_block}"
|
||||
"\nJournal chronologique de la session courante (du plus ancien au plus récent) :\n"
|
||||
f"{current_block}\n\n"
|
||||
"IMPORTANT : tu es l'assistant du MJ PENDANT la partie. Tes réponses doivent :\n"
|
||||
"- Tenir compte des évènements déjà capturés (sessions précédentes + journal courant).\n"
|
||||
"- Être concrètes et utiles en temps réel : descriptions sensorielles, "
|
||||
"réactions de PNJ cohérentes avec leur fiche, suggestions de complications "
|
||||
"qui s'enchaînent à ce qui vient de se passer.\n"
|
||||
"- Éviter les longs développements : le MJ est en train d'animer une partie, "
|
||||
"il a besoin d'idées immédiatement actionnables."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_hub_status(sc: SessionContext) -> str:
|
||||
"""Bloc Hub : quêtes ouvertes + flags actifs.
|
||||
|
||||
Vide si la campagne n'a aucun Arc HUB (toutes les listes vides côté Core).
|
||||
Les quêtes LOCKED apparaissent par leur TITRE uniquement : l'IA sait
|
||||
qu'elles existent (utile pour les teasers, les rumeurs en jeu) mais ne
|
||||
peut pas spoiler leurs détails.
|
||||
"""
|
||||
if (not sc.available_quests
|
||||
and not sc.in_progress_quests
|
||||
and not sc.locked_quest_titles
|
||||
and not sc.active_flags):
|
||||
return ""
|
||||
|
||||
lines = ["", "État du Hub (quêtes parallèles et faits narratifs) :"]
|
||||
|
||||
if sc.in_progress_quests:
|
||||
lines.append(" Quêtes en cours :")
|
||||
lines.extend(ChatUseCase._format_quest_lines(sc.in_progress_quests))
|
||||
|
||||
if sc.available_quests:
|
||||
lines.append(" Quêtes disponibles (non démarrées, prêtes à être lancées) :")
|
||||
lines.extend(ChatUseCase._format_quest_lines(sc.available_quests))
|
||||
|
||||
if sc.locked_quest_titles:
|
||||
titles = ", ".join(f'"{t}"' for t in sc.locked_quest_titles)
|
||||
lines.append(
|
||||
" Quêtes encore verrouillées (existent mais non accessibles — "
|
||||
f"tu peux y faire allusion sous forme de rumeurs sans spoiler leur contenu) : {titles}"
|
||||
)
|
||||
|
||||
if sc.active_flags:
|
||||
lines.append(
|
||||
" Faits actifs : " + ", ".join(f"`{f}`" for f in sc.active_flags)
|
||||
)
|
||||
|
||||
lines.append(
|
||||
" Conseille des actions cohérentes avec ces quêtes ouvertes. Ne fais "
|
||||
"PAS comme si une quête verrouillée était déjà accessible aux PJ."
|
||||
)
|
||||
lines.append("") # séparateur visuel avant le récap des sessions précédentes
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _format_quest_lines(quests: list[QuestSummary]) -> list[str]:
|
||||
"""Sérialise une liste de QuestSummary en lignes indentées."""
|
||||
out: list[str] = []
|
||||
for q in quests:
|
||||
arc = f" [arc : {q.arc_name}]" if q.arc_name else ""
|
||||
out.append(f" - {q.name}{arc}")
|
||||
if q.description:
|
||||
out.append(f" Synopsis : {q.description}")
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _format_previous_events(events: list[JournalEntrySummary]) -> str:
|
||||
"""Bloc "Story so far" : EVENTs marquants des sessions antérieures.
|
||||
|
||||
Vide si la campagne en est à sa première session. On groupe par
|
||||
session source pour aider l'IA à situer chaque évènement temporellement.
|
||||
"""
|
||||
if not events:
|
||||
return ""
|
||||
|
||||
# Groupement par session source en préservant l'ordre d'apparition.
|
||||
grouped: dict[str, list[JournalEntrySummary]] = {}
|
||||
for e in events:
|
||||
key = e.source_session_name or "(session inconnue)"
|
||||
grouped.setdefault(key, []).append(e)
|
||||
|
||||
lines = ["\nRécapitulatif des sessions précédentes (évènements marquants uniquement) :"]
|
||||
for session_name, items in grouped.items():
|
||||
lines.append(f" • {session_name} :")
|
||||
for e in items:
|
||||
ts = f" [{e.occurred_at}]" if e.occurred_at else ""
|
||||
content = e.content.replace("\n", "\n ")
|
||||
lines.append(f" - {content}{ts}")
|
||||
lines.append("") # ligne vide avant le bloc journal courant
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _format_narrative_entity(ne: NarrativeEntityContext) -> str:
|
||||
"""Bloc équivalent à _format_page mais pour Arc/Chapter/Scene."""
|
||||
|
||||
123
brain/app/application/chunking.py
Normal file
123
brain/app/application/chunking.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Découpage d'un long texte en morceaux qui tiennent dans la fenêtre LLM.
|
||||
|
||||
Partagé par les imports (règles, campagne) : un livre dépasse la fenêtre de
|
||||
contexte, on le découpe par paragraphes jusqu'à une cible de tokens, en coupant
|
||||
les paragraphes géants si besoin. Dimensionnement via tiktoken (cl100k_base),
|
||||
approximation suffisante (±10% vs tokenizer natif).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Cible conservatrice : tient dans une fenêtre Ollama (num_ctx 16384) en laissant
|
||||
# la place au prompt + à la sortie JSON. Les providers à grand contexte (1min.ai)
|
||||
# le supportent largement.
|
||||
CHUNK_TARGET_TOKENS = 6000
|
||||
|
||||
|
||||
def chunk_text(
|
||||
full_text: str,
|
||||
target_tokens: int = CHUNK_TARGET_TOKENS,
|
||||
overlap_tokens: int = 0,
|
||||
) -> list[str]:
|
||||
"""Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §).
|
||||
|
||||
`overlap_tokens` > 0 : chaque morceau reprend la fin du précédent (les derniers
|
||||
paragraphes, jusqu'à ~`overlap_tokens` tokens). Utile pour le RAG : une phrase-clé
|
||||
à cheval sur deux morceaux reste retrouvable dans au moins l'un des deux. À
|
||||
laisser à 0 pour les imports (recopie) : un overlap y DUPLIQUERAIT du texte.
|
||||
Un morceau peut légèrement dépasser la cible (jusqu'à target + overlap).
|
||||
"""
|
||||
if not full_text.strip():
|
||||
return []
|
||||
|
||||
import tiktoken
|
||||
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
paragraphs = [p for p in full_text.split("\n\n") if p.strip()]
|
||||
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
current_tokens = 0
|
||||
fresh = False # `current` contient-il du contenu pas encore émis ? (évite de
|
||||
# ré-émettre un morceau composé uniquement de l'overlap en fin de texte)
|
||||
for para in paragraphs:
|
||||
para_tokens = len(enc.encode(para))
|
||||
# Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs.
|
||||
if para_tokens > target_tokens:
|
||||
if current and fresh:
|
||||
chunks.append("\n\n".join(current))
|
||||
current, current_tokens, fresh = [], 0, False
|
||||
chunks.extend(_split_oversized(para, enc, target_tokens, overlap_tokens))
|
||||
continue
|
||||
if current_tokens + para_tokens > target_tokens and current:
|
||||
if fresh:
|
||||
chunks.append("\n\n".join(current))
|
||||
current, current_tokens = _overlap_tail(current, enc, overlap_tokens)
|
||||
fresh = False
|
||||
current.append(para)
|
||||
current_tokens += para_tokens
|
||||
fresh = True
|
||||
|
||||
if current and fresh:
|
||||
chunks.append("\n\n".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
def _overlap_tail(parts: list[str], enc, overlap_tokens: int) -> tuple[list[str], int]:
|
||||
"""Derniers paragraphes de `parts` totalisant au plus `overlap_tokens` tokens —
|
||||
le « rappel » recopié en tête du morceau suivant."""
|
||||
if overlap_tokens <= 0 or not parts:
|
||||
return [], 0
|
||||
tail: list[str] = []
|
||||
total = 0
|
||||
for para in reversed(parts):
|
||||
para_tokens = len(enc.encode(para))
|
||||
if total + para_tokens > overlap_tokens:
|
||||
break
|
||||
tail.insert(0, para)
|
||||
total += para_tokens
|
||||
if not tail:
|
||||
# Aucun paragraphe entier ne tient dans le budget (paragraphes longs) :
|
||||
# on reprend la FIN du dernier paragraphe pour garantir le recouvrement.
|
||||
tokens = enc.encode(parts[-1])
|
||||
tail = [enc.decode(tokens[-overlap_tokens:])]
|
||||
total = min(overlap_tokens, len(tokens))
|
||||
return tail, total
|
||||
|
||||
|
||||
def _split_oversized(paragraph: str, enc, target_tokens: int, overlap_tokens: int = 0) -> list[str]:
|
||||
"""Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens (fenêtre
|
||||
glissante avec recouvrement si `overlap_tokens` > 0)."""
|
||||
tokens = enc.encode(paragraph)
|
||||
step = max(1, target_tokens - overlap_tokens)
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
out.append(enc.decode(tokens[i : i + target_tokens]))
|
||||
if i + target_tokens >= len(tokens):
|
||||
break
|
||||
i += step
|
||||
return out
|
||||
|
||||
|
||||
def split_in_half(text: str) -> tuple[str, str]:
|
||||
"""Coupe `text` en deux moitiés ~égales, de préférence sur un saut de ligne
|
||||
proche du milieu (pour ne pas trancher en plein mot/phrase).
|
||||
|
||||
Sert au repli anti-troncature des imports : quand la SORTIE d'un morceau est
|
||||
coupée (le modèle ne peut pas tout réécrire en une réponse), on retraite ce
|
||||
morceau en deux moitiés. Renvoie ('', '') si le texte est trop court pour
|
||||
être découpé utilement (garde-fou anti-récursion infinie).
|
||||
"""
|
||||
text = text.strip()
|
||||
if len(text) < 400:
|
||||
return "", ""
|
||||
mid = len(text) // 2
|
||||
# Cherche un saut de ligne juste avant le milieu, sinon juste après.
|
||||
cut = text.rfind("\n", 0, mid)
|
||||
if cut < len(text) // 4:
|
||||
nxt = text.find("\n", mid)
|
||||
cut = nxt if nxt != -1 else mid
|
||||
left, right = text[:cut].strip(), text[cut:].strip()
|
||||
if not left or not right:
|
||||
return "", ""
|
||||
return left, right
|
||||
26
brain/app/application/embeddings.py
Normal file
26
brain/app/application/embeddings.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Port d'embeddings (RAG des notebooks).
|
||||
|
||||
Abstraction du calcul de vecteurs : un texte → une liste de floats. Les adapters
|
||||
concrets (Ollama local, Mistral cloud) la satisfont par duck typing, comme pour
|
||||
les LLMProvider. Le RAG n'en dépend que via cette interface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class EmbeddingError(Exception):
|
||||
"""Échec du calcul d'embeddings (modèle indisponible, réseau, quota…)."""
|
||||
|
||||
|
||||
class EmbeddingProvider(Protocol):
|
||||
"""Calcule les vecteurs d'une liste de textes (ordre préservé).
|
||||
|
||||
`kind` distingue les DOCUMENTS indexés ("document") de la QUESTION posée
|
||||
("query") : certains modèles (nomic-embed-text) sont entraînés avec des
|
||||
préfixes de tâche distincts et perdent en pertinence sans eux. Les adapters
|
||||
qui n'en ont pas besoin (mistral-embed) ignorent simplement le paramètre.
|
||||
"""
|
||||
|
||||
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
|
||||
...
|
||||
775
brain/app/application/import_campaign.py
Normal file
775
brain/app/application/import_campaign.py
Normal file
@@ -0,0 +1,775 @@
|
||||
"""Use case : import d'un PDF de campagne → arbre arc → chapitre → scène.
|
||||
|
||||
Couche APPLICATION. Même chaîne que l'import de règles (extraction + OCR +
|
||||
chunking + map-reduce) mais la cible est une ARBORESCENCE narrative :
|
||||
- MAP : chaque morceau → un sous-arbre {arcs:[{chapters:[{scenes}]}]}
|
||||
- REDUCE : fusion par NOM à chaque niveau (un chapitre coupé entre 2 morceaux
|
||||
est recollé ; ses scènes s'accumulent).
|
||||
|
||||
PROPOSITION non persistée : le Core crée les entités seulement après revue.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from app.application.chunking import chunk_text, split_in_half
|
||||
from app.application.import_status import (
|
||||
notify_status,
|
||||
reset_status_queue,
|
||||
set_status_queue,
|
||||
)
|
||||
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
from app.application.streaming import with_heartbeat
|
||||
|
||||
# Repli anti-troncature : si la sortie d'un morceau est coupée, on le retraite en
|
||||
# 2 moitiés. Borné en profondeur (3 niveaux => jusqu'à 8 sous-blocs).
|
||||
_MAX_SPLIT_DEPTH = 3
|
||||
from app.domain.models import (
|
||||
ArcProposal,
|
||||
CampaignImportResult,
|
||||
ChapterProposal,
|
||||
NpcImportProposal,
|
||||
RoomProposal,
|
||||
SceneProposal,
|
||||
)
|
||||
from app.domain.ports import (
|
||||
LLMGenerationTimeout,
|
||||
LLMProvider,
|
||||
LLMProviderError,
|
||||
PdfTextExtractor,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
|
||||
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
|
||||
_TEMPERATURE = 0.1
|
||||
|
||||
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
|
||||
_DEFAULT_ARC_NAME = "Aventure principale"
|
||||
|
||||
# Morceaux PLUS GROS que pour les règles : l'IA voit une quête/un chapitre entier
|
||||
# d'un coup et le structure de façon cohérente (1 scène par lieu) au lieu de le
|
||||
# fragmenter en dizaines de scènes. Adapté aux providers à grand contexte (1min.ai).
|
||||
_CHUNK_TARGET_TOKENS = 10000
|
||||
|
||||
_MAP_SYSTEM = """Tu es un assistant qui structure un livre de campagne de jeu de rôle.
|
||||
On te donne un EXTRAIT brut d'un PDF de campagne (texte parfois mal coupé par la mise en page).
|
||||
|
||||
Ta tâche : en dégager une ARBORESCENCE narrative à GROS GRAIN : arcs → chapitres → scènes,
|
||||
et — pour les lieux explorables — leurs PIÈCES (rooms).
|
||||
- Un ARC = un acte / une grande partie de la campagne (souvent un seul pour une aventure courte).
|
||||
- Un CHAPITRE = une étape majeure du récit : un chapitre du livre, OU — dans une
|
||||
campagne "hub" / bac-à-sable — UNE QUÊTE ou UN LIEU principal débloqué depuis le
|
||||
point central (ex : Dragon of Icespire Peak → chaque quête/lieu = un chapitre).
|
||||
- Une SCÈNE = un temps fort jouable du chapitre : un lieu, une rencontre clé, un moment pivot.
|
||||
- Une PIÈCE (room) = une salle d'un lieu explorable (donjon, crypte, manoir...).
|
||||
|
||||
TYPE D'ARC ("type") :
|
||||
- "HUB" si la campagne est un bac-à-sable : des quêtes/lieux optionnels, parallèles,
|
||||
débloqués depuis un point central, SANS ordre fixe imposé (ex : Dragon of Icespire Peak).
|
||||
- "LINEAR" si les chapitres se jouent dans un ordre séquentiel imposé.
|
||||
- Dans le doute : "LINEAR".
|
||||
|
||||
GRANULARITÉ (évite la sur-détection) :
|
||||
- Vise PEU de scènes : typiquement 1 à 6 par chapitre. PAS des dizaines.
|
||||
- Un LIEU EXPLORABLE (donjon, crypte, manoir, grotte à plusieurs salles) = UNE SEULE
|
||||
scène. Ses salles vont dans le tableau "rooms" de cette scène — JAMAIS en scènes séparées.
|
||||
- NE crée PAS une scène par rencontre isolée, par PNJ, par monstre ou par paragraphe.
|
||||
- IGNORE : blocs de stats, listes de monstres, encarts de règles, légendes de cartes,
|
||||
pieds de page, sommaires, crédits.
|
||||
|
||||
CONTENU D'UNE SCÈNE (fidélité au livre — important) :
|
||||
- `description` = synopsis de la scène, 2 à 4 phrases (plus que 1 ligne, mais pas le texte intégral).
|
||||
- `player_narration` = le texte d'AMBIANCE « à lire aux joueurs » (encadrés / boxed text /
|
||||
« lecture à voix haute »), recopié FIDÈLEMENT s'il existe dans l'extrait. Vide sinon.
|
||||
- `gm_notes` = les informations pour le MJ : secrets, développement, ce qui se passe,
|
||||
conséquences, indices cachés. Vide si rien de tel.
|
||||
- Ne RÉSUME pas abusivement player_narration et gm_notes : recopie le contenu utile du livre.
|
||||
|
||||
PIÈCES (rooms) — uniquement pour les scènes qui sont des lieux explorables :
|
||||
- Une entrée par salle numérotée/nommée du donjon (ex : "1. Entrée", "2. Salle des gardes").
|
||||
- `enemies` = créatures/boss de la salle (vide si aucune). `loot` = trésor/récompense (vide si aucun).
|
||||
- Pour une scène narrative classique (pas un donjon), "rooms" est un tableau vide [].
|
||||
|
||||
PNJ ET CRÉATURES NOTABLES ("npcs", tableau au niveau racine) :
|
||||
- Recense les PNJ NOMMÉS (alliés, marchands, antagonistes) et les créatures UNIQUES
|
||||
(boss, monstre récurrent) présents dans l'extrait.
|
||||
- `description` = courte fiche utile au MJ : rôle dans l'histoire, apparence,
|
||||
motivations, où on le rencontre. 2 à 4 phrases, fidèles au livre.
|
||||
- N'inclus PAS les monstres génériques sans nom (« 3 gobelins », « un loup »).
|
||||
- Aucun PNJ nommé dans l'extrait → "npcs": [].
|
||||
|
||||
Format de réponse :
|
||||
- Tu réponds UNIQUEMENT par un objet JSON valide, sans markdown ni commentaire autour.
|
||||
- Schéma EXACT :
|
||||
{{"arcs": [{{"name": "...", "description": "...", "type": "LINEAR",
|
||||
"chapters": [{{"name": "...", "description": "...", "scenes": [
|
||||
{{"name": "...", "description": "...", "player_narration": "...", "gm_notes": "...",
|
||||
"rooms": [{{"name": "...", "description": "...", "enemies": "...", "loot": "..."}}]}}
|
||||
]}}]}}
|
||||
],
|
||||
"npcs": [{{"name": "...", "description": "..."}}]}}
|
||||
- Utilise les VRAIS titres du livre pour les noms (pas de paraphrase).
|
||||
- Si le livre n'est PAS découpé en actes/parties, regroupe tout sous un seul arc nommé "{default_arc}".
|
||||
- N'invente pas de contenu : tu réorganises et recopies ce qui est présent dans l'extrait.
|
||||
- Si l'extrait ne contient aucune matière narrative, renvoie {{"arcs": []}}."""
|
||||
|
||||
# Schéma de l'arbre attendu, passé aux providers à sorties structurées (Ollama
|
||||
# contraint la grammaire : un modèle local ne PEUT plus produire de clés
|
||||
# inventées, d'objets bavards type "thought" ni de texte hors JSON). Les
|
||||
# adapters cloud le traduisent en mode JSON natif. Seuls les "name" sont
|
||||
# requis : le _TreeMerger tolère déjà tous les champs absents.
|
||||
_TREE_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"arcs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["LINEAR", "HUB"]},
|
||||
"chapters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"player_narration": {"type": "string"},
|
||||
"gm_notes": {"type": "string"},
|
||||
"rooms": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"enemies": {"type": "string"},
|
||||
"loot": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
"npcs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["name"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["arcs"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
# Bloc TOC injecté quand le PDF a des bookmarks : les morceaux étant traités
|
||||
# séparément, c'est CE référentiel commun qui garantit que tous nomment les
|
||||
# mêmes chapitres à l'identique → la fusion par nom du _TreeMerger recolle
|
||||
# les chapitres coupés au lieu de créer des doublons.
|
||||
_TOC_BLOCK = """
|
||||
|
||||
--- STRUCTURE OFFICIELLE DU LIVRE (table des matières du PDF) ---
|
||||
{toc}
|
||||
--- FIN DE LA STRUCTURE ---
|
||||
IMPORTANT : pour nommer les arcs et chapitres, reprends EXACTEMENT les titres
|
||||
de cette structure (caractère pour caractère). Rattache le contenu de l'extrait
|
||||
au bon chapitre de la structure, même si son titre n'apparaît pas dans l'extrait."""
|
||||
|
||||
# Garde-fou prompt : une TOC de gros livre peut compter des centaines d'entrées
|
||||
# (sous-sous-sections). On la limite aux niveaux hauts et à un nombre raisonnable.
|
||||
_TOC_MAX_LEVEL = 2
|
||||
_TOC_MAX_ENTRIES = 80
|
||||
|
||||
|
||||
# Consolidation finale : le squelette (noms seuls) est minuscule, donc l'appel
|
||||
# est quasi gratuit comparé aux MAP. Température 0 et consigne CONSERVATRICE :
|
||||
# ne fusionner que les doublons évidents, jamais des entités distinctes.
|
||||
_CONSOLIDATE_PROMPT = """Voici le squelette d'une arborescence arc → chapitre → scène issue d'une
|
||||
fusion AUTOMATIQUE de morceaux d'un livre de campagne de jeu de rôle. La fusion par nom exact
|
||||
peut avoir laissé des QUASI-DOUBLONS : le même chapitre ou la même scène sous deux libellés
|
||||
légèrement différents (ex: "La Crypte" et "Crypte de Karrak", "3. Salle des gardes" et
|
||||
"Salle des gardes").
|
||||
|
||||
{skeleton}
|
||||
|
||||
Identifie UNIQUEMENT les fusions ÉVIDENTES (même entité du livre sous deux noms). Sois
|
||||
CONSERVATEUR : dans le doute, ne fusionne PAS. Deux lieux/évènements distincts ne doivent
|
||||
JAMAIS être fusionnés.
|
||||
|
||||
Réponds UNIQUEMENT par un objet JSON valide :
|
||||
{{"chapter_merges": [{{"into": "nom du chapitre à garder", "merge": ["nom à fusionner", ...]}}],
|
||||
"scene_merges": [{{"chapter": "nom du chapitre", "into": "nom de la scène à garder",
|
||||
"merge": ["nom à fusionner", ...]}}]}}
|
||||
S'il n'y a RIEN à fusionner (cas le plus fréquent) : {{"chapter_merges": [], "scene_merges": []}}"""
|
||||
|
||||
|
||||
def _format_toc(toc) -> str:
|
||||
"""Formate la TOC du PDF en liste indentée, bornée (niveaux hauts d'abord)."""
|
||||
entries = [e for e in toc if e.level <= _TOC_MAX_LEVEL][:_TOC_MAX_ENTRIES]
|
||||
if not entries:
|
||||
return ""
|
||||
return "\n".join(f"{' ' * (e.level - 1)}- {e.title} (p. {e.page})" for e in entries)
|
||||
|
||||
|
||||
class _TreeMerger:
|
||||
"""Fusionne les sous-arbres des morceaux en un seul arbre, ordre préservé.
|
||||
|
||||
Clés insensibles à la casse à chaque niveau (nom d'arc / chapitre / scène).
|
||||
Description : la première non-vide rencontrée l'emporte (les morceaux suivants
|
||||
ne l'écrasent pas).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# arc_key -> {"name", "description", "chapters": {chap_key -> {...}}}
|
||||
self._arcs: dict[str, dict] = {}
|
||||
# npc_key (nom en minuscules) -> {"name", "description"}
|
||||
self._npcs: dict[str, dict] = {}
|
||||
|
||||
def add(self, arcs_json: list[dict]) -> None:
|
||||
for arc in arcs_json or []:
|
||||
name = str(arc.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
a = self._arcs.setdefault(
|
||||
name.lower(), {"name": name, "description": "", "type": "LINEAR", "chapters": {}})
|
||||
self._fill_desc(a, arc)
|
||||
# Type d'arc : HUB l'emporte si un seul morceau le signale (propriété globale
|
||||
# souvent énoncée une fois, dans l'intro du livre).
|
||||
if str(arc.get("type", "")).strip().upper() == "HUB":
|
||||
a["type"] = "HUB"
|
||||
for chap in arc.get("chapters", []) or []:
|
||||
cname = str(chap.get("name", "")).strip()
|
||||
if not cname:
|
||||
continue
|
||||
c = a["chapters"].setdefault(cname.lower(), {"name": cname, "description": "", "scenes": {}})
|
||||
self._fill_desc(c, chap)
|
||||
for sc in chap.get("scenes", []) or []:
|
||||
sname = str(sc.get("name", "")).strip()
|
||||
if not sname:
|
||||
continue
|
||||
s = c["scenes"].setdefault(
|
||||
sname.lower(),
|
||||
{"name": sname, "description": "", "player_narration": "",
|
||||
"gm_notes": "", "rooms": {}})
|
||||
self._fill_desc(s, sc)
|
||||
# Narration/notes : CONCATÉNATION (pas premier-gagne) — une
|
||||
# scène coupée entre deux morceaux apporte la suite de son
|
||||
# contenu dans le morceau suivant ; la jeter perdrait la
|
||||
# moitié du donjon. Le doublon exact (overlap) est filtré.
|
||||
self._append_field(s, sc, "player_narration")
|
||||
self._append_field(s, sc, "gm_notes")
|
||||
for rm in sc.get("rooms", []) or []:
|
||||
rname = str(rm.get("name", "")).strip()
|
||||
if not rname:
|
||||
continue
|
||||
r = s["rooms"].setdefault(
|
||||
rname.lower(),
|
||||
{"name": rname, "description": "", "enemies": "", "loot": ""})
|
||||
self._fill_desc(r, rm)
|
||||
self._fill_field(r, rm, "enemies")
|
||||
self._fill_field(r, rm, "loot")
|
||||
|
||||
def add_npcs(self, npcs_json: list[dict]) -> None:
|
||||
"""Accumule les PNJ détectés. Un PNJ revu dans un autre morceau garde la
|
||||
description la plus COMPLÈTE (la plus longue) — un PNJ récurrent est
|
||||
souvent décrit en détail une seule fois."""
|
||||
for npc in npcs_json or []:
|
||||
name = str(npc.get("name", "")).strip()
|
||||
if not name:
|
||||
continue
|
||||
desc = str(npc.get("description") or "").strip()
|
||||
entry = self._npcs.setdefault(name.lower(), {"name": name, "description": ""})
|
||||
if len(desc) > len(entry["description"]):
|
||||
entry["description"] = desc
|
||||
|
||||
def npcs(self) -> list[NpcImportProposal]:
|
||||
return [NpcImportProposal(n["name"], n["description"]) for n in self._npcs.values()]
|
||||
|
||||
@staticmethod
|
||||
def _fill_desc(node: dict, src: dict) -> None:
|
||||
if not node["description"]:
|
||||
node["description"] = str(src.get("description") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _fill_field(node: dict, src: dict, field_name: str) -> None:
|
||||
if not node[field_name]:
|
||||
node[field_name] = str(src.get(field_name) or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _append_field(node: dict, src: dict, field_name: str) -> None:
|
||||
"""Accumule la valeur de `src` à la suite de l'existante (scène coupée
|
||||
entre deux morceaux). Ignore le vide et le contenu déjà présent (un
|
||||
morceau redondant — relecture d'overlap — ne duplique rien)."""
|
||||
new = str(src.get(field_name) or "").strip()
|
||||
if not new:
|
||||
return
|
||||
current = node[field_name]
|
||||
if not current:
|
||||
node[field_name] = new
|
||||
elif new not in current and current not in new:
|
||||
node[field_name] = current + "\n\n" + new
|
||||
elif current in new:
|
||||
# Le nouveau contenu ENGLOBE l'ancien (version plus complète) → on le prend.
|
||||
node[field_name] = new
|
||||
|
||||
def result(self) -> list[ArcProposal]:
|
||||
arcs: list[ArcProposal] = []
|
||||
for a in self._arcs.values():
|
||||
chapters: list[ChapterProposal] = []
|
||||
for c in a["chapters"].values():
|
||||
scenes: list[SceneProposal] = []
|
||||
for s in c["scenes"].values():
|
||||
rooms = [
|
||||
RoomProposal(r["name"], r["description"], r["enemies"], r["loot"])
|
||||
for r in s["rooms"].values()
|
||||
]
|
||||
scenes.append(SceneProposal(
|
||||
s["name"], s["description"], s["player_narration"], s["gm_notes"], rooms))
|
||||
chapters.append(ChapterProposal(c["name"], c["description"], scenes))
|
||||
arcs.append(ArcProposal(a["name"], a["description"], a["type"], chapters))
|
||||
return arcs
|
||||
|
||||
def counts(self) -> tuple[int, int, int]:
|
||||
arcs = len(self._arcs)
|
||||
chapters = sum(len(a["chapters"]) for a in self._arcs.values())
|
||||
scenes = sum(len(c["scenes"]) for a in self._arcs.values() for c in a["chapters"].values())
|
||||
return arcs, chapters, scenes
|
||||
|
||||
# --- Consolidation : fusion des quasi-doublons détectés par le LLM ---------
|
||||
|
||||
def skeleton_text(self) -> str:
|
||||
"""Squelette de l'arbre (noms seuls) — entrée compacte de la consolidation."""
|
||||
lines: list[str] = []
|
||||
for a in self._arcs.values():
|
||||
lines.append(f"ARC: {a['name']}")
|
||||
for c in a["chapters"].values():
|
||||
lines.append(f" CHAPITRE: {c['name']}")
|
||||
for s in c["scenes"].values():
|
||||
lines.append(f" SCENE: {s['name']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def merge_chapters(self, into_name: str, merge_names: list[str]) -> bool:
|
||||
"""Fusionne les chapitres `merge_names` dans `into_name` (tous arcs).
|
||||
|
||||
Best-effort : les noms inconnus sont ignorés. Renvoie True si modifié.
|
||||
"""
|
||||
target = self._find_chapter(into_name)
|
||||
if target is None:
|
||||
return False
|
||||
changed = False
|
||||
for mname in merge_names:
|
||||
key = str(mname).strip().lower()
|
||||
if not key or key == str(into_name).strip().lower():
|
||||
continue
|
||||
for a in self._arcs.values():
|
||||
src = a["chapters"].pop(key, None)
|
||||
if src is None or src is target:
|
||||
continue
|
||||
self._fill_desc(target, src)
|
||||
for skey, sdict in src["scenes"].items():
|
||||
if skey in target["scenes"]:
|
||||
self._merge_scene_into(target["scenes"][skey], sdict)
|
||||
else:
|
||||
target["scenes"][skey] = sdict
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def merge_scenes(self, chapter_name: str, into_name: str, merge_names: list[str]) -> bool:
|
||||
"""Fusionne les scènes `merge_names` dans `into_name` au sein du chapitre."""
|
||||
chapter = self._find_chapter(chapter_name)
|
||||
if chapter is None:
|
||||
return False
|
||||
target = chapter["scenes"].get(str(into_name).strip().lower())
|
||||
if target is None:
|
||||
return False
|
||||
changed = False
|
||||
for mname in merge_names:
|
||||
key = str(mname).strip().lower()
|
||||
if not key or key == str(into_name).strip().lower():
|
||||
continue
|
||||
src = chapter["scenes"].pop(key, None)
|
||||
if src is None or src is target:
|
||||
continue
|
||||
self._merge_scene_into(target, src)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def _find_chapter(self, name: str) -> dict | None:
|
||||
key = str(name).strip().lower()
|
||||
for a in self._arcs.values():
|
||||
if key in a["chapters"]:
|
||||
return a["chapters"][key]
|
||||
return None
|
||||
|
||||
def _merge_scene_into(self, target: dict, src: dict) -> None:
|
||||
self._fill_desc(target, src)
|
||||
self._append_field(target, src, "player_narration")
|
||||
self._append_field(target, src, "gm_notes")
|
||||
for rkey, rdict in src.get("rooms", {}).items():
|
||||
target["rooms"].setdefault(rkey, rdict)
|
||||
|
||||
|
||||
class ImportCampaignUseCase:
|
||||
"""Transforme un PDF de campagne en proposition d'arbre arc→chapitre→scène."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLMProvider,
|
||||
extractor: PdfTextExtractor,
|
||||
chunk_target_tokens: int = _CHUNK_TARGET_TOKENS,
|
||||
map_concurrency: int = 1,
|
||||
) -> None:
|
||||
self._llm = llm
|
||||
self._extractor = extractor
|
||||
self._chunk_target_tokens = chunk_target_tokens
|
||||
# Appels MAP par VAGUES de cette taille : l'ordre narratif est préservé
|
||||
# (fusion vague par vague, dans l'ordre du livre) mais le mur d'attente
|
||||
# des appels LLM est divisé d'autant. 1 = comportement séquentiel.
|
||||
self._map_concurrency = max(1, map_concurrency)
|
||||
|
||||
async def execute(self, pdf_bytes: bytes) -> CampaignImportResult:
|
||||
"""Variante non-streamée : traite tout puis renvoie l'arbre complet."""
|
||||
doc = self._extractor.extract(pdf_bytes)
|
||||
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
|
||||
toc_block = _format_toc(doc.toc)
|
||||
merger = _TreeMerger()
|
||||
total = len(chunks)
|
||||
for start in range(0, total, self._map_concurrency):
|
||||
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
|
||||
results = await asyncio.gather(*(
|
||||
self._map_chunk(c, index=i, total=total, toc_block=toc_block)
|
||||
for i, c in wave
|
||||
))
|
||||
for res in results:
|
||||
merger.add(res["arcs"])
|
||||
merger.add_npcs(res["npcs"])
|
||||
if total > 1:
|
||||
await self._consolidate(merger)
|
||||
return CampaignImportResult(
|
||||
arcs=merger.result(),
|
||||
page_count=doc.page_count,
|
||||
ocr_page_count=doc.ocr_page_count,
|
||||
npcs=merger.npcs(),
|
||||
)
|
||||
|
||||
async def stream(self, pdf_bytes: bytes):
|
||||
"""Variante streamée : yield des évènements d'avancement.
|
||||
|
||||
{"type":"extracting"}, puis {"type":"start", page_count, ocr_page_count,
|
||||
total}, puis un {"type":"progress", current, total, arc_count,
|
||||
chapter_count, scene_count} par morceau, et enfin
|
||||
{"type":"done", arcs:[...], page_count, ocr_page_count}.
|
||||
"""
|
||||
yield {"type": "extracting"}
|
||||
|
||||
doc = self._extractor.extract(pdf_bytes)
|
||||
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
|
||||
toc_block = _format_toc(doc.toc)
|
||||
total = len(chunks)
|
||||
logger.info(
|
||||
"Import campagne (stream) : %s page(s) (%s via OCR), %s morceau(x), TOC %s.",
|
||||
doc.page_count, doc.ocr_page_count, total,
|
||||
"présente" if toc_block else "absente",
|
||||
)
|
||||
yield {
|
||||
"type": "start",
|
||||
"page_count": doc.page_count,
|
||||
"ocr_page_count": doc.ocr_page_count,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
merger = _TreeMerger()
|
||||
skipped = 0
|
||||
last_error: str | None = None
|
||||
done_count = 0
|
||||
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||
status_queue: asyncio.Queue = asyncio.Queue()
|
||||
status_token = set_status_queue(status_queue)
|
||||
try:
|
||||
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
|
||||
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
|
||||
# vague par vague, dans l'ordre du livre.
|
||||
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
|
||||
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
|
||||
# si AUCUN morceau ne passe (cf. après la boucle).
|
||||
# HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
|
||||
# laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
|
||||
for start in range(0, total, self._map_concurrency):
|
||||
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
|
||||
gathered = asyncio.gather(
|
||||
*(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
|
||||
for i, c in wave),
|
||||
return_exceptions=True,
|
||||
)
|
||||
results: list | None = None
|
||||
async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
|
||||
if kind == "heartbeat":
|
||||
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
|
||||
elif kind == "status":
|
||||
yield {"type": "status", "message": payload,
|
||||
"current": done_count + 1, "total": total}
|
||||
else:
|
||||
results = payload
|
||||
for (i, _), res in zip(wave, results or []):
|
||||
done_count += 1
|
||||
if isinstance(res, LLMProviderError):
|
||||
skipped += 1
|
||||
last_error = str(res)
|
||||
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
|
||||
yield {"type": "chunk_failed", "current": i + 1, "total": total,
|
||||
"message": str(res)[:300]}
|
||||
elif isinstance(res, BaseException):
|
||||
raise res # bug inattendu : ne pas l'avaler en silence
|
||||
else:
|
||||
merger.add((res or {}).get("arcs") or [])
|
||||
merger.add_npcs((res or {}).get("npcs") or [])
|
||||
arcs, chapters, scenes = merger.counts()
|
||||
yield {
|
||||
"type": "progress",
|
||||
"current": done_count,
|
||||
"total": total,
|
||||
"arc_count": arcs,
|
||||
"chapter_count": chapters,
|
||||
"scene_count": scenes,
|
||||
"npc_count": len(merger.npcs()),
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
if total > 0 and skipped == total:
|
||||
# Tout a échoué : "done" vide serait trompeur → erreur explicite.
|
||||
yield {"type": "error",
|
||||
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
|
||||
f"Dernier message : {last_error or 'inconnu'}"}
|
||||
return
|
||||
|
||||
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
|
||||
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
|
||||
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
|
||||
# l'utilisateur conclut à tort que le PDF est illisible.
|
||||
yield {"type": "error",
|
||||
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
|
||||
"aucune structure exploitable (réponses JSON vides ou coupées). "
|
||||
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
|
||||
"de contexte (num_ctx) ou essayez un autre modèle."}
|
||||
return
|
||||
|
||||
# Consolidation finale : fusion des quasi-doublons inter-morceaux
|
||||
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
|
||||
if total > 1:
|
||||
yield {"type": "consolidating", "total": total}
|
||||
async for kind, payload in with_heartbeat(
|
||||
self._consolidate(merger), status_queue=status_queue
|
||||
):
|
||||
if kind == "heartbeat":
|
||||
yield {"type": "heartbeat", "current": total, "total": total}
|
||||
elif kind == "status":
|
||||
yield {"type": "status", "message": payload,
|
||||
"current": total, "total": total}
|
||||
|
||||
yield {
|
||||
"type": "done",
|
||||
"arcs": _serialize_arcs(merger.result()),
|
||||
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
|
||||
"page_count": doc.page_count,
|
||||
"ocr_page_count": doc.ocr_page_count,
|
||||
"skipped": skipped,
|
||||
}
|
||||
finally:
|
||||
reset_status_queue(status_token)
|
||||
|
||||
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
|
||||
|
||||
async def _consolidate(self, merger: _TreeMerger) -> None:
|
||||
"""Une passe LLM sur le squelette pour fusionner les quasi-doublons.
|
||||
|
||||
BEST-EFFORT : toute erreur (LLM indisponible, JSON invalide, noms
|
||||
inconnus) laisse l'arbre tel quel — la consolidation ne peut qu'améliorer,
|
||||
jamais bloquer un import.
|
||||
"""
|
||||
_, chapters, scenes = merger.counts()
|
||||
if chapters + scenes < 3:
|
||||
return # rien à dédoublonner sur un arbre minuscule
|
||||
skeleton = merger.skeleton_text()
|
||||
try:
|
||||
raw = await generate_with_retry(
|
||||
self._llm, _CONSOLIDATE_PROMPT.format(skeleton=skeleton),
|
||||
output_format="json", temperature=0.0)
|
||||
except Exception as exc: # noqa: BLE001 — best-effort STRICT : une erreur ici
|
||||
# (LLM, réseau, bug) ne doit JAMAIS faire perdre un import terminé.
|
||||
logger.warning("Consolidation ignorée (échec) : %s", exc)
|
||||
return
|
||||
parsed, _ = load_json_object(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("Consolidation ignorée (réponse non-JSON).")
|
||||
return
|
||||
merged = 0
|
||||
for cm in parsed.get("chapter_merges") or []:
|
||||
if isinstance(cm, dict) and merger.merge_chapters(
|
||||
str(cm.get("into") or ""), list(cm.get("merge") or [])):
|
||||
merged += 1
|
||||
for sm in parsed.get("scene_merges") or []:
|
||||
if isinstance(sm, dict) and merger.merge_scenes(
|
||||
str(sm.get("chapter") or ""), str(sm.get("into") or ""),
|
||||
list(sm.get("merge") or [])):
|
||||
merged += 1
|
||||
if merged:
|
||||
logger.info("Consolidation : %s fusion(s) de quasi-doublons appliquée(s).", merged)
|
||||
|
||||
# --- MAP : un morceau → sous-arbre ---------------------------------------
|
||||
|
||||
async def _map_chunk(
|
||||
self, chunk: str, *, index: int, total: int, toc_block: str = ""
|
||||
) -> dict:
|
||||
"""Phase MAP d'un morceau → {"arcs": [...], "npcs": [...]}."""
|
||||
return await self._extract_payload(
|
||||
chunk, index=index, total=total, depth=0, toc_block=toc_block)
|
||||
|
||||
async def _extract_payload(
|
||||
self, text: str, *, index: int, total: int, depth: int, toc_block: str = ""
|
||||
) -> dict:
|
||||
"""Extrait l'arborescence + les PNJ d'un texte. Si la SORTIE est tronquée,
|
||||
retraite le texte en DEUX moitiés et concatène — le `_TreeMerger` final
|
||||
dédoublonne par nom (un arc/chapitre coupé entre les moitiés est recollé)."""
|
||||
toc_section = _TOC_BLOCK.format(toc=toc_block) if toc_block else ""
|
||||
prompt = (
|
||||
_MAP_SYSTEM.format(default_arc=_DEFAULT_ARC_NAME)
|
||||
+ toc_section
|
||||
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
|
||||
"Renvoie maintenant le JSON de l'arborescence."
|
||||
)
|
||||
try:
|
||||
raw = await generate_with_retry(
|
||||
self._llm, prompt, output_format=_TREE_SCHEMA, temperature=_TEMPERATURE)
|
||||
except LLMGenerationTimeout:
|
||||
# Génération trop lente pour la taille demandée (fréquent en local /
|
||||
# tier gratuit) : même remède que la troncature, deux moitiés →
|
||||
# sortie 2× plus courte. Re-lever si plus découpable.
|
||||
if depth >= _MAX_SPLIT_DEPTH:
|
||||
raise
|
||||
left, right = split_in_half(text)
|
||||
if not left or not right:
|
||||
raise
|
||||
logger.info(
|
||||
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
notify_status(
|
||||
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||
"re-découpage en 2 moitiés plus digestes…")
|
||||
a = await self._extract_payload(
|
||||
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||
b = await self._extract_payload(
|
||||
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
|
||||
payload, truncated = self._parse_payload(raw, index=index)
|
||||
|
||||
if truncated and depth < _MAX_SPLIT_DEPTH:
|
||||
left, right = split_in_half(text)
|
||||
if left and right:
|
||||
logger.info(
|
||||
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
notify_status(
|
||||
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
||||
"re-découpage en 2 moitiés plus digestes…")
|
||||
a = await self._extract_payload(
|
||||
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||
b = await self._extract_payload(
|
||||
right, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||
return {"arcs": a["arcs"] + b["arcs"], "npcs": a["npcs"] + b["npcs"]}
|
||||
if truncated:
|
||||
logger.warning(
|
||||
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _parse_payload(raw: str, *, index: int) -> tuple[dict, bool]:
|
||||
"""Parse robuste → ({"arcs", "npcs"}, tronqué). `tronqué`=True si partiel."""
|
||||
empty = {"arcs": [], "npcs": []}
|
||||
parsed, recovered = load_json_object(raw)
|
||||
if parsed is None:
|
||||
truncated = looks_like_truncated_json(raw)
|
||||
if not truncated:
|
||||
logger.warning(
|
||||
"Morceau %s : aucun objet JSON exploitable, ignoré. "
|
||||
"Début de la réponse du modèle : %r",
|
||||
index, (raw or "").strip()[:300] or "(réponse VIDE)")
|
||||
return empty, truncated
|
||||
if isinstance(parsed, dict):
|
||||
arcs = parsed.get("arcs", [])
|
||||
npcs = parsed.get("npcs", [])
|
||||
return {
|
||||
"arcs": arcs if isinstance(arcs, list) else [],
|
||||
"npcs": npcs if isinstance(npcs, list) else [],
|
||||
}, recovered
|
||||
return empty, recovered
|
||||
|
||||
|
||||
def _serialize_arcs(arcs: list[ArcProposal]) -> list[dict]:
|
||||
"""Sérialise l'arbre de dataclasses en dicts JSON pour le flux SSE."""
|
||||
return [
|
||||
{
|
||||
"name": a.name,
|
||||
"description": a.description,
|
||||
"type": a.arc_type,
|
||||
"chapters": [
|
||||
{
|
||||
"name": c.name,
|
||||
"description": c.description,
|
||||
"scenes": [
|
||||
{
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"player_narration": s.player_narration,
|
||||
"gm_notes": s.gm_notes,
|
||||
"rooms": [
|
||||
{
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"enemies": r.enemies,
|
||||
"loot": r.loot,
|
||||
}
|
||||
for r in s.rooms
|
||||
],
|
||||
}
|
||||
for s in c.scenes
|
||||
],
|
||||
}
|
||||
for c in a.chapters
|
||||
],
|
||||
}
|
||||
for a in arcs
|
||||
]
|
||||
552
brain/app/application/import_rules.py
Normal file
552
brain/app/application/import_rules.py
Normal file
@@ -0,0 +1,552 @@
|
||||
"""Use case : import d'un PDF de règles → sections markdown structurées.
|
||||
|
||||
Couche APPLICATION. Orchestre :
|
||||
PDF (bytes) → extraction texte (port PdfTextExtractor)
|
||||
→ CHUNKING (le texte d'un livre dépasse la fenêtre de contexte)
|
||||
→ MAP : chaque morceau → {titre de section → markdown}
|
||||
→ REDUCE: fusion des sections de même titre entre morceaux
|
||||
→ RulesImportResult (proposition, NON persistée)
|
||||
|
||||
Ne dépend que des abstractions du domaine (ports LLMProvider + PdfTextExtractor)
|
||||
→ testable avec des fakes, et indépendant du provider concret (Ollama/1min.ai).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
|
||||
from app.application.import_status import (
|
||||
notify_status,
|
||||
reset_status_queue,
|
||||
set_status_queue,
|
||||
)
|
||||
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
from app.application.streaming import with_heartbeat
|
||||
|
||||
# Repli anti-troncature : si la SORTIE d'un morceau est coupée (le modèle ne peut
|
||||
# pas tout réécrire en une réponse), on retraite ce morceau en 2 moitiés. Borné en
|
||||
# profondeur pour éviter une récursion infinie (3 niveaux => jusqu'à 8 sous-blocs ;
|
||||
# 1-2 niveaux suffisent en pratique, le reste est un garde-fou).
|
||||
_MAX_SPLIT_DEPTH = 3
|
||||
from app.domain.models import RulesImportResult
|
||||
from app.domain.ports import (
|
||||
LLMGenerationTimeout,
|
||||
LLMProvider,
|
||||
LLMProviderError,
|
||||
PdfTextExtractor,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Température basse : tâche de tri/réécriture fidèle, pas de créativité.
|
||||
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
|
||||
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
|
||||
_TEMPERATURE = 0.1
|
||||
|
||||
# Schéma de la sortie attendue : objet PLAT {titre: markdown}. Passé tel quel à
|
||||
# Ollama (structured outputs : la grammaire interdit physiquement les objets
|
||||
# imbriqués, les clés "thought" à valeur non-string, le bavardage hors JSON…
|
||||
# indispensable pour les petits modèles locaux qui ne suivent pas les consignes).
|
||||
# Les adapters cloud le traduisent en mode JSON natif (json_object).
|
||||
_SECTIONS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
}
|
||||
|
||||
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
|
||||
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
|
||||
# Le modèle reste libre d'en créer d'autres si rien ne correspond.
|
||||
_CANONICAL_SECTIONS = [
|
||||
"Règles générales",
|
||||
"Création de personnage",
|
||||
"Caractéristiques et tests",
|
||||
"Compétences",
|
||||
"Combat",
|
||||
"Magie et sorts",
|
||||
"Équipement et objets",
|
||||
"États et conditions",
|
||||
"Repos et récupération",
|
||||
"Progression et niveaux",
|
||||
"Conseils au Maître de Jeu",
|
||||
]
|
||||
|
||||
_MAP_SYSTEM = """Tu es un assistant qui réorganise un livre de règles de jeu de rôle.
|
||||
On te donne un EXTRAIT brut d'un PDF de règles (texte parfois mal coupé par la mise en page).
|
||||
|
||||
Ta tâche : répartir le contenu de cet extrait dans des SECTIONS THÉMATIQUES.
|
||||
|
||||
Format EXACT attendu — un objet JSON plat {{titre de section: contenu markdown}} :
|
||||
{{"Combat": "## Initiative\\n\\nChaque participant lance 1d20...", "Magie et sorts": "## Sorts\\n\\n..."}}
|
||||
|
||||
Règles impératives :
|
||||
- Tu réponds UNIQUEMENT par cet objet JSON, sans texte avant ni après.
|
||||
- Les CLÉS sont des titres de section (texte court). Les VALEURS sont le contenu de la règle en markdown (chaîne de caractères, jamais un objet ou une liste).
|
||||
- INTERDIT : des clés génériques comme "title", "content", "sections", "thought" ou "notes" ; des objets imbriqués ; tout commentaire sur ta démarche ou ton raisonnement.
|
||||
- Utilise EN PRIORITÉ ces titres canoniques quand le contenu y correspond :
|
||||
{canonical}
|
||||
- Si un contenu ne rentre dans aucun, crée un titre clair et concis (en français).
|
||||
- Reproduis FIDÈLEMENT les règles : tu peux nettoyer la coupure des lignes, recoller les mots coupés
|
||||
par un tiret en fin de ligne, retirer les en-têtes/pieds de page et numéros de page parasites.
|
||||
- N'INVENTE AUCUNE règle, ne résume pas abusivement : tu réorganises, tu ne réécris pas le fond.
|
||||
- Ignore les pages de garde, sommaires, crédits, pages vides (renvoie {{}} si l'extrait n'a aucune règle)."""
|
||||
|
||||
# --- Mode SEGMENTATION (modèles locaux) --------------------------------------
|
||||
# Réécrire tout le texte en JSON impose une SORTIE ≈ taille de l'ENTRÉE : à
|
||||
# ~100 tokens/s en local, un livre = des dizaines de minutes et des troncatures
|
||||
# en cascade. Ici le modèle ne renvoie que les FRONTIÈRES des sections (titre +
|
||||
# premiers mots exacts) — ~200 tokens quel que soit le morceau — et c'est NOUS
|
||||
# qui découpons le texte original. ~50× plus rapide, fidélité parfaite du
|
||||
# contenu (texte source intact), plus de troncature possible.
|
||||
|
||||
_SEGMENT_SYSTEM = """Tu analyses un EXTRAIT brut d'un livre de règles de jeu de rôle.
|
||||
Ta tâche : repérer où COMMENCENT les sections thématiques. Tu ne réécris RIEN.
|
||||
|
||||
Format EXACT attendu :
|
||||
{{"sections": [{{"titre": "Combat", "debut": "Le combat se déroule en tours de"}}, ...]}}
|
||||
|
||||
Règles impératives :
|
||||
- "debut" = les 5 à 10 PREMIERS MOTS du passage où la section commence, COPIÉS À L'IDENTIQUE
|
||||
depuis l'extrait (même orthographe, même ponctuation, même langue). JAMAIS un résumé.
|
||||
- La PREMIÈRE entrée commence aux tout premiers mots de l'extrait (même si le contenu
|
||||
poursuit une section entamée avant cet extrait).
|
||||
- Les entrées suivent l'ordre du texte. Vise des sections LARGES (un thème), pas un titre
|
||||
par paragraphe : un extrait contient typiquement 1 à 6 sections.
|
||||
- Titres : EN PRIORITÉ parmi :
|
||||
{canonical}
|
||||
sinon un titre court et clair en français.
|
||||
- Pages de garde, sommaires, crédits : n'en fais pas des sections. Si l'extrait n'est que ça,
|
||||
renvoie {{"sections": []}}."""
|
||||
|
||||
# Schéma passé à Ollama (structured outputs) : un objet {"sections": [...]}.
|
||||
# Racine objet (pas tableau) car l'extraction côté Brain repère le premier {…}.
|
||||
_ANCHORS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"titre": {"type": "string"},
|
||||
"debut": {"type": "string"},
|
||||
},
|
||||
"required": ["titre", "debut"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["sections"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
class _SectionMerger:
|
||||
"""Fusionne les sections issues des différents morceaux, ordre préservé.
|
||||
|
||||
Titres insensibles à la casse ("Combat" / "combat" → une seule clé). Chaque
|
||||
`add()` renvoie la liste (dé-dupliquée, ordonnée) des titres touchés par ce
|
||||
morceau — sert au flux de progression pour annoncer les sections trouvées.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._merged: dict[str, list[str]] = {}
|
||||
self._canonical_key: dict[str, str] = {}
|
||||
|
||||
def add(self, sections: dict[str, str]) -> list[str]:
|
||||
touched: list[str] = []
|
||||
for title, content in sections.items():
|
||||
title = title.strip()
|
||||
content = (content or "").strip()
|
||||
if not title or not content:
|
||||
continue
|
||||
key = title.lower()
|
||||
if key not in self._canonical_key:
|
||||
self._canonical_key[key] = title
|
||||
self._merged[title] = []
|
||||
canonical = self._canonical_key[key]
|
||||
self._merged[canonical].append(content)
|
||||
touched.append(canonical)
|
||||
# Dé-duplication en préservant l'ordre d'apparition.
|
||||
seen: set[str] = set()
|
||||
return [t for t in touched if not (t in seen or seen.add(t))]
|
||||
|
||||
def result(self) -> dict[str, str]:
|
||||
return {title: "\n\n".join(parts) for title, parts in self._merged.items()}
|
||||
|
||||
|
||||
# Clés "méta" que certains modèles glissent dans le JSON (fuite de raisonnement,
|
||||
# schéma title/content inventé…) : jamais des titres de section voulus.
|
||||
_META_KEYS = frozenset({
|
||||
"thought", "thoughts", "thinking", "reasoning", "raisonnement",
|
||||
"comment", "commentaire", "commentaires", "note", "notes", "explanation",
|
||||
})
|
||||
|
||||
|
||||
def _normalize_sections(parsed: dict) -> dict:
|
||||
"""Ramène les formes déviantes courantes au format attendu {titre: contenu}.
|
||||
|
||||
Observé sur les petits modèles locaux (gemma 12b) malgré les consignes :
|
||||
- enveloppe {"sections": {...}} ou {"règles": {...}} autour du vrai contenu ;
|
||||
- schéma inventé {"title": "...", "content": "...", "thought": "..."} →
|
||||
une seule section dont le titre est la valeur de "title" ;
|
||||
- clés méta ("thought", "notes"…) mêlées aux vraies sections → retirées.
|
||||
"""
|
||||
by_lower = {str(k).strip().lower(): k for k in parsed}
|
||||
# Enveloppe : un unique conteneur connu dont la valeur est l'objet attendu.
|
||||
if len(parsed) == 1:
|
||||
only_key, only_val = next(iter(parsed.items()))
|
||||
if (isinstance(only_val, dict)
|
||||
and str(only_key).strip().lower() in {"sections", "règles", "regles", "rules"}):
|
||||
return _normalize_sections(only_val)
|
||||
# Schéma {"title": ..., "content": ...} : le titre est une VALEUR, pas une clé.
|
||||
if "title" in by_lower and "content" in by_lower:
|
||||
title = str(parsed[by_lower["title"]]).strip()
|
||||
content = parsed[by_lower["content"]]
|
||||
if title and not isinstance(content, dict):
|
||||
return {title: content}
|
||||
return {k: v for k, v in parsed.items()
|
||||
if str(k).strip().lower() not in _META_KEYS}
|
||||
|
||||
|
||||
def _coerce_markdown(value: object) -> str:
|
||||
"""Convertit une valeur de section renvoyée par le LLM en markdown plat.
|
||||
|
||||
Malgré la consigne « valeurs = markdown », certains modèles nichent des
|
||||
sous-sections ({titre: {sous-titre: contenu}}) ou des listes. Un `str(v)`
|
||||
naïf produirait du repr Python ({'k': 'v'}) ; on aplatit récursivement à la
|
||||
place pour ne perdre aucun contenu.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
parts = []
|
||||
for k, v in value.items():
|
||||
content = _coerce_markdown(v)
|
||||
# Clé = sous-titre (cas normal) ; si la "valeur" est vide, la clé
|
||||
# elle-même porte le contenu (dérive observée sur certains modèles).
|
||||
parts.append(f"{k}\n\n{content}".strip() if content else str(k))
|
||||
return "\n\n".join(parts)
|
||||
if isinstance(value, list):
|
||||
return "\n\n".join(_coerce_markdown(v) for v in value)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _find_anchor(text: str, anchor: str, start: int) -> int | None:
|
||||
"""Position de `anchor` dans `text` à partir de `start`, ou None.
|
||||
|
||||
Le modèle recopie les premiers mots d'un passage, mais le texte extrait du
|
||||
PDF contient des sauts de ligne/espaces multiples au même endroit, et le
|
||||
modèle normalise parfois la casse. Trois passes, de la plus stricte à la
|
||||
plus tolérante : exacte → espaces≈\\s+ → idem insensible à la casse."""
|
||||
pos = text.find(anchor, start)
|
||||
if pos != -1:
|
||||
return pos
|
||||
words = anchor.split()
|
||||
if not words:
|
||||
return None
|
||||
pattern = r"\s+".join(re.escape(w) for w in words)
|
||||
match = re.compile(pattern).search(text, start)
|
||||
if match:
|
||||
return match.start()
|
||||
match = re.compile(pattern, re.IGNORECASE).search(text, start)
|
||||
return match.start() if match else None
|
||||
|
||||
|
||||
def _combine_sections(a: dict[str, str], b: dict[str, str]) -> dict[str, str]:
|
||||
"""Fusionne deux dicts de sections (issus des 2 moitiés d'un morceau re-découpé).
|
||||
|
||||
Titres insensibles à la casse : un même titre présent des deux côtés (une section
|
||||
coupée par le re-découpage) voit ses contenus concaténés au lieu d'être écrasés.
|
||||
"""
|
||||
out = dict(a)
|
||||
by_lower = {k.lower(): k for k in out}
|
||||
for title, content in b.items():
|
||||
key = by_lower.get(title.lower())
|
||||
if key is not None:
|
||||
out[key] = f"{out[key]}\n\n{content}".strip()
|
||||
else:
|
||||
out[title] = content
|
||||
by_lower[title.lower()] = title
|
||||
return out
|
||||
|
||||
|
||||
class ImportRulesUseCase:
|
||||
"""Transforme un PDF de règles en proposition de sections markdown."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLMProvider,
|
||||
extractor: PdfTextExtractor,
|
||||
chunk_target_tokens: int = CHUNK_TARGET_TOKENS,
|
||||
segment_only: bool = False,
|
||||
) -> None:
|
||||
"""`segment_only=True` (modèles locaux) : le LLM ne renvoie que les
|
||||
frontières des sections (titre + premiers mots) et le texte original est
|
||||
découpé localement — sortie minuscule, pas de réécriture. False (cloud) :
|
||||
le LLM réécrit le contenu en sections markdown nettoyées."""
|
||||
self._llm = llm
|
||||
self._extractor = extractor
|
||||
self._chunk_target_tokens = chunk_target_tokens
|
||||
self._segment_only = segment_only
|
||||
|
||||
async def execute(self, pdf_bytes: bytes) -> RulesImportResult:
|
||||
"""Variante non-streamée : traite tout puis renvoie le résultat complet."""
|
||||
doc = self._extractor.extract(pdf_bytes)
|
||||
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
|
||||
logger.info(
|
||||
"Import règles : %s page(s) (%s via OCR), %s morceau(x) à traiter.",
|
||||
doc.page_count, doc.ocr_page_count, len(chunks),
|
||||
)
|
||||
merger = _SectionMerger()
|
||||
for i, chunk in enumerate(chunks):
|
||||
merger.add(await self._map_chunk(chunk, index=i, total=len(chunks)))
|
||||
return RulesImportResult(
|
||||
sections=merger.result(),
|
||||
page_count=doc.page_count,
|
||||
ocr_page_count=doc.ocr_page_count,
|
||||
)
|
||||
|
||||
async def stream(self, pdf_bytes: bytes):
|
||||
"""Variante streamée : yield des évènements d'avancement au fil de l'eau.
|
||||
|
||||
Évènements (dicts) : {"type": "extracting"}, puis
|
||||
{"type": "start", page_count, ocr_page_count, total}, puis un
|
||||
{"type": "progress", current, total, new_sections:[...]} par morceau,
|
||||
et enfin {"type": "done", sections, page_count, ocr_page_count}.
|
||||
"""
|
||||
# Émis AVANT l'extraction (potentiellement lente si OCR) pour que l'UI
|
||||
# affiche tout de suite "Extraction…" plutôt qu'un écran figé.
|
||||
yield {"type": "extracting"}
|
||||
|
||||
doc = self._extractor.extract(pdf_bytes)
|
||||
chunks = chunk_text(doc.full_text, self._chunk_target_tokens)
|
||||
total = len(chunks)
|
||||
logger.info(
|
||||
"Import règles (stream) : %s page(s) (%s via OCR), %s morceau(x).",
|
||||
doc.page_count, doc.ocr_page_count, total,
|
||||
)
|
||||
yield {
|
||||
"type": "start",
|
||||
"page_count": doc.page_count,
|
||||
"ocr_page_count": doc.ocr_page_count,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
merger = _SectionMerger()
|
||||
skipped = 0
|
||||
last_error: str | None = None
|
||||
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||
status_queue: asyncio.Queue = asyncio.Queue()
|
||||
status_token = set_status_queue(status_queue)
|
||||
try:
|
||||
for i, chunk in enumerate(chunks):
|
||||
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
|
||||
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
|
||||
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
|
||||
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
|
||||
new_titles: list[str] = []
|
||||
try:
|
||||
sections: dict[str, str] | None = None
|
||||
async for kind, payload in with_heartbeat(
|
||||
self._map_chunk(chunk, index=i, total=total),
|
||||
status_queue=status_queue,
|
||||
):
|
||||
if kind == "heartbeat":
|
||||
yield {"type": "heartbeat", "current": i + 1, "total": total}
|
||||
elif kind == "status":
|
||||
yield {"type": "status", "message": payload,
|
||||
"current": i + 1, "total": total}
|
||||
else:
|
||||
sections = payload
|
||||
new_titles = merger.add(sections or {})
|
||||
except LLMProviderError as exc:
|
||||
skipped += 1
|
||||
last_error = str(exc)
|
||||
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
|
||||
yield {"type": "chunk_failed", "current": i + 1, "total": total,
|
||||
"message": str(exc)[:300]}
|
||||
yield {
|
||||
"type": "progress",
|
||||
"current": i + 1,
|
||||
"total": total,
|
||||
"new_sections": new_titles,
|
||||
"skipped": skipped,
|
||||
}
|
||||
finally:
|
||||
reset_status_queue(status_token)
|
||||
|
||||
if total > 0 and skipped == total:
|
||||
yield {"type": "error",
|
||||
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
|
||||
f"Dernier message : {last_error or 'inconnu'}"}
|
||||
return
|
||||
|
||||
sections = merger.result()
|
||||
if total > 0 and not sections:
|
||||
# Le texte a bien été extrait mais AUCUN morceau n'a produit de JSON
|
||||
# exploitable (sorties coupées/illisibles). Sans ce signal, l'UI reçoit
|
||||
# un `done` vide et l'utilisateur conclut à tort que le PDF est illisible.
|
||||
yield {"type": "error",
|
||||
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
|
||||
"aucune section exploitable (réponses JSON vides ou coupées). "
|
||||
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
|
||||
"de contexte (num_ctx) ou essayez un autre modèle."}
|
||||
return
|
||||
|
||||
yield {
|
||||
"type": "done",
|
||||
"sections": sections,
|
||||
"page_count": doc.page_count,
|
||||
"ocr_page_count": doc.ocr_page_count,
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
# --- MAP : un morceau → sections -----------------------------------------
|
||||
|
||||
async def _map_chunk(self, chunk: str, *, index: int, total: int) -> dict[str, str]:
|
||||
return await self._extract_sections(chunk, index=index, total=total, depth=0)
|
||||
|
||||
async def _extract_sections(
|
||||
self, text: str, *, index: int, total: int, depth: int
|
||||
) -> dict[str, str]:
|
||||
"""Extrait les sections d'un texte. Si la SORTIE est tronquée, retraite le
|
||||
texte en DEUX moitiés (chacune produit une réponse complète) et fusionne —
|
||||
ainsi aucune section n'est perdue, quel que soit le plafond de sortie."""
|
||||
system = _SEGMENT_SYSTEM if self._segment_only else _MAP_SYSTEM
|
||||
schema = _ANCHORS_SCHEMA if self._segment_only else _SECTIONS_SCHEMA
|
||||
prompt = (
|
||||
system.format(
|
||||
canonical="\n".join(f" - {s}" for s in _CANONICAL_SECTIONS)
|
||||
)
|
||||
+ f"\n\n--- EXTRAIT {index + 1}/{total} ---\n{text}\n\n"
|
||||
"Renvoie maintenant le JSON des sections."
|
||||
)
|
||||
try:
|
||||
raw = await generate_with_retry(
|
||||
self._llm, prompt, output_format=schema, temperature=_TEMPERATURE)
|
||||
except LLMGenerationTimeout:
|
||||
# Le modèle générait mais trop lentement pour réécrire tout le morceau
|
||||
# dans le temps imparti (fréquent sur tier gratuit + gros morceaux).
|
||||
# Même remède que la troncature : deux moitiés → sortie 2× plus courte.
|
||||
if depth >= _MAX_SPLIT_DEPTH:
|
||||
raise
|
||||
left, right = split_in_half(text)
|
||||
if not left or not right:
|
||||
raise
|
||||
logger.info(
|
||||
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
notify_status(
|
||||
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||
"re-découpage en 2 moitiés plus digestes…")
|
||||
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
||||
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
||||
return _combine_sections(a, b)
|
||||
if self._segment_only:
|
||||
sections, truncated = self._parse_anchors(raw, text, index=index)
|
||||
else:
|
||||
sections, truncated = self._parse_sections(raw, index=index)
|
||||
|
||||
if truncated and depth < _MAX_SPLIT_DEPTH:
|
||||
left, right = split_in_half(text)
|
||||
if left and right:
|
||||
logger.info(
|
||||
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
||||
index, depth + 1)
|
||||
notify_status(
|
||||
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
||||
"re-découpage en 2 moitiés plus digestes…")
|
||||
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
||||
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
||||
return _combine_sections(a, b)
|
||||
if truncated:
|
||||
logger.warning(
|
||||
"Morceau %s : sortie tronquée, profondeur max atteinte — partiel conservé.", index)
|
||||
return sections
|
||||
|
||||
@staticmethod
|
||||
def _parse_anchors(raw: str, text: str, *, index: int) -> tuple[dict[str, str], bool]:
|
||||
"""Mode segmentation : réponse {"sections": [{titre, debut}, …]} → on localise
|
||||
chaque `debut` dans le texte ORIGINAL et on découpe entre les ancres.
|
||||
|
||||
Une ancre introuvable est abandonnée (son contenu reste dans la section
|
||||
précédente — aucun texte n'est perdu). Le texte avant la première ancre
|
||||
trouvée est rattaché à la première section (le prompt demande au modèle de
|
||||
faire démarrer la première entrée aux premiers mots de l'extrait)."""
|
||||
parsed, recovered = load_json_object(raw)
|
||||
if parsed is None:
|
||||
truncated = looks_like_truncated_json(raw)
|
||||
if not truncated:
|
||||
logger.warning(
|
||||
"Morceau %s : aucun objet JSON exploitable (segmentation), ignoré. "
|
||||
"Début de la réponse du modèle : %r",
|
||||
index, (raw or "").strip()[:300] or "(réponse VIDE)")
|
||||
return {}, truncated
|
||||
entries = parsed.get("sections") if isinstance(parsed, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
logger.warning("Morceau %s : pas de liste 'sections' exploitable, ignoré.", index)
|
||||
return {}, False
|
||||
|
||||
# Localisation séquentielle : chaque ancre est cherchée APRÈS la précédente
|
||||
# (préserve l'ordre du texte, évite qu'une phrase répétée matche trop tôt).
|
||||
located: list[tuple[str, int]] = []
|
||||
cursor = 0
|
||||
dropped = 0
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
title = str(entry.get("titre") or "").strip()
|
||||
anchor = str(entry.get("debut") or "").strip()
|
||||
if not title or not anchor:
|
||||
continue
|
||||
pos = _find_anchor(text, anchor, cursor)
|
||||
if pos is None:
|
||||
dropped += 1
|
||||
continue
|
||||
located.append((title, pos))
|
||||
cursor = pos + 1
|
||||
if dropped:
|
||||
logger.info(
|
||||
"Morceau %s : %s ancre(s) de section introuvable(s) — contenu rattaché "
|
||||
"à la section précédente.", index, dropped)
|
||||
if not located:
|
||||
return {}, False
|
||||
|
||||
# Découpe entre ancres ; le préambule éventuel rejoint la première section.
|
||||
located[0] = (located[0][0], 0)
|
||||
sections: dict[str, str] = {}
|
||||
for i, (title, start) in enumerate(located):
|
||||
end = located[i + 1][1] if i + 1 < len(located) else len(text)
|
||||
content = text[start:end].strip()
|
||||
if not content:
|
||||
continue
|
||||
if title in sections:
|
||||
sections[title] = f"{sections[title]}\n\n{content}"
|
||||
else:
|
||||
sections[title] = content
|
||||
return sections, recovered
|
||||
|
||||
@staticmethod
|
||||
def _parse_sections(raw: str, *, index: int) -> tuple[dict[str, str], bool]:
|
||||
"""Parse robuste → (sections, tronqué). `tronqué`=True si récupération partielle."""
|
||||
parsed, recovered = load_json_object(raw)
|
||||
if parsed is None:
|
||||
# Rien d'exploitable : soit prose (échec), soit JSON coupé avant toute
|
||||
# structure complète (→ on signalera 'tronqué' pour re-découper).
|
||||
truncated = looks_like_truncated_json(raw)
|
||||
if not truncated:
|
||||
logger.warning(
|
||||
"Morceau %s : aucun objet JSON exploitable, ignoré. "
|
||||
"Début de la réponse du modèle : %r",
|
||||
index, (raw or "").strip()[:300] or "(réponse VIDE)")
|
||||
return {}, truncated
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning("Morceau %s : le LLM n'a pas renvoyé un objet, ignoré.", index)
|
||||
return {}, False
|
||||
normalized = _normalize_sections(parsed)
|
||||
return {str(k): _coerce_markdown(v) for k, v in normalized.items()}, recovered
|
||||
39
brain/app/application/import_status.py
Normal file
39
brain/app/application/import_status.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Canal de statut des imports : remonte à l'UI ce qui n'existait qu'en logs.
|
||||
|
||||
Problème résolu : pendant un import, les événements internes (retry parce que
|
||||
le fournisseur IA est saturé, re-découpage d'un morceau trop gros…) n'étaient
|
||||
visibles que dans les logs Docker. L'utilisateur voyait une barre de
|
||||
progression figée sans explication.
|
||||
|
||||
Mécanisme : le flux d'import (use case `stream()`) installe une Queue dans une
|
||||
ContextVar ; les couches profondes (retry LLM, re-découpage) y publient des
|
||||
messages via `notify_status()` sans connaître le flux SSE. La ContextVar est
|
||||
propagée automatiquement aux tâches asyncio enfants → chaque import concurrent
|
||||
a SA queue, sans couplage ni paramètre à faire transiter partout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextvars import ContextVar, Token
|
||||
|
||||
_QUEUE: ContextVar[asyncio.Queue | None] = ContextVar("import_status_queue", default=None)
|
||||
|
||||
|
||||
def set_status_queue(queue: asyncio.Queue | None) -> Token:
|
||||
"""Installe la queue de statut pour le contexte courant (et ses tâches filles).
|
||||
|
||||
Renvoie le token à passer à `reset_status_queue` en fin d'import.
|
||||
"""
|
||||
return _QUEUE.set(queue)
|
||||
|
||||
|
||||
def reset_status_queue(token: Token) -> None:
|
||||
_QUEUE.reset(token)
|
||||
|
||||
|
||||
def notify_status(message: str) -> None:
|
||||
"""Publie un message de statut si un import écoute. No-op sinon (appels
|
||||
LLM hors import : chat, génération de page…)."""
|
||||
queue = _QUEUE.get()
|
||||
if queue is not None:
|
||||
queue.put_nowait(message)
|
||||
160
brain/app/application/llm_json.py
Normal file
160
brain/app/application/llm_json.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Extraction robuste d'un objet JSON depuis une réponse LLM.
|
||||
|
||||
Les LLM enrobent souvent leur JSON : fences markdown ```json … ```, texte
|
||||
d'introduction, commentaire de fin, voire un 2e objet. Un simple
|
||||
`json.loads(raw)` ou un `raw[first_brace:last_brace]` échoue dans ces cas
|
||||
("Extra data", accolade parasite dans une string, etc.).
|
||||
|
||||
Cette fonction scanne depuis la PREMIÈRE `{` et renvoie exactement le premier
|
||||
objet `{…}` ÉQUILIBRÉ, en ignorant les accolades à l'intérieur des chaînes JSON
|
||||
et tout ce qui suit. Renvoie None si aucun objet complet n'est trouvé
|
||||
(sortie tronquée / accolades non refermées).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
# Blocs de "réflexion" des modèles raisonneurs (Nemotron, DeepSeek-R1, QwQ…).
|
||||
# Leur contenu est de la prose truffée d'accolades qui piège le détecteur de JSON
|
||||
# (et n'est jamais la réponse) → on le retire avant toute analyse.
|
||||
_REASONING_RE = re.compile(r"<think(?:ing)?>.*?</think(?:ing)?>", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
def _strip_reasoning(raw: str) -> str:
|
||||
return _REASONING_RE.sub("", raw)
|
||||
|
||||
|
||||
def load_json_object(raw: str) -> tuple[object | None, bool]:
|
||||
"""Parse un objet JSON depuis une réponse LLM, avec récupération si tronqué.
|
||||
|
||||
Renvoie (objet_parsé, récupéré_partiellement) :
|
||||
- d'abord on tente le 1er objet complet (extract_json_object) ;
|
||||
- sinon on tente une réparation du JSON tronqué (repair_truncated_json),
|
||||
auquel cas le second élément vaut True.
|
||||
(None, False) si rien d'exploitable.
|
||||
"""
|
||||
raw = _strip_reasoning(raw)
|
||||
obj = extract_json_object(raw)
|
||||
if obj is not None:
|
||||
try:
|
||||
# strict=False : tolère les caractères de contrôle BRUTS (retours à la
|
||||
# ligne non échappés…) dans les chaînes — erreur fréquente des LLM hors
|
||||
# mode JSON natif, qui invalidait toute la réponse.
|
||||
return json.loads(obj, strict=False), False
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
repaired = repair_truncated_json(raw)
|
||||
if repaired is not None:
|
||||
try:
|
||||
return json.loads(repaired, strict=False), True
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None, False
|
||||
|
||||
|
||||
def looks_like_truncated_json(raw: str) -> bool:
|
||||
"""La sortie ressemble-t-elle à un JSON COUPÉ (accolades/crochets non refermés)
|
||||
plutôt qu'à de la prose ? Sert à déclencher un re-découpage même quand RIEN n'a
|
||||
pu être récupéré (cas où le 1er contenu est si long qu'il est coupé avant toute
|
||||
sous-structure complète).
|
||||
|
||||
Une réponse qui COMMENCE par `{` est jugée sur le seul équilibre des accolades,
|
||||
même très courte : en mode JSON un `{"` de 2 caractères est une génération
|
||||
interrompue net (contexte plein, plafond de sortie), pas de la prose — c'est le
|
||||
signal de re-découpage. Pour le reste (prose contenant des accolades), on exige
|
||||
un contenu substantiel pour éviter les faux positifs."""
|
||||
s = _strip_reasoning(raw or "").strip()
|
||||
if "{" not in s:
|
||||
return False
|
||||
unbalanced = s.count("{") > s.count("}") or s.count("[") > s.count("]")
|
||||
if s.startswith("{"):
|
||||
return unbalanced
|
||||
return len(s) >= 100 and unbalanced
|
||||
|
||||
|
||||
def extract_json_object(raw: str) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
text = raw.strip()
|
||||
start = text.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for i in range(start, len(text)):
|
||||
c = text[i]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif c == "\\":
|
||||
escape = True
|
||||
elif c == '"':
|
||||
in_string = False
|
||||
else:
|
||||
if c == '"':
|
||||
in_string = True
|
||||
elif c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start : i + 1]
|
||||
return None # accolades non refermées (réponse probablement tronquée)
|
||||
|
||||
|
||||
# Fermeture correspondante de chaque ouvrant, pour reconstituer un JSON tronqué.
|
||||
_CLOSE_OF = {"{": "}", "[": "]"}
|
||||
|
||||
|
||||
def repair_truncated_json(raw: str) -> str | None:
|
||||
"""Répare un JSON COUPÉ (sortie LLM tronquée) en gardant les éléments complets.
|
||||
|
||||
On scanne depuis la première `{` et on retient le DERNIER point où un conteneur
|
||||
(`}` ou `]`) vient de se fermer — donc juste après une sous-structure complète
|
||||
(un arc / chapitre / scène / pièce / section entièrement écrit). On coupe là et
|
||||
on referme les conteneurs encore ouverts. L'élément en cours d'écriture au moment
|
||||
de la troncature est abandonné, mais tous les précédents sont sauvés.
|
||||
|
||||
Renvoie une chaîne JSON équilibrée (à valider par json.loads) ou None.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
text = raw.strip()
|
||||
start = text.find("{")
|
||||
if start == -1:
|
||||
return None
|
||||
|
||||
stack: list[str] = []
|
||||
in_string = False
|
||||
escape = False
|
||||
best_cut = -1 # index (exclusif) où couper
|
||||
best_closing = "" # fermetures à ajouter pour rééquilibrer
|
||||
|
||||
for i in range(start, len(text)):
|
||||
c = text[i]
|
||||
if in_string:
|
||||
if escape:
|
||||
escape = False
|
||||
elif c == "\\":
|
||||
escape = True
|
||||
elif c == '"':
|
||||
in_string = False
|
||||
else:
|
||||
if c == '"':
|
||||
in_string = True
|
||||
elif c in "{[":
|
||||
stack.append(c)
|
||||
elif c in "}]":
|
||||
if stack:
|
||||
stack.pop()
|
||||
# Point de coupe sûr : on vient de fermer une sous-structure complète.
|
||||
best_cut = i + 1
|
||||
best_closing = "".join(_CLOSE_OF[b] for b in reversed(stack))
|
||||
|
||||
if best_cut == -1:
|
||||
return None # rien de complet à sauver
|
||||
head = text[start:best_cut].rstrip().rstrip(",")
|
||||
return head + best_closing
|
||||
117
brain/app/application/llm_retry.py
Normal file
117
brain/app/application/llm_retry.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Retry avec backoff pour les appels LLM one-shot (imports).
|
||||
|
||||
Les imports enchaînent de nombreux appels en série ; un échec TRANSITOIRE sur un
|
||||
seul morceau (503/502 surcharge serveur, 504/524 passerelle, timeout réseau) ne
|
||||
doit pas faire échouer tout l'import. On réessaie quelques fois avec une attente
|
||||
croissante. Après épuisement, on relaie l'erreur (problème durable : quota, panne).
|
||||
|
||||
Réservé aux appels `generate` (one-shot, bufferisé) : réessayer est propre, sans
|
||||
risque de doublons. À NE PAS utiliser sur le streaming (re-jouerait des tokens).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.application.import_status import notify_status
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 3 tentatives : assez pour absorber un hoquet transitoire, sans s'acharner des
|
||||
# minutes sur un modèle durablement lent/saturé (les heartbeats gardent le flux
|
||||
# vivant, mais inutile de faire patienter l'utilisateur 15 min pour rien).
|
||||
_ATTEMPTS = 3
|
||||
_BASE_DELAY_SECONDS = 3.0
|
||||
# Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus
|
||||
# longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min).
|
||||
_RATE_LIMIT_DELAYS = [10.0, 25.0, 45.0]
|
||||
|
||||
|
||||
def _is_rate_limit(exc: LLMProviderError) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return "429" in msg or "rate" in msg or "too many requests" in msg
|
||||
|
||||
|
||||
def _is_daily_quota(exc: LLMProviderError) -> bool:
|
||||
"""Limite PAR JOUR (vs par minute) : réessayer est inutile, elle ne se libère
|
||||
qu'au reset quotidien. OpenRouter le précise dans le corps du 429."""
|
||||
msg = str(exc).lower()
|
||||
return "per-day" in msg or "per day" in msg or "free-models-per-day" in msg
|
||||
|
||||
|
||||
# OpenRouter renvoie souvent le délai conseillé (saturation amont) :
|
||||
# "retry_after_seconds": 8 ou "Retry-After": "8". On le respecte plutôt que
|
||||
# d'attendre une durée fixe arbitraire.
|
||||
_RETRY_AFTER_RE = re.compile(r'retry[_-]?after(?:_seconds)?"?\s*:\s*"?([0-9]+(?:\.[0-9]+)?)', re.IGNORECASE)
|
||||
|
||||
|
||||
def _suggested_retry_after(exc: LLMProviderError) -> float | None:
|
||||
match = _RETRY_AFTER_RE.search(str(exc))
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
return float(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def generate_with_retry(
|
||||
llm: LLMProvider,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | dict | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
|
||||
|
||||
Backoff plus long pour les 429 (rate limit) afin de laisser la fenêtre se
|
||||
libérer. Nombre de tentatives borné : si le quota est durablement épuisé
|
||||
(ex. limite/jour), l'erreur finit par remonter au lieu de boucler sans fin.
|
||||
"""
|
||||
delay = _BASE_DELAY_SECONDS
|
||||
last_error: LLMProviderError | None = None
|
||||
for attempt in range(_ATTEMPTS):
|
||||
try:
|
||||
return await llm.generate(prompt, output_format=output_format, temperature=temperature)
|
||||
except LLMGenerationTimeout:
|
||||
# Timeout de DÉBIT (génération trop lente pour la sortie demandée) :
|
||||
# rejouer le même prompt re-timeoutera à l'identique — on a déjà perdu
|
||||
# `timeout` secondes. On remonte tout de suite : l'appelant (import)
|
||||
# sait re-découper le morceau en deux pour réduire la sortie.
|
||||
raise
|
||||
except LLMProviderError as exc:
|
||||
last_error = exc
|
||||
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
|
||||
# (sinon on enchaîne des attentes longues pour rien, et on spamme l'API).
|
||||
if _is_daily_quota(exc):
|
||||
logger.warning("Quota journalier du fournisseur épuisé — abandon : %s", exc)
|
||||
raise
|
||||
if attempt < _ATTEMPTS - 1:
|
||||
if _is_rate_limit(exc):
|
||||
suggested = _suggested_retry_after(exc)
|
||||
if suggested is not None:
|
||||
# Indication serveur (saturation amont) + petite marge, plafonnée.
|
||||
wait = min(suggested + 2.0, 60.0)
|
||||
else:
|
||||
wait = _RATE_LIMIT_DELAYS[min(attempt, len(_RATE_LIMIT_DELAYS) - 1)]
|
||||
else:
|
||||
wait = delay
|
||||
delay *= 2
|
||||
logger.warning(
|
||||
"Appel LLM échoué (tentative %s/%s)%s : %s — nouvelle tentative dans %ss.",
|
||||
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
|
||||
exc, wait,
|
||||
)
|
||||
# Remonte aussi l'info à l'UI (flux d'import) : sans ça l'utilisateur
|
||||
# voit une barre figée sans savoir que le fournisseur est saturé.
|
||||
notify_status(
|
||||
("Fournisseur IA saturé (rate limit)" if _is_rate_limit(exc)
|
||||
else "Appel IA échoué")
|
||||
+ f" — tentative {attempt + 1}/{_ATTEMPTS}, nouvel essai dans {int(wait)}s. "
|
||||
+ str(exc)[:160]
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
155
brain/app/application/notebook_chat.py
Normal file
155
brain/app/application/notebook_chat.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Use case : chat ANCRÉ sur les sources d'un notebook (RAG).
|
||||
|
||||
À chaque message, on retrouve les passages pertinents des sources (via le RAG) et
|
||||
on les injecte dans le prompt système, en plus du contexte de campagne. Le modèle
|
||||
répond donc en s'appuyant sur la/les source(s) — pas sur ses connaissances générales.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import AsyncIterator
|
||||
|
||||
from app.application.notebook_rag import NotebookRagUseCase
|
||||
from app.application.query_rewrite import standalone_question
|
||||
from app.application.rerank import pool_size, rerank
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMChatProvider
|
||||
|
||||
_SYSTEM_PROMPT = """Tu es un assistant de jeu de rôle qui aide à ADAPTER une source (PDF) à la CAMPAGNE de l'utilisateur.
|
||||
|
||||
Tu disposes de DEUX connaissances, toutes deux ci-dessous :
|
||||
1) LA CAMPAGNE de l'utilisateur (sa structure arcs/chapitres/scènes, ses PNJ, son univers) ;
|
||||
2) LA SOURCE (extraits pertinents du PDF).
|
||||
|
||||
Règles :
|
||||
- Pour une question sur SA CAMPAGNE (ex. « mon chapitre 3 », « mes PNJ »), appuie-toi sur la section CAMPAGNE.
|
||||
- Pour une question sur le livre, appuie-toi sur les EXTRAITS DE LA SOURCE.
|
||||
- CROISE les deux pour proposer des adaptations cohérentes avec sa campagne existante.
|
||||
- N'invente pas ce qui ne figure ni dans la campagne ni dans la source ; si tu ne sais pas, dis-le.
|
||||
- Quand un extrait porte un numéro de page (« (p. 12) »), cite-le (« d'après la p. 12 »).
|
||||
|
||||
{context_block}
|
||||
--- EXTRAITS PERTINENTS DE LA SOURCE ---
|
||||
{sources_block}
|
||||
--- FIN DES EXTRAITS ---
|
||||
|
||||
PROPOSITIONS D'INTÉGRATION (IMPORTANT) :
|
||||
Quand l'utilisateur veut CRÉER ou ADAPTER un élément concret pour sa campagne (un PNJ,
|
||||
une scène, un chapitre, une quête, un arc, une table aléatoire), termine ta réponse par
|
||||
un ou plusieurs BLOCS D'ACTION — un objet JSON par bloc, dans une clôture
|
||||
```loremind-action. L'interface les transformera en boutons « Créer dans la campagne ».
|
||||
Si l'utilisateur demande PLUSIEURS éléments (« propose-moi 3 quêtes »), produis UN bloc
|
||||
par élément. N'en mets pas si l'utilisateur pose une simple question.
|
||||
|
||||
VOCABULAIRE DE LA CAMPAGNE : une « quête » n'est PAS un type à part — c'est un CHAPITRE
|
||||
rangé dans un arc de type HUB (quêtes parallèles, sans ordre imposé), tandis qu'un arc
|
||||
LINEAR contient des chapitres joués en séquence. Donc :
|
||||
- demande de QUÊTE → action "chapter" (l'utilisateur la placera dans son arc HUB) ;
|
||||
s'il n'a aucun arc HUB dans sa campagne, propose AUSSI une action "arc" avec
|
||||
"arcType": "HUB" pour les accueillir.
|
||||
- demande de CHAPITRE → action "chapter" (destinée plutôt à un arc LINEAR).
|
||||
|
||||
RÈGLE CLÉ : remplis TOUS les champs pour lesquels tu as de la matière — pas seulement
|
||||
le résumé ou les notes MJ. Chaque champ rempli atterrit au bon endroit de la fiche ;
|
||||
un champ laissé vide est une fiche que l'utilisateur devra compléter à la main. Vise
|
||||
2 à 5 phrases concrètes par champ narratif, tirées de la source et de la campagne.
|
||||
Omets simplement un champ si tu n'as rien de précis à y mettre. Formats acceptés :
|
||||
|
||||
```loremind-action
|
||||
{{"type": "npc", "name": "Nom",
|
||||
"description": "Résumé du PNJ (rôle, apparence, motivation).",
|
||||
"values": {{"<champ de la fiche PNJ>": "contenu", "<autre champ>": "contenu"}}}}
|
||||
```
|
||||
(`values` : utilise comme clés les CHAMPS DE LA FICHE PNJ listés dans le contexte
|
||||
campagne s'ils y figurent — ex. "Histoire", "Apparence" — sinon omets `values`.)
|
||||
|
||||
```loremind-action
|
||||
{{"type": "scene", "name": "Nom",
|
||||
"description": "Résumé court de la scène.",
|
||||
"location": "Lieu précis", "timing": "Quand elle survient",
|
||||
"atmosphere": "Ambiance sensorielle (sons, odeurs, lumière…)",
|
||||
"playerNarration": "Texte d'ambiance À LIRE AUX JOUEURS, immersif, à la 2e personne.",
|
||||
"gmSecretNotes": "Secrets, vérités cachées, notes pour le MJ uniquement.",
|
||||
"choicesConsequences": "Choix offerts aux joueurs et leurs conséquences.",
|
||||
"combatDifficulty": "Difficulté du combat éventuel", "enemies": "Ennemis présents (effectifs, tactiques)"}}
|
||||
```
|
||||
```loremind-action
|
||||
{{"type": "chapter", "name": "Nom",
|
||||
"description": "Résumé du chapitre (ou de la quête).",
|
||||
"playerObjectives": "Objectifs tels que les joueurs les perçoivent.",
|
||||
"narrativeStakes": "Enjeux narratifs (ce qui se joue vraiment).",
|
||||
"gmNotes": "Notes MJ : fils à tirer, points d'attention."}}
|
||||
```
|
||||
```loremind-action
|
||||
{{"type": "arc", "name": "Nom", "description": "Résumé", "arcType": "LINEAR",
|
||||
"themes": "Thèmes de l'arc", "stakes": "Enjeux",
|
||||
"rewards": "Récompenses attendues", "resolution": "Issues possibles",
|
||||
"gmNotes": "Notes MJ."}}
|
||||
```
|
||||
(`arcType` : "LINEAR" pour des chapitres en séquence, "HUB" pour un recueil de
|
||||
quêtes parallèles.)
|
||||
```loremind-action
|
||||
{{"type": "table", "name": "Nom", "diceFormula": "1d8", "entries": [{{"minRoll":1,"maxRoll":4,"label":"...","detail":"..."}}]}}
|
||||
```
|
||||
|
||||
Réponds en français, de façon utile et concise. Mets le texte explicatif AVANT les blocs d'action."""
|
||||
|
||||
|
||||
class NotebookChatUseCase:
|
||||
def __init__(
|
||||
self, rag: NotebookRagUseCase, llm: LLMChatProvider, rerank_enabled: bool = False
|
||||
) -> None:
|
||||
self._rag = rag
|
||||
self._llm = llm
|
||||
# Reranking LLM d'un pool élargi avant injection (voir app.application.rerank).
|
||||
self._rerank_enabled = rerank_enabled
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
source_ids: list[str],
|
||||
messages: list[ChatMessage],
|
||||
context: str = "",
|
||||
top_k: int = 6,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Yield des évènements : {type:'sources', sources:[…]} (une fois, avant la
|
||||
réponse — transparence sur les passages utilisés), puis {type:'token', token}."""
|
||||
# Question AUTONOME pour la recherche : sur une relance (« et ses
|
||||
# faiblesses ? »), l'embedding du dernier message seul ne contient pas
|
||||
# le sujet → on le résout depuis l'historique (best-effort, 1 appel léger,
|
||||
# uniquement à partir du 2e tour). La réponse, elle, voit tout l'historique.
|
||||
search_query = await standalone_question(self._llm, messages)
|
||||
if self._rerank_enabled:
|
||||
# Pool élargi → notation LLM → top_k final (meilleure précision sur
|
||||
# les questions ambiguës, au prix d'un appel avant le premier token).
|
||||
pool = await self._rag.retrieve(
|
||||
source_ids, search_query, top_k=pool_size(top_k))
|
||||
passages = await rerank(self._llm, search_query, pool, top_k)
|
||||
else:
|
||||
passages = await self._rag.retrieve(source_ids, search_query, top_k=top_k)
|
||||
# Évènement 'sources' AVANT le premier token : l'UI peut afficher les
|
||||
# pages utilisées (« 📖 p. 12, 47 ») dès le début de la réponse.
|
||||
yield {"type": "sources", "sources": [
|
||||
{
|
||||
"source_id": p.get("source_id"),
|
||||
"page": p.get("page"),
|
||||
"score": round(float(p.get("score") or 0.0), 3),
|
||||
}
|
||||
for p in passages
|
||||
]}
|
||||
sources_block = (
|
||||
"\n\n".join(self._format_passage(p) for p in passages)
|
||||
if passages else "(aucun passage pertinent trouvé dans les sources)"
|
||||
)
|
||||
context_block = (
|
||||
f"--- TA CAMPAGNE ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
|
||||
if context.strip() else "--- TA CAMPAGNE ---\n(aucune donnée de campagne)\n--- FIN CAMPAGNE ---\n\n"
|
||||
)
|
||||
system_prompt = _SYSTEM_PROMPT.format(
|
||||
context_block=context_block, sources_block=sources_block)
|
||||
async for token in self._llm.stream_chat(messages, system_prompt=system_prompt):
|
||||
yield {"type": "token", "token": token}
|
||||
|
||||
@staticmethod
|
||||
def _format_passage(p: dict) -> str:
|
||||
page = p.get("page")
|
||||
prefix = f"(p. {page}) " if page else ""
|
||||
return f"• {prefix}{p['text'].strip()}"
|
||||
285
brain/app/application/notebook_deep.py
Normal file
285
brain/app/application/notebook_deep.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Use case « Analyse approfondie » d'un notebook : map-reduce sur TOUT le document.
|
||||
|
||||
Contrairement au chat RAG (qui ne ramène que les top-k extraits), ce mode lit
|
||||
l'INTÉGRALITÉ des sources par lots :
|
||||
- MAP : pour chaque lot, le modèle extrait ce qui est pertinent pour la question
|
||||
(ou « RAS » si rien) ;
|
||||
- REDUCE : il synthétise toutes les notes en une réponse finale (streamée).
|
||||
|
||||
→ Répond aux questions globales/exhaustives (« liste tous les… ») quel que soit le
|
||||
modèle, au prix de plusieurs appels (comme l'import). Le lot est dimensionné par
|
||||
`batch_tokens` (= taille de morceau d'import) : avec un modèle gros-contexte, peu de
|
||||
lots ; avec un petit modèle local, plus de lots (mais ça reste exhaustif).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import tiktoken
|
||||
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
from app.application.query_rewrite import standalone_question
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMChatProvider, LLMProvider, LLMProviderError
|
||||
from app.infrastructure import vector_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NO_MATCH = "RAS"
|
||||
_MAP_TEMPERATURE = 0.2
|
||||
|
||||
# --- Index de résumés (pré-filtrage des lots) --------------------------------
|
||||
# Sans index : CHAQUE question relit TOUT le document (1 appel LLM par lot).
|
||||
# Avec : les résumés de lots (construits UNE fois, cache disque) sont comparés
|
||||
# à la question par embedding, et seuls les lots plausiblement pertinents sont
|
||||
# relus. Sélection volontairement CONSERVATRICE (on préfère relire un lot de
|
||||
# trop que rater une mention) ; désactivable via deep_summary_filter=False.
|
||||
_SUMMARY_PROMPT = """Résume l'EXTRAIT ci-dessous en 4 à 8 puces factuelles : lieux, PNJ et
|
||||
créatures nommés, objets notables, évènements, règles particulières. Pas d'analyse, pas
|
||||
d'introduction — uniquement les puces, pour servir d'index de recherche.
|
||||
|
||||
--- EXTRAIT ---
|
||||
{excerpt}
|
||||
--- FIN EXTRAIT ---
|
||||
|
||||
Résumé :"""
|
||||
|
||||
# Un lot est gardé si son score est proche du meilleur (marge) OU bon dans
|
||||
# l'absolu ; et on garde toujours au moins _MIN_KEPT lots.
|
||||
_SELECT_MARGIN = 0.10
|
||||
_SELECT_FLOOR = 0.5
|
||||
_MIN_KEPT = 3
|
||||
|
||||
_MAP_PROMPT = """Voici un EXTRAIT d'un document. Extrais UNIQUEMENT les informations
|
||||
pertinentes pour répondre à la question ci-dessous. Conserve les détails utiles et
|
||||
indique les numéros de page (format « p. X »). Si l'extrait ne contient RIEN de
|
||||
pertinent, réponds EXACTEMENT « {no_match} » et rien d'autre.
|
||||
|
||||
QUESTION : {question}
|
||||
|
||||
--- EXTRAIT ---
|
||||
{excerpt}
|
||||
--- FIN EXTRAIT ---
|
||||
|
||||
Informations pertinentes (ou « {no_match} ») :"""
|
||||
|
||||
_REDUCE_SYSTEM = """Tu es l'assistant-MJ d'un jeu de rôle. Tu réponds à la demande du MJ en
|
||||
t'appuyant sur TROIS sources : (1) des NOTES extraites de l'ENSEMBLE du document source (vue
|
||||
complète — mais POSSIBLEMENT VIDE si rien d'utile n'y figure), (2) le contexte de sa CAMPAGNE,
|
||||
(3) la conversation ci-dessous.
|
||||
|
||||
- Si les notes contiennent des éléments utiles : exploite-les et CITE les pages (« p. X »).
|
||||
- Si les notes sont VIDES ou pauvres (cas fréquent d'une demande CRÉATIVE portant sur des
|
||||
éléments INVENTÉS par le MJ) : ne te bloque surtout PAS. Aide-le quand même en t'appuyant
|
||||
sur sa CAMPAGNE, la CONVERSATION et ta connaissance du genre — propose des adaptations
|
||||
concrètes (arcs, chapitres, scènes, PNJ), structurées et jouables.
|
||||
- Sois concret et utile. N'affirme rien de FAUX sur le contenu du document.
|
||||
|
||||
{context_block}
|
||||
--- NOTES EXTRAITES DE TOUT LE DOCUMENT ---
|
||||
{notes_block}
|
||||
--- FIN DES NOTES ---
|
||||
|
||||
Réponds en français."""
|
||||
|
||||
|
||||
class NotebookDeepUseCase:
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLMProvider,
|
||||
batch_tokens: int = 10000,
|
||||
map_concurrency: int = 1,
|
||||
embedder=None,
|
||||
summary_filter: bool = True,
|
||||
) -> None:
|
||||
self._llm = llm
|
||||
self._batch_tokens = max(2000, batch_tokens)
|
||||
# Lots MAP traités par vagues de cette taille (parallélisme LLM).
|
||||
self._map_concurrency = max(1, map_concurrency)
|
||||
# EmbeddingProvider (duck typing) pour l'index de résumés ; None = pas
|
||||
# de pré-filtrage (plein scan, comportement historique).
|
||||
self._embedder = embedder
|
||||
self._summary_filter = summary_filter
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
source_ids: list[str],
|
||||
messages: list[ChatMessage],
|
||||
context: str = "",
|
||||
history_limit: int = 8,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Yield des évènements : {type:'progress',current,total}, {type:'token',token},
|
||||
{type:'done'}. (Les erreurs LLM des lots sont tolérées : lot ignoré.)
|
||||
|
||||
La dernière question utilisateur sert à la LECTURE du document (map) ; la
|
||||
SYNTHÈSE (reduce) reçoit les `history_limit` derniers messages → les relances
|
||||
conversationnelles (« et pour les autres ? ») fonctionnent aussi en approfondi.
|
||||
"""
|
||||
# Question autonome : la phase MAP lit chaque lot avec LA question — sur
|
||||
# une relance conversationnelle, il faut y résoudre les références
|
||||
# implicites, sinon les lots sont filtrés sur un texte sans sujet.
|
||||
question = await standalone_question(self._llm, messages)
|
||||
# Lots PAR SOURCE (l'index de résumés est caché par source).
|
||||
per_source: list[tuple[str, list[dict]]] = []
|
||||
for sid in source_ids:
|
||||
chunks = vector_store.all_chunks(sid)
|
||||
for batch in self._group(chunks):
|
||||
per_source.append((sid, batch))
|
||||
if not per_source:
|
||||
yield {"type": "token", "token": "Aucune source indexée à analyser."}
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# Pré-filtrage par index de résumés (best-effort : tout échec → plein scan).
|
||||
selected: set[int] | None = None
|
||||
if self._summary_filter and self._embedder is not None:
|
||||
try:
|
||||
async for ev_or_result in self._select_batches(per_source, question):
|
||||
if isinstance(ev_or_result, dict):
|
||||
yield ev_or_result # progress de construction de l'index
|
||||
else:
|
||||
selected = ev_or_result
|
||||
except Exception as exc: # noqa: BLE001 — le filtre ne doit jamais bloquer
|
||||
logger.warning("Index de résumés ignoré (échec) : %s", exc)
|
||||
selected = None
|
||||
if selected is not None:
|
||||
logger.info(
|
||||
"Analyse approfondie : %s/%s lot(s) retenus via l'index de résumés.",
|
||||
len(selected), len(per_source))
|
||||
|
||||
indices = sorted(selected) if selected is not None else list(range(len(per_source)))
|
||||
total = len(indices)
|
||||
notes: list[str] = []
|
||||
# Lots traités par VAGUES parallèles ; les notes restent dans l'ordre du
|
||||
# document (gather préserve l'ordre des tâches de la vague).
|
||||
for start in range(0, total, self._map_concurrency):
|
||||
yield {"type": "progress", "current": start, "total": total}
|
||||
wave = indices[start:start + self._map_concurrency]
|
||||
results = await asyncio.gather(
|
||||
*(self._map_batch(question, per_source[i][1]) for i in wave),
|
||||
return_exceptions=True)
|
||||
for j, res in enumerate(results):
|
||||
if isinstance(res, LLMProviderError):
|
||||
logger.warning(
|
||||
"Analyse approfondie : lot %s/%s ignoré : %s", start + j + 1, total, res)
|
||||
elif isinstance(res, BaseException):
|
||||
raise res # bug inattendu : ne pas l'avaler
|
||||
elif res:
|
||||
notes.append(res)
|
||||
yield {"type": "progress", "current": total, "total": total}
|
||||
|
||||
notes_block = "\n\n".join(notes) if notes else "(aucune information pertinente trouvée dans le document)"
|
||||
context_block = (
|
||||
f"--- TA CAMPAGNE (structure, PNJ, univers) ---\n{context.strip()}\n--- FIN CAMPAGNE ---\n\n"
|
||||
if context.strip() else ""
|
||||
)
|
||||
system_prompt = _REDUCE_SYSTEM.format(context_block=context_block, notes_block=notes_block)
|
||||
# Historique récent pour la cohérence des relances ; on garantit que le
|
||||
# dernier message est bien la question courante.
|
||||
reduce_messages = messages[-history_limit:] if messages else [ChatMessage(role="user", content=question)]
|
||||
llm_chat: LLMChatProvider = self._llm # type: ignore[assignment]
|
||||
produced = False
|
||||
async for token in llm_chat.stream_chat(reduce_messages, system_prompt=system_prompt):
|
||||
if token:
|
||||
produced = True
|
||||
yield {"type": "token", "token": token}
|
||||
if not produced:
|
||||
# Jamais de bulle vide : message de repli + orientation vers le mode rapide,
|
||||
# mieux adapté aux demandes créatives (et qui propose des cartes d'action).
|
||||
yield {"type": "token", "token": (
|
||||
"Je n'ai pas trouvé d'éléments pertinents dans le document pour cette demande "
|
||||
"(elle porte sans doute sur des éléments que tu as inventés). Pour une "
|
||||
"**adaptation créative** — proposer des arcs, chapitres, scènes ou PNJ — "
|
||||
"utilise plutôt le bouton **« Envoyer »** (mode rapide) : il est conversationnel, "
|
||||
"voit ta campagne, et te propose des cartes « Créer dans la campagne »."
|
||||
)}
|
||||
yield {"type": "done"}
|
||||
|
||||
# --- Index de résumés ------------------------------------------------------
|
||||
|
||||
async def _select_batches(self, per_source: list[tuple[str, list[dict]]], question: str):
|
||||
"""Générateur : yield des évènements `progress` pendant la construction de
|
||||
l'index (1ère analyse d'une source), puis le set des indices retenus —
|
||||
ou None si le filtre n'apporte rien (tous retenus)."""
|
||||
# 1. Charge/construit les résumés par source (cache disque).
|
||||
by_sid: dict[str, list[int]] = {}
|
||||
for i, (sid, _) in enumerate(per_source):
|
||||
by_sid.setdefault(sid, []).append(i)
|
||||
vectors: list[list[float] | None] = [None] * len(per_source)
|
||||
|
||||
to_build = []
|
||||
for sid, idxs in by_sid.items():
|
||||
cached = vector_store.load_summaries(sid, self._batch_tokens)
|
||||
if cached is not None and len(cached) == len(idxs):
|
||||
for i, entry in zip(idxs, cached):
|
||||
vectors[i] = entry.get("vector")
|
||||
else:
|
||||
to_build.append((sid, idxs))
|
||||
|
||||
total_build = sum(len(idxs) for _, idxs in to_build)
|
||||
done_build = 0
|
||||
for sid, idxs in to_build:
|
||||
summaries: list[str] = []
|
||||
for start in range(0, len(idxs), self._map_concurrency):
|
||||
yield {"type": "progress", "current": done_build, "total": total_build}
|
||||
wave = idxs[start:start + self._map_concurrency]
|
||||
results = await asyncio.gather(
|
||||
*(self._summarize_batch(per_source[i][1]) for i in wave))
|
||||
summaries.extend(results)
|
||||
done_build += len(wave)
|
||||
vecs = await self._embedder.embed(summaries, kind="document")
|
||||
entries = [{"summary": s, "vector": v} for s, v in zip(summaries, vecs)]
|
||||
vector_store.save_summaries(sid, self._batch_tokens, entries)
|
||||
for i, entry in zip(idxs, entries):
|
||||
vectors[i] = entry["vector"]
|
||||
|
||||
# 2. Score de chaque lot face à la question, sélection conservatrice.
|
||||
qv = (await self._embedder.embed([question], kind="query"))[0]
|
||||
scores = [
|
||||
vector_store.cosine_similarity(qv, v) if v else 0.0
|
||||
for v in vectors
|
||||
]
|
||||
best = max(scores)
|
||||
keep = {i for i, s in enumerate(scores) if s >= best - _SELECT_MARGIN or s >= _SELECT_FLOOR}
|
||||
floor = min(_MIN_KEPT, len(scores))
|
||||
if len(keep) < floor:
|
||||
keep = set(sorted(range(len(scores)), key=lambda i: -scores[i])[:floor])
|
||||
yield keep if len(keep) < len(scores) else None
|
||||
|
||||
async def _summarize_batch(self, batch: list[dict]) -> str:
|
||||
excerpt = "\n\n".join(c.get("text", "").strip() for c in batch)
|
||||
raw = await generate_with_retry(
|
||||
self._llm, _SUMMARY_PROMPT.format(excerpt=excerpt), temperature=_MAP_TEMPERATURE)
|
||||
return (raw or "").strip()
|
||||
|
||||
async def _map_batch(self, question: str, batch: list[dict]) -> str:
|
||||
"""Phase MAP d'un lot : extrait les infos pertinentes ('' si RAS)."""
|
||||
excerpt = "\n\n".join(
|
||||
f"(p. {c['page']}) {c['text'].strip()}" if c.get("page") else c["text"].strip()
|
||||
for c in batch
|
||||
)
|
||||
prompt = _MAP_PROMPT.format(no_match=_NO_MATCH, question=question, excerpt=excerpt)
|
||||
raw = await generate_with_retry(self._llm, prompt, temperature=_MAP_TEMPERATURE)
|
||||
answer = raw.strip()
|
||||
if answer and answer.upper().rstrip(".") != _NO_MATCH:
|
||||
return answer
|
||||
return ""
|
||||
|
||||
def _group(self, chunks: list[dict]) -> list[list[dict]]:
|
||||
"""Regroupe les extraits en lots ~`batch_tokens` (compte tiktoken)."""
|
||||
enc = tiktoken.get_encoding("cl100k_base")
|
||||
batches: list[list[dict]] = []
|
||||
current: list[dict] = []
|
||||
current_tokens = 0
|
||||
for c in chunks:
|
||||
t = len(enc.encode(c.get("text", "")))
|
||||
if current and current_tokens + t > self._batch_tokens:
|
||||
batches.append(current)
|
||||
current, current_tokens = [], 0
|
||||
current.append(c)
|
||||
current_tokens += t
|
||||
if current:
|
||||
batches.append(current)
|
||||
return batches
|
||||
94
brain/app/application/notebook_rag.py
Normal file
94
brain/app/application/notebook_rag.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Use case RAG des notebooks : indexer une source PDF et retrouver les passages
|
||||
pertinents pour une question.
|
||||
|
||||
Chaîne d'indexation : PDF → extraction texte (+OCR) → découpage en extraits courts
|
||||
→ embeddings → stockage vectoriel (fichier). À la requête : on embed la question
|
||||
et on récupère les extraits les plus proches (cosinus) pour ancrer le chat.
|
||||
|
||||
Extraits PLUS COURTS que pour l'import (recopie) : ici on veut une granularité fine
|
||||
pour que la recherche pointe un passage précis, pas un demi-chapitre.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.application.chunking import chunk_text
|
||||
from app.application.embeddings import EmbeddingProvider
|
||||
from app.domain.ports import PdfTextExtractor
|
||||
from app.infrastructure import vector_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RAG_CHUNK_TOKENS = 600
|
||||
# Recouvrement entre extraits consécutifs (~13% de la cible) : une phrase-clé à
|
||||
# cheval sur deux extraits reste retrouvable dans au moins l'un des deux.
|
||||
_RAG_OVERLAP_TOKENS = 80
|
||||
# Un extrait avec quasi aucun texte réel (en-tête/pied de page, fragment de numéro
|
||||
# de page isolé « 249 250 ») ne sert à rien en RAG → on l'écarte. Seuil bas et
|
||||
# conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase.
|
||||
_MIN_LETTERS = 15
|
||||
|
||||
|
||||
def _has_enough_text(piece: str) -> bool:
|
||||
return sum(c.isalpha() for c in piece) >= _MIN_LETTERS
|
||||
|
||||
|
||||
class NotebookRagUseCase:
|
||||
def __init__(
|
||||
self,
|
||||
extractor: PdfTextExtractor,
|
||||
embedder: EmbeddingProvider,
|
||||
chunk_target_tokens: int = _RAG_CHUNK_TOKENS,
|
||||
min_score: float = 0.0,
|
||||
) -> None:
|
||||
self._extractor = extractor
|
||||
self._embedder = embedder
|
||||
self._chunk_target_tokens = chunk_target_tokens
|
||||
# Cosinus minimal pour qu'un extrait soit injecté dans le prompt : sous ce
|
||||
# seuil, l'extrait n'a aucun rapport avec la question → bruit. 0 = désactivé.
|
||||
self._min_score = min_score
|
||||
|
||||
async def index_source(self, source_id: str, pdf_bytes: bytes) -> dict:
|
||||
"""Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed
|
||||
et stocke une source. Renvoie un récap."""
|
||||
doc = self._extractor.extract(pdf_bytes)
|
||||
chunks: list[str] = []
|
||||
pages: list[int] = []
|
||||
for page in doc.pages:
|
||||
for piece in chunk_text(
|
||||
page.text, self._chunk_target_tokens, overlap_tokens=_RAG_OVERLAP_TOKENS
|
||||
):
|
||||
if not _has_enough_text(piece):
|
||||
continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré
|
||||
chunks.append(piece)
|
||||
pages.append(page.index + 1) # n° de page 1-based pour l'affichage
|
||||
logger.info(
|
||||
"Indexation notebook source=%s : %s page(s) (%s OCR), %s extrait(s).",
|
||||
source_id, doc.page_count, doc.ocr_page_count, len(chunks),
|
||||
)
|
||||
if not chunks:
|
||||
vector_store.save(source_id, [], [])
|
||||
return {"chunks": 0, "page_count": doc.page_count, "ocr_page_count": doc.ocr_page_count}
|
||||
vectors = await self._embedder.embed(chunks)
|
||||
count = vector_store.save(source_id, chunks, vectors, pages)
|
||||
return {
|
||||
"chunks": count,
|
||||
"page_count": doc.page_count,
|
||||
"ocr_page_count": doc.ocr_page_count,
|
||||
}
|
||||
|
||||
async def retrieve(self, source_ids: list[str], query: str, top_k: int = 6) -> list[dict]:
|
||||
"""Passages les plus pertinents (toutes sources) pour `query`.
|
||||
|
||||
Recherche hybride (cosinus + bonus lexical sur les mots de la question) ;
|
||||
peut renvoyer moins de `top_k` passages si le seuil de pertinence écarte
|
||||
les extraits hors-sujet."""
|
||||
ids = [s for s in source_ids if vector_store.exists(s)]
|
||||
if not ids or not query.strip():
|
||||
return []
|
||||
query_vectors = await self._embedder.embed([query], kind="query")
|
||||
if not query_vectors:
|
||||
return []
|
||||
return vector_store.search(
|
||||
ids, query_vectors[0], top_k, query_text=query, min_score=self._min_score
|
||||
)
|
||||
66
brain/app/application/query_rewrite.py
Normal file
66
brain/app/application/query_rewrite.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Réécriture de la question courante en question AUTONOME (chat des ateliers).
|
||||
|
||||
Problème : le retrieval (embedding) et la phase MAP de l'analyse approfondie ne
|
||||
voient que le DERNIER message. Une relance comme « et ses faiblesses ? » ne
|
||||
contient pas le sujet (Strahd) → recherche aveugle. La parade standard
|
||||
(conversational query rewriting) : un appel LLM léger condense la conversation
|
||||
en une question autonome, utilisée UNIQUEMENT pour la recherche — la réponse
|
||||
finale, elle, voit toujours l'historique complet.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.domain.models import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Nombre de messages récents fournis au réécrivain (assez pour résoudre les
|
||||
# pronoms, pas plus — la latence de cet appel doit rester négligeable).
|
||||
_MAX_HISTORY = 6
|
||||
|
||||
# Garde-fou : une « question » réécrite anormalement longue est suspecte (le
|
||||
# modèle a divagué) → on retombe sur la question brute.
|
||||
_MAX_REWRITE_CHARS = 400
|
||||
|
||||
_REWRITE_PROMPT = """Voici la fin d'une conversation entre un Maître de Jeu et son assistant.
|
||||
Réécris le DERNIER message de l'utilisateur en une question AUTONOME et complète :
|
||||
remplace les pronoms et références implicites (« il », « ses », « ce lieu », « et pour
|
||||
les autres ? ») par ce qu'ils désignent dans la conversation.
|
||||
|
||||
Règles :
|
||||
- Réponds UNIQUEMENT par la question réécrite, sans guillemets ni préfixe.
|
||||
- Conserve la langue et l'intention d'origine. N'ajoute RIEN qui n'est pas demandé.
|
||||
- Si le dernier message est déjà autonome, recopie-le tel quel.
|
||||
|
||||
--- CONVERSATION ---
|
||||
{conversation}
|
||||
--- FIN ---
|
||||
|
||||
Question autonome :"""
|
||||
|
||||
|
||||
async def standalone_question(llm, messages: list[ChatMessage]) -> str:
|
||||
"""Condense `messages` en une question autonome pour la RECHERCHE.
|
||||
|
||||
Best-effort : premier message de la conversation, échec LLM ou réponse
|
||||
suspecte → on renvoie simplement la dernière question brute (comportement
|
||||
historique). `llm` doit exposer `generate()` (duck typing des adapters).
|
||||
"""
|
||||
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
|
||||
user_turns = sum(1 for m in messages if m.role == "user" and m.content.strip())
|
||||
if user_turns <= 1 or not last_user.strip():
|
||||
return last_user # pas d'historique à résoudre → appel LLM inutile
|
||||
|
||||
recent = [m for m in messages if m.content.strip()][-_MAX_HISTORY:]
|
||||
conversation = "\n".join(f"{m.role.upper()}: {m.content.strip()}" for m in recent)
|
||||
try:
|
||||
raw = await llm.generate(
|
||||
_REWRITE_PROMPT.format(conversation=conversation), temperature=0.0)
|
||||
except Exception as exc: # noqa: BLE001 — la recherche dégradée vaut mieux que pas de réponse
|
||||
logger.warning("Réécriture de question ignorée (échec LLM) : %s", exc)
|
||||
return last_user
|
||||
rewritten = (raw or "").strip().strip('"').strip()
|
||||
if not rewritten or len(rewritten) > _MAX_REWRITE_CHARS:
|
||||
return last_user
|
||||
return rewritten
|
||||
73
brain/app/application/rerank.py
Normal file
73
brain/app/application/rerank.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Reranking LLM des passages RAG (chat des ateliers).
|
||||
|
||||
Le cosinus classe par similarité de SURFACE ; sur les questions ambiguës, des
|
||||
passages proches lexicalement mais inutiles passent devant l'extrait qui répond
|
||||
vraiment. Le reranking récupère un POOL élargi (ex. 3× top_k), fait noter la
|
||||
pertinence de chaque extrait par le LLM en UN appel, et garde les top_k mieux
|
||||
notés. Coût : ~1 appel LLM avant le premier token — opt-in via RAG_RERANK.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.application.llm_json import load_json_object
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Taille du pool élargi : multiple du top_k demandé, plafonné (le prompt de
|
||||
# notation doit rester raisonnable même avec rag_top_k élevé).
|
||||
POOL_FACTOR = 3
|
||||
POOL_MAX = 24
|
||||
|
||||
# Un extrait long n'a pas besoin d'être noté en entier : tronquer borne le
|
||||
# prompt sans changer le jugement de pertinence.
|
||||
_EXCERPT_CHARS = 600
|
||||
|
||||
_RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question.
|
||||
Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres.
|
||||
|
||||
QUESTION : {question}
|
||||
|
||||
{passages}
|
||||
|
||||
Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}}
|
||||
Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits."""
|
||||
|
||||
|
||||
def pool_size(top_k: int) -> int:
|
||||
"""Taille du pool à récupérer avant reranking."""
|
||||
return min(max(top_k * POOL_FACTOR, top_k), POOL_MAX)
|
||||
|
||||
|
||||
async def rerank(llm, question: str, passages: list[dict], top_k: int) -> list[dict]:
|
||||
"""Renvoie les `top_k` passages les mieux notés par le LLM (tri stable :
|
||||
à note égale, l'ordre cosinus d'origine est préservé).
|
||||
|
||||
BEST-EFFORT : échec LLM, JSON invalide ou nombre de notes incohérent →
|
||||
on renvoie simplement les `top_k` premiers du classement cosinus.
|
||||
"""
|
||||
if len(passages) <= top_k:
|
||||
return passages
|
||||
numbered = "\n\n".join(
|
||||
f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}"
|
||||
for i, p in enumerate(passages)
|
||||
)
|
||||
prompt = _RERANK_PROMPT.format(
|
||||
question=question, passages=numbered, count=len(passages))
|
||||
try:
|
||||
raw = await llm.generate(prompt, temperature=0.0)
|
||||
except Exception as exc: # noqa: BLE001 — un chat dégradé vaut mieux que pas de chat
|
||||
logger.warning("Reranking ignoré (échec LLM) : %s", exc)
|
||||
return passages[:top_k]
|
||||
parsed, _ = load_json_object(raw)
|
||||
scores = parsed.get("scores") if isinstance(parsed, dict) else None
|
||||
if not isinstance(scores, list) or len(scores) != len(passages):
|
||||
logger.warning("Reranking ignoré (notes inexploitables).")
|
||||
return passages[:top_k]
|
||||
try:
|
||||
scored = [(float(s), i) for i, s in enumerate(scores)]
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Reranking ignoré (notes non numériques).")
|
||||
return passages[:top_k]
|
||||
order = sorted(range(len(passages)), key=lambda i: (-scored[i][0], i))
|
||||
return [passages[i] for i in order[:top_k]]
|
||||
67
brain/app/application/streaming.py
Normal file
67
brain/app/application/streaming.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Heartbeats pour garder un flux SSE 'vivant' pendant une coroutine longue.
|
||||
|
||||
Problème résolu : pendant un appel LLM lent (import sur provider gratuit), le
|
||||
Brain ne produit AUCUN évènement SSE. Le Core (WebClient) ne 'voit aucun item'
|
||||
et coupe la connexion sur timeout d'inactivité :
|
||||
|
||||
ReactiveException: Did not observe any item or terminal signal within Nms
|
||||
|
||||
C'est le piège classique du SSE long. La parade standard = envoyer un keep-alive
|
||||
périodique. `with_heartbeat` exécute une coroutine en émettant un évènement
|
||||
'heartbeat' toutes les `interval` secondes tant qu'elle tourne, puis son résultat
|
||||
('result', valeur). Le Core remet son chrono à zéro sur n'importe quel évènement
|
||||
reçu (même inconnu) → plus de coupure, quelle que soit la lenteur du modèle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, AsyncIterator, Awaitable
|
||||
|
||||
# Bien sous le timeout d'inactivité du Core (600s) ET de tout proxy (nginx ~60s).
|
||||
HEARTBEAT_INTERVAL_SECONDS = 15.0
|
||||
|
||||
|
||||
async def with_heartbeat(
|
||||
coro: Awaitable[Any],
|
||||
*,
|
||||
interval: float = HEARTBEAT_INTERVAL_SECONDS,
|
||||
status_queue: "asyncio.Queue | None" = None,
|
||||
) -> AsyncIterator[tuple[str, Any]]:
|
||||
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
|
||||
qu'elle n'est pas terminée, puis ('result', valeur).
|
||||
|
||||
Si `status_queue` est fournie, les messages qui y sont publiés pendant
|
||||
l'exécution (cf. import_status.notify_status : retry LLM, re-découpage…)
|
||||
sont émis AU FIL DE L'EAU sous forme ('status', message) — c'est ce qui
|
||||
permet à l'UI d'expliquer une attente au lieu d'une barre figée.
|
||||
|
||||
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`),
|
||||
donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée
|
||||
(client déconnecté), la tâche sous-jacente est annulée.
|
||||
"""
|
||||
task: asyncio.Task = asyncio.ensure_future(coro)
|
||||
getter: asyncio.Task | None = None
|
||||
try:
|
||||
while not task.done():
|
||||
waiters: set[asyncio.Task] = {task}
|
||||
if status_queue is not None and getter is None:
|
||||
getter = asyncio.ensure_future(status_queue.get())
|
||||
if getter is not None:
|
||||
waiters.add(getter)
|
||||
done, _ = await asyncio.wait(
|
||||
waiters, timeout=interval, return_when=asyncio.FIRST_COMPLETED)
|
||||
if getter is not None and getter in done:
|
||||
yield ("status", getter.result())
|
||||
getter = None # un nouveau get() sera créé au tour suivant
|
||||
if not done:
|
||||
yield ("heartbeat", None)
|
||||
# Vide les statuts restés en file (publiés juste avant la fin de la tâche).
|
||||
if status_queue is not None:
|
||||
while not status_queue.empty():
|
||||
yield ("status", status_queue.get_nowait())
|
||||
yield ("result", task.result())
|
||||
finally:
|
||||
if getter is not None and not getter.done():
|
||||
getter.cancel()
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
@@ -25,12 +25,16 @@ class Settings(BaseSettings):
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
|
||||
llm_provider: Literal["ollama", "onemin"] = "ollama"
|
||||
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ;
|
||||
# "openrouter" = OpenRouter ; "mistral" = Mistral ; "gemini" = Google Gemini.
|
||||
llm_provider: Literal["ollama", "onemin", "openrouter", "mistral", "gemini"] = "ollama"
|
||||
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
llm_model: str = "gemma4:26b"
|
||||
llm_timeout_seconds: int = 120
|
||||
# Timeout HTTP des appels au LLM. Les imports/adaptations PDF génèrent de gros
|
||||
# blocs (surtout avec l'extraction riche) → 120s était trop court. Surchargeable
|
||||
# depuis l'UI (Paramètres) si un import lourd dépasse encore.
|
||||
llm_timeout_seconds: int = 300
|
||||
|
||||
# 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
|
||||
@@ -44,6 +48,74 @@ class Settings(BaseSettings):
|
||||
onemin_api_key: str = ""
|
||||
onemin_model: str = "gpt-4o-mini"
|
||||
|
||||
# OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
|
||||
# Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
|
||||
# Pour un modele precis gratuit : id finissant par `:free`.
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_model: str = "openrouter/free"
|
||||
|
||||
# Mistral (La Plateforme, OpenAI-compatible). Cle + modele modifiables depuis
|
||||
# l'UI. Tier gratuit « Experiment » sur console.mistral.ai (sans CB). Defaut =
|
||||
# mistral-large-latest (128k contexte, bon en francais et en JSON fidele).
|
||||
mistral_api_key: str = ""
|
||||
mistral_model: str = "mistral-large-latest"
|
||||
|
||||
# Google Gemini (endpoint OpenAI-compatible). Cle gratuite sur
|
||||
# aistudio.google.com (sans CB). Defaut = gemini-2.0-flash : ~1M de contexte
|
||||
# (un livre tient en 1-2 appels), rapide, fidele, quota gratuit genereux.
|
||||
gemini_api_key: str = ""
|
||||
gemini_model: str = "gemini-2.0-flash"
|
||||
|
||||
# Embeddings (RAG des notebooks/ateliers). Modele SEPARE du chat.
|
||||
# "ollama" = local (gratuit, illimite, ideal pour indexer un livre = bcp
|
||||
# d'appels) ; "mistral" = cloud EU (mistral-embed, soumis au rate limit).
|
||||
embedding_provider: Literal["ollama", "mistral"] = "ollama"
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
mistral_embedding_model: str = "mistral-embed"
|
||||
# Au démarrage, si le provider d'embeddings est Ollama et que le modèle n'est
|
||||
# pas présent, le Brain le télécharge automatiquement (en arrière-plan) → le RAG
|
||||
# marche "out of the box" pour un nouvel utilisateur. Désactivable (connexion
|
||||
# limitée, gestion manuelle des modèles).
|
||||
auto_pull_embedding_model: bool = True
|
||||
|
||||
# Nombre d'extraits récupérés par question dans le chat des ateliers (RAG).
|
||||
# Plus haut = plus de couverture pour les questions larges (« liste les… »),
|
||||
# mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte).
|
||||
rag_top_k: int = 8
|
||||
|
||||
# Analyse approfondie : pré-filtrage des lots via un index de résumés
|
||||
# (construit une fois par source, cache disque). Les questions ciblées ne
|
||||
# relisent que les lots plausiblement pertinents (3-5x moins d'appels) ;
|
||||
# False = relire TOUT le document à chaque question (exhaustivité maximale).
|
||||
deep_summary_filter: bool = True
|
||||
|
||||
# Reranking LLM du chat atelier : recupere un pool elargi (3x top_k, max 24)
|
||||
# puis fait NOTER la pertinence de chaque extrait par le LLM avant d'injecter
|
||||
# les top_k meilleurs. Meilleure precision sur les questions ambigues, MAIS
|
||||
# +1 appel LLM avant le premier token (quelques secondes sur un petit modele
|
||||
# local). Desactive par defaut ; recommande avec un provider cloud rapide.
|
||||
rag_rerank: bool = False
|
||||
|
||||
# Cosinus minimal pour qu'un extrait soit injecté dans le prompt du chat
|
||||
# atelier : en dessous, l'extrait n'a aucun rapport avec la question → mieux
|
||||
# vaut moins d'extraits que du bruit. Défaut conservateur (0.30) : les paires
|
||||
# pertinentes scorent typiquement 0.6+ avec nomic-embed-text/mistral-embed,
|
||||
# les hors-sujet 0.2-0.4. Montable à ~0.4 si trop de bruit, 0 = désactivé.
|
||||
rag_min_score: float = 0.30
|
||||
|
||||
# Nombre d'appels LLM MAP menes EN PARALLELE (import de campagne, analyse
|
||||
# approfondie). 3 = bon defaut cloud (divise le temps d'un gros livre par ~3).
|
||||
# Ollama local sequence les requetes de toute facon (pas de gain, pas de mal).
|
||||
# Baisser a 1 si un provider gratuit rate-limite agressivement.
|
||||
llm_map_concurrency: int = 3
|
||||
|
||||
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
|
||||
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
|
||||
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.
|
||||
# Defaut prudent (compatible Ollama num_ctx 16384). Sur un modele a grand
|
||||
# contexte (ex: GPT-5 mini, 400k), monter a ~100000 traite un livre en 1 passe.
|
||||
import_chunk_tokens: int = 10000
|
||||
|
||||
# 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).
|
||||
|
||||
@@ -29,6 +29,18 @@ _ALLOWED_KEYS = frozenset({
|
||||
"llm_num_ctx",
|
||||
"onemin_api_key",
|
||||
"onemin_model",
|
||||
"openrouter_api_key",
|
||||
"openrouter_model",
|
||||
"mistral_api_key",
|
||||
"mistral_model",
|
||||
"gemini_api_key",
|
||||
"gemini_model",
|
||||
"embedding_provider",
|
||||
"ollama_embedding_model",
|
||||
"mistral_embedding_model",
|
||||
"auto_pull_embedding_model",
|
||||
"rag_top_k",
|
||||
"import_chunk_tokens",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -122,9 +122,29 @@ class SceneBranchHint:
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomBranchHint:
|
||||
"""Indice d'une sortie entre pièces (donjon). target_room_name déjà résolu côté Core."""
|
||||
|
||||
label: str
|
||||
target_room_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomSummary:
|
||||
"""Pièce d'un lieu explorable. Projection plate pour le prompt IA (pas de notes MJ)."""
|
||||
|
||||
name: str
|
||||
floor: int | None = None
|
||||
description: str | None = None
|
||||
enemies: str | None = None
|
||||
branches: list[RoomBranchHint] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneSummary:
|
||||
"""Résumé d'une scène : nom + description courte + illustrations + branches."""
|
||||
"""Résumé d'une scène : nom + description courte + illustrations + branches + pièces."""
|
||||
|
||||
name: str
|
||||
description: str | None
|
||||
@@ -133,6 +153,8 @@ class SceneSummary:
|
||||
illustration_count: int = 0
|
||||
# Connexions narratives sortantes (livre dont vous etes le heros).
|
||||
branches: list[SceneBranchHint] = field(default_factory=list)
|
||||
# Pièces du lieu explorable (vide = scène classique).
|
||||
rooms: list[RoomSummary] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -229,3 +251,208 @@ class GameSystemContext:
|
||||
system_name: str
|
||||
system_description: str | None
|
||||
sections: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JournalEntrySummary:
|
||||
"""Une entrée du journal d'une Session.
|
||||
|
||||
`source_session_name` n'est renseigné que pour les entrées issues de
|
||||
sessions précédentes (option 3 : continuité narrative entre séances).
|
||||
"""
|
||||
|
||||
type: str
|
||||
content: str
|
||||
occurred_at: str | None
|
||||
source_session_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestSummary:
|
||||
"""Résumé d'une quête (Chapter dans un Arc HUB) pour le system prompt.
|
||||
|
||||
Volontairement sans notes MJ ni statut texte : c'est déjà classé côté Core
|
||||
dans available_quests / in_progress_quests / locked_quest_titles.
|
||||
"""
|
||||
|
||||
name: str
|
||||
arc_name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionContext:
|
||||
"""Contexte d'une Session de jeu en cours (Play Context).
|
||||
|
||||
Combine plusieurs niveaux :
|
||||
- `entries` : journal COMPLET de la session courante (cappé ~80 entrées)
|
||||
- `previous_events` : EVENTs marquants des sessions précédentes (continuité)
|
||||
- `available_quests` / `in_progress_quests` : quêtes du Hub ouvertes
|
||||
- `locked_quest_titles` : titres seuls des quêtes verrouillées (anti-spoiler)
|
||||
- `active_flags` : noms des flags de campagne actuellement à true
|
||||
"""
|
||||
|
||||
session_name: str
|
||||
active: bool
|
||||
started_at: str | None
|
||||
entries: list[JournalEntrySummary]
|
||||
previous_events: list[JournalEntrySummary]
|
||||
available_quests: list[QuestSummary] = field(default_factory=list)
|
||||
in_progress_quests: list[QuestSummary] = field(default_factory=list)
|
||||
locked_quest_titles: list[str] = field(default_factory=list)
|
||||
active_flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ─────────────────────── Import de PDF (règles → GameSystem) ───────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtractedPage:
|
||||
"""Texte extrait d'UNE page de PDF, avec la trace de la méthode utilisée.
|
||||
|
||||
`used_ocr=True` signale que la page n'avait pas de couche texte exploitable
|
||||
(born-digital absent) et a donc été rasterisée puis passée à l'OCR. Permet
|
||||
au CLI/diagnostic de dire à l'utilisateur si son PDF est "texte" ou "scan".
|
||||
"""
|
||||
|
||||
index: int # 0-based
|
||||
text: str
|
||||
used_ocr: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TocEntry:
|
||||
"""Une entrée de la table des matières (bookmarks/outline) du PDF.
|
||||
|
||||
`level` : profondeur 1-based (1 = chapitre, 2 = section…). `page` : 1-based.
|
||||
"""
|
||||
|
||||
level: int
|
||||
title: str
|
||||
page: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtractedDocument:
|
||||
"""Résultat brut de l'extraction d'un PDF : une entrée par page."""
|
||||
|
||||
pages: list[ExtractedPage]
|
||||
# Table des matières (bookmarks PDF). Vide si le PDF n'en a pas — fréquent
|
||||
# pour les scans ; les livres born-digital en ont presque toujours une.
|
||||
toc: list[TocEntry] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def page_count(self) -> int:
|
||||
return len(self.pages)
|
||||
|
||||
@property
|
||||
def ocr_page_count(self) -> int:
|
||||
return sum(1 for p in self.pages if p.used_ocr)
|
||||
|
||||
@property
|
||||
def full_text(self) -> str:
|
||||
"""Concatène le texte de toutes les pages, séparées par un saut double."""
|
||||
return "\n\n".join(p.text for p in self.pages if p.text.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RulesImportResult:
|
||||
"""Proposition structurée de règles : sections markdown indexées par titre.
|
||||
|
||||
`sections` = {titre H2 → contenu markdown}. C'est une PROPOSITION : rien
|
||||
n'est persisté côté Core tant que l'utilisateur n'a pas validé/édité.
|
||||
`page_count` / `ocr_page_count` remontent au diagnostic d'extraction.
|
||||
"""
|
||||
|
||||
sections: dict[str, str]
|
||||
page_count: int
|
||||
ocr_page_count: int
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Assemble les sections en un markdown monolithique (## titre + contenu).
|
||||
|
||||
Format aligné sur `GameSystem.rulesMarkdown` côté Core (découpé par H2).
|
||||
"""
|
||||
blocks = [f"## {title}\n\n{content.strip()}" for title, content in self.sections.items()]
|
||||
return "\n\n".join(blocks).strip() + "\n"
|
||||
|
||||
|
||||
# ─────────────────────── Import de PDF de campagne (arbre arc→chapitre→scène) ──────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoomProposal:
|
||||
"""Pièce d'un lieu explorable (donjon) proposée pour une scène."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
enemies: str = ""
|
||||
loot: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneProposal:
|
||||
"""Scène proposée. `rooms` non vide => donjon/lieu explorable.
|
||||
|
||||
On capture aussi, quand le livre les fournit, le texte d'encadré « à lire aux
|
||||
joueurs » (`player_narration`) et les secrets/développement MJ (`gm_notes`).
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
player_narration: str = ""
|
||||
gm_notes: str = ""
|
||||
rooms: list[RoomProposal] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChapterProposal:
|
||||
"""Chapitre proposé : nom + synopsis + ses scènes."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
scenes: list[SceneProposal] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArcProposal:
|
||||
"""Arc proposé : nom + synopsis + type (LINEAR/HUB) + ses chapitres."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
arc_type: str = "LINEAR"
|
||||
chapters: list[ChapterProposal] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NpcImportProposal:
|
||||
"""PNJ/créature notable détecté à l'import d'un PDF de campagne.
|
||||
|
||||
PNJ NOMMÉS et créatures uniques (boss) — pas les monstres génériques.
|
||||
`description` = courte fiche (rôle, apparence, motivations, où on le croise).
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CampaignImportResult:
|
||||
"""Proposition d'arborescence narrative extraite d'un PDF de campagne.
|
||||
|
||||
PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
|
||||
avant la création effective des arcs/chapitres/scènes côté Core.
|
||||
"""
|
||||
|
||||
arcs: list[ArcProposal]
|
||||
page_count: int
|
||||
ocr_page_count: int
|
||||
# PNJ/créatures notables détectés au fil des morceaux (proposition, à cocher
|
||||
# dans l'écran de revue avant création).
|
||||
npcs: list[NpcImportProposal] = field(default_factory=list)
|
||||
|
||||
def counts(self) -> tuple[int, int, int]:
|
||||
"""(nb arcs, nb chapitres, nb scènes) — pour le diagnostic / la progression."""
|
||||
chapters = sum(len(a.chapters) for a in self.arcs)
|
||||
scenes = sum(len(c.scenes) for a in self.arcs for c in a.chapters)
|
||||
return len(self.arcs), chapters, scenes
|
||||
|
||||
@@ -7,7 +7,10 @@ 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
|
||||
from typing import TYPE_CHECKING, AsyncIterator, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.domain.models import ExtractedDocument
|
||||
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
@@ -21,17 +24,20 @@ class LLMProvider(Protocol):
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
output_format: str | dict | 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.
|
||||
output_format: contrainte de format optionnelle. "json" pour forcer
|
||||
un JSON valide ; un dict = SCHÉMA JSON décrivant la structure
|
||||
attendue (les fournisseurs qui supportent les sorties
|
||||
structurées — ex. Ollama — contraignent la génération au schéma,
|
||||
les autres retombent sur leur mode JSON natif). 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 :
|
||||
@@ -78,9 +84,46 @@ class LLMChatProvider(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class PdfTextExtractor(Protocol):
|
||||
"""Port sortant — extrait le texte d'un PDF (born-digital ou scan).
|
||||
|
||||
L'implémentation décide de sa stratégie (couche texte directe, repli OCR
|
||||
page par page…). Le domaine ne connaît ni PyMuPDF ni Tesseract.
|
||||
"""
|
||||
|
||||
def extract(self, pdf_bytes: bytes) -> "ExtractedDocument":
|
||||
"""Extrait le texte du PDF fourni sous forme d'octets.
|
||||
|
||||
Args:
|
||||
pdf_bytes: contenu binaire du fichier PDF.
|
||||
|
||||
Returns:
|
||||
ExtractedDocument : une entrée par page (texte + flag OCR).
|
||||
|
||||
Raises:
|
||||
PdfExtractionError: si le PDF est illisible/corrompu.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class PdfExtractionError(Exception):
|
||||
"""Erreur du domaine : un PDF n'a pas pu être lu/extrait."""
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
class LLMGenerationTimeout(LLMProviderError):
|
||||
"""La génération a démarré mais n'a pas FINI dans le temps imparti.
|
||||
|
||||
Cas distinct d'un échec transitoire (file d'attente, 503) : le modèle
|
||||
produisait des tokens mais trop lentement pour la taille de sortie demandée.
|
||||
Réessayer à l'identique est inutile (même entrée → même lenteur) ; la bonne
|
||||
réaction est de RÉDUIRE la sortie demandée (ex. import : re-découper le
|
||||
morceau en deux moitiés).
|
||||
"""
|
||||
|
||||
194
brain/app/infrastructure/gemini_adapter.py
Normal file
194
brain/app/infrastructure/gemini_adapter.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Adapter Google Gemini — implémente les ports LLMProvider / LLMChatProvider.
|
||||
|
||||
Gemini expose un endpoint COMPATIBLE OpenAI
|
||||
(POST {base}/openai/chat/completions, SSE), donc cet adapter est un client
|
||||
"OpenAI-compatible" — même structure que les adapters OpenRouter / Mistral.
|
||||
|
||||
Tier GRATUIT : clé API sur aistudio.google.com (sans CB). Atout majeur pour
|
||||
l'extraction de PDF : un CONTEXTE de ~1M tokens → un livre entier tient en 1-2
|
||||
appels, donc quasi aucun morceau perdu et peu de requêtes (limites jamais
|
||||
atteintes). Modèle conseillé : `gemini-2.0-flash` (rapide, gros contexte, fidèle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
|
||||
|
||||
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle ne produit
|
||||
# rien). Gemini répond vite ; 120s est large.
|
||||
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
class GeminiLLMProvider:
|
||||
"""Adapter Gemini (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
if not settings.gemini_api_key:
|
||||
raise LLMProviderError(
|
||||
"Clé API Gemini manquante. Configure-la depuis l'écran Paramètres "
|
||||
"(clé gratuite sur aistudio.google.com)."
|
||||
)
|
||||
self._api_key = settings.gemini_api_key
|
||||
self._model = settings.gemini_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""One-shot via streaming (puis recollage), avec garde-fous au temps écoulé."""
|
||||
return await self._collect_with_timeouts(
|
||||
[ChatMessage(role="user", content=prompt)], temperature, output_format
|
||||
)
|
||||
|
||||
async def _collect_with_timeouts(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
temperature: float | None,
|
||||
output_format: str | None,
|
||||
) -> str:
|
||||
"""Collecte le stream avec deux garde-fous : 1er token borné (échec rapide
|
||||
si rien ne sort) + ceiling global `self._timeout`."""
|
||||
async def _collect() -> str:
|
||||
chunks: list[str] = []
|
||||
agen = self._stream(messages, None, temperature, output_format)
|
||||
try:
|
||||
while True:
|
||||
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
|
||||
try:
|
||||
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise LLMProviderError(
|
||||
f"Erreur Gemini : aucun contenu produit en "
|
||||
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s. Réessayez ou vérifiez "
|
||||
"votre quota gratuit."
|
||||
)
|
||||
chunks.append(token)
|
||||
finally:
|
||||
await agen.aclose()
|
||||
return "".join(chunks)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMGenerationTimeout(
|
||||
f"Erreur Gemini : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import ou augmentez le timeout."
|
||||
) from exc
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
async for token in self._stream(messages, system_prompt, temperature):
|
||||
yield token
|
||||
|
||||
async def _stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
system_prompt: str | None,
|
||||
temperature: float | None,
|
||||
output_format: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
payload_messages: list[dict[str, str]] = []
|
||||
if system_prompt:
|
||||
payload_messages.append({"role": "system", "content": system_prompt})
|
||||
for m in messages:
|
||||
payload_messages.append({"role": m.role, "content": m.content})
|
||||
|
||||
body: dict[str, object] = {
|
||||
"model": self._model,
|
||||
"messages": payload_messages,
|
||||
"stream": True,
|
||||
}
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
# Mode JSON natif (supporté par l'endpoint OpenAI-compatible de Gemini) :
|
||||
# supprime fences ```json et JSON invalide, principale cause de morceaux
|
||||
# ignorés. Un SCHÉMA (dict) est traduit en json_object : suffisant, les
|
||||
# grands modèles cloud respectent la structure demandée par le prompt.
|
||||
if output_format is not None:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", _API_URL, headers=self._headers(), json=body
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
detail = (await response.aread()).decode("utf-8", "replace").strip()
|
||||
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) :
|
||||
# message actionnable plutôt que le JSON brut de l'API.
|
||||
if response.status_code in (401, 403):
|
||||
raise LLMProviderError(
|
||||
"Erreur Gemini : clé API refusée par Google "
|
||||
f"(HTTP {response.status_code}). Vérifiez que la clé vient bien "
|
||||
"de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
|
||||
"restrictions (API ou adresse IP) dans la Google Cloud Console. "
|
||||
f"Détail : {detail[:300]}"
|
||||
)
|
||||
raise LLMProviderError(
|
||||
f"Erreur Gemini (HTTP {response.status_code})"
|
||||
+ (f" : {detail[:500]}" if detail else "")
|
||||
)
|
||||
async for token in self._parse_sse(response):
|
||||
yield token
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(self._format_http_error(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
|
||||
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
|
||||
async for line in response.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
data = line[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = obj.get("choices")
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
def _format_http_error(self, exc: httpx.HTTPError) -> str:
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return (
|
||||
f"Erreur Gemini : délai dépassé (timeout {self._timeout}s). Le modèle a "
|
||||
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
|
||||
)
|
||||
detail = str(exc) or exc.__class__.__name__
|
||||
return f"Erreur Gemini ({exc.__class__.__name__}) : {detail}"
|
||||
195
brain/app/infrastructure/mistral_adapter.py
Normal file
195
brain/app/infrastructure/mistral_adapter.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Adapter Mistral — implémente les ports LLMProvider / LLMChatProvider.
|
||||
|
||||
Mistral (La Plateforme) expose l'API OpenAI standard (POST {base}/chat/completions,
|
||||
SSE), donc cet adapter est un client "OpenAI-compatible" — même structure que
|
||||
l'adapter OpenRouter. Le `generate` one-shot passe par le streaming (puis
|
||||
recollage) avec un timeout au temps écoulé pour ne jamais pendre à l'infini.
|
||||
|
||||
Tier GRATUIT : compte sur console.mistral.ai (tier « Experiment »), clé API à
|
||||
coller dans l'écran Paramètres. Modèles conseillés pour l'extraction : un grand
|
||||
contexte fidèle comme `mistral-large-latest` (128k) ou `mistral-small-latest`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_URL = "https://api.mistral.ai/v1/chat/completions"
|
||||
|
||||
# Délai max pour le PREMIER token de contenu (échec rapide si le modèle est en file
|
||||
# d'attente et n'envoie que des keep-alive). Généreux car la file d'un tier gratuit
|
||||
# peut être longue.
|
||||
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
class MistralLLMProvider:
|
||||
"""Adapter Mistral (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
if not settings.mistral_api_key:
|
||||
raise LLMProviderError(
|
||||
"Clé API Mistral manquante. Configure-la depuis l'écran Paramètres."
|
||||
)
|
||||
self._api_key = settings.mistral_api_key
|
||||
self._model = settings.mistral_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
|
||||
|
||||
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx :
|
||||
si le provider envoyait des keep-alive sans contenu, l'appel pendrait à
|
||||
l'infini. Ici on coupe net après `self._timeout` secondes.
|
||||
"""
|
||||
return await self._collect_with_timeouts(
|
||||
[ChatMessage(role="user", content=prompt)], temperature, output_format
|
||||
)
|
||||
|
||||
async def _collect_with_timeouts(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
temperature: float | None,
|
||||
output_format: str | None,
|
||||
) -> str:
|
||||
"""Collecte le stream avec deux garde-fous au temps écoulé : 1er token borné
|
||||
(file d'attente → échec rapide) + ceiling global `self._timeout`."""
|
||||
async def _collect() -> str:
|
||||
chunks: list[str] = []
|
||||
agen = self._stream(messages, None, temperature, output_format)
|
||||
try:
|
||||
while True:
|
||||
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
|
||||
try:
|
||||
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise LLMProviderError(
|
||||
f"Erreur Mistral : aucun contenu produit en "
|
||||
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle est probablement "
|
||||
"en file d'attente (tier gratuit, 2 req/min). Réessayez plus tard ou "
|
||||
"choisissez un modèle plus disponible."
|
||||
)
|
||||
chunks.append(token)
|
||||
finally:
|
||||
await agen.aclose()
|
||||
return "".join(chunks)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMGenerationTimeout(
|
||||
f"Erreur Mistral : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
||||
) from exc
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
async for token in self._stream(messages, system_prompt, temperature):
|
||||
yield token
|
||||
|
||||
async def _stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
system_prompt: str | None,
|
||||
temperature: float | None,
|
||||
output_format: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
payload_messages: list[dict[str, str]] = []
|
||||
if system_prompt:
|
||||
payload_messages.append({"role": "system", "content": system_prompt})
|
||||
for m in messages:
|
||||
payload_messages.append({"role": m.role, "content": m.content})
|
||||
|
||||
body: dict[str, object] = {
|
||||
"model": self._model,
|
||||
"messages": payload_messages,
|
||||
"stream": True,
|
||||
}
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
# Mode JSON natif : TOUS les modèles Mistral le supportent → plus de fences
|
||||
# ```json ni de JSON invalide (retours à la ligne bruts dans les chaînes),
|
||||
# principale cause de morceaux d'import ignorés. Un SCHÉMA (dict) est
|
||||
# traduit en json_object : suffisant ici, les grands modèles cloud
|
||||
# respectent la structure demandée par le prompt.
|
||||
if output_format is not None:
|
||||
body["response_format"] = {"type": "json_object"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", _API_URL, headers=self._headers(), json=body
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
# En streaming le corps n'est pas lu automatiquement : on le
|
||||
# lit pour exposer le détail de Mistral (modèle inconnu, clé
|
||||
# invalide 401, quota 429…), sinon on n'a que le code HTTP nu.
|
||||
detail = (await response.aread()).decode("utf-8", "replace").strip()
|
||||
raise LLMProviderError(
|
||||
f"Erreur Mistral (HTTP {response.status_code})"
|
||||
+ (f" : {detail[:500]}" if detail else "")
|
||||
)
|
||||
async for token in self._parse_sse(response):
|
||||
yield token
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(self._format_http_error(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
|
||||
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
|
||||
async for line in response.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue # lignes vides ou keep-alive (`: ...`)
|
||||
data = line[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = obj.get("choices")
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
def _format_http_error(self, exc: httpx.HTTPError) -> str:
|
||||
"""Message lisible (timeout, quota 429, clé invalide 401, modèle inconnu…)."""
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return (
|
||||
f"Erreur Mistral : délai dépassé (timeout {self._timeout}s). Le modèle a "
|
||||
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
|
||||
)
|
||||
detail = str(exc) or exc.__class__.__name__
|
||||
return f"Erreur Mistral ({exc.__class__.__name__}) : {detail}"
|
||||
59
brain/app/infrastructure/mistral_embedding_adapter.py
Normal file
59
brain/app/infrastructure/mistral_embedding_adapter.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Adapter d'embeddings Mistral (cloud, EU) — POST /v1/embeddings.
|
||||
|
||||
Soumis au rate limit du tier gratuit : pour indexer un gros document on envoie
|
||||
les textes par lots (et l'appelant peut espacer les appels si besoin).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.embeddings import EmbeddingError
|
||||
from app.core.config import Settings
|
||||
|
||||
_API_URL = "https://api.mistral.ai/v1/embeddings"
|
||||
# Lot raisonnable pour ne pas envoyer un payload géant d'un coup.
|
||||
_BATCH_SIZE = 64
|
||||
|
||||
|
||||
class MistralEmbeddingProvider:
|
||||
"""Implémente EmbeddingProvider via l'API Mistral embeddings."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
if not settings.mistral_api_key:
|
||||
raise EmbeddingError(
|
||||
"Clé API Mistral manquante (requise pour les embeddings Mistral). "
|
||||
"Configure-la dans les Paramètres ou choisis Ollama pour les embeddings."
|
||||
)
|
||||
self._api_key = settings.mistral_api_key
|
||||
self._model = settings.mistral_embedding_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
|
||||
# `kind` ignoré : mistral-embed n'utilise pas de préfixe de tâche.
|
||||
if not texts:
|
||||
return []
|
||||
out: list[list[float]] = []
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
for start in range(0, len(texts), _BATCH_SIZE):
|
||||
batch = texts[start:start + _BATCH_SIZE]
|
||||
try:
|
||||
response = await client.post(
|
||||
_API_URL, headers=headers, json={"model": self._model, "input": batch})
|
||||
if response.status_code >= 400:
|
||||
raise EmbeddingError(
|
||||
f"Mistral embeddings HTTP {response.status_code} : "
|
||||
f"{response.text.strip()[:300]}")
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise EmbeddingError(f"Erreur Mistral embeddings : {exc}") from exc
|
||||
|
||||
items = data.get("data")
|
||||
if not isinstance(items, list) or len(items) != len(batch):
|
||||
raise EmbeddingError("Réponse d'embeddings Mistral inattendue (taille incohérente).")
|
||||
for item in items:
|
||||
out.append([float(x) for x in item.get("embedding", [])])
|
||||
return out
|
||||
@@ -5,13 +5,16 @@ Isole le reste de l'application des spécificités du protocole Ollama
|
||||
demain, on écrit un nouvel adapter sans toucher au reste du code.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaLLMProvider:
|
||||
@@ -45,7 +48,7 @@ class OllamaLLMProvider:
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
output_format: str | dict | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
url = f"{self._base_url}/api/generate"
|
||||
@@ -55,6 +58,10 @@ class OllamaLLMProvider:
|
||||
"stream": False,
|
||||
"options": self._build_options(temperature),
|
||||
}
|
||||
# "json" (mode JSON simple) ou un SCHÉMA JSON complet (structured outputs) :
|
||||
# Ollama contraint alors la grammaire de génération au schéma — un petit
|
||||
# modèle local ne PEUT physiquement plus produire d'objets imbriqués, de
|
||||
# clés "thought" bavardes ou de texte hors JSON.
|
||||
if output_format is not None:
|
||||
payload["format"] = output_format
|
||||
|
||||
@@ -71,12 +78,45 @@ class OllamaLLMProvider:
|
||||
raise LLMProviderError(
|
||||
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||
)
|
||||
except httpx.ConnectTimeout as exc:
|
||||
# Serveur injoignable : erreur d'infrastructure, pas de lenteur.
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||
) from exc
|
||||
except httpx.TimeoutException as exc:
|
||||
# `stream: False` → le read-timeout court jusqu'à la réponse COMPLÈTE,
|
||||
# donc le dépasser = génération trop lente pour la sortie demandée
|
||||
# (fréquent : modèle local modeste + gros morceau d'import à réécrire).
|
||||
# Type dédié → pas de retry à l'identique ; l'import re-découpe le
|
||||
# morceau en deux moitiés (sortie 2× plus courte) à la place.
|
||||
raise LLMGenerationTimeout(
|
||||
f"Erreur Ollama : génération non terminée en {self._timeout}s. Réduisez "
|
||||
"la taille des morceaux d'import, augmentez le timeout, ou utilisez un "
|
||||
"modèle plus rapide."
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(
|
||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||
) from exc
|
||||
|
||||
return response.json()["response"]
|
||||
data = response.json()
|
||||
# Diagnostic crucial pour les imports : `done_reason` != "stop" signifie que
|
||||
# la génération a été INTERROMPUE (fenêtre de contexte pleine, num_predict…)
|
||||
# et non terminée par le modèle. Sans ce log, on ne voit qu'un JSON coupé
|
||||
# en aval, sans la cause. `prompt_eval_count` révèle aussi la VRAIE taille
|
||||
# du prompt en tokens du modèle (les morceaux sont mesurés en tokens
|
||||
# cl100k, ~20-40% plus compacts que les tokenizers locaux).
|
||||
done_reason = data.get("done_reason")
|
||||
if done_reason and done_reason != "stop":
|
||||
logger.warning(
|
||||
"Ollama a interrompu la génération (done_reason=%s) : prompt=%s tokens, "
|
||||
"sortie=%s tokens, num_ctx demandé=%s. Si prompt+sortie ≈ num_ctx, la "
|
||||
"fenêtre de contexte est pleine : réduisez la taille des morceaux "
|
||||
"d'import ou augmentez num_ctx (Paramètres).",
|
||||
done_reason, data.get("prompt_eval_count"),
|
||||
data.get("eval_count"), self._num_ctx,
|
||||
)
|
||||
return data["response"]
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
|
||||
62
brain/app/infrastructure/ollama_embedding_adapter.py
Normal file
62
brain/app/infrastructure/ollama_embedding_adapter.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Adapter d'embeddings Ollama (local) — endpoint /api/embed.
|
||||
|
||||
Gratuit et illimité (tourne sur la machine). Nécessite d'avoir pullé le modèle
|
||||
d'embedding (ex. `ollama pull nomic-embed-text`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.application.embeddings import EmbeddingError
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
# Préfixes de tâche des modèles nomic-embed : le modèle est ENTRAÎNÉ avec
|
||||
# (search_document pour le corpus, search_query pour la question). Sans eux,
|
||||
# la pertinence du retrieval est mesurablement dégradée. Ne s'applique qu'aux
|
||||
# modèles nomic — les autres (mxbai, bge…) ont leurs propres conventions ou
|
||||
# aucune ; on reste neutre pour eux.
|
||||
_NOMIC_PREFIXES = {"document": "search_document: ", "query": "search_query: "}
|
||||
|
||||
|
||||
class OllamaEmbeddingProvider:
|
||||
"""Implémente EmbeddingProvider via Ollama /api/embed (batch)."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.ollama_base_url
|
||||
self._model = settings.ollama_embedding_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
def _prepare(self, texts: list[str], kind: str) -> list[str]:
|
||||
"""Applique le préfixe de tâche si le modèle est de la famille nomic-embed.
|
||||
|
||||
NB : les sources indexées AVANT l'introduction des préfixes doivent être
|
||||
ré-uploadées pour que documents et questions vivent dans le même espace.
|
||||
"""
|
||||
if "nomic-embed" not in self._model:
|
||||
return texts
|
||||
prefix = _NOMIC_PREFIXES.get(kind, _NOMIC_PREFIXES["document"])
|
||||
return [prefix + t for t in texts]
|
||||
|
||||
async def embed(self, texts: list[str], kind: str = "document") -> list[list[float]]:
|
||||
if not texts:
|
||||
return []
|
||||
url = f"{self._base_url}/api/embed"
|
||||
payload = {"model": self._model, "input": self._prepare(texts, kind)}
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
response = await client.post(url, json=payload)
|
||||
if response.status_code >= 400:
|
||||
body = response.text
|
||||
raise EmbeddingError(
|
||||
f"Ollama embeddings HTTP {response.status_code} : {body.strip()[:300]}. "
|
||||
f"Le modèle '{self._model}' est-il installé ? (ollama pull {self._model})"
|
||||
)
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise EmbeddingError(f"Erreur Ollama embeddings : {exc}") from exc
|
||||
|
||||
vectors = data.get("embeddings")
|
||||
if not isinstance(vectors, list) or len(vectors) != len(texts):
|
||||
raise EmbeddingError("Réponse d'embeddings Ollama inattendue (taille incohérente).")
|
||||
return [[float(x) for x in v] for v in vectors]
|
||||
51
brain/app/infrastructure/ollama_model_installer.py
Normal file
51
brain/app/infrastructure/ollama_model_installer.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Auto-installation du modèle d'embeddings Ollama au démarrage du Brain.
|
||||
|
||||
Adapter d'infrastructure : parle directement à l'API HTTP d'Ollama. Best-effort
|
||||
(Ollama peut être absent / la connexion limitée) — n'empêche jamais le démarrage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_ollama_embedding_model(base_url: str, model: str) -> None:
|
||||
"""Télécharge `model` sur le serveur Ollama s'il n'y est pas déjà.
|
||||
|
||||
Attend qu'Ollama soit joignable (ordre de démarrage des conteneurs), puis
|
||||
vérifie la présence du modèle avant de le tirer.
|
||||
"""
|
||||
for attempt in range(10):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
tags = await client.get(f"{base_url}/api/tags")
|
||||
tags.raise_for_status()
|
||||
names = [m.get("name", "") for m in tags.json().get("models", [])]
|
||||
if any(n == model or n.startswith(model + ":") for n in names):
|
||||
logger.info("Modèle d'embedding '%s' déjà présent.", model)
|
||||
return
|
||||
break # Ollama joignable, modèle absent → on tire (ci-dessous)
|
||||
except httpx.HTTPError:
|
||||
await asyncio.sleep(min(5 * (attempt + 1), 30))
|
||||
else:
|
||||
logger.warning(
|
||||
"Ollama injoignable au démarrage — modèle d'embedding '%s' non auto-installé "
|
||||
"(il sera tirable manuellement : ollama pull %s).", model, model)
|
||||
return
|
||||
|
||||
logger.info("Téléchargement automatique du modèle d'embedding '%s'…", model)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream("POST", f"{base_url}/api/pull", json={"name": model}) as resp:
|
||||
resp.raise_for_status()
|
||||
async for _line in resp.aiter_lines():
|
||||
pass # on draine la progression NDJSON jusqu'à la fin
|
||||
logger.info("Modèle d'embedding '%s' prêt.", model)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning(
|
||||
"Auto-installation du modèle d'embedding '%s' échouée : %s "
|
||||
"(tirage manuel possible : ollama pull %s).", model, exc, model)
|
||||
@@ -14,6 +14,7 @@ avec des marqueurs de role lisibles pour le modele.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
@@ -22,6 +23,8 @@ from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
from app.domain.ports import LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_BASE = "https://api.1min.ai/api/chat-with-ai"
|
||||
_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI"
|
||||
|
||||
@@ -48,6 +51,18 @@ class OneMinAiLLMProvider:
|
||||
"promptObject": {"prompt": prompt},
|
||||
}
|
||||
|
||||
def _format_http_error(self, exc: httpx.HTTPError) -> str:
|
||||
"""Message d'erreur lisible. Un timeout httpx a un str() vide → on le nomme."""
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return (
|
||||
f"Erreur 1min.ai : délai dépassé (timeout {self._timeout}s). Le modèle a mis "
|
||||
"trop de temps à répondre — typique d'un morceau d'import trop gros. "
|
||||
"Réduisez « Taille des morceaux à l'import » (Paramètres → Import de PDF) "
|
||||
"ou augmentez le timeout LLM."
|
||||
)
|
||||
detail = str(exc) or exc.__class__.__name__
|
||||
return f"Erreur 1min.ai ({exc.__class__.__name__}) : {detail}"
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -55,18 +70,18 @@ class OneMinAiLLMProvider:
|
||||
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
|
||||
"""One-shot, mais via l'endpoint STREAMING (puis recollage).
|
||||
|
||||
return self._extract_result(data)
|
||||
On NE passe PAS par l'endpoint non-streame `chat-with-ai` : sur les longues
|
||||
generations (gros imports), la passerelle Cloudflare de 1min.ai coupe la
|
||||
connexion au bout de ~100s et renvoie un HTTP 524. En streaming, des octets
|
||||
circulent en continu => pas de 524, quelle que soit la duree. On accumule
|
||||
tous les fragments pour reconstituer la reponse complete.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
async for token in self._stream_prompt(prompt):
|
||||
chunks.append(token)
|
||||
return "".join(chunks)
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
@@ -75,17 +90,18 @@ class OneMinAiLLMProvider:
|
||||
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.
|
||||
"""
|
||||
"""Streame une conversation : aplatit les messages puis delegue au coeur SSE."""
|
||||
prompt = self._flatten_messages(messages, system_prompt)
|
||||
url = f"{_API_BASE}?isStreaming=true"
|
||||
async for token in self._stream_prompt(prompt):
|
||||
yield token
|
||||
|
||||
async def _stream_prompt(self, prompt: str) -> AsyncIterator[str]:
|
||||
"""Coeur du streaming SSE 1min.ai (`?isStreaming=true`) pour un prompt brut.
|
||||
|
||||
1min.ai expose : `event: content` → `data: {"content": "..."}`, `event: done`,
|
||||
`event: error`. On yield le champ `content` au fil de l'arrivee.
|
||||
"""
|
||||
url = f"{_API_BASE}?isStreaming=true"
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
@@ -95,9 +111,7 @@ class OneMinAiLLMProvider:
|
||||
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
|
||||
raise LLMProviderError(self._format_http_error(exc)) from exc
|
||||
|
||||
# --- Helpers ------------------------------------------------------------
|
||||
|
||||
@@ -146,12 +160,21 @@ class OneMinAiLLMProvider:
|
||||
"""
|
||||
record = payload.get("aiRecord") or {}
|
||||
detail = record.get("aiRecordDetail") or {}
|
||||
result = detail.get("resultObject") or []
|
||||
if isinstance(result, list):
|
||||
result = detail.get("resultObject")
|
||||
if isinstance(result, list) and result:
|
||||
return "".join(str(x) for x in result)
|
||||
if isinstance(result, str):
|
||||
if isinstance(result, str) and result:
|
||||
return result
|
||||
raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.")
|
||||
|
||||
# Schema inattendu : on remonte un EXTRAIT du vrai payload pour diagnostiquer.
|
||||
# Causes frequentes : credits/quota 1min.ai epuises, moderation, modele
|
||||
# indisponible, ou reponse asynchrone (record cree mais resultat pas encore
|
||||
# pret). Sans ce detail, l'erreur "resultObject absent" est aveugle.
|
||||
snippet = json.dumps(payload, ensure_ascii=False)
|
||||
if len(snippet) > 800:
|
||||
snippet = snippet[:800] + "…"
|
||||
logger.warning("Reponse 1min.ai inattendue (resultObject absent) : %s", snippet)
|
||||
raise LLMProviderError(f"Reponse 1min.ai inattendue (resultObject absent) : {snippet}")
|
||||
|
||||
@staticmethod
|
||||
def _flatten_messages(
|
||||
|
||||
205
brain/app/infrastructure/openrouter_adapter.py
Normal file
205
brain/app/infrastructure/openrouter_adapter.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
|
||||
|
||||
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
|
||||
cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot
|
||||
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de
|
||||
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524).
|
||||
|
||||
Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
|
||||
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
|
||||
qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import ChatMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
||||
|
||||
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
# Délai max pour le PREMIER token de contenu. Un modèle gratuit "en file d'attente"
|
||||
# n'envoie que des keep-alive (aucun contenu) → on échoue vite et clairement au lieu
|
||||
# de pendre. Généreux (2 min) car la file d'attente d'un tier gratuit peut être longue.
|
||||
_FIRST_TOKEN_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
class OpenRouterLLMProvider:
|
||||
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
if not settings.openrouter_api_key:
|
||||
raise LLMProviderError(
|
||||
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
|
||||
)
|
||||
self._api_key = settings.openrouter_api_key
|
||||
self._model = settings.openrouter_model
|
||||
self._timeout = settings.llm_timeout_seconds
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
|
||||
"HTTP-Referer": "https://loremind.app",
|
||||
"X-Title": "LoreMind",
|
||||
}
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> str:
|
||||
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties.
|
||||
|
||||
Timeout au TEMPS ÉCOULÉ (asyncio) en plus du timeout réseau d'httpx : un
|
||||
modèle gratuit saturé/en file d'attente envoie des keep-alive (`: OPENROUTER
|
||||
PROCESSING`) mais AUCUN contenu → httpx ne déclenche jamais son read-timeout
|
||||
(des octets arrivent) et l'appel pendrait à l'infini. Ici on coupe net après
|
||||
`self._timeout` secondes, quoi qu'il arrive.
|
||||
"""
|
||||
return await self._collect_with_timeouts(
|
||||
[ChatMessage(role="user", content=prompt)], temperature, output_format, "OpenRouter"
|
||||
)
|
||||
|
||||
async def _collect_with_timeouts(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
temperature: float | None,
|
||||
output_format: str | None,
|
||||
provider: str,
|
||||
) -> str:
|
||||
"""Collecte le stream avec DEUX garde-fous au temps écoulé :
|
||||
- 1er token borné (`_FIRST_TOKEN_TIMEOUT_SECONDS`) : détecte un modèle bloqué
|
||||
en file d'attente (que des keep-alive, aucun contenu) → échec rapide ;
|
||||
- ceiling global (`self._timeout`) : génération qui ne se termine jamais.
|
||||
Le timeout réseau d'httpx ne suffit pas : des keep-alive font 'arriver des
|
||||
octets' et empêchent son read-timeout de se déclencher.
|
||||
"""
|
||||
async def _collect() -> str:
|
||||
chunks: list[str] = []
|
||||
agen = self._stream(messages, None, temperature, output_format)
|
||||
try:
|
||||
while True:
|
||||
# Borne SEULEMENT l'attente du 1er token (file d'attente) ; ensuite
|
||||
# on laisse générer (le ceiling global couvre le reste).
|
||||
first = _FIRST_TOKEN_TIMEOUT_SECONDS if not chunks else None
|
||||
try:
|
||||
token = await asyncio.wait_for(agen.__anext__(), timeout=first)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise LLMProviderError(
|
||||
f"Erreur {provider} : aucun contenu produit en "
|
||||
f"{int(_FIRST_TOKEN_TIMEOUT_SECONDS)}s — le modèle gratuit est "
|
||||
"probablement en file d'attente / saturé. Réessayez plus tard ou "
|
||||
"choisissez un autre modèle (1min.ai, ou payant)."
|
||||
)
|
||||
chunks.append(token)
|
||||
finally:
|
||||
await agen.aclose()
|
||||
return "".join(chunks)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(_collect(), timeout=self._timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise LLMGenerationTimeout(
|
||||
f"Erreur {provider} : génération non terminée en {self._timeout}s. Réduisez la "
|
||||
"taille des morceaux d'import, augmentez le timeout, ou changez de modèle."
|
||||
) from exc
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
async for token in self._stream(messages, system_prompt, temperature):
|
||||
yield token
|
||||
|
||||
async def _stream(
|
||||
self,
|
||||
messages: list[ChatMessage],
|
||||
system_prompt: str | None,
|
||||
temperature: float | None,
|
||||
output_format: str | None = None,
|
||||
) -> AsyncIterator[str]:
|
||||
payload_messages: list[dict[str, str]] = []
|
||||
if system_prompt:
|
||||
payload_messages.append({"role": "system", "content": system_prompt})
|
||||
for m in messages:
|
||||
payload_messages.append({"role": m.role, "content": m.content})
|
||||
|
||||
body: dict[str, object] = {
|
||||
"model": self._model,
|
||||
"messages": payload_messages,
|
||||
"stream": True,
|
||||
}
|
||||
if temperature is not None:
|
||||
body["temperature"] = temperature
|
||||
# NB : on n'impose PAS `response_format=json_object`. Beaucoup de modèles/
|
||||
# providers GRATUITS ne le supportent pas et renvoient une réponse VIDE.
|
||||
# On laisse le modèle répondre librement ; l'extraction JSON en aval
|
||||
# (load_json_object + nettoyage du raisonnement) récupère le JSON dans la prose.
|
||||
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", _API_URL, headers=self._headers(), json=body
|
||||
) as response:
|
||||
if response.status_code >= 400:
|
||||
# En streaming, le corps n'est pas lu automatiquement : on le
|
||||
# lit pour exposer le détail d'OpenRouter (ex. le 429 précise
|
||||
# "free-models-per-day" vs "per-minute"), sinon on n'a que le
|
||||
# code HTTP nu et le diagnostic est impossible.
|
||||
detail = (await response.aread()).decode("utf-8", "replace").strip()
|
||||
raise LLMProviderError(
|
||||
f"Erreur OpenRouter (HTTP {response.status_code})"
|
||||
+ (f" : {detail[:500]}" if detail else "")
|
||||
)
|
||||
async for token in self._parse_sse(response):
|
||||
yield token
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMProviderError(self._format_http_error(exc)) from exc
|
||||
|
||||
@staticmethod
|
||||
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
|
||||
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
|
||||
async for line in response.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue # lignes vides ou commentaires keep-alive (`: ...`)
|
||||
data = line[len("data:"):].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
obj = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = obj.get("choices")
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
def _format_http_error(self, exc: httpx.HTTPError) -> str:
|
||||
"""Message lisible (timeout, quota 429, crédits 402, modèle inconnu…)."""
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return (
|
||||
f"Erreur OpenRouter : délai dépassé (timeout {self._timeout}s). Le modèle a "
|
||||
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
|
||||
)
|
||||
detail = str(exc) or exc.__class__.__name__
|
||||
return f"Erreur OpenRouter ({exc.__class__.__name__}) : {detail}"
|
||||
113
brain/app/infrastructure/pdf_extractor.py
Normal file
113
brain/app/infrastructure/pdf_extractor.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Adapter d'extraction de texte PDF — implémente le port PdfTextExtractor.
|
||||
|
||||
Stratégie HYBRIDE auto :
|
||||
1. On tente d'abord l'extraction de la couche texte (PyMuPDF). Les PDF
|
||||
"born-digital" (livres de règles officiels type Nimble, faits dans
|
||||
InDesign/Affinity : très graphiques mais avec une vraie couche texte)
|
||||
passent par là → rapide, fidèle, AUCUN OCR.
|
||||
2. Si une page ne rend (quasi) aucun texte → c'est probablement une image
|
||||
(scan ou page 100% illustrée). On rasterise la page et on la passe à
|
||||
Tesseract (OCR). Gère donc aussi les scans purs et les PDF mixtes.
|
||||
|
||||
Tesseract est un binaire SYSTÈME (installé dans l'image Docker du Brain). S'il
|
||||
est absent (ex: exécution locale Windows sans install), l'OCR est désactivé
|
||||
proprement : les pages-images ressortent vides mais l'extraction ne plante pas,
|
||||
et le diagnostic le signale (used_ocr reste False, texte vide).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pymupdf as fitz # PyMuPDF — on importe par le nom canonique `pymupdf`
|
||||
# (et NON `import fitz`) pour éviter la collision avec le faux paquet PyPI "fitz"
|
||||
# qui échoue sur `from frontend import *`.
|
||||
|
||||
from app.domain.models import ExtractedDocument, ExtractedPage, TocEntry
|
||||
from app.domain.ports import PdfExtractionError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# En dessous de ce nombre de caractères "significatifs" sur une page, on
|
||||
# considère qu'il n'y a pas de couche texte exploitable → repli OCR.
|
||||
_MIN_TEXT_CHARS = 20
|
||||
|
||||
# DPI de rasterisation avant OCR. 300 = bon compromis qualité/vitesse pour du
|
||||
# texte de livre. Plus haut = plus lent et plus gourmand en mémoire.
|
||||
_OCR_DPI = 300
|
||||
|
||||
# Langues Tesseract : français + anglais (la plupart des règles de JDR FR ont
|
||||
# des termes anglais résiduels). Doivent être installées dans l'image Docker
|
||||
# (tesseract-ocr-fra, tesseract-ocr-eng).
|
||||
_OCR_LANGS = "fra+eng"
|
||||
|
||||
|
||||
class PyMuPdfTextExtractor:
|
||||
"""Extracteur PDF basé sur PyMuPDF, avec repli OCR Tesseract optionnel."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# On détecte la disponibilité de l'OCR une seule fois (le binaire
|
||||
# Tesseract ne va pas apparaître/disparaître en cours d'exécution).
|
||||
self._ocr_available = self._detect_ocr()
|
||||
|
||||
@staticmethod
|
||||
def _detect_ocr() -> bool:
|
||||
"""True si pytesseract + le binaire Tesseract sont disponibles."""
|
||||
try:
|
||||
import pytesseract
|
||||
|
||||
pytesseract.get_tesseract_version()
|
||||
return True
|
||||
except Exception as exc: # ImportError, TesseractNotFoundError, etc.
|
||||
logger.warning(
|
||||
"OCR indisponible (Tesseract non installé ?) : %s. "
|
||||
"Les pages sans couche texte ressortiront vides.",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
def extract(self, pdf_bytes: bytes) -> ExtractedDocument:
|
||||
try:
|
||||
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
|
||||
except Exception as exc:
|
||||
raise PdfExtractionError(f"PDF illisible ou corrompu : {exc}") from exc
|
||||
|
||||
pages: list[ExtractedPage] = []
|
||||
toc: list[TocEntry] = []
|
||||
try:
|
||||
# Bookmarks/outline du PDF : structure officielle du livre, gratuite
|
||||
# (pas d'appel LLM). Sert de squelette de référence aux imports.
|
||||
try:
|
||||
for level, title, page_no in doc.get_toc(simple=True) or []:
|
||||
title = str(title or "").strip()
|
||||
if title:
|
||||
toc.append(TocEntry(level=int(level), title=title, page=int(page_no)))
|
||||
except Exception as exc: # noqa: BLE001 — TOC best-effort, jamais bloquante
|
||||
logger.warning("Lecture de la table des matières impossible : %s", exc)
|
||||
|
||||
for index, page in enumerate(doc):
|
||||
text = (page.get_text() or "").strip()
|
||||
used_ocr = False
|
||||
if len(text) < _MIN_TEXT_CHARS and self._ocr_available:
|
||||
ocr_text = self._ocr_page(page)
|
||||
if ocr_text.strip():
|
||||
text = ocr_text.strip()
|
||||
used_ocr = True
|
||||
pages.append(ExtractedPage(index=index, text=text, used_ocr=used_ocr))
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
return ExtractedDocument(pages=pages, toc=toc)
|
||||
|
||||
@staticmethod
|
||||
def _ocr_page(page: "fitz.Page") -> str:
|
||||
"""Rasterise une page et lui applique l'OCR Tesseract."""
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
pix = page.get_pixmap(dpi=_OCR_DPI)
|
||||
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
try:
|
||||
return pytesseract.image_to_string(img, lang=_OCR_LANGS)
|
||||
except Exception as exc:
|
||||
logger.warning("Échec OCR sur la page %s : %s", page.number, exc)
|
||||
return ""
|
||||
218
brain/app/infrastructure/vector_store.py
Normal file
218
brain/app/infrastructure/vector_store.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Stockage vectoriel fichier (RAG des notebooks) — sans dépendance lourde.
|
||||
|
||||
Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain :
|
||||
data/notebooks/{source_id}.json = {"dim": N, "chunks": [{"text":..., "vector":[...]}]}
|
||||
|
||||
À l'échelle d'un livre (quelques centaines d'extraits), une recherche cosinus en
|
||||
Python pur est instantanée — inutile d'ajouter numpy/pgvector/une base vectorielle.
|
||||
Les fichiers sont mis en cache mémoire (invalidation par mtime) : le coûteux est
|
||||
le re-parse JSON des vecteurs, pas le cosinus.
|
||||
|
||||
Recherche HYBRIDE : score = cosinus + bonus lexical (mots significatifs de la
|
||||
question présents dans l'extrait). Sur du JdR, les requêtes sont souvent des noms
|
||||
propres exacts (« Strahd », « Barovia ») où le lexical bat l'embedding.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STORE_DIR = Path("data/notebooks")
|
||||
_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]")
|
||||
|
||||
# Cache mémoire {source_id: (mtime_ns, chunks)} — évite de relire/re-parser le JSON
|
||||
# (vecteurs = gros) à chaque question. Invalidé si le fichier change (mtime).
|
||||
_CACHE: dict[str, tuple[int, list[dict]]] = {}
|
||||
_CACHE_MAX_SOURCES = 32 # garde-fou mémoire : ~10 Mo par gros livre en cache
|
||||
|
||||
# Poids du bonus lexical dans le score hybride. Le cosinus reste dominant ; le
|
||||
# bonus (0..0.15) sert surtout à départager / repêcher les correspondances exactes.
|
||||
_LEX_WEIGHT = 0.15
|
||||
_WORD_RE = re.compile(r"[a-z0-9àâäçéèêëîïôöùûüœæ]{3,}")
|
||||
# Mots-outils FR/EN fréquents (≥3 lettres) : sans eux, le bonus lexical serait
|
||||
# dominé par « les », « pour », « the »… au lieu des termes porteurs de sens.
|
||||
_STOPWORDS = frozenset({
|
||||
"les", "des", "une", "est", "son", "ses", "aux", "par", "pour", "dans",
|
||||
"sur", "avec", "qui", "que", "quoi", "dont", "mais", "comme", "plus",
|
||||
"pas", "tout", "tous", "toute", "toutes", "ils", "elles", "leur", "leurs",
|
||||
"nous", "vous", "cette", "ces", "cet", "ont", "sont", "fait", "etre",
|
||||
"être", "avoir", "peut", "quel", "quelle", "quels", "quelles", "ainsi",
|
||||
"the", "and", "for", "with", "this", "that", "are", "was", "has", "have",
|
||||
"not", "you", "his", "her", "its", "they", "them", "from", "what", "which",
|
||||
})
|
||||
|
||||
|
||||
def _path(source_id: str) -> Path:
|
||||
safe = _SAFE_ID.sub("_", str(source_id))
|
||||
return _STORE_DIR / f"{safe}.json"
|
||||
|
||||
|
||||
def save(
|
||||
source_id: str,
|
||||
chunks: list[str],
|
||||
vectors: list[list[float]],
|
||||
pages: list[int] | None = None,
|
||||
) -> int:
|
||||
"""Persiste les (chunk, vecteur[, page]) d'une source. Renvoie le nb d'extraits."""
|
||||
if len(chunks) != len(vectors):
|
||||
raise ValueError("chunks et vectors de tailles différentes")
|
||||
if pages is not None and len(pages) != len(chunks):
|
||||
raise ValueError("pages et chunks de tailles différentes")
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
items = []
|
||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
||||
item = {"text": c, "vector": v}
|
||||
if pages is not None:
|
||||
item["page"] = pages[i]
|
||||
items.append(item)
|
||||
payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items}
|
||||
_path(source_id).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
_CACHE.pop(source_id, None) # le mtime suffirait, mais soyons explicites
|
||||
return len(chunks)
|
||||
|
||||
|
||||
def exists(source_id: str) -> bool:
|
||||
return _path(source_id).exists()
|
||||
|
||||
|
||||
def delete(source_id: str) -> None:
|
||||
_CACHE.pop(source_id, None)
|
||||
_path(source_id).unlink(missing_ok=True)
|
||||
_summaries_path(source_id).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --- Index de résumés (analyse approfondie) ----------------------------------
|
||||
# Cache disque des résumés PAR LOT d'une source : construit paresseusement à la
|
||||
# première analyse approfondie, réutilisé ensuite pour ne relire que les lots
|
||||
# pertinents. Invalidé avec la source (delete) et si batch_tokens change.
|
||||
|
||||
|
||||
def _summaries_path(source_id: str) -> Path:
|
||||
safe = _SAFE_ID.sub("_", str(source_id))
|
||||
return _STORE_DIR / f"{safe}.summaries.json"
|
||||
|
||||
|
||||
def save_summaries(source_id: str, batch_tokens: int, entries: list[dict]) -> None:
|
||||
"""Persiste les résumés de lots ({"summary": str, "vector": [...]})."""
|
||||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"batch_tokens": int(batch_tokens), "entries": entries}
|
||||
_summaries_path(source_id).write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def load_summaries(source_id: str, batch_tokens: int) -> list[dict] | None:
|
||||
"""Résumés de lots d'une source, ou None si absents / construits avec une
|
||||
autre taille de lot (le découpage ne correspondrait plus)."""
|
||||
p = _summaries_path(source_id)
|
||||
if not p.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get("batch_tokens") != int(batch_tokens):
|
||||
return None
|
||||
entries = data.get("entries")
|
||||
return entries if isinstance(entries, list) else None
|
||||
|
||||
|
||||
def _load(source_id: str) -> list[dict]:
|
||||
p = _path(source_id)
|
||||
try:
|
||||
mtime = p.stat().st_mtime_ns
|
||||
except OSError:
|
||||
_CACHE.pop(source_id, None)
|
||||
return []
|
||||
cached = _CACHE.get(source_id)
|
||||
if cached is not None and cached[0] == mtime:
|
||||
return cached[1]
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
chunks = data.get("chunks", []) if isinstance(data, dict) else []
|
||||
if len(_CACHE) >= _CACHE_MAX_SOURCES:
|
||||
_CACHE.pop(next(iter(_CACHE))) # éviction FIFO simple
|
||||
_CACHE[source_id] = (mtime, chunks)
|
||||
return chunks
|
||||
|
||||
|
||||
def all_chunks(source_id: str) -> list[dict]:
|
||||
"""Tous les extraits d'une source (texte + page), sans vecteurs — pour le mode
|
||||
« analyse approfondie » (map-reduce sur tout le document)."""
|
||||
return [{"text": c.get("text", ""), "page": c.get("page")} for c in _load(source_id)]
|
||||
|
||||
|
||||
def _cosine(a: list[float], b: list[float]) -> float:
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = 0.0
|
||||
na = 0.0
|
||||
nb = 0.0
|
||||
for x, y in zip(a, b):
|
||||
dot += x * y
|
||||
na += x * x
|
||||
nb += y * y
|
||||
if na == 0.0 or nb == 0.0:
|
||||
return 0.0
|
||||
return dot / (math.sqrt(na) * math.sqrt(nb))
|
||||
|
||||
|
||||
def _significant_words(text: str) -> frozenset[str]:
|
||||
"""Mots porteurs de sens d'un texte (minuscules, ≥3 lettres, hors mots-outils)."""
|
||||
return frozenset(w for w in _WORD_RE.findall(text.lower()) if w not in _STOPWORDS)
|
||||
|
||||
|
||||
def _chunk_words(chunk: dict) -> frozenset[str]:
|
||||
"""Mots significatifs d'un extrait, mémoïsés sur le dict caché (calculés à la
|
||||
1ère recherche, réutilisés tant que la source reste en cache)."""
|
||||
words = chunk.get("_words")
|
||||
if words is None:
|
||||
words = _significant_words(chunk.get("text", ""))
|
||||
chunk["_words"] = words
|
||||
return words
|
||||
|
||||
|
||||
# Alias public du cosinus (réutilisé par l'index de résumés de l'analyse
|
||||
# approfondie — même métrique que la recherche).
|
||||
cosine_similarity = _cosine
|
||||
|
||||
|
||||
def search(
|
||||
source_ids: list[str],
|
||||
query_vector: list[float],
|
||||
top_k: int = 6,
|
||||
query_text: str = "",
|
||||
min_score: float = 0.0,
|
||||
) -> list[dict]:
|
||||
"""Renvoie les `top_k` extraits les plus proches, toutes sources confondues.
|
||||
|
||||
Score HYBRIDE : cosinus + `_LEX_WEIGHT` × (part des mots significatifs de
|
||||
`query_text` présents dans l'extrait). Les extraits dont le cosinus est sous
|
||||
`min_score` sont écartés (peut donc renvoyer MOINS de `top_k` résultats —
|
||||
mieux vaut aucun extrait que du bruit injecté dans le prompt).
|
||||
|
||||
Chaque résultat : {"text": str, "score": float, "source_id": str, "page": int|None}.
|
||||
"""
|
||||
query_words = _significant_words(query_text) if query_text else frozenset()
|
||||
scored: list[dict] = []
|
||||
for sid in source_ids:
|
||||
for chunk in _load(sid):
|
||||
vector = chunk.get("vector") or []
|
||||
cos = _cosine(query_vector, vector)
|
||||
if cos < min_score:
|
||||
continue
|
||||
score = cos
|
||||
if query_words:
|
||||
overlap = len(query_words & _chunk_words(chunk)) / len(query_words)
|
||||
score += _LEX_WEIGHT * overlap
|
||||
scored.append({
|
||||
"text": chunk.get("text", ""),
|
||||
"score": score,
|
||||
"source_id": sid,
|
||||
"page": chunk.get("page"),
|
||||
})
|
||||
scored.sort(key=lambda c: c["score"], reverse=True)
|
||||
return scored[:top_k]
|
||||
@@ -1,66 +1,35 @@
|
||||
"""Point d'entrée FastAPI du Brain LoreMind.
|
||||
"""Point d'entrée FastAPI du Brain LoreMind : assemblage de l'application.
|
||||
|
||||
Controller volontairement FIN : il valide l'entrée (DTOs Pydantic), délègue
|
||||
au domaine via injection de dépendance (ports + use cases), et transforme les
|
||||
erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici.
|
||||
Responsabilité UNIQUE : créer l'app, brancher le middleware d'auth inter-service,
|
||||
le hook de démarrage et les routers (un par responsabilité, voir `app.api.routers`).
|
||||
Toute la logique HTTP vit dans les routers ; la logique métier dans `app.application`.
|
||||
"""
|
||||
import json
|
||||
from typing import Annotated, AsyncIterator, Literal
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import httpx
|
||||
import tiktoken
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
import logging
|
||||
|
||||
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,
|
||||
CharacterSummary,
|
||||
NpcSummary,
|
||||
ChatMessage,
|
||||
GameSystemContext,
|
||||
LoreStructuralContext,
|
||||
NarrativeEntityContext,
|
||||
PageContext,
|
||||
PageGenerationContext,
|
||||
PageSummary,
|
||||
SceneBranchHint,
|
||||
SceneSummary,
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.routers import (
|
||||
chat,
|
||||
generation,
|
||||
imports,
|
||||
models,
|
||||
notebooks,
|
||||
settings as settings_router,
|
||||
tables,
|
||||
)
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
from app.core.config import get_settings
|
||||
from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_model
|
||||
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.6.6",
|
||||
version="0.13.0-beta",
|
||||
)
|
||||
|
||||
|
||||
# Encodeur tiktoken partagé — chargé une fois pour éviter le coût de lookup
|
||||
# à chaque requête. On utilise cl100k_base (GPT-3.5/4) comme tokenizer
|
||||
# universel approximatif : ±10% d'écart avec Llama/Gemma mais largement
|
||||
# suffisant pour une jauge visuelle à l'utilisateur.
|
||||
_TOKEN_ENCODER: tiktoken.Encoding | None = None
|
||||
|
||||
|
||||
def _count_tokens(text: str | None) -> int:
|
||||
"""Compte les tokens d'un texte via tiktoken. Null/empty → 0."""
|
||||
if not text:
|
||||
return 0
|
||||
global _TOKEN_ENCODER
|
||||
if _TOKEN_ENCODER is None:
|
||||
_TOKEN_ENCODER = tiktoken.get_encoding("cl100k_base")
|
||||
return len(_TOKEN_ENCODER.encode(text))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Chemins exemptes d'auth inter-service : healthcheck docker + introspection
|
||||
# FastAPI (docs uniquement utiles en dev ; en prod docker-compose, le Brain
|
||||
@@ -92,778 +61,31 @@ async def require_internal_secret(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# --- DTOs HTTP (frontière, c'est ici et seulement ici qu'on utilise Pydantic) ---
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
model: str
|
||||
response: str
|
||||
|
||||
|
||||
class GeneratePageRequestDTO(BaseModel):
|
||||
"""Contexte envoyé par le Core Java pour remplir une page via le LLM."""
|
||||
|
||||
lore_name: str
|
||||
folder_name: str
|
||||
template_name: str
|
||||
template_fields: list[str] = Field(min_length=1)
|
||||
page_title: str
|
||||
lore_description: str | None = None
|
||||
|
||||
|
||||
class GeneratePageResponseDTO(BaseModel):
|
||||
"""Retour : une valeur textuelle par champ du template (clé = field name)."""
|
||||
|
||||
values: dict[str, str]
|
||||
|
||||
|
||||
class ChatMessageDTO(BaseModel):
|
||||
"""Un message de la conversation. Rôles acceptés : user, assistant, system."""
|
||||
|
||||
role: str = Field(pattern="^(user|assistant|system)$")
|
||||
content: str
|
||||
|
||||
|
||||
class PageSummaryDTO(BaseModel):
|
||||
"""Résumé enrichi d'une page : identité + contenu + interconnexions.
|
||||
|
||||
Depuis b9 : values/tags/related_page_titles sont optionnels côté JSON —
|
||||
le Core Java ne les sérialise que s'ils sont non-vides (payload léger
|
||||
pour un Lore avec beaucoup de pages vierges).
|
||||
"""
|
||||
|
||||
title: str
|
||||
template_name: str
|
||||
values: dict[str, str] = Field(default_factory=dict)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
related_page_titles: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LoreContextDTO(BaseModel):
|
||||
"""Carte structurelle du Lore avec contenu des pages (b9+)."""
|
||||
|
||||
lore_name: str
|
||||
lore_description: str | None = None
|
||||
folders: dict[str, list[PageSummaryDTO]] = Field(default_factory=dict)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PageContextDTO(BaseModel):
|
||||
"""Contexte d'une page spécifique pour focaliser le chat (optionnel)."""
|
||||
|
||||
title: str
|
||||
template_name: str
|
||||
template_fields: list[str] = Field(default_factory=list)
|
||||
values: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SceneBranchHintDTO(BaseModel):
|
||||
"""Indice d'une branche narrative (le Core a deja resolu le nom cible)."""
|
||||
|
||||
label: str
|
||||
target_scene_name: str
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class 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 CharacterSummaryDTO(BaseModel):
|
||||
"""Résumé d'un PJ : nom + snippet. Pas de fiche complète au niveau résumé."""
|
||||
|
||||
name: str
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class NpcSummaryDTO(BaseModel):
|
||||
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
|
||||
|
||||
name: str
|
||||
snippet: str = ""
|
||||
|
||||
|
||||
class CampaignContextDTO(BaseModel):
|
||||
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||
|
||||
campaign_name: str
|
||||
campaign_description: str | None = None
|
||||
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
||||
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NarrativeEntityDTO(BaseModel):
|
||||
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
||||
|
||||
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
|
||||
title: str
|
||||
fields: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GameSystemContextDTO(BaseModel):
|
||||
"""Règles de JDR présélectionnées par le Core (filtrées par intent).
|
||||
|
||||
Les sections sont un dict titre_H2 → contenu_markdown. Peuvent être
|
||||
vides si aucune section ne matchait l'intent de génération courant.
|
||||
"""
|
||||
|
||||
system_name: str
|
||||
system_description: str | None = None
|
||||
sections: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class 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
|
||||
game_system_context: GameSystemContextDTO | 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.
|
||||
@app.on_event("startup")
|
||||
async def _auto_install_embedding_model() -> None:
|
||||
"""Au démarrage : si le provider d'embeddings est Ollama et que le modèle n'est
|
||||
pas installé, on le télécharge EN ARRIÈRE-PLAN → le RAG marche d'emblée pour un
|
||||
nouvel utilisateur, sans bloquer le démarrage du Brain. Best-effort (Ollama peut
|
||||
être absent / la connexion limitée) ; désactivable via `auto_pull_embedding_model`.
|
||||
"""
|
||||
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)
|
||||
game_system_context = _to_game_system_context(body.game_system_context)
|
||||
|
||||
# --- Comptage tokens pour la jauge de contexte frontend ---
|
||||
# On construit le system prompt une fois ici pour le compter — le use case
|
||||
# le reconstruira à l'identique en interne (coût négligeable : concat de str).
|
||||
# Cette duplication évite de complexifier le contrat stream() avec un
|
||||
# paramètre optionnel system_prompt précalculé.
|
||||
system_prompt_preview = use_case.build_system_prompt(
|
||||
lore_context=lore_context,
|
||||
page_context=page_context,
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
)
|
||||
# Dernier message = "current" (souvent user), le reste = historique accumulé.
|
||||
current_msg = messages[-1] if messages else None
|
||||
history_msgs = messages[:-1] if messages else []
|
||||
settings = get_settings()
|
||||
usage_payload = {
|
||||
"system": _count_tokens(system_prompt_preview),
|
||||
"history": sum(_count_tokens(m.content) for m in history_msgs),
|
||||
"current": _count_tokens(current_msg.content) if current_msg else 0,
|
||||
"max": settings.llm_num_ctx,
|
||||
}
|
||||
|
||||
async def event_stream() -> AsyncIterator[str]:
|
||||
# Event 'usage' émis en tout premier : le frontend peut afficher la
|
||||
# jauge avant même le premier token de réponse.
|
||||
yield f"event: usage\ndata: {json.dumps(usage_payload, ensure_ascii=False)}\n\n"
|
||||
try:
|
||||
async for token in use_case.stream(
|
||||
messages,
|
||||
lore_context=lore_context,
|
||||
page_context=page_context,
|
||||
campaign_context=campaign_context,
|
||||
narrative_entity=narrative_entity,
|
||||
game_system_context=game_system_context,
|
||||
):
|
||||
# json.dumps avec ensure_ascii=False pour préserver les accents
|
||||
yield f"data: {json.dumps({'token': token}, ensure_ascii=False)}\n\n"
|
||||
yield "event: done\ndata: {}\n\n"
|
||||
except LLMProviderError as exc:
|
||||
yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# --- Auto-titre d'une conversation persistee --------------------------------
|
||||
|
||||
|
||||
class SummarizeTitleMessageDTO(BaseModel):
|
||||
role: Literal["user", "assistant", "system"]
|
||||
content: str
|
||||
|
||||
|
||||
class SummarizeTitleRequestDTO(BaseModel):
|
||||
"""Premiers messages d'une conversation pour auto-generer un titre court."""
|
||||
|
||||
messages: list[SummarizeTitleMessageDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SummarizeTitleResponseDTO(BaseModel):
|
||||
title: str
|
||||
|
||||
|
||||
_TITLE_SYSTEM_PROMPT = (
|
||||
"Tu generes un titre court (4 a 7 mots max) qui resume le sujet de la "
|
||||
"conversation ci-dessous. Reponds UNIQUEMENT par le titre, sans guillemets, "
|
||||
"sans ponctuation finale, sans prefixe type 'Titre :'. Le titre doit etre "
|
||||
"en francais et capturer le sujet metier (pas 'Conversation IA')."
|
||||
)
|
||||
|
||||
|
||||
@app.post("/summarize/conversation-title", response_model=SummarizeTitleResponseDTO)
|
||||
async def summarize_conversation_title(
|
||||
body: SummarizeTitleRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
) -> SummarizeTitleResponseDTO:
|
||||
"""Genere un titre court a partir des premiers echanges de la conversation.
|
||||
|
||||
Appele par le core apres le 1er couple user/assistant, pour remplacer le
|
||||
titre provisoire "Nouvelle conversation" par quelque chose de parlant.
|
||||
"""
|
||||
if not body.messages:
|
||||
raise HTTPException(status_code=422, detail="Au moins un message requis")
|
||||
|
||||
transcript = "\n".join(f"{m.role.upper()}: {m.content}" for m in body.messages[:6])
|
||||
prompt = f"{_TITLE_SYSTEM_PROMPT}\n\nConversation :\n{transcript}\n\nTitre :"
|
||||
try:
|
||||
raw = await llm.generate(prompt)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
title = raw.strip().splitlines()[0].strip().strip('"').strip("'").rstrip(".")
|
||||
if len(title) > 80:
|
||||
title = title[:80].rstrip()
|
||||
if not title:
|
||||
title = "Nouvelle conversation"
|
||||
return SummarizeTitleResponseDTO(title=title)
|
||||
|
||||
|
||||
# --- 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
|
||||
]
|
||||
characters = [
|
||||
CharacterSummary(name=c.name, snippet=c.snippet)
|
||||
for c in dto.characters
|
||||
]
|
||||
npcs = [
|
||||
NpcSummary(name=n.name, snippet=n.snippet)
|
||||
for n in dto.npcs
|
||||
]
|
||||
return CampaignStructuralContext(
|
||||
campaign_name=dto.campaign_name,
|
||||
campaign_description=dto.campaign_description,
|
||||
arcs=arcs,
|
||||
characters=characters,
|
||||
npcs=npcs,
|
||||
)
|
||||
|
||||
|
||||
# --- Settings (parametrage runtime depuis l'UI) ------------------------------
|
||||
|
||||
|
||||
class SettingsDTO(BaseModel):
|
||||
"""Vue serialisable des settings modifiables depuis l'UI.
|
||||
|
||||
Expose uniquement les champs que l'utilisateur peut changer a chaud.
|
||||
Les secrets (onemin_api_key) sont masques en lecture.
|
||||
"""
|
||||
|
||||
llm_provider: Literal["ollama", "onemin"]
|
||||
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
|
||||
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
|
||||
# aussi de plafond a la jauge de contexte UI.
|
||||
llm_num_ctx: int
|
||||
|
||||
|
||||
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
|
||||
llm_num_ctx: int | None = None
|
||||
|
||||
|
||||
def _to_settings_dto(s: Settings) -> SettingsDTO:
|
||||
return SettingsDTO(
|
||||
llm_provider=s.llm_provider,
|
||||
ollama_base_url=s.ollama_base_url,
|
||||
llm_model=s.llm_model,
|
||||
onemin_model=s.onemin_model,
|
||||
onemin_api_key_set=bool(s.onemin_api_key),
|
||||
llm_num_ctx=s.llm_num_ctx,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/settings", response_model=SettingsDTO)
|
||||
def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO:
|
||||
"""Retourne la config courante (secrets masques)."""
|
||||
return _to_settings_dto(settings)
|
||||
|
||||
|
||||
@app.put("/settings", response_model=SettingsDTO)
|
||||
def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO:
|
||||
"""Applique un patch partiel aux settings et persiste les overrides.
|
||||
|
||||
Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache).
|
||||
"""
|
||||
overrides = {k: v for k, v in patch.model_dump().items() if v is not None}
|
||||
if overrides:
|
||||
save_overrides(overrides)
|
||||
# Relit .env + overrides fusionnes pour confirmation.
|
||||
return _to_settings_dto(get_settings())
|
||||
|
||||
|
||||
@app.get("/models/ollama")
|
||||
async def list_ollama_models(
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> dict[str, list[str]]:
|
||||
"""Liste les modeles disponibles sur le serveur Ollama configure.
|
||||
|
||||
Retourne une liste vide si Ollama est injoignable — l'UI affichera un
|
||||
message plutot qu'une 500.
|
||||
"""
|
||||
url = f"{settings.ollama_base_url}/api/tags"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError:
|
||||
return {"models": []}
|
||||
models = [m.get("name", "") for m in data.get("models", []) if m.get("name")]
|
||||
return {"models": sorted(models)}
|
||||
|
||||
|
||||
class OllamaModelInfoDTO(BaseModel):
|
||||
"""Info utile extraite de /api/show pour un modele Ollama donne.
|
||||
|
||||
`context_length` = fenetre de contexte max supportee par le modele
|
||||
(extraite des metadonnees GGUF). 0 si inconnue. Le frontend s'en sert
|
||||
pour borner le slider de num_ctx dans les Parametres.
|
||||
"""
|
||||
|
||||
context_length: int = 0
|
||||
|
||||
|
||||
@app.post("/models/ollama/info", response_model=OllamaModelInfoDTO)
|
||||
async def get_ollama_model_info(
|
||||
body: dict[str, str],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> OllamaModelInfoDTO:
|
||||
"""Retourne les metadonnees d'un modele Ollama via /api/show.
|
||||
|
||||
On passe par POST (et pas GET /models/ollama/{name}) parce que les noms
|
||||
Ollama contiennent souvent un `:` (ex: `gemma3:e2b`) qui se segmente
|
||||
mal dans une URL — le body JSON evite le probleme d'escaping.
|
||||
|
||||
Le champ qui nous interesse est `model_info["<arch>.context_length"]`
|
||||
(ex: `gemma3.context_length: 131072`). L'arch varie selon le modele, on
|
||||
scanne donc tous les champs finissant par `.context_length`.
|
||||
"""
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/show"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.post(url, json={"model": name})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError:
|
||||
return OllamaModelInfoDTO(context_length=0)
|
||||
model_info = data.get("model_info") or {}
|
||||
for key, value in model_info.items():
|
||||
if key.endswith(".context_length") and isinstance(value, int):
|
||||
return OllamaModelInfoDTO(context_length=value)
|
||||
return OllamaModelInfoDTO(context_length=0)
|
||||
|
||||
|
||||
@app.post("/models/ollama/pull")
|
||||
async def pull_ollama_model(
|
||||
body: dict[str, str],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> StreamingResponse:
|
||||
"""Telecharge un modele depuis Ollama et streame la progression.
|
||||
|
||||
Proxifie l'endpoint `/api/pull` d'Ollama qui renvoie du JSON ligne par
|
||||
ligne (NDJSON) avec le statut de chaque etape : manifest, layers,
|
||||
digest, success. On reemet ce flux tel quel au client (le front
|
||||
parsera les lignes et affichera une barre de progression).
|
||||
|
||||
Le timeout est intentionnellement tres long (60 min) car certains
|
||||
modeles font 30+ Go.
|
||||
"""
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/pull"
|
||||
|
||||
async def stream() -> AsyncIterator[bytes]:
|
||||
# On utilise un timeout long pour la lecture (60 min) mais court pour
|
||||
# la connexion (10s) — si Ollama n'est pas joignable, on echoue vite.
|
||||
timeout = httpx.Timeout(connect=10, read=3600, write=10, pool=10)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with client.stream("POST", url, json={"model": name, "stream": True}) as r:
|
||||
if r.status_code != 200:
|
||||
# Ollama renvoie un message JSON d'erreur. On le passe
|
||||
# tel quel au client en preservant le code HTTP.
|
||||
body_text = await r.aread()
|
||||
yield body_text
|
||||
if not settings.auto_pull_embedding_model or settings.embedding_provider != "ollama":
|
||||
return
|
||||
async for chunk in r.aiter_bytes():
|
||||
yield chunk
|
||||
except httpx.HTTPError as e:
|
||||
# Erreur reseau : on emet une ligne JSON d'erreur compatible
|
||||
# avec le format NDJSON d'Ollama.
|
||||
err = json.dumps({"error": f"Connexion a Ollama impossible : {e}"}) + "\n"
|
||||
yield err.encode("utf-8")
|
||||
|
||||
# application/x-ndjson : un objet JSON par ligne, pas de wrapping SSE.
|
||||
# C'est le format natif d'Ollama, le front le parsera ligne par ligne.
|
||||
return StreamingResponse(stream(), media_type="application/x-ndjson")
|
||||
asyncio.create_task(ensure_ollama_embedding_model(
|
||||
settings.ollama_base_url, settings.ollama_embedding_model))
|
||||
|
||||
|
||||
@app.delete("/models/ollama/{name:path}")
|
||||
async def delete_ollama_model(
|
||||
name: str,
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> dict[str, str]:
|
||||
"""Supprime un modele du serveur Ollama.
|
||||
|
||||
Le `:path` dans le pattern autorise les `:` du nom (ex: `gemma4:e4b`)
|
||||
sans avoir besoin de URL-encoder cote client.
|
||||
"""
|
||||
if not name.strip():
|
||||
raise HTTPException(status_code=400, detail="name requis")
|
||||
url = f"{settings.ollama_base_url}/api/delete"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.request("DELETE", url, json={"model": name})
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail=f"Modele '{name}' introuvable")
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Ollama injoignable : {e}")
|
||||
return {"status": "deleted", "name": name}
|
||||
|
||||
|
||||
@app.get("/models/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),
|
||||
)
|
||||
|
||||
|
||||
def _to_game_system_context(dto: GameSystemContextDTO | None) -> GameSystemContext | None:
|
||||
if dto is None:
|
||||
return None
|
||||
return GameSystemContext(
|
||||
system_name=dto.system_name,
|
||||
system_description=dto.system_description,
|
||||
sections=dict(dto.sections),
|
||||
)
|
||||
# Un router par responsabilité (SRP) — chemins identiques à l'ancien monolithe.
|
||||
app.include_router(generation.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(tables.router)
|
||||
app.include_router(imports.router)
|
||||
app.include_router(notebooks.router)
|
||||
app.include_router(settings_router.router)
|
||||
app.include_router(models.router)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
fastapi==0.115.*
|
||||
fastapi==0.136.*
|
||||
# Pin EXPLICITE : fastapi n'exige que starlette>=0.46.0 — sans ce pin, un
|
||||
# environnement existant peut garder une starlette vulnérable.
|
||||
# >= 0.49.1 : corrige CVE-2025-54121 et CVE-2025-62727.
|
||||
starlette>=0.49.1,<1.0
|
||||
uvicorn[standard]==0.32.*
|
||||
httpx==0.27.*
|
||||
pydantic-settings==2.6.*
|
||||
# Requis par FastAPI pour les uploads multipart (UploadFile) — import de PDF.
|
||||
python-multipart==0.0.*
|
||||
|
||||
pydantic
|
||||
|
||||
@@ -10,3 +16,14 @@ pydantic
|
||||
# la plupart des modeles Llama/Gemma/Mistral (~5-10% d'ecart) — suffisant
|
||||
# pour une jauge visuelle.
|
||||
tiktoken==0.8.*
|
||||
|
||||
# Import de PDF de regles (-> GameSystem). Extraction de la couche texte
|
||||
# (born-digital) avec repli OCR par page pour les scans.
|
||||
# - pymupdf : extraction texte + rasterisation des pages (pas besoin de poppler)
|
||||
# - pytesseract + Pillow : OCR Tesseract sur les pages sans couche texte
|
||||
# (le binaire tesseract-ocr est installe dans le Dockerfile, langues fra+eng)
|
||||
pymupdf==1.24.*
|
||||
pytesseract==0.3.*
|
||||
# 12.2+ : corrige 6 CVE de parsing d'images (surface critique : pages de PDF
|
||||
# uploadés par l'utilisateur rasterisées puis passées à l'OCR).
|
||||
Pillow==12.2.*
|
||||
|
||||
84
brain/scripts/sanity_rag_check.py
Normal file
84
brain/scripts/sanity_rag_check.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sanity check temporaire : overlap du chunking + recherche hybride du vector store."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.application.chunking import chunk_text
|
||||
|
||||
# --- 1. Chunking avec overlap ---
|
||||
paras = [f"Paragraphe {i} : " + ("lorem ipsum dolor sit amet " * 8) for i in range(12)]
|
||||
text = "\n\n".join(paras)
|
||||
|
||||
no_overlap = chunk_text(text, target_tokens=200)
|
||||
with_overlap = chunk_text(text, target_tokens=200, overlap_tokens=40)
|
||||
|
||||
assert len(with_overlap) >= len(no_overlap), "l'overlap ne doit pas réduire le nb de chunks"
|
||||
# Chaque chunk (sauf le 1er) doit commencer par la fin du précédent
|
||||
overlapped = 0
|
||||
for prev, cur in zip(with_overlap, with_overlap[1:]):
|
||||
first_para = cur.split("\n\n")[0]
|
||||
if first_para in prev:
|
||||
overlapped += 1
|
||||
assert overlapped >= len(with_overlap) - 2, f"overlap absent: {overlapped}/{len(with_overlap)-1}"
|
||||
# Pas de chunk composé uniquement de l'overlap (dernier chunk dupliqué)
|
||||
assert with_overlap[-1] != with_overlap[-2], "dernier chunk = pur overlap (dupliqué)"
|
||||
# overlap_tokens=0 → comportement identique à l'ancien
|
||||
assert no_overlap == chunk_text(text, target_tokens=200, overlap_tokens=0)
|
||||
print(f"[OK] chunking : {len(no_overlap)} chunks sans overlap, {len(with_overlap)} avec ({overlapped} recouvrements)")
|
||||
|
||||
# --- Paragraphe géant ---
|
||||
giant = "mot " * 2000
|
||||
sub = chunk_text(giant, target_tokens=300, overlap_tokens=50)
|
||||
assert len(sub) > 1
|
||||
print(f"[OK] paragraphe géant coupé en {len(sub)} sous-blocs")
|
||||
|
||||
# --- 2. Vector store : hybride + seuil + cache ---
|
||||
import tempfile, os
|
||||
from app.infrastructure import vector_store
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
vector_store._STORE_DIR = Path(tmp)
|
||||
chunks = [
|
||||
"Strahd von Zarovich règne sur la sombre vallée de Barovia depuis son château.",
|
||||
"Les règles de combat utilisent un d20 plus le modificateur de caractéristique.",
|
||||
"La taverne du village sert un ragoût de navets aux voyageurs fatigués.",
|
||||
]
|
||||
# Vecteurs factices : chunk 0 et 1 proches de la query, chunk 2 orthogonal
|
||||
vectors = [[1.0, 0.1, 0.0], [0.9, 0.4, 0.1], [0.0, 0.0, 1.0]]
|
||||
vector_store.save("src1", chunks, vectors, pages=[10, 20, 30])
|
||||
|
||||
q = [1.0, 0.2, 0.0]
|
||||
# Sans seuil ni texte : 3 résultats, ordre cosinus
|
||||
r = vector_store.search(["src1"], q, top_k=10)
|
||||
assert len(r) == 3 and r[0]["page"] == 10
|
||||
|
||||
# Avec seuil : le chunk orthogonal (cos~0) est écarté
|
||||
r = vector_store.search(["src1"], q, top_k=10, min_score=0.30)
|
||||
assert len(r) == 2, f"seuil non appliqué: {len(r)}"
|
||||
print(f"[OK] seuil : 2/3 extraits gardés (orthogonal écarté)")
|
||||
|
||||
# Bonus lexical : la query mentionne « Strahd Barovia » → chunk 0 doit dominer
|
||||
r = vector_store.search(["src1"], q, top_k=10, query_text="Parle-moi de Strahd et de Barovia", min_score=0.30)
|
||||
assert r[0]["text"].startswith("Strahd"), r[0]["text"]
|
||||
assert r[0]["score"] > vector_store._cosine(q, vectors[0]), "bonus lexical absent"
|
||||
print(f"[OK] hybride : bonus lexical appliqué (score={r[0]['score']:.3f})")
|
||||
|
||||
# Le set "_words" mémoïsé ne doit PAS fuiter dans les résultats
|
||||
assert all("_words" not in res for res in r)
|
||||
|
||||
# Cache : 2e recherche sert depuis la mémoire (même objet liste)
|
||||
c1 = vector_store._load("src1")
|
||||
c2 = vector_store._load("src1")
|
||||
assert c1 is c2, "cache mtime inopérant"
|
||||
# save() invalide le cache
|
||||
vector_store.save("src1", chunks[:1], vectors[:1], pages=[10])
|
||||
c3 = vector_store._load("src1")
|
||||
assert len(c3) == 1, "cache non invalidé après save"
|
||||
# delete() purge cache + fichier
|
||||
vector_store.delete("src1")
|
||||
assert vector_store._load("src1") == []
|
||||
print("[OK] cache mémoire : hit, invalidation save, purge delete")
|
||||
|
||||
print("\nTous les sanity checks passent.")
|
||||
107
brain/scripts/test_import_rules.py
Normal file
107
brain/scripts/test_import_rules.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""CLI de test pour l'import de règles PDF — boucle de feedback rapide.
|
||||
|
||||
But : tester l'extraction + la structuration en sections sur un VRAI PDF
|
||||
(ex: livre Nimble), SANS passer par HTTP, le Core ou l'UI.
|
||||
|
||||
Usage (depuis le dossier brain/, venv activé) :
|
||||
python scripts/test_import_rules.py "chemin/vers/regles.pdf"
|
||||
|
||||
Le provider LLM utilisé est celui configuré (.env + overrides de l'écran
|
||||
Paramètres) — donc 1min.ai si tu l'as sélectionné. Le script :
|
||||
1. extrait le texte (et dit, page par page, si l'OCR s'est déclenché),
|
||||
2. découpe + structure via le LLM,
|
||||
3. affiche un résumé des sections,
|
||||
4. écrit le markdown complet dans "<pdf>.rules.md" à côté du PDF.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Permet `import app...` quel que soit le cwd : on ajoute la racine brain/.
|
||||
_BRAIN_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(_BRAIN_ROOT))
|
||||
|
||||
from app.application.import_rules import ImportRulesUseCase # noqa: E402
|
||||
from app.core.config import get_settings # noqa: E402
|
||||
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError # noqa: E402
|
||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider # noqa: E402
|
||||
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider # noqa: E402
|
||||
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor # noqa: E402
|
||||
|
||||
|
||||
def _build_provider() -> LLMProvider:
|
||||
"""Réplique la factory de main.py (choix provider selon les settings)."""
|
||||
settings = get_settings()
|
||||
print(f"→ Provider LLM : {settings.llm_provider} "
|
||||
f"(modèle : {settings.onemin_model if settings.llm_provider == 'onemin' else settings.llm_model})")
|
||||
if settings.llm_provider == "onemin":
|
||||
return OneMinAiLLMProvider(settings)
|
||||
return OllamaLLMProvider(settings)
|
||||
|
||||
|
||||
async def _run(pdf_path: Path) -> None:
|
||||
pdf_bytes = pdf_path.read_bytes()
|
||||
print(f"→ PDF chargé : {pdf_path.name} ({len(pdf_bytes) // 1024} Ko)\n")
|
||||
|
||||
extractor = PyMuPdfTextExtractor()
|
||||
|
||||
# 1. Extraction seule d'abord, pour le diagnostic page/OCR avant tout LLM.
|
||||
try:
|
||||
doc = extractor.extract(pdf_bytes)
|
||||
except PdfExtractionError as exc:
|
||||
print(f"✗ Extraction impossible : {exc}")
|
||||
return
|
||||
|
||||
print(f"=== Extraction : {doc.page_count} page(s), "
|
||||
f"{doc.ocr_page_count} via OCR, "
|
||||
f"{len(doc.full_text)} caractères ===")
|
||||
if doc.ocr_page_count == 0:
|
||||
print(" → PDF born-digital détecté (couche texte présente, OCR non nécessaire).")
|
||||
else:
|
||||
print(f" → {doc.ocr_page_count} page(s) sans couche texte : OCR déclenché.")
|
||||
if not doc.full_text.strip():
|
||||
print("\n✗ Aucun texte extrait. Si le PDF est un scan, vérifie que Tesseract "
|
||||
"est installé (sinon l'OCR est désactivé).")
|
||||
return
|
||||
|
||||
# 2. Structuration via le LLM (réutilise l'extraction déjà faite indirectement :
|
||||
# le use case ré-extrait, coût négligeable vs l'appel LLM).
|
||||
print("\n=== Structuration via LLM (peut prendre un moment selon le nombre de morceaux)… ===")
|
||||
use_case = ImportRulesUseCase(llm=_build_provider(), extractor=extractor)
|
||||
try:
|
||||
result = await use_case.execute(pdf_bytes)
|
||||
except LLMProviderError as exc:
|
||||
print(f"✗ Échec LLM : {exc}")
|
||||
return
|
||||
|
||||
if not result.sections:
|
||||
print("\n✗ Aucune section proposée (le modèle n'a rien renvoyé d'exploitable).")
|
||||
return
|
||||
|
||||
print(f"\n=== {len(result.sections)} section(s) proposée(s) ===")
|
||||
for title, content in result.sections.items():
|
||||
preview = content.strip().replace("\n", " ")[:90]
|
||||
print(f" • {title} ({len(content)} car.) — {preview}…")
|
||||
|
||||
out_path = pdf_path.with_suffix(".rules.md")
|
||||
out_path.write_text(result.to_markdown(), encoding="utf-8")
|
||||
print(f"\n✓ Markdown complet écrit dans : {out_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s — %(message)s")
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage : python scripts/test_import_rules.py \"chemin/vers/regles.pdf\"")
|
||||
sys.exit(1)
|
||||
pdf_path = Path(sys.argv[1]).expanduser()
|
||||
if not pdf_path.is_file():
|
||||
print(f"✗ Fichier introuvable : {pdf_path}")
|
||||
sys.exit(1)
|
||||
asyncio.run(_run(pdf_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
46
core/pom.xml
46
core/pom.xml
@@ -8,18 +8,22 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.0</version>
|
||||
<version>3.5.14</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.8.1</version>
|
||||
<version>0.13.0-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<!-- Override de la transitive minio → commons-compress → commons-lang3 3.17.
|
||||
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
|
||||
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
|
||||
<commons-lang3.version>3.20.0</commons-lang3.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -77,24 +81,52 @@
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images). -->
|
||||
<!-- MinIO — client S3-compatible pour le stockage d'images (Shared Kernel images).
|
||||
8.6.x = derniere ligne 8.x (la 9.x change l'API) ; transitives a jour. -->
|
||||
<dependency>
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<version>8.5.11</version>
|
||||
<version>8.6.0</version>
|
||||
<exclusions>
|
||||
<!-- OkHttp 5 : l'artefact `okhttp` est un alias multiplateforme dont la
|
||||
resolution vers les classes JVM passe par les metadonnees Gradle —
|
||||
que Maven ignore. On exclut l'alias et on declare `okhttp-jvm`
|
||||
(les vraies classes) explicitement ci-dessous. -->
|
||||
<exclusion>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp-jvm</artifactId>
|
||||
<version>5.1.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
|
||||
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle. -->
|
||||
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle.
|
||||
>= 10.0.2 : corrige CVE-2025-53864 (DoS par JSON profondement imbrique
|
||||
dans le claim set — surface critique : JWT colle par l'utilisateur). -->
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>9.40</version>
|
||||
<version>10.9.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
<version>1.78.1</version>
|
||||
<version>1.84</version>
|
||||
</dependency>
|
||||
<!-- Google Tink : runtime requis par com.nimbusds.jose.crypto.Ed25519Verifier
|
||||
(depuis Nimbus 9.x, la verification EdDSA delegue a Tink.subtle.Ed25519Verify).
|
||||
Tink n'est PAS une dependance transitive de nimbus-jose-jwt → il faut
|
||||
l'ajouter explicitement, sinon NoClassDefFoundError au premier verify().
|
||||
>= 1.15 : embarque un protobuf-java corrige (CVE-2024-7254). -->
|
||||
<dependency>
|
||||
<groupId>com.google.crypto.tink</groupId>
|
||||
<artifactId>tink</artifactId>
|
||||
<version>1.21.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -50,6 +50,15 @@ public class ArcService {
|
||||
return arcRepository.save(arc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Création à partir d'un Arc complet (utilisé par le controller pour faire passer
|
||||
* les nouveaux champs comme type sans démultiplier les paramètres).
|
||||
*/
|
||||
public Arc createArc(Arc input) {
|
||||
input.setId(null);
|
||||
return arcRepository.save(input);
|
||||
}
|
||||
|
||||
public Optional<Arc> getArcById(String id) {
|
||||
return arcRepository.findById(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignPdfAdvisor;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Service applicatif : conseils d'adaptation d'un PDF à une campagne existante.
|
||||
*
|
||||
* <p>Assemble un « brief » de la campagne (structure + PNJ + univers/lore) via
|
||||
* {@link CampaignBriefBuilder} et délègue la génération streamée au Brain via
|
||||
* {@link CampaignPdfAdvisor}. Ne persiste rien : la sortie est du conseil libre.</p>
|
||||
*/
|
||||
@Service
|
||||
public class CampaignAdaptService {
|
||||
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final CampaignBriefBuilder briefBuilder;
|
||||
private final CampaignPdfAdvisor advisor;
|
||||
|
||||
public CampaignAdaptService(
|
||||
CampaignRepository campaignRepository,
|
||||
CampaignBriefBuilder briefBuilder,
|
||||
CampaignPdfAdvisor advisor) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.briefBuilder = briefBuilder;
|
||||
this.advisor = advisor;
|
||||
}
|
||||
|
||||
public void adviseStreaming(
|
||||
String campaignId,
|
||||
byte[] pdfBytes,
|
||||
String filename,
|
||||
String messagesJson,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
Campaign campaign = campaignRepository.findById(campaignId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Campagne introuvable : " + campaignId));
|
||||
advisor.adviseStreaming(
|
||||
pdfBytes, filename, briefBuilder.build(campaign), messagesJson, onToken, onComplete, onError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.application.generationcontext.CampaignStructuralContextBuilder;
|
||||
import com.loremind.application.generationcontext.LoreStructuralContextBuilder;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext.PageSummary;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Construit un résumé markdown d'une campagne (structure arcs→chapitres→scènes +
|
||||
* PNJ + univers/lore). Partagé par les fonctions IA qui doivent « voir » la
|
||||
* campagne : conseils d'adaptation PDF (CampaignAdaptService) et ateliers RAG
|
||||
* (NotebookService). Centralisé ici pour une seule source de vérité.
|
||||
*/
|
||||
@Service
|
||||
public class CampaignBriefBuilder {
|
||||
|
||||
private final CampaignStructuralContextBuilder campaignContextBuilder;
|
||||
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||
|
||||
public CampaignBriefBuilder(
|
||||
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||
LoreStructuralContextBuilder loreContextBuilder) {
|
||||
this.campaignContextBuilder = campaignContextBuilder;
|
||||
this.loreContextBuilder = loreContextBuilder;
|
||||
}
|
||||
|
||||
public String build(Campaign campaign) {
|
||||
CampaignStructuralContext cc = campaignContextBuilder.build(campaign.getId());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
sb.append("# Campagne : ").append(cc.campaignName()).append("\n");
|
||||
if (notBlank(cc.campaignDescription())) sb.append(cc.campaignDescription()).append("\n");
|
||||
|
||||
sb.append("\n## Structure (arcs → chapitres → scènes)\n");
|
||||
sb.append("_Un arc HUB contient des chapitres parallèles appelés « quêtes » ; ")
|
||||
.append("un arc LINEAR contient des chapitres en séquence._\n");
|
||||
if (cc.arcs().isEmpty()) {
|
||||
sb.append("_(aucun arc pour le moment)_\n");
|
||||
}
|
||||
for (ArcSummary arc : cc.arcs()) {
|
||||
sb.append(arc.hub() ? "### Arc HUB (à quêtes) : " : "### Arc : ").append(arc.name());
|
||||
if (notBlank(arc.description())) sb.append(" — ").append(arc.description());
|
||||
sb.append("\n");
|
||||
for (ChapterSummary ch : arc.chapters()) {
|
||||
sb.append(arc.hub() ? "- Quête : " : "- Chapitre : ").append(ch.name());
|
||||
if (notBlank(ch.description())) sb.append(" — ").append(ch.description());
|
||||
sb.append("\n");
|
||||
for (SceneSummary sc : ch.scenes()) {
|
||||
sb.append(" - Scène : ").append(sc.name());
|
||||
if (notBlank(sc.description())) sb.append(" — ").append(sc.description());
|
||||
sb.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cc.npcs().isEmpty()) {
|
||||
sb.append("\n## PNJ existants\n");
|
||||
for (NpcSummary n : cc.npcs()) {
|
||||
sb.append("- ").append(n.name());
|
||||
if (notBlank(n.snippet())) sb.append(" : ").append(n.snippet());
|
||||
sb.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (campaign.isLinkedToLore()) {
|
||||
loreContextBuilder.buildOptional(campaign.getLoreId()).ifPresent(lore -> appendLore(sb, lore));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendLore(StringBuilder sb, LoreStructuralContext lore) {
|
||||
sb.append("\n## Univers (Lore) : ").append(lore.loreName()).append("\n");
|
||||
if (notBlank(lore.loreDescription())) sb.append(lore.loreDescription()).append("\n");
|
||||
for (Map.Entry<String, List<PageSummary>> entry : lore.folders().entrySet()) {
|
||||
sb.append("### ").append(entry.getKey()).append("\n");
|
||||
for (PageSummary page : entry.getValue()) {
|
||||
sb.append("- ").append(page.title()).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean notBlank(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProgress;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ArcProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.ChapterProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.NpcProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.RoomProposal;
|
||||
import com.loremind.domain.campaigncontext.CampaignImportProposal.SceneProposal;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Room;
|
||||
import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Service applicatif pour l'import d'un PDF de campagne.
|
||||
*
|
||||
* <p>Deux temps, conformes au principe « revue avant écriture » :
|
||||
* 1. {@link #importStructureStreaming} génère une PROPOSITION d'arbre (rien
|
||||
* n'est persisté), streamée pour l'avancement.
|
||||
* 2. {@link #applyStructure} crée réellement les arcs/chapitres/scènes une fois
|
||||
* l'arbre révisé/édité par l'utilisateur.</p>
|
||||
*/
|
||||
@Service
|
||||
public class CampaignImportService {
|
||||
|
||||
private final CampaignPdfImporter campaignPdfImporter;
|
||||
private final CampaignService campaignService;
|
||||
private final ArcService arcService;
|
||||
private final ChapterService chapterService;
|
||||
private final SceneService sceneService;
|
||||
private final NpcService npcService;
|
||||
|
||||
public CampaignImportService(
|
||||
CampaignPdfImporter campaignPdfImporter,
|
||||
CampaignService campaignService,
|
||||
ArcService arcService,
|
||||
ChapterService chapterService,
|
||||
SceneService sceneService,
|
||||
NpcService npcService) {
|
||||
this.campaignPdfImporter = campaignPdfImporter;
|
||||
this.campaignService = campaignService;
|
||||
this.arcService = arcService;
|
||||
this.chapterService = chapterService;
|
||||
this.sceneService = sceneService;
|
||||
this.npcService = npcService;
|
||||
}
|
||||
|
||||
/** Résumé de ce qui a été créé par {@link #applyStructure}. */
|
||||
public record ApplyResult(
|
||||
int arcsCreated, int chaptersCreated, int scenesCreated, int npcsCreated) {}
|
||||
|
||||
/** Génère la proposition d'arbre (streamée). Ne persiste rien. */
|
||||
public void importStructureStreaming(
|
||||
byte[] pdfBytes,
|
||||
String filename,
|
||||
Consumer<CampaignImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
campaignPdfImporter.importCampaignStreaming(
|
||||
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée les arcs/chapitres/scènes de l'arbre révisé dans la campagne. Les
|
||||
* arcs sont ajoutés APRÈS les arcs existants (ordre continué). Tout est créé
|
||||
* dans une seule transaction (rollback si une étape échoue).
|
||||
*/
|
||||
@Transactional
|
||||
public ApplyResult applyStructure(String campaignId, CampaignImportProposal proposal) {
|
||||
if (!campaignService.campaignExists(campaignId)) {
|
||||
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
|
||||
}
|
||||
|
||||
int arcsCreated = 0, chaptersCreated = 0, scenesCreated = 0;
|
||||
|
||||
// Les nouveaux nœuds sont ordonnés APRÈS les frères existants (déjà comptés
|
||||
// via leur existingId dans l'arbre fusionné venu de la revue).
|
||||
int arcOrder = countExisting(proposal.arcs(), ArcProposal::existingId);
|
||||
for (ArcProposal arcP : proposal.arcs()) {
|
||||
if (isBlank(arcP.name())) continue;
|
||||
String arcId;
|
||||
if (!isBlank(arcP.existingId())) {
|
||||
arcId = arcP.existingId(); // arc déjà présent → on s'y rattache
|
||||
} else {
|
||||
arcOrder++;
|
||||
Arc arc = arcService.createArc(Arc.builder()
|
||||
.name(arcP.name().trim())
|
||||
.description(nullIfBlank(arcP.description()))
|
||||
.campaignId(campaignId)
|
||||
.order(arcOrder)
|
||||
.type(parseArcType(arcP.type()))
|
||||
.build());
|
||||
arcId = arc.getId();
|
||||
arcsCreated++;
|
||||
}
|
||||
|
||||
int chapterOrder = countExisting(arcP.chapters(), ChapterProposal::existingId);
|
||||
for (ChapterProposal chapP : safe(arcP.chapters())) {
|
||||
if (isBlank(chapP.name())) continue;
|
||||
String chapId;
|
||||
if (!isBlank(chapP.existingId())) {
|
||||
chapId = chapP.existingId();
|
||||
} else {
|
||||
chapterOrder++;
|
||||
Chapter chapter = chapterService.createChapter(
|
||||
chapP.name().trim(), nullIfBlank(chapP.description()), arcId, chapterOrder);
|
||||
chapId = chapter.getId();
|
||||
chaptersCreated++;
|
||||
}
|
||||
|
||||
int sceneOrder = countExisting(chapP.scenes(), SceneProposal::existingId);
|
||||
for (SceneProposal sceneP : safe(chapP.scenes())) {
|
||||
if (isBlank(sceneP.name())) continue;
|
||||
if (!isBlank(sceneP.existingId())) continue; // scène déjà présente
|
||||
sceneOrder++;
|
||||
sceneService.createScene(Scene.builder()
|
||||
.name(sceneP.name().trim())
|
||||
.description(nullIfBlank(sceneP.description()))
|
||||
.playerNarration(nullIfBlank(sceneP.playerNarration()))
|
||||
.gmSecretNotes(nullIfBlank(sceneP.gmNotes()))
|
||||
.chapterId(chapId)
|
||||
.order(sceneOrder)
|
||||
.rooms(toRooms(sceneP.rooms()))
|
||||
.build());
|
||||
scenesCreated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int npcsCreated = createNpcs(campaignId, proposal.npcs());
|
||||
return new ApplyResult(arcsCreated, chaptersCreated, scenesCreated, npcsCreated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée les PNJ proposés (description → values["Description"], même convention
|
||||
* que les cartes d'action des ateliers). Les PNJ portant un nom déjà présent
|
||||
* dans la campagne sont ignorés (ré-import sans doublon).
|
||||
*/
|
||||
private int createNpcs(String campaignId, List<NpcProposal> proposals) {
|
||||
if (proposals == null || proposals.isEmpty()) return 0;
|
||||
java.util.Set<String> existingNames = new java.util.HashSet<>();
|
||||
for (var npc : npcService.getNpcsByCampaignId(campaignId)) {
|
||||
existingNames.add(npc.getName().trim().toLowerCase());
|
||||
}
|
||||
int created = 0;
|
||||
for (NpcProposal p : proposals) {
|
||||
if (isBlank(p.name()) || !existingNames.add(p.name().trim().toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
npcService.createNpc(new NpcService.NpcData(
|
||||
p.name().trim(), null, null,
|
||||
isBlank(p.description())
|
||||
? java.util.Map.of()
|
||||
: java.util.Map.of("Description", p.description().trim()),
|
||||
null, null, campaignId, null, null, null));
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Compte les nœuds déjà présents (existingId non vide) d'une liste. */
|
||||
private static <T> int countExisting(List<T> list, java.util.function.Function<T, String> idOf) {
|
||||
if (list == null) return 0;
|
||||
int n = 0;
|
||||
for (T t : list) {
|
||||
if (!isBlank(idOf.apply(t))) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** "HUB" (insensible à la casse) → {@link ArcType#HUB} ; tout le reste → LINEAR. */
|
||||
private static ArcType parseArcType(String type) {
|
||||
return "HUB".equalsIgnoreCase(type == null ? "" : type.trim()) ? ArcType.HUB : ArcType.LINEAR;
|
||||
}
|
||||
|
||||
/** Convertit les pièces proposées en {@link Room} (ID généré, ordre = index). */
|
||||
private static List<Room> toRooms(List<RoomProposal> proposals) {
|
||||
List<Room> rooms = new ArrayList<>();
|
||||
if (proposals == null) return rooms;
|
||||
int order = 0;
|
||||
for (RoomProposal r : proposals) {
|
||||
if (isBlank(r.name())) continue;
|
||||
rooms.add(Room.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.name(r.name().trim())
|
||||
.description(nullIfBlank(r.description()))
|
||||
.enemies(nullIfBlank(r.enemies()))
|
||||
.loot(nullIfBlank(r.loot()))
|
||||
.order(order++)
|
||||
.build());
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
private static <T> java.util.List<T> safe(java.util.List<T> list) {
|
||||
return list == null ? java.util.List.of() : list;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.isBlank();
|
||||
}
|
||||
|
||||
private static String nullIfBlank(String s) {
|
||||
return isBlank(s) ? null : s.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet})
|
||||
* référencés par les chapitres d'une Campagne.
|
||||
*
|
||||
* <p>Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
|
||||
* globalement. Un fait existe dès qu'au moins une quête le référence dans ses
|
||||
* prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
|
||||
* la Partie, autocomplete dans l'éditeur de prérequis).</p>
|
||||
*/
|
||||
@Service
|
||||
public class CampaignReferencedFlagsService {
|
||||
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
|
||||
public CampaignReferencedFlagsService(ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
}
|
||||
|
||||
/** Retourne la liste triée alphabétiquement des noms de faits référencés. */
|
||||
public List<String> listForCampaign(String campaignId) {
|
||||
TreeSet<String> unique = new TreeSet<>();
|
||||
for (Arc arc : arcRepository.findByCampaignId(campaignId)) {
|
||||
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
|
||||
if (chapter.getPrerequisites() == null) continue;
|
||||
for (Prerequisite p : chapter.getPrerequisites()) {
|
||||
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
|
||||
unique.add(f.flagName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(unique);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.application.playcontext.PlaythroughService;
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -15,9 +17,11 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le contexte Campaign.
|
||||
* Orchestre la logique métier en utilisant le Port CampaignRepository.
|
||||
* Fait partie de la couche Application de l'Architecture Hexagonale.
|
||||
* Service d'application pour le contexte Campaign (scénario).
|
||||
*
|
||||
* <p>Depuis Playthrough : les PJ et les sessions ne sont plus rattachés directement
|
||||
* à la Campagne ; ils dépendent d'un Playthrough (Partie). La cascade de suppression
|
||||
* d'une campagne englobe donc ses Playthroughs (qui à leur tour cascadent).</p>
|
||||
*/
|
||||
@Service
|
||||
public class CampaignService {
|
||||
@@ -26,35 +30,27 @@ public class CampaignService {
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final PlaythroughService playthroughService;
|
||||
|
||||
public CampaignService(
|
||||
CampaignRepository campaignRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository) {
|
||||
PlaythroughRepository playthroughRepository,
|
||||
PlaythroughService playthroughService) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.playthroughService = playthroughService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'une Campaign.
|
||||
* Évite une signature à rallonge et rend les évolutions futures (theme,
|
||||
* coverImageUrl, etc.) sans casser les appelants.
|
||||
*
|
||||
* <p>{@code loreId} est nullable : une campagne peut exister sans univers associé.</p>
|
||||
*/
|
||||
public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
|
||||
|
||||
/**
|
||||
* Compte des entités qui seront supprimées en cascade si la campagne est effacée.
|
||||
* Utilisé par l'UI pour afficher un récapitulatif dans le dialogue de confirmation.
|
||||
*/
|
||||
public record DeletionImpact(int arcs, int chapters, int scenes, int characters) {}
|
||||
public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
|
||||
|
||||
public Campaign createCampaign(CampaignData data) {
|
||||
Campaign campaign = Campaign.builder()
|
||||
@@ -64,7 +60,12 @@ public class CampaignService {
|
||||
.gameSystemId(normalizeId(data.gameSystemId()))
|
||||
.arcsCount(0)
|
||||
.build();
|
||||
return campaignRepository.save(campaign);
|
||||
Campaign saved = campaignRepository.save(campaign);
|
||||
|
||||
// Une campagne sans Partie n'a pas de sens jouable : on crée d'office
|
||||
// une "Partie principale" pour que l'utilisateur puisse jouer immédiatement.
|
||||
playthroughService.create(saved.getId(), "Partie principale", null);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Optional<Campaign> getCampaignById(String id) {
|
||||
@@ -89,19 +90,10 @@ public class CampaignService {
|
||||
return campaignRepository.save(campaign);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise un ID entrant : une chaîne vide/blanche est traitée comme "pas de lien".
|
||||
* Utile car les payloads JSON peuvent envoyer "" au lieu de null.
|
||||
*/
|
||||
private String normalizeId(String id) {
|
||||
return (id == null || id.isBlank()) ? null : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule l'impact d'une suppression en cascade : nombre d'arcs, chapitres,
|
||||
* scènes et personnages qui disparaîtront avec la campagne. Utilisé par l'UI
|
||||
* pour afficher "X arcs, Y chapitres, Z scènes seront supprimés".
|
||||
*/
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||
int chapterTotal = 0;
|
||||
@@ -113,22 +105,21 @@ public class CampaignService {
|
||||
sceneTotal += sceneRepository.findByChapterId(chapter.getId()).size();
|
||||
}
|
||||
}
|
||||
int characterTotal = characterRepository.findByCampaignId(id).size();
|
||||
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, characterTotal);
|
||||
int playthroughTotal = playthroughRepository.findByCampaignId(id).size();
|
||||
return new DeletionImpact(arcs.size(), chapterTotal, sceneTotal, playthroughTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime la campagne et toutes ses entités dépendantes (arcs → chapitres →
|
||||
* scènes, plus les personnages). L'opération est transactionnelle : soit
|
||||
* tout disparaît, soit rien ne change. Les FKs applicatives n'ayant pas
|
||||
* de contrainte CASCADE au niveau DB, on orchestre la cascade ici.
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteCampaign(String id) {
|
||||
List<Arc> arcs = arcRepository.findByCampaignId(id);
|
||||
for (Arc arc : arcs) {
|
||||
List<Chapter> chapters = chapterRepository.findByArcId(arc.getId());
|
||||
for (Chapter chapter : chapters) {
|
||||
// 1. Cascade des Playthroughs (qui cascadent eux-mêmes sur PJ/sessions/valeurs flags/progressions).
|
||||
for (Playthrough p : playthroughRepository.findByCampaignId(id)) {
|
||||
playthroughService.delete(p.getId());
|
||||
}
|
||||
// 2. Cascade du scénario : arcs → chapitres → scènes
|
||||
// (Pas de déclarations globales de faits : ils existent implicitement via les
|
||||
// prérequis FLAG_SET des chapitres, qui partent avec les chapitres.)
|
||||
for (Arc arc : arcRepository.findByCampaignId(id)) {
|
||||
for (Chapter chapter : chapterRepository.findByArcId(arc.getId())) {
|
||||
for (var scene : sceneRepository.findByChapterId(chapter.getId())) {
|
||||
sceneRepository.deleteById(scene.getId());
|
||||
}
|
||||
@@ -136,9 +127,6 @@ public class CampaignService {
|
||||
}
|
||||
arcRepository.deleteById(arc.getId());
|
||||
}
|
||||
for (var character : characterRepository.findByCampaignId(id)) {
|
||||
characterRepository.deleteById(character.getId());
|
||||
}
|
||||
campaignRepository.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,16 @@ public class ChapterService {
|
||||
return chapterRepository.save(chapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Création à partir d'un Chapter complet (utilisé par le controller pour faire passer
|
||||
* les nouveaux champs comme progressionStatus / prerequisites sans démultiplier les
|
||||
* paramètres). L'id est forcé à null pour laisser la DB le générer.
|
||||
*/
|
||||
public Chapter createChapter(Chapter input) {
|
||||
input.setId(null);
|
||||
return chapterRepository.save(input);
|
||||
}
|
||||
|
||||
public Optional<Chapter> getChapterById(String id) {
|
||||
return chapterRepository.findById(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
|
||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
||||
import com.loremind.domain.campaigncontext.QuestStatus;
|
||||
import com.loremind.domain.playcontext.QuestProgression;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import com.loremind.infrastructure.web.dto.campaigncontext.ChapterDTO;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service applicatif : enrichit des ChapterDTO avec leur {@link QuestStatus} effectif,
|
||||
* relatif à un Playthrough donné.
|
||||
*
|
||||
* <p>Depuis l'introduction de Playthrough : la progression et les flags vivent au niveau
|
||||
* de la Partie, plus de la Campagne. L'enrichissement nécessite donc un playthroughId.</p>
|
||||
*/
|
||||
@Service
|
||||
public class ChapterStatusEnricher {
|
||||
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final QuestProgressionRepository progressionRepository;
|
||||
private final PlaythroughFlagRepository flagRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
private final PrerequisiteEvaluator evaluator = new PrerequisiteEvaluator();
|
||||
|
||||
public ChapterStatusEnricher(PlaythroughRepository playthroughRepository,
|
||||
QuestProgressionRepository progressionRepository,
|
||||
PlaythroughFlagRepository flagRepository,
|
||||
SessionRepository sessionRepository) {
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.progressionRepository = progressionRepository;
|
||||
this.flagRepository = flagRepository;
|
||||
this.sessionRepository = sessionRepository;
|
||||
}
|
||||
|
||||
/** Contexte d'évaluation + map chapterId -> ProgressionStatus pour ce Playthrough. */
|
||||
public record PlaythroughEvalSnapshot(
|
||||
PrerequisiteEvaluator.EvaluationContext ctx,
|
||||
Map<String, ProgressionStatus> progressionByChapterId
|
||||
) {}
|
||||
|
||||
/** Construit le snapshot d'évaluation pour un Playthrough. */
|
||||
public PlaythroughEvalSnapshot buildSnapshot(String playthroughId) {
|
||||
if (playthroughId == null || playthroughRepository.findById(playthroughId).isEmpty()) {
|
||||
return new PlaythroughEvalSnapshot(
|
||||
new PrerequisiteEvaluator.EvaluationContext(Collections.emptySet(), 0, Collections.emptyMap()),
|
||||
Collections.emptyMap()
|
||||
);
|
||||
}
|
||||
Map<String, Boolean> flags = flagRepository.findByPlaythroughId(playthroughId);
|
||||
Set<String> completedQuestIds = progressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
|
||||
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
|
||||
|
||||
Map<String, ProgressionStatus> progressionMap = new HashMap<>();
|
||||
for (QuestProgression qp : progressionRepository.findByPlaythroughId(playthroughId)) {
|
||||
progressionMap.put(qp.getChapterId(), qp.getStatus());
|
||||
}
|
||||
|
||||
return new PlaythroughEvalSnapshot(
|
||||
new PrerequisiteEvaluator.EvaluationContext(completedQuestIds, sessionCount, flags),
|
||||
progressionMap
|
||||
);
|
||||
}
|
||||
|
||||
/** Calcule le statut effectif d'un seul chapitre relatif à un Playthrough. */
|
||||
public QuestStatus computeFor(Chapter chapter, String playthroughId) {
|
||||
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
|
||||
ProgressionStatus progression = snap.progressionByChapterId()
|
||||
.getOrDefault(chapter.getId(), ProgressionStatus.NOT_STARTED);
|
||||
return evaluator.computeStatus(progression, chapter.getPrerequisites(), snap.ctx());
|
||||
}
|
||||
|
||||
/**
|
||||
* Injecte le {@code effectiveStatus} et le {@code progressionStatus} dans une liste de DTOs.
|
||||
* Un seul build du snapshot pour toute la liste (optimal pour les vues qui listent un arc).
|
||||
*/
|
||||
public void enrich(List<ChapterDTO> dtos, List<Chapter> domain, String playthroughId) {
|
||||
if (dtos == null || dtos.isEmpty()) return;
|
||||
PlaythroughEvalSnapshot snap = buildSnapshot(playthroughId);
|
||||
Map<String, Chapter> byId = domain.stream()
|
||||
.collect(Collectors.toMap(Chapter::getId, c -> c));
|
||||
for (ChapterDTO dto : dtos) {
|
||||
Chapter c = byId.get(dto.getId());
|
||||
if (c == null) continue;
|
||||
ProgressionStatus progression = snap.progressionByChapterId()
|
||||
.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
|
||||
QuestStatus status = evaluator.computeStatus(progression, c.getPrerequisites(), snap.ctx());
|
||||
dto.setProgressionStatus(progression.name());
|
||||
dto.setEffectiveStatus(status.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import com.loremind.domain.campaigncontext.Character;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour les fiches de personnages (PJ).
|
||||
* Les PJ appartiennent désormais à un Playthrough (Partie), pas à la Campagne.
|
||||
*/
|
||||
@Service
|
||||
public class CharacterService {
|
||||
@@ -19,20 +22,29 @@ public class CharacterService {
|
||||
this.characterRepository = characterRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un Character.
|
||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||
*/
|
||||
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
|
||||
public record CharacterData(
|
||||
String name,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
String playthroughId,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public Character createCharacter(CharacterData data) {
|
||||
int order = data.order() != null
|
||||
? data.order()
|
||||
: nextOrderFor(data.campaignId());
|
||||
: nextOrderFor(data.playthroughId());
|
||||
Character character = Character.builder()
|
||||
.name(data.name())
|
||||
.markdownContent(data.markdownContent())
|
||||
.campaignId(data.campaignId())
|
||||
.portraitImageId(data.portraitImageId())
|
||||
.headerImageId(data.headerImageId())
|
||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||
.playthroughId(data.playthroughId())
|
||||
.order(order)
|
||||
.build();
|
||||
return characterRepository.save(character);
|
||||
@@ -42,19 +54,23 @@ public class CharacterService {
|
||||
return characterRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Character> getCharactersByCampaignId(String campaignId) {
|
||||
return characterRepository.findByCampaignId(campaignId);
|
||||
public List<Character> getCharactersByPlaythroughId(String playthroughId) {
|
||||
return characterRepository.findByPlaythroughId(playthroughId);
|
||||
}
|
||||
|
||||
public Character updateCharacter(String id, CharacterData data) {
|
||||
Character existing = characterRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setMarkdownContent(data.markdownContent());
|
||||
existing.setPortraitImageId(data.portraitImageId());
|
||||
existing.setHeaderImageId(data.headerImageId());
|
||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
// campaignId n'est pas modifiable après création (cross-campagne move hors scope MVP).
|
||||
// playthroughId immuable après création.
|
||||
return characterRepository.save(existing);
|
||||
}
|
||||
|
||||
@@ -62,9 +78,13 @@ public class CharacterService {
|
||||
characterRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return characterRepository.findByCampaignId(campaignId).stream()
|
||||
public List<Character> searchCharacters(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return characterRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
private int nextOrderFor(String playthroughId) {
|
||||
return characterRepository.findByPlaythroughId(playthroughId).stream()
|
||||
.mapToInt(Character::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Enemy;
|
||||
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour les fiches d'ennemis (bestiaire de campagne).
|
||||
* Miroir de {@link NpcService} : fiche pilotée par le template ENNEMI du GameSystem.
|
||||
*/
|
||||
@Service
|
||||
public class EnemyService {
|
||||
|
||||
private final EnemyRepository enemyRepository;
|
||||
|
||||
public EnemyService(EnemyRepository enemyRepository) {
|
||||
this.enemyRepository = enemyRepository;
|
||||
}
|
||||
|
||||
public record EnemyData(
|
||||
String name,
|
||||
String level,
|
||||
String folder,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
String campaignId,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public Enemy createEnemy(EnemyData data) {
|
||||
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
|
||||
Enemy enemy = Enemy.builder()
|
||||
.name(data.name())
|
||||
.level(normalize(data.level()))
|
||||
.folder(normalize(data.folder()))
|
||||
.portraitImageId(data.portraitImageId())
|
||||
.headerImageId(data.headerImageId())
|
||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
return enemyRepository.save(enemy);
|
||||
}
|
||||
|
||||
public Optional<Enemy> getEnemyById(String id) {
|
||||
return enemyRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Enemy> getEnemiesByCampaignId(String campaignId) {
|
||||
return enemyRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public Enemy updateEnemy(String id, EnemyData data) {
|
||||
Enemy existing = enemyRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Enemy non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setLevel(normalize(data.level()));
|
||||
existing.setFolder(normalize(data.folder()));
|
||||
existing.setPortraitImageId(data.portraitImageId());
|
||||
existing.setHeaderImageId(data.headerImageId());
|
||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
return enemyRepository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteEnemy(String id) {
|
||||
enemyRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public List<Enemy> searchEnemies(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return enemyRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/** Trim ; chaîne vide → null (= non renseigné / non classé). */
|
||||
private static String normalize(String value) {
|
||||
if (value == null) return null;
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return enemyRepository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(Enemy::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.CatalogItem;
|
||||
import com.loremind.domain.campaigncontext.ItemCatalog;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ItemCatalogGenerator;
|
||||
import com.loremind.domain.campaigncontext.ports.ItemCatalogRepository;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application des catalogues d'objets (campagne) : CRUD + génération IA.
|
||||
*/
|
||||
@Service
|
||||
public class ItemCatalogService {
|
||||
|
||||
private final ItemCatalogRepository repository;
|
||||
private final ItemCatalogGenerator generator;
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
|
||||
public ItemCatalogService(
|
||||
ItemCatalogRepository repository,
|
||||
ItemCatalogGenerator generator,
|
||||
CampaignRepository campaignRepository,
|
||||
GameSystemRepository gameSystemRepository) {
|
||||
this.repository = repository;
|
||||
this.generator = generator;
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
public record CatalogData(
|
||||
String name,
|
||||
String description,
|
||||
String icon,
|
||||
List<CatalogItem> items,
|
||||
String campaignId,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public ItemCatalog createCatalog(CatalogData data) {
|
||||
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
|
||||
ItemCatalog catalog = ItemCatalog.builder()
|
||||
.name(data.name())
|
||||
.description(data.description())
|
||||
.icon(data.icon())
|
||||
.items(copyItems(data.items()))
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
return repository.save(catalog);
|
||||
}
|
||||
|
||||
public Optional<ItemCatalog> getCatalogById(String id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
public List<ItemCatalog> getCatalogsByCampaignId(String campaignId) {
|
||||
return repository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public ItemCatalog updateCatalog(String id, CatalogData data) {
|
||||
ItemCatalog existing = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Catalogue d'objets introuvable: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setDescription(data.description());
|
||||
existing.setIcon(data.icon());
|
||||
existing.setItems(copyItems(data.items()));
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
return repository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteCatalog(String id) {
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
public List<ItemCatalog> searchCatalogs(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return repository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de catalogue (non persistée) via l'IA, contextualisée campagne. */
|
||||
public ItemCatalog generateProposal(String campaignId, String description) {
|
||||
ItemCatalogGenerator.GeneratedCatalog g = generator.generate(description, buildContext(campaignId));
|
||||
return ItemCatalog.builder()
|
||||
.name(g.name())
|
||||
.description(g.description())
|
||||
.campaignId(campaignId)
|
||||
.items(g.items() != null ? new ArrayList<>(g.items()) : new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static List<CatalogItem> copyItems(List<CatalogItem> items) {
|
||||
return items != null ? new ArrayList<>(items) : new ArrayList<>();
|
||||
}
|
||||
|
||||
private String buildContext(String campaignId) {
|
||||
if (campaignId == null) return "";
|
||||
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
|
||||
if (campaign == null) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Campagne : ").append(campaign.getName());
|
||||
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
|
||||
sb.append(" — ").append(campaign.getDescription().trim());
|
||||
}
|
||||
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
|
||||
gameSystemRepository.findById(campaign.getGameSystemId())
|
||||
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return repository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(ItemCatalog::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Notebook;
|
||||
import com.loremind.domain.campaigncontext.NotebookMessage;
|
||||
import com.loremind.domain.campaigncontext.NotebookSource;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NotebookIndexer;
|
||||
import com.loremind.domain.campaigncontext.ports.NotebookRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Service d'application des notebooks (atelier RAG) : CRUD, indexation des sources
|
||||
* (déléguée au Brain), conversation persistée, et assemblage du contexte campagne.
|
||||
*/
|
||||
@Service
|
||||
public class NotebookService {
|
||||
|
||||
private final NotebookRepository repository;
|
||||
private final NotebookIndexer indexer;
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final CampaignBriefBuilder briefBuilder;
|
||||
private final com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository;
|
||||
|
||||
public NotebookService(
|
||||
NotebookRepository repository,
|
||||
NotebookIndexer indexer,
|
||||
CampaignRepository campaignRepository,
|
||||
CampaignBriefBuilder briefBuilder,
|
||||
com.loremind.domain.gamesystemcontext.ports.GameSystemRepository gameSystemRepository) {
|
||||
this.repository = repository;
|
||||
this.indexer = indexer;
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.briefBuilder = briefBuilder;
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
// --- Notebooks ---
|
||||
|
||||
public Notebook createNotebook(String campaignId, String name) {
|
||||
String safeName = (name == null || name.isBlank()) ? "Nouvel atelier" : name.trim();
|
||||
return repository.save(Notebook.builder().campaignId(campaignId).name(safeName).build());
|
||||
}
|
||||
|
||||
public java.util.Optional<Notebook> getNotebook(String id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
public List<Notebook> getNotebooksByCampaign(String campaignId) {
|
||||
return repository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public Notebook renameNotebook(String id, String name) {
|
||||
Notebook nb = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Notebook introuvable: " + id));
|
||||
nb.setName((name == null || name.isBlank()) ? nb.getName() : name.trim());
|
||||
return repository.save(nb);
|
||||
}
|
||||
|
||||
public void deleteNotebook(String id) {
|
||||
// Supprime les vecteurs de chaque source côté Brain (best-effort) avant la BDD.
|
||||
for (NotebookSource s : repository.findSourcesByNotebookId(id)) {
|
||||
indexer.delete(s.getId());
|
||||
}
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
// --- Sources ---
|
||||
|
||||
public List<NotebookSource> getSources(String notebookId) {
|
||||
return repository.findSourcesByNotebookId(notebookId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une source : crée la ligne (INDEXING), lance l'indexation Brain, puis
|
||||
* met à jour (READY + compteurs) ou (FAILED) en cas d'échec — et relaie l'erreur.
|
||||
*/
|
||||
public NotebookSource addSource(String notebookId, String filename, byte[] pdfBytes) {
|
||||
if (!repository.existsById(notebookId)) {
|
||||
throw new IllegalArgumentException("Notebook introuvable: " + notebookId);
|
||||
}
|
||||
NotebookSource source = repository.saveSource(NotebookSource.builder()
|
||||
.notebookId(notebookId)
|
||||
.filename(filename != null && !filename.isBlank() ? filename : "source.pdf")
|
||||
.status("INDEXING")
|
||||
.build());
|
||||
try {
|
||||
NotebookIndexer.IndexResult result = indexer.index(source.getId(), pdfBytes, filename);
|
||||
source.setStatus("READY");
|
||||
source.setChunkCount(result.chunks());
|
||||
source.setPageCount(result.pageCount());
|
||||
return repository.saveSource(source);
|
||||
} catch (RuntimeException e) {
|
||||
source.setStatus("FAILED");
|
||||
repository.saveSource(source);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteSource(String sourceId) {
|
||||
repository.findSourceById(sourceId).ifPresent(s -> indexer.delete(s.getId()));
|
||||
repository.deleteSourceById(sourceId);
|
||||
}
|
||||
|
||||
public List<String> readySourceIds(String notebookId) {
|
||||
return repository.findSourcesByNotebookId(notebookId).stream()
|
||||
.filter(s -> "READY".equals(s.getStatus()))
|
||||
.map(NotebookSource::getId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- Conversation ---
|
||||
|
||||
public List<NotebookMessage> getMessages(String notebookId) {
|
||||
return repository.findMessagesByNotebookId(notebookId);
|
||||
}
|
||||
|
||||
public NotebookMessage addMessage(String notebookId, String role, String content) {
|
||||
return repository.saveMessage(NotebookMessage.builder()
|
||||
.notebookId(notebookId).role(role).content(content).build());
|
||||
}
|
||||
|
||||
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
|
||||
public void clearChat(String notebookId) {
|
||||
repository.archiveMessagesByNotebookId(notebookId);
|
||||
}
|
||||
|
||||
/** Messages archivés, chronologiques — l'appelant regroupe par {@code archivedAt}. */
|
||||
public List<NotebookMessage> getArchivedMessages(String notebookId) {
|
||||
return repository.findArchivedMessagesByNotebookId(notebookId);
|
||||
}
|
||||
|
||||
// Budget total (caractères ≈ tokens/4) des archives injectées en référence :
|
||||
// borne le prompt même si l'utilisateur coche plusieurs longues conversations.
|
||||
private static final int ARCHIVE_CONTEXT_MAX_CHARS = 16000;
|
||||
|
||||
/**
|
||||
* Bloc de contexte construit à partir des archives COCHÉES par l'utilisateur
|
||||
* (clés = {@code archivedAt.toString()}). Injecté dans le prompt du chat pour
|
||||
* que l'IA puisse s'appuyer sur d'anciennes conversations. Chaîne vide si
|
||||
* aucune clé valide. Chaque archive est tronquée PAR LE DÉBUT au-delà de son
|
||||
* budget : la fin d'une conversation (conclusions) est la partie utile.
|
||||
*/
|
||||
public String buildArchiveContext(String notebookId, List<String> archivedAtKeys) {
|
||||
if (archivedAtKeys == null || archivedAtKeys.isEmpty()) return "";
|
||||
var wanted = new java.util.HashSet<>(archivedAtKeys);
|
||||
var groups = new java.util.LinkedHashMap<java.time.LocalDateTime, List<NotebookMessage>>();
|
||||
for (NotebookMessage m : repository.findArchivedMessagesByNotebookId(notebookId)) {
|
||||
if (m.getArchivedAt() != null && wanted.contains(m.getArchivedAt().toString())) {
|
||||
groups.computeIfAbsent(m.getArchivedAt(), k -> new java.util.ArrayList<>()).add(m);
|
||||
}
|
||||
}
|
||||
if (groups.isEmpty()) return "";
|
||||
|
||||
int budgetPerArchive = Math.max(2000, ARCHIVE_CONTEXT_MAX_CHARS / groups.size());
|
||||
StringBuilder out = new StringBuilder(
|
||||
"--- ANCIENNES CONVERSATIONS DE CET ATELIER (références choisies par le MJ : "
|
||||
+ "tu peux t'appuyer sur leurs conclusions) ---\n");
|
||||
groups.forEach((archivedAt, messages) -> {
|
||||
StringBuilder convo = new StringBuilder();
|
||||
for (NotebookMessage m : messages) {
|
||||
convo.append("user".equals(m.getRole()) ? "MJ : " : "IA : ")
|
||||
.append(m.getContent()).append('\n');
|
||||
}
|
||||
String text = convo.toString();
|
||||
if (text.length() > budgetPerArchive) {
|
||||
text = "[…début tronqué…]\n" + text.substring(text.length() - budgetPerArchive);
|
||||
}
|
||||
out.append("[Archive du ").append(archivedAt).append("]\n").append(text).append('\n');
|
||||
});
|
||||
out.append("--- FIN DES ANCIENNES CONVERSATIONS ---");
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
// --- Contexte campagne (oriente l'IA) ---
|
||||
|
||||
/** Brief COMPLET de la campagne (structure arcs/chapitres/scènes + PNJ + lore) :
|
||||
* l'IA « voit » la campagne, pas seulement son nom. */
|
||||
public String buildContext(String campaignId) {
|
||||
if (campaignId == null) return "";
|
||||
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
|
||||
if (campaign == null) return "";
|
||||
String brief = briefBuilder.build(campaign);
|
||||
// Champs TEXT de la fiche PNJ du système de jeu : permet à l'IA de remplir
|
||||
// `values` des actions "npc" avec les BONS noms de champs (Histoire,
|
||||
// Apparence…) au lieu de tout entasser dans une description générique.
|
||||
String npcFields = npcSheetFields(campaign.getGameSystemId());
|
||||
return npcFields.isEmpty() ? brief : brief + "\n\n" + npcFields;
|
||||
}
|
||||
|
||||
private String npcSheetFields(String gameSystemId) {
|
||||
if (gameSystemId == null || gameSystemId.isBlank()) return "";
|
||||
var gameSystem = gameSystemRepository.findById(gameSystemId).orElse(null);
|
||||
if (gameSystem == null || gameSystem.getNpcTemplate() == null) return "";
|
||||
var names = gameSystem.getNpcTemplate().stream()
|
||||
.filter(f -> f.getType() == com.loremind.domain.shared.template.FieldType.TEXT)
|
||||
.map(com.loremind.domain.shared.template.TemplateField::getName)
|
||||
.filter(n -> n != null && !n.isBlank())
|
||||
.toList();
|
||||
if (names.isEmpty()) return "";
|
||||
return "FICHE PNJ — champs texte disponibles (clés à utiliser dans `values` "
|
||||
+ "d'une action npc) : " + String.join(", ", names);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Npc;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -14,16 +19,25 @@ import java.util.Optional;
|
||||
public class NpcService {
|
||||
|
||||
private final NpcRepository npcRepository;
|
||||
private final CampaignRepository campaignRepository;
|
||||
|
||||
public NpcService(NpcRepository npcRepository) {
|
||||
public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) {
|
||||
this.npcRepository = npcRepository;
|
||||
this.campaignRepository = campaignRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un Npc.
|
||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||
*/
|
||||
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {}
|
||||
public record NpcData(
|
||||
String name,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
String campaignId,
|
||||
List<String> relatedPageIds,
|
||||
String folder,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public Npc createNpc(NpcData data) {
|
||||
int order = data.order() != null
|
||||
@@ -31,8 +45,14 @@ public class NpcService {
|
||||
: nextOrderFor(data.campaignId());
|
||||
Npc npc = Npc.builder()
|
||||
.name(data.name())
|
||||
.markdownContent(data.markdownContent())
|
||||
.portraitImageId(data.portraitImageId())
|
||||
.headerImageId(data.headerImageId())
|
||||
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||
.keyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>())
|
||||
.campaignId(data.campaignId())
|
||||
.relatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>())
|
||||
.folder(normalizeFolder(data.folder()))
|
||||
.order(order)
|
||||
.build();
|
||||
return npcRepository.save(npc);
|
||||
@@ -46,11 +66,32 @@ public class NpcService {
|
||||
return npcRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
/**
|
||||
* PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}).
|
||||
* Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent.
|
||||
* Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé.
|
||||
*/
|
||||
public List<Npc> getNpcsByLoreId(String loreId) {
|
||||
List<Npc> out = new ArrayList<>();
|
||||
for (Campaign campaign : campaignRepository.findAll()) {
|
||||
if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) {
|
||||
out.addAll(npcRepository.findByCampaignId(campaign.getId()));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public Npc updateNpc(String id, NpcData data) {
|
||||
Npc existing = npcRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setMarkdownContent(data.markdownContent());
|
||||
existing.setPortraitImageId(data.portraitImageId());
|
||||
existing.setHeaderImageId(data.headerImageId());
|
||||
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||
existing.setKeyValueValues(data.keyValueValues() != null ? new HashMap<>(data.keyValueValues()) : new HashMap<>());
|
||||
existing.setRelatedPageIds(data.relatedPageIds() != null ? new ArrayList<>(data.relatedPageIds()) : new ArrayList<>());
|
||||
existing.setFolder(normalizeFolder(data.folder()));
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
@@ -61,7 +102,18 @@ public class NpcService {
|
||||
npcRepository.deleteById(id);
|
||||
}
|
||||
|
||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
||||
public List<Npc> searchNpcs(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return npcRepository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/** Trim le dossier ; chaîne vide → null (= non classé). */
|
||||
private static String normalizeFolder(String folder) {
|
||||
if (folder == null) return null;
|
||||
String trimmed = folder.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return npcRepository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(Npc::getOrder)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.loremind.application.campaigncontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.RandomTable;
|
||||
import com.loremind.domain.campaigncontext.RandomTableEntry;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
|
||||
import com.loremind.domain.campaigncontext.ports.RandomTableRepository;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour les tables aléatoires (campagne).
|
||||
*/
|
||||
@Service
|
||||
public class RandomTableService {
|
||||
|
||||
private final RandomTableRepository repository;
|
||||
private final RandomTableGenerator generator;
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
|
||||
public RandomTableService(
|
||||
RandomTableRepository repository,
|
||||
RandomTableGenerator generator,
|
||||
CampaignRepository campaignRepository,
|
||||
GameSystemRepository gameSystemRepository) {
|
||||
this.repository = repository;
|
||||
this.generator = generator;
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
}
|
||||
|
||||
public record TableData(
|
||||
String name,
|
||||
String description,
|
||||
String diceFormula,
|
||||
String icon,
|
||||
List<RandomTableEntry> entries,
|
||||
String campaignId,
|
||||
Integer order
|
||||
) {}
|
||||
|
||||
public RandomTable createTable(TableData data) {
|
||||
int order = data.order() != null ? data.order() : nextOrderFor(data.campaignId());
|
||||
RandomTable table = RandomTable.builder()
|
||||
.name(data.name())
|
||||
.description(data.description())
|
||||
.diceFormula(data.diceFormula())
|
||||
.icon(data.icon())
|
||||
.entries(copyEntries(data.entries()))
|
||||
.campaignId(data.campaignId())
|
||||
.order(order)
|
||||
.build();
|
||||
return repository.save(table);
|
||||
}
|
||||
|
||||
public Optional<RandomTable> getTableById(String id) {
|
||||
return repository.findById(id);
|
||||
}
|
||||
|
||||
public List<RandomTable> getTablesByCampaignId(String campaignId) {
|
||||
return repository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public RandomTable updateTable(String id, TableData data) {
|
||||
RandomTable existing = repository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Table aléatoire introuvable: " + id));
|
||||
existing.setName(data.name());
|
||||
existing.setDescription(data.description());
|
||||
existing.setDiceFormula(data.diceFormula());
|
||||
existing.setIcon(data.icon());
|
||||
existing.setEntries(copyEntries(data.entries()));
|
||||
if (data.order() != null) {
|
||||
existing.setOrder(data.order());
|
||||
}
|
||||
return repository.save(existing);
|
||||
}
|
||||
|
||||
public void deleteTable(String id) {
|
||||
repository.deleteById(id);
|
||||
}
|
||||
|
||||
public List<RandomTable> searchTables(String query) {
|
||||
if (query == null || query.isBlank()) return List.of();
|
||||
return repository.searchByName(query.trim());
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de table (non persistée) via l'IA, contextualisée campagne. */
|
||||
public RandomTable generateProposal(String campaignId, String description, String diceFormula) {
|
||||
String formula = (diceFormula == null || diceFormula.isBlank()) ? "1d20" : diceFormula;
|
||||
RandomTableGenerator.GeneratedTable g = generator.generate(description, formula, buildContext(campaignId));
|
||||
return RandomTable.builder()
|
||||
.name(g.name())
|
||||
.description(g.description())
|
||||
.diceFormula(formula)
|
||||
.campaignId(campaignId)
|
||||
.entries(g.entries() != null ? new ArrayList<>(g.entries()) : new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Brode un court récit IA sur un résultat tiré (pour la partie). */
|
||||
public String improviseRoll(String campaignId, String tableName, String resultLabel, String resultDetail) {
|
||||
return generator.improvise(tableName, resultLabel, resultDetail, buildContext(campaignId));
|
||||
}
|
||||
|
||||
/** Contexte libre (nom de campagne + description + système) pour orienter l'IA. */
|
||||
private String buildContext(String campaignId) {
|
||||
if (campaignId == null) return "";
|
||||
Campaign campaign = campaignRepository.findById(campaignId).orElse(null);
|
||||
if (campaign == null) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Campagne : ").append(campaign.getName());
|
||||
if (campaign.getDescription() != null && !campaign.getDescription().isBlank()) {
|
||||
sb.append(" — ").append(campaign.getDescription().trim());
|
||||
}
|
||||
if (campaign.getGameSystemId() != null && !campaign.getGameSystemId().isBlank()) {
|
||||
gameSystemRepository.findById(campaign.getGameSystemId())
|
||||
.ifPresent(gs -> sb.append("\nSystème de jeu : ").append(gs.getName()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static List<RandomTableEntry> copyEntries(List<RandomTableEntry> entries) {
|
||||
return entries != null ? new ArrayList<>(entries) : new ArrayList<>();
|
||||
}
|
||||
|
||||
private int nextOrderFor(String campaignId) {
|
||||
return repository.findByCampaignId(campaignId).stream()
|
||||
.mapToInt(RandomTable::getOrder)
|
||||
.max()
|
||||
.orElse(-1) + 1;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -40,6 +41,19 @@ public class SceneService {
|
||||
return sceneRepository.save(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une scène à partir d'un objet Scene complet (tous les champs : narration
|
||||
* joueurs, notes MJ, pièces…). Utilisé par l'import de campagne. L'ID est forcé
|
||||
* à null pour laisser le repo en générer un nouveau.
|
||||
*/
|
||||
public Scene createScene(Scene input) {
|
||||
input.setId(null);
|
||||
if (input.getRooms() == null) {
|
||||
input.setRooms(new ArrayList<>());
|
||||
}
|
||||
return sceneRepository.save(input);
|
||||
}
|
||||
|
||||
public Optional<Scene> getSceneById(String id) {
|
||||
return sceneRepository.findById(id);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.loremind.application.gamesystemcontext;
|
||||
|
||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||
import com.loremind.domain.gamesystemcontext.RulesImportResult;
|
||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||
import com.loremind.domain.gamesystemcontext.ports.RulesPdfImporter;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
@@ -11,18 +14,50 @@ import java.util.Optional;
|
||||
public class GameSystemService {
|
||||
|
||||
private final GameSystemRepository gameSystemRepository;
|
||||
private final RulesPdfImporter rulesPdfImporter;
|
||||
|
||||
public GameSystemService(GameSystemRepository gameSystemRepository) {
|
||||
public GameSystemService(GameSystemRepository gameSystemRepository,
|
||||
RulesPdfImporter rulesPdfImporter) {
|
||||
this.gameSystemRepository = gameSystemRepository;
|
||||
this.rulesPdfImporter = rulesPdfImporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Importe un PDF de règles et renvoie une PROPOSITION de sections (titre →
|
||||
* markdown). Ne persiste rien : l'UI laisse l'utilisateur réviser/éditer
|
||||
* puis enregistrer le GameSystem via {@link #updateGameSystem}/{@link #createGameSystem}.
|
||||
*/
|
||||
public RulesImportResult importRulesFromPdf(byte[] pdfBytes, String filename) {
|
||||
return rulesPdfImporter.importRules(pdfBytes, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante streamée de {@link #importRulesFromPdf} : remonte l'avancement via
|
||||
* callbacks (import long → l'UI affiche une progression). Ne persiste rien.
|
||||
*/
|
||||
public void importRulesFromPdfStreaming(
|
||||
byte[] pdfBytes,
|
||||
String filename,
|
||||
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
java.util.function.Consumer<String> onStatus,
|
||||
java.util.function.Consumer<RulesImportResult> onDone,
|
||||
java.util.function.Consumer<Throwable> onError) {
|
||||
rulesPdfImporter.importRulesStreaming(
|
||||
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
||||
* Les templates peuvent etre null (interpretes comme listes vides).
|
||||
*/
|
||||
public record GameSystemData(
|
||||
String name,
|
||||
String description,
|
||||
String rulesMarkdown,
|
||||
List<TemplateField> characterTemplate,
|
||||
List<TemplateField> npcTemplate,
|
||||
List<TemplateField> enemyTemplate,
|
||||
String author,
|
||||
boolean isPublic
|
||||
) {}
|
||||
@@ -35,6 +70,9 @@ public class GameSystemService {
|
||||
.author(normalize(data.author()))
|
||||
.isPublic(data.isPublic())
|
||||
.build();
|
||||
gameSystem.replaceCharacterTemplate(data.characterTemplate());
|
||||
gameSystem.replaceNpcTemplate(data.npcTemplate());
|
||||
gameSystem.replaceEnemyTemplate(data.enemyTemplate());
|
||||
return gameSystemRepository.save(gameSystem);
|
||||
}
|
||||
|
||||
@@ -52,6 +90,9 @@ public class GameSystemService {
|
||||
existing.setName(data.name());
|
||||
existing.setDescription(data.description());
|
||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||
existing.replaceCharacterTemplate(data.characterTemplate());
|
||||
existing.replaceNpcTemplate(data.npcTemplate());
|
||||
existing.replaceEnemyTemplate(data.enemyTemplate());
|
||||
existing.setAuthor(normalize(data.author()));
|
||||
existing.setPublic(data.isPublic());
|
||||
return gameSystemRepository.save(existing);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.Character;
|
||||
@@ -10,6 +11,7 @@ import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
@@ -18,6 +20,8 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHin
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomBranchHint;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.RoomSummary;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -46,6 +50,7 @@ public class CampaignStructuralContextBuilder {
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final NpcRepository npcRepository;
|
||||
private final EnemyRepository enemyRepository;
|
||||
|
||||
public CampaignStructuralContextBuilder(
|
||||
CampaignRepository campaignRepository,
|
||||
@@ -53,34 +58,53 @@ public class CampaignStructuralContextBuilder {
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository,
|
||||
NpcRepository npcRepository) {
|
||||
NpcRepository npcRepository,
|
||||
EnemyRepository enemyRepository) {
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.npcRepository = npcRepository;
|
||||
this.enemyRepository = enemyRepository;
|
||||
}
|
||||
|
||||
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
|
||||
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
||||
|
||||
/**
|
||||
* Construit la carte narrative d'une Campagne (arcs → chapitres → scènes,
|
||||
* nom + description courte à chaque niveau).
|
||||
* @throws IllegalArgumentException si la Campagne est introuvable
|
||||
* Construit la carte narrative d'une Campagne. Sans playthroughId, les PJ
|
||||
* sont omis (ils sont propres à une Partie).
|
||||
*/
|
||||
public CampaignStructuralContext build(String campaignId) {
|
||||
return build(campaignId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante avec playthroughId : injecte les PJ de la Partie indiquée.
|
||||
* Les PNJ restent campagne-scope (donnée de scénario).
|
||||
*/
|
||||
public CampaignStructuralContext build(String campaignId, String playthroughId) {
|
||||
Campaign campaign = campaignRepository.findById(campaignId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Campagne non trouvée avec l'ID: " + campaignId));
|
||||
|
||||
// Libellés du bestiaire (« Nom (niveau) ») chargés UNE fois pour résoudre
|
||||
// les enemyIds des pièces sans N+1 sur le repo.
|
||||
Map<String, String> enemyLabelById = enemyRepository.findByCampaignId(campaignId).stream()
|
||||
.collect(Collectors.toMap(
|
||||
com.loremind.domain.campaigncontext.Enemy::getId,
|
||||
CampaignStructuralContextBuilder::enemyLabel,
|
||||
(a, b) -> a));
|
||||
|
||||
List<ArcSummary> arcs = arcRepository.findByCampaignId(campaignId).stream()
|
||||
.sorted(Comparator.comparingInt(Arc::getOrder))
|
||||
.map(this::toArcSummary)
|
||||
.map(arc -> toArcSummary(arc, enemyLabelById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<CharacterSummary> characters = characterRepository.findByCampaignId(campaignId).stream()
|
||||
List<CharacterSummary> characters = (playthroughId == null || playthroughId.isBlank())
|
||||
? List.of()
|
||||
: characterRepository.findByPlaythroughId(playthroughId).stream()
|
||||
.sorted(Comparator.comparingInt(Character::getOrder))
|
||||
.map(this::toCharacterSummary)
|
||||
.collect(Collectors.toList());
|
||||
@@ -104,38 +128,48 @@ public class CampaignStructuralContextBuilder {
|
||||
* sans injecter toute sa fiche.
|
||||
*/
|
||||
private CharacterSummary toCharacterSummary(Character c) {
|
||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
||||
return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
|
||||
}
|
||||
|
||||
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
||||
private NpcSummary toNpcSummary(Npc n) {
|
||||
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent()));
|
||||
return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
|
||||
}
|
||||
|
||||
private static String extractSnippet(String markdown) {
|
||||
if (markdown == null || markdown.isBlank()) return "";
|
||||
String firstLine = markdown.lines()
|
||||
/**
|
||||
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
|
||||
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
|
||||
*/
|
||||
private static String extractSnippet(java.util.Map<String, String> values) {
|
||||
if (values == null || values.isEmpty()) return "";
|
||||
for (String value : values.values()) {
|
||||
if (value == null || value.isBlank()) continue;
|
||||
String firstLine = value.lines()
|
||||
.map(String::strip)
|
||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
if (firstLine.isEmpty()) continue;
|
||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private ArcSummary toArcSummary(Arc arc) {
|
||||
private ArcSummary toArcSummary(Arc arc, Map<String, String> enemyLabelById) {
|
||||
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
||||
.sorted(Comparator.comparingInt(Chapter::getOrder))
|
||||
.map(this::toChapterSummary)
|
||||
.map(chapter -> toChapterSummary(chapter, enemyLabelById))
|
||||
.collect(Collectors.toList());
|
||||
return new ArcSummary(
|
||||
arc.getName(),
|
||||
arc.getDescription(),
|
||||
arc.getType() == ArcType.HUB,
|
||||
countImages(arc.getIllustrationImageIds()),
|
||||
chapters);
|
||||
}
|
||||
|
||||
private ChapterSummary toChapterSummary(Chapter chapter) {
|
||||
private ChapterSummary toChapterSummary(Chapter chapter, Map<String, String> enemyLabelById) {
|
||||
List<Scene> scenes = sceneRepository.findByChapterId(chapter.getId()).stream()
|
||||
.sorted(Comparator.comparingInt(Scene::getOrder))
|
||||
.toList();
|
||||
@@ -146,7 +180,7 @@ public class CampaignStructuralContextBuilder {
|
||||
.collect(Collectors.toMap(Scene::getId, Scene::getName));
|
||||
|
||||
List<SceneSummary> summaries = scenes.stream()
|
||||
.map(s -> toSceneSummary(s, nameById))
|
||||
.map(s -> toSceneSummary(s, nameById, enemyLabelById))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new ChapterSummary(
|
||||
@@ -156,7 +190,8 @@ public class CampaignStructuralContextBuilder {
|
||||
summaries);
|
||||
}
|
||||
|
||||
private SceneSummary toSceneSummary(Scene scene, Map<String, String> nameById) {
|
||||
private SceneSummary toSceneSummary(
|
||||
Scene scene, Map<String, String> nameById, Map<String, String> enemyLabelById) {
|
||||
List<BranchHint> hints = scene.getBranches() == null
|
||||
? List.of()
|
||||
: scene.getBranches().stream()
|
||||
@@ -166,11 +201,67 @@ public class CampaignStructuralContextBuilder {
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<RoomSummary> rooms = toRoomSummaries(scene, enemyLabelById);
|
||||
|
||||
return new SceneSummary(
|
||||
scene.getName(),
|
||||
scene.getDescription(),
|
||||
countImages(scene.getIllustrationImageIds()),
|
||||
hints);
|
||||
hints,
|
||||
rooms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projette les pièces d'une scène en RoomSummary pour le contexte IA.
|
||||
* Pas de notes MJ, pas de loot ni pièges : le prompt reste lisible. L'IA
|
||||
* connaît la structure du lieu (nom des pièces, ennemis, sorties) — c'est
|
||||
* suffisant pour proposer de la narration ou anticiper les choix.
|
||||
*/
|
||||
private List<RoomSummary> toRoomSummaries(Scene scene, Map<String, String> enemyLabelById) {
|
||||
if (scene.getRooms() == null || scene.getRooms().isEmpty()) return List.of();
|
||||
Map<String, String> nameById = scene.getRooms().stream()
|
||||
.collect(Collectors.toMap(
|
||||
com.loremind.domain.campaigncontext.Room::getId,
|
||||
com.loremind.domain.campaigncontext.Room::getName,
|
||||
(a, b) -> a));
|
||||
return scene.getRooms().stream()
|
||||
.map(r -> {
|
||||
List<RoomBranchHint> hints = r.getBranches() == null
|
||||
? List.of()
|
||||
: r.getBranches().stream()
|
||||
.map(b -> new RoomBranchHint(
|
||||
b.label(),
|
||||
nameById.getOrDefault(b.targetRoomId(), "(pièce inconnue)"),
|
||||
b.condition()))
|
||||
.collect(Collectors.toList());
|
||||
return new RoomSummary(
|
||||
r.getName(), r.getFloor(), r.getDescription(),
|
||||
roomEnemiesText(r, enemyLabelById), hints);
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Texte « ennemis » d'une pièce pour le prompt : fiches du bestiaire
|
||||
* référencées (libellés résolus, IDs orphelins ignorés) suivies du texte
|
||||
* libre. L'un ou l'autre peut être vide.
|
||||
*/
|
||||
private static String roomEnemiesText(
|
||||
com.loremind.domain.campaigncontext.Room room, Map<String, String> enemyLabelById) {
|
||||
String linked = room.getEnemyIds() == null ? "" : room.getEnemyIds().stream()
|
||||
.map(enemyLabelById::get)
|
||||
.filter(l -> l != null && !l.isBlank())
|
||||
.collect(Collectors.joining(", "));
|
||||
String freeText = room.getEnemies() == null ? "" : room.getEnemies().strip();
|
||||
if (linked.isEmpty()) return freeText;
|
||||
if (freeText.isEmpty()) return linked;
|
||||
return linked + " — " + freeText;
|
||||
}
|
||||
|
||||
/** Libellé court d'une fiche du bestiaire : « Nom (niveau) » ou « Nom ». */
|
||||
private static String enemyLabel(com.loremind.domain.campaigncontext.Enemy enemy) {
|
||||
String level = enemy.getLevel() == null ? "" : enemy.getLevel().strip();
|
||||
return level.isEmpty() ? enemy.getName() : enemy.getName() + " (" + level + ")";
|
||||
}
|
||||
|
||||
/** Helper defensif : compte les illustrations attachees (null-safe). */
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.loremind.domain.campaigncontext.Scene;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||
@@ -32,18 +33,21 @@ public class NarrativeEntityContextBuilder {
|
||||
private final SceneRepository sceneRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final NpcRepository npcRepository;
|
||||
private final EnemyRepository enemyRepository;
|
||||
|
||||
public NarrativeEntityContextBuilder(
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
SceneRepository sceneRepository,
|
||||
CharacterRepository characterRepository,
|
||||
NpcRepository npcRepository) {
|
||||
NpcRepository npcRepository,
|
||||
EnemyRepository enemyRepository) {
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.sceneRepository = sceneRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.npcRepository = npcRepository;
|
||||
this.enemyRepository = enemyRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,19 +128,56 @@ public class NarrativeEntityContextBuilder {
|
||||
putField(fields, "choicesConsequences", s.getChoicesConsequences());
|
||||
putField(fields, "combatDifficulty", s.getCombatDifficulty());
|
||||
putField(fields, "enemies", s.getEnemies());
|
||||
putField(fields, "linkedEnemies", resolveLinkedEnemies(s));
|
||||
putField(fields, "gmSecretNotes", s.getGmSecretNotes());
|
||||
return new NarrativeEntityContext("scene", s.getName(), fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les fiches du bestiaire référencées par la scène en une ligne par
|
||||
* ennemi : « Nom (niveau) — champ: valeur ; … ». Valeurs tronquées : le
|
||||
* contexte focus doit camper la rencontre, pas embarquer la fiche complète.
|
||||
* Les IDs orphelins (fiche supprimée) sont ignorés silencieusement.
|
||||
*/
|
||||
private String resolveLinkedEnemies(Scene s) {
|
||||
if (s.getEnemyIds() == null || s.getEnemyIds().isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String enemyId : s.getEnemyIds()) {
|
||||
enemyRepository.findById(enemyId).ifPresent(e -> {
|
||||
if (sb.length() > 0) sb.append("\n");
|
||||
sb.append("- ").append(e.getName());
|
||||
if (e.getLevel() != null && !e.getLevel().isBlank()) {
|
||||
sb.append(" (").append(e.getLevel().trim()).append(")");
|
||||
}
|
||||
String stats = e.getValues().entrySet().stream()
|
||||
.filter(en -> en.getValue() != null && !en.getValue().isBlank())
|
||||
.map(en -> en.getKey() + ": " + truncate(en.getValue().trim(), 100))
|
||||
.collect(java.util.stream.Collectors.joining(" ; "));
|
||||
if (!stats.isEmpty()) sb.append(" — ").append(stats);
|
||||
});
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String truncate(String value, int maxLen) {
|
||||
return value.length() <= maxLen ? value : value.substring(0, maxLen - 1).stripTrailing() + "…";
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromCharacter(Character c) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
||||
if (c.getValues() != null) {
|
||||
// Champs templates exposes individuellement — meilleur pour le LLM que
|
||||
// l'ancien blob markdown monolithique.
|
||||
c.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||
}
|
||||
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||
}
|
||||
|
||||
private NarrativeEntityContext fromNpc(Npc n) {
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
putField(fields, "fiche complète (markdown)", n.getMarkdownContent());
|
||||
if (n.getValues() != null) {
|
||||
n.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||
}
|
||||
return new NarrativeEntityContext("npc", n.getName(), fields);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Arc;
|
||||
import com.loremind.domain.campaigncontext.ArcType;
|
||||
import com.loremind.domain.campaigncontext.Chapter;
|
||||
import com.loremind.domain.campaigncontext.PrerequisiteEvaluator;
|
||||
import com.loremind.domain.campaigncontext.ProgressionStatus;
|
||||
import com.loremind.domain.campaigncontext.QuestStatus;
|
||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||
import com.loremind.domain.generationcontext.SessionContext;
|
||||
import com.loremind.domain.generationcontext.SessionContext.JournalEntrySummary;
|
||||
import com.loremind.domain.generationcontext.SessionContext.QuestSummary;
|
||||
import com.loremind.domain.playcontext.EntryType;
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
import com.loremind.domain.playcontext.QuestProgression;
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
import com.loremind.domain.playcontext.SessionEntry;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Construit le SessionContext injecté dans le prompt IA pendant une partie.
|
||||
*
|
||||
* <p>Depuis la refonte Playthrough : la session connaît son playthroughId ; la progression
|
||||
* et les flags viennent du Playthrough (pas plus de la Campagne ni du Chapter).</p>
|
||||
*/
|
||||
@Component
|
||||
public class SessionStructuralContextBuilder {
|
||||
|
||||
private static final int MAX_CURRENT_ENTRIES = 80;
|
||||
private static final int MAX_PREVIOUS_EVENTS = 60;
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final SessionEntryRepository entryRepository;
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final ArcRepository arcRepository;
|
||||
private final ChapterRepository chapterRepository;
|
||||
private final PlaythroughFlagRepository playthroughFlagRepository;
|
||||
private final QuestProgressionRepository questProgressionRepository;
|
||||
private final PrerequisiteEvaluator prerequisiteEvaluator = new PrerequisiteEvaluator();
|
||||
|
||||
public SessionStructuralContextBuilder(SessionRepository sessionRepository,
|
||||
SessionEntryRepository entryRepository,
|
||||
PlaythroughRepository playthroughRepository,
|
||||
ArcRepository arcRepository,
|
||||
ChapterRepository chapterRepository,
|
||||
PlaythroughFlagRepository playthroughFlagRepository,
|
||||
QuestProgressionRepository questProgressionRepository) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.entryRepository = entryRepository;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.arcRepository = arcRepository;
|
||||
this.chapterRepository = chapterRepository;
|
||||
this.playthroughFlagRepository = playthroughFlagRepository;
|
||||
this.questProgressionRepository = questProgressionRepository;
|
||||
}
|
||||
|
||||
public Optional<SessionContext> buildOptional(String sessionId) {
|
||||
return sessionRepository.findById(sessionId).map(this::toContext);
|
||||
}
|
||||
|
||||
public SessionContext build(String sessionId) {
|
||||
Session session = sessionRepository.findById(sessionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
||||
return toContext(session);
|
||||
}
|
||||
|
||||
private SessionContext toContext(Session session) {
|
||||
List<JournalEntrySummary> currentEntries = loadCurrentEntries(session);
|
||||
List<JournalEntrySummary> previousEvents = loadPreviousEvents(session);
|
||||
HubStatus hub = computeHubStatus(session.getPlaythroughId());
|
||||
|
||||
return new SessionContext(
|
||||
session.getName(),
|
||||
session.isActive(),
|
||||
session.getStartedAt(),
|
||||
currentEntries,
|
||||
previousEvents,
|
||||
hub.available(),
|
||||
hub.inProgress(),
|
||||
hub.lockedTitles(),
|
||||
hub.activeFlags());
|
||||
}
|
||||
|
||||
private List<JournalEntrySummary> loadCurrentEntries(Session session) {
|
||||
List<SessionEntry> allEntries = entryRepository.findBySessionId(session.getId());
|
||||
List<SessionEntry> kept = allEntries.size() <= MAX_CURRENT_ENTRIES
|
||||
? allEntries
|
||||
: allEntries.subList(allEntries.size() - MAX_CURRENT_ENTRIES, allEntries.size());
|
||||
|
||||
return kept.stream()
|
||||
.map(e -> toSummary(e, null))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* EVENTs des sessions précédentes du MÊME Playthrough (même table).
|
||||
* On ne mélange jamais les EVENTs de tables différentes.
|
||||
*/
|
||||
private List<JournalEntrySummary> loadPreviousEvents(Session current) {
|
||||
if (current.getPlaythroughId() == null) return List.of();
|
||||
List<Session> siblingSessions = sessionRepository.findByPlaythroughId(current.getPlaythroughId());
|
||||
List<JournalEntrySummary> events = new ArrayList<>();
|
||||
|
||||
for (Session past : siblingSessions) {
|
||||
if (past.getId().equals(current.getId())) continue;
|
||||
for (SessionEntry entry : entryRepository.findBySessionId(past.getId())) {
|
||||
if (entry.getType() == EntryType.EVENT) {
|
||||
events.add(toSummary(entry, past.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.sort(Comparator.comparing(
|
||||
JournalEntrySummary::occurredAt,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
|
||||
if (events.size() > MAX_PREVIOUS_EVENTS) {
|
||||
return events.subList(events.size() - MAX_PREVIOUS_EVENTS, events.size());
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private JournalEntrySummary toSummary(SessionEntry entry, String sourceSessionName) {
|
||||
return new JournalEntrySummary(
|
||||
entry.getType() != null ? entry.getType().name() : "NOTE",
|
||||
entry.getContent(),
|
||||
entry.getOccurredAt(),
|
||||
sourceSessionName);
|
||||
}
|
||||
|
||||
/** Agrégat interne des données Hub à injecter dans le SessionContext. */
|
||||
private record HubStatus(
|
||||
List<QuestSummary> available,
|
||||
List<QuestSummary> inProgress,
|
||||
List<String> lockedTitles,
|
||||
List<String> activeFlags
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Calcule l'état des quêtes Hub du Playthrough courant :
|
||||
* - AVAILABLE / IN_PROGRESS → résumé complet
|
||||
* - LOCKED → titre uniquement (anti-spoiler)
|
||||
* - COMPLETED → omis (déjà raconté par les EVENTs)
|
||||
*/
|
||||
private HubStatus computeHubStatus(String playthroughId) {
|
||||
if (playthroughId == null) {
|
||||
return new HubStatus(List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
Optional<Playthrough> maybePlaythrough = playthroughRepository.findById(playthroughId);
|
||||
if (maybePlaythrough.isEmpty()) {
|
||||
return new HubStatus(List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
String campaignId = maybePlaythrough.get().getCampaignId();
|
||||
|
||||
Map<String, Boolean> flags = playthroughFlagRepository.findByPlaythroughId(playthroughId);
|
||||
List<String> activeFlags = buildActiveFlags(flags);
|
||||
|
||||
List<Arc> arcs = arcRepository.findByCampaignId(campaignId);
|
||||
Map<String, Arc> arcsById = arcs.stream()
|
||||
.filter(a -> a.getId() != null)
|
||||
.collect(Collectors.toMap(Arc::getId, a -> a));
|
||||
|
||||
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
|
||||
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
|
||||
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
|
||||
|
||||
// Map chapterId -> ProgressionStatus pour ce Playthrough
|
||||
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
|
||||
for (QuestProgression qp : questProgressionRepository.findByPlaythroughId(playthroughId)) {
|
||||
progressionByChapter.put(qp.getChapterId(), qp.getStatus());
|
||||
}
|
||||
|
||||
// IDs des chapitres COMPLETED dans la campagne (pour les prérequis QuestCompleted)
|
||||
var completedIds = questProgressionRepository.findCompletedChapterIdsByPlaythroughId(playthroughId);
|
||||
|
||||
int sessionCount = sessionRepository.findByPlaythroughId(playthroughId).size();
|
||||
PrerequisiteEvaluator.EvaluationContext ctx =
|
||||
new PrerequisiteEvaluator.EvaluationContext(completedIds, sessionCount, flags);
|
||||
|
||||
List<QuestSummary> available = new ArrayList<>();
|
||||
List<QuestSummary> inProgress = new ArrayList<>();
|
||||
List<String> lockedTitles = new ArrayList<>();
|
||||
|
||||
for (Arc arc : arcs) {
|
||||
boolean isHub = arc.getType() == ArcType.HUB;
|
||||
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
|
||||
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
|
||||
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
|
||||
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
|
||||
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
|
||||
Arc parent = arcsById.get(c.getArcId());
|
||||
String arcName = parent != null ? parent.getName() : null;
|
||||
switch (status) {
|
||||
case AVAILABLE:
|
||||
available.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
|
||||
break;
|
||||
case IN_PROGRESS:
|
||||
inProgress.add(new QuestSummary(c.getName(), arcName, c.getDescription()));
|
||||
break;
|
||||
case LOCKED:
|
||||
lockedTitles.add(c.getName());
|
||||
break;
|
||||
case COMPLETED:
|
||||
// Omis (déjà dans le journal des EVENTs).
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new HubStatus(available, inProgress, lockedTitles, activeFlags);
|
||||
}
|
||||
|
||||
private List<String> buildActiveFlags(Map<String, Boolean> flags) {
|
||||
return flags.entrySet().stream()
|
||||
.filter(Map.Entry::getValue)
|
||||
.map(Map.Entry::getKey)
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.loremind.application.generationcontext;
|
||||
|
||||
import com.loremind.application.gamesystemcontext.GameSystemContextBuilder;
|
||||
import com.loremind.domain.campaigncontext.Campaign;
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.gamesystemcontext.GenerationIntent;
|
||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||
import com.loremind.domain.generationcontext.ChatMessage;
|
||||
import com.loremind.domain.generationcontext.ChatRequest;
|
||||
import com.loremind.domain.generationcontext.ChatUsage;
|
||||
import com.loremind.domain.generationcontext.GameSystemContext;
|
||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||
import com.loremind.domain.generationcontext.SessionContext;
|
||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Use case applicatif : chat IA pendant une Session de jeu.
|
||||
* <p>
|
||||
* Orchestre la composition des contextes :
|
||||
* 1. Charge la Session puis la Campagne associée (weak reference).
|
||||
* 2. Construit le CampaignStructuralContext (carte narrative + PJ/PNJ).
|
||||
* 3. Construit le LoreStructuralContext si la campagne est liée à un Lore.
|
||||
* 4. Construit le GameSystemContext si elle a un système de JDR.
|
||||
* 5. Construit le SessionContext (journal horodaté, statut).
|
||||
* 6. Délègue au port {@link AiChatProvider} pour le streaming.
|
||||
* </p>
|
||||
*
|
||||
* <p>La conversation est éphémère (pas de persistance) : pendant une partie,
|
||||
* l'utilité est d'avoir une assistance immédiate, pas de garder un historique.
|
||||
* Le journal de session joue déjà ce rôle de mémoire persistante.</p>
|
||||
*/
|
||||
@Service
|
||||
public class StreamChatForSessionUseCase {
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final CampaignStructuralContextBuilder campaignContextBuilder;
|
||||
private final LoreStructuralContextBuilder loreContextBuilder;
|
||||
private final GameSystemContextBuilder gameSystemContextBuilder;
|
||||
private final SessionStructuralContextBuilder sessionContextBuilder;
|
||||
private final AiChatProvider aiChatProvider;
|
||||
|
||||
public StreamChatForSessionUseCase(
|
||||
SessionRepository sessionRepository,
|
||||
PlaythroughRepository playthroughRepository,
|
||||
CampaignRepository campaignRepository,
|
||||
CampaignStructuralContextBuilder campaignContextBuilder,
|
||||
LoreStructuralContextBuilder loreContextBuilder,
|
||||
GameSystemContextBuilder gameSystemContextBuilder,
|
||||
SessionStructuralContextBuilder sessionContextBuilder,
|
||||
AiChatProvider aiChatProvider) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.campaignContextBuilder = campaignContextBuilder;
|
||||
this.loreContextBuilder = loreContextBuilder;
|
||||
this.gameSystemContextBuilder = gameSystemContextBuilder;
|
||||
this.sessionContextBuilder = sessionContextBuilder;
|
||||
this.aiChatProvider = aiChatProvider;
|
||||
}
|
||||
|
||||
public void execute(
|
||||
String sessionId,
|
||||
List<ChatMessage> messages,
|
||||
Consumer<ChatUsage> onUsage,
|
||||
Consumer<String> onToken,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
Session session = sessionRepository.findById(sessionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + sessionId));
|
||||
|
||||
// Chaîne de résolution : Session → Playthrough → Campaign.
|
||||
Playthrough playthrough = playthroughRepository.findById(session.getPlaythroughId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Partie associée à la session introuvable : " + session.getPlaythroughId()));
|
||||
|
||||
Campaign campaign = campaignRepository.findById(playthrough.getCampaignId())
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Campagne associée à la partie introuvable : " + playthrough.getCampaignId()));
|
||||
|
||||
// Le campaign context inclut les PJ de CE Playthrough (les PJ sont par-table).
|
||||
CampaignStructuralContext campaignContext = campaignContextBuilder.build(campaign.getId(), playthrough.getId());
|
||||
LoreStructuralContext loreContext = loadLoreContextOrNull(campaign);
|
||||
GameSystemContext gameSystemContext = loadGameSystemContextOrNull(campaign);
|
||||
SessionContext sessionContext = sessionContextBuilder.build(sessionId);
|
||||
|
||||
ChatRequest request = ChatRequest.builder()
|
||||
.messages(messages)
|
||||
.loreContext(loreContext)
|
||||
.campaignContext(campaignContext)
|
||||
.gameSystemContext(gameSystemContext)
|
||||
.sessionContext(sessionContext)
|
||||
.build();
|
||||
|
||||
aiChatProvider.streamChat(request, onUsage, onToken, onComplete, onError);
|
||||
}
|
||||
|
||||
private LoreStructuralContext loadLoreContextOrNull(Campaign campaign) {
|
||||
if (!campaign.isLinkedToLore()) return null;
|
||||
return loreContextBuilder.buildOptional(campaign.getLoreId()).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pendant une session active, on injecte les sections les plus utiles en partie
|
||||
* (combats, PNJ, mécaniques) — intent SCENE est le plus proche de ce besoin.
|
||||
*/
|
||||
private GameSystemContext loadGameSystemContextOrNull(Campaign campaign) {
|
||||
if (!campaign.isLinkedToGameSystem()) return null;
|
||||
return gameSystemContextBuilder.buildOptional(campaign.getGameSystemId(), GenerationIntent.SCENE)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.loremind.application.licensing;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Orchestre la bascule de canal stable <-> beta via le sidecar `switcher`.
|
||||
*
|
||||
* <p>Le sidecar tourne en permanence et watch un fichier {@code command.json}
|
||||
* dans un volume partage. Quand on depose une commande, il :
|
||||
* <ol>
|
||||
* <li>Sed la ligne IMAGE_NAMESPACE du .env</li>
|
||||
* <li>Lance docker compose pull + up -d</li>
|
||||
* <li>Ecrit son resultat dans {@code result.json}</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Le Core n'a PAS acces au socket Docker — il delegue tout au sidecar
|
||||
* via fichiers, ce qui evite que la compromission du Core ne donne RCE
|
||||
* sur l'hote. Le sidecar valide strictement le contenu de la commande
|
||||
* (channel ∈ {stable, beta} uniquement).
|
||||
*
|
||||
* <p>Le canal actuel se deduit du prefixe d'image courant (recupere via
|
||||
* la variable d'env {@code IMAGE_NAMESPACE} ou {@code UPDATE_CHECK_IMAGES}) :
|
||||
* presence de "loremind-beta-" => canal beta, sinon stable.
|
||||
*/
|
||||
@Service
|
||||
public class ChannelSwitcherService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChannelSwitcherService.class);
|
||||
|
||||
public enum Channel { STABLE, BETA }
|
||||
|
||||
public enum SwitchStatus { IN_PROGRESS, SUCCESS, ERROR }
|
||||
|
||||
/** Snapshot du dernier resultat de switch ecrit par le sidecar. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record SwitchResult(
|
||||
String id,
|
||||
SwitchStatus status,
|
||||
Channel channel,
|
||||
String message,
|
||||
Instant completedAt) {}
|
||||
|
||||
private final Path switcherDataPath;
|
||||
private final String imageNamespace;
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
public ChannelSwitcherService(
|
||||
@Value("${SWITCHER_DATA_PATH:/shared/switcher}") String switcherDataPath,
|
||||
// On lit IMAGE_NAMESPACE en priorite, puis UPDATE_CHECK_IMAGES en fallback
|
||||
// (la deuxieme est toujours injectee par compose, contrairement a la premiere
|
||||
// qui peut etre absente dans les .env legacy).
|
||||
@Value("${IMAGE_NAMESPACE:${UPDATE_CHECK_IMAGES:}}") String imageNamespaceRaw) {
|
||||
this.switcherDataPath = Path.of(switcherDataPath);
|
||||
this.imageNamespace = imageNamespaceRaw != null ? imageNamespaceRaw : "";
|
||||
log.info("ChannelSwitcherService initialized: dataPath={} imageNamespace={}",
|
||||
switcherDataPath, this.imageNamespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection du canal courant a partir du prefixe d'image charge au demarrage.
|
||||
* Pas de magie : si le namespace contient "beta-" on est en beta, sinon stable.
|
||||
*/
|
||||
public Channel getCurrentChannel() {
|
||||
return imageNamespace.contains("loremind-beta-") ? Channel.BETA : Channel.STABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si le sidecar est disponible (volume partage accessible).
|
||||
* Si non, on degrade en lecture seule (l'UI affichera l'ancien message
|
||||
* avec instructions manuelles).
|
||||
*/
|
||||
public boolean isSwitcherAvailable() {
|
||||
return Files.isDirectory(switcherDataPath) && Files.isWritable(switcherDataPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Depose une commande de switch dans le volume partage. Renvoie l'ID
|
||||
* de la commande, que le client peut utiliser pour poller le status.
|
||||
*
|
||||
* @throws IllegalStateException si le sidecar n'est pas disponible
|
||||
* @throws IOException si l'ecriture du fichier echoue
|
||||
*/
|
||||
public String requestSwitch(Channel target) throws IOException {
|
||||
if (!isSwitcherAvailable()) {
|
||||
throw new IllegalStateException("Switcher sidecar not available (volume mount missing)");
|
||||
}
|
||||
String id = UUID.randomUUID().toString();
|
||||
Map<String, Object> command = new LinkedHashMap<>();
|
||||
command.put("id", id);
|
||||
command.put("channel", target.name().toLowerCase());
|
||||
command.put("requestedAt", Instant.now().toString());
|
||||
|
||||
Path commandFile = switcherDataPath.resolve("command.json");
|
||||
Path tmp = Files.createTempFile(switcherDataPath, "command-", ".tmp");
|
||||
try {
|
||||
json.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), command);
|
||||
// Atomic move : evite que le sidecar lise un fichier partiellement ecrit.
|
||||
Files.move(tmp, commandFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} finally {
|
||||
// Cleanup au cas ou move aurait echoue avant le rename.
|
||||
Files.deleteIfExists(tmp);
|
||||
}
|
||||
log.info("Switch command written: id={} channel={}", id, target);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le dernier resultat ecrit par le sidecar, s'il existe.
|
||||
* Renvoie null si aucun switch n'a encore ete tente sur cette instance.
|
||||
*/
|
||||
public SwitchResult getLastResult() {
|
||||
Path resultFile = switcherDataPath.resolve("result.json");
|
||||
if (!Files.exists(resultFile)) return null;
|
||||
try {
|
||||
return json.readValue(resultFile.toFile(), SwitchResult.class);
|
||||
} catch (IOException e) {
|
||||
log.warn("Cannot parse switcher result.json: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,8 @@ public class PageService {
|
||||
existing.setNodeId(changes.getNodeId());
|
||||
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
|
||||
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
|
||||
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
|
||||
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
|
||||
existing.setNotes(changes.getNotes());
|
||||
existing.setTags(CollectionUtils.copyList(changes.getTags()));
|
||||
existing.setRelatedPageIds(CollectionUtils.copyList(changes.getRelatedPageIds()));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.loremind.application.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.Template;
|
||||
import com.loremind.domain.lorecontext.TemplateField;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.loremind.application.playcontext;
|
||||
|
||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||
import com.loremind.domain.playcontext.Playthrough;
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughFlagRepository;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.QuestProgressionRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service applicatif pour le cycle de vie d'un Playthrough (Partie).
|
||||
*
|
||||
* <p>Cascade de suppression : un Playthrough supprime ses flags, ses progressions,
|
||||
* ses PJ et ses sessions (avec leurs entrées de journal).</p>
|
||||
*/
|
||||
@Service
|
||||
public class PlaythroughService {
|
||||
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
private final CampaignRepository campaignRepository;
|
||||
private final PlaythroughFlagRepository flagRepository;
|
||||
private final QuestProgressionRepository progressionRepository;
|
||||
private final CharacterRepository characterRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
private final SessionService sessionService;
|
||||
|
||||
public PlaythroughService(PlaythroughRepository playthroughRepository,
|
||||
CampaignRepository campaignRepository,
|
||||
PlaythroughFlagRepository flagRepository,
|
||||
QuestProgressionRepository progressionRepository,
|
||||
CharacterRepository characterRepository,
|
||||
SessionRepository sessionRepository,
|
||||
SessionService sessionService) {
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
this.campaignRepository = campaignRepository;
|
||||
this.flagRepository = flagRepository;
|
||||
this.progressionRepository = progressionRepository;
|
||||
this.characterRepository = characterRepository;
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.sessionService = sessionService;
|
||||
}
|
||||
|
||||
/** Compte des entités qui seront supprimées en cascade avec la Partie. */
|
||||
public record DeletionImpact(int sessions, int characters, int flags, int progressions) {}
|
||||
|
||||
public Playthrough create(String campaignId, String name, String description) {
|
||||
if (campaignId == null || campaignId.isBlank()) {
|
||||
throw new IllegalArgumentException("campaignId requis.");
|
||||
}
|
||||
if (!campaignRepository.existsById(campaignId)) {
|
||||
throw new IllegalArgumentException("Campagne introuvable : " + campaignId);
|
||||
}
|
||||
Playthrough p = Playthrough.builder()
|
||||
.campaignId(campaignId)
|
||||
.name((name == null || name.isBlank()) ? "Partie principale" : name.trim())
|
||||
.description(description)
|
||||
.build();
|
||||
return playthroughRepository.save(p);
|
||||
}
|
||||
|
||||
public Playthrough update(String id, Playthrough updated) {
|
||||
Playthrough existing = playthroughRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Partie introuvable : " + id));
|
||||
BeanUtils.copyProperties(updated, existing, "id", "campaignId", "createdAt");
|
||||
return playthroughRepository.save(existing);
|
||||
}
|
||||
|
||||
public Optional<Playthrough> getById(String id) {
|
||||
return playthroughRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<Playthrough> getByCampaignId(String campaignId) {
|
||||
return playthroughRepository.findByCampaignId(campaignId);
|
||||
}
|
||||
|
||||
public DeletionImpact getDeletionImpact(String id) {
|
||||
int sessions = sessionRepository.findByPlaythroughId(id).size();
|
||||
int characters = characterRepository.findByPlaythroughId(id).size();
|
||||
int flags = flagRepository.findByPlaythroughId(id).size();
|
||||
int progressions = progressionRepository.findByPlaythroughId(id).size();
|
||||
return new DeletionImpact(sessions, characters, flags, progressions);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(String id) {
|
||||
if (!playthroughRepository.existsById(id)) {
|
||||
throw new IllegalArgumentException("Partie introuvable : " + id);
|
||||
}
|
||||
// Cascade : sessions (et leurs entries), PJ, flags, progressions
|
||||
for (Session s : sessionRepository.findByPlaythroughId(id)) {
|
||||
sessionService.deleteSession(s.getId());
|
||||
}
|
||||
characterRepository.findByPlaythroughId(id).forEach(c -> characterRepository.deleteById(c.getId()));
|
||||
flagRepository.deleteAllByPlaythroughId(id);
|
||||
progressionRepository.deleteAllByPlaythroughId(id);
|
||||
playthroughRepository.deleteById(id);
|
||||
}
|
||||
|
||||
public boolean exists(String id) {
|
||||
return playthroughRepository.existsById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.loremind.application.playcontext;
|
||||
|
||||
import com.loremind.domain.playcontext.EntryType;
|
||||
import com.loremind.domain.playcontext.SessionEntry;
|
||||
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le journal d'une Session.
|
||||
* Gère le cycle CRUD des entrées (note, évènement, jet, action joueur).
|
||||
*/
|
||||
@Service
|
||||
public class SessionEntryService {
|
||||
|
||||
private final SessionEntryRepository entryRepository;
|
||||
private final SessionRepository sessionRepository;
|
||||
|
||||
public SessionEntryService(SessionEntryRepository entryRepository,
|
||||
SessionRepository sessionRepository) {
|
||||
this.entryRepository = entryRepository;
|
||||
this.sessionRepository = sessionRepository;
|
||||
}
|
||||
|
||||
/** Données fournies par l'API pour créer ou éditer une entrée. */
|
||||
public record EntryData(EntryType type, String content, LocalDateTime occurredAt) {}
|
||||
|
||||
public SessionEntry createEntry(String sessionId, EntryData data) {
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
throw new IllegalArgumentException("sessionId est requis.");
|
||||
}
|
||||
if (!sessionRepository.existsById(sessionId)) {
|
||||
throw new IllegalArgumentException("Session introuvable : " + sessionId);
|
||||
}
|
||||
validateContent(data.content());
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
SessionEntry entry = SessionEntry.builder()
|
||||
.sessionId(sessionId)
|
||||
.type(data.type() != null ? data.type() : EntryType.NOTE)
|
||||
.content(data.content().trim())
|
||||
.occurredAt(data.occurredAt() != null ? data.occurredAt() : now)
|
||||
.build();
|
||||
return entryRepository.save(entry);
|
||||
}
|
||||
|
||||
public SessionEntry updateEntry(String id, EntryData data) {
|
||||
validateContent(data.content());
|
||||
SessionEntry existing = entryRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Entrée introuvable : " + id));
|
||||
if (data.type() != null) existing.setType(data.type());
|
||||
existing.setContent(data.content().trim());
|
||||
if (data.occurredAt() != null) existing.setOccurredAt(data.occurredAt());
|
||||
return entryRepository.save(existing);
|
||||
}
|
||||
|
||||
public Optional<SessionEntry> getById(String id) {
|
||||
return entryRepository.findById(id);
|
||||
}
|
||||
|
||||
public List<SessionEntry> getBySessionId(String sessionId) {
|
||||
return entryRepository.findBySessionId(sessionId);
|
||||
}
|
||||
|
||||
public void deleteEntry(String id) {
|
||||
if (!entryRepository.existsById(id)) {
|
||||
throw new IllegalArgumentException("Entrée introuvable : " + id);
|
||||
}
|
||||
entryRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private void validateContent(String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new IllegalArgumentException("Le contenu d'une entrée ne peut pas être vide.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.loremind.application.playcontext;
|
||||
|
||||
import com.loremind.domain.playcontext.Session;
|
||||
import com.loremind.domain.playcontext.ports.PlaythroughRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionEntryRepository;
|
||||
import com.loremind.domain.playcontext.ports.SessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'application pour le Play Context.
|
||||
* Orchestre le cycle de vie d'une Session (lancement, fin, renommage).
|
||||
*
|
||||
* <p>Règle métier : une seule Session peut être active (endedAt null) à la fois.</p>
|
||||
* <p>Depuis Playthrough : une Session appartient à un Playthrough (pas directement à une Campaign).</p>
|
||||
*/
|
||||
@Service
|
||||
public class SessionService {
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final SessionEntryRepository entryRepository;
|
||||
private final PlaythroughRepository playthroughRepository;
|
||||
|
||||
public SessionService(SessionRepository sessionRepository,
|
||||
SessionEntryRepository entryRepository,
|
||||
PlaythroughRepository playthroughRepository) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.entryRepository = entryRepository;
|
||||
this.playthroughRepository = playthroughRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance une nouvelle session sur le Playthrough donné.
|
||||
* Échoue si une session est déjà active ou si le Playthrough n'existe pas.
|
||||
*/
|
||||
public Session startSession(String playthroughId) {
|
||||
if (playthroughId == null || playthroughId.isBlank()) {
|
||||
throw new IllegalArgumentException("playthroughId est requis pour démarrer une session.");
|
||||
}
|
||||
if (!playthroughRepository.existsById(playthroughId)) {
|
||||
throw new IllegalArgumentException("Partie introuvable : " + playthroughId);
|
||||
}
|
||||
// Règle métier : une seule session active par Partie (pas de verrou global cross-Partie).
|
||||
sessionRepository.findActiveByPlaythroughId(playthroughId).ifPresent(s -> {
|
||||
throw new IllegalStateException(
|
||||
"Une session est déjà en cours pour cette Partie (id=" + s.getId() +
|
||||
"). Termine-la avant d'en lancer une nouvelle.");
|
||||
});
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Session session = Session.builder()
|
||||
.name(generateDefaultName(now))
|
||||
.playthroughId(playthroughId)
|
||||
.startedAt(now)
|
||||
.build();
|
||||
return sessionRepository.save(session);
|
||||
}
|
||||
|
||||
public Session endSession(String id) {
|
||||
Session session = sessionRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
||||
if (!session.isActive()) {
|
||||
throw new IllegalStateException("Cette session est déjà terminée.");
|
||||
}
|
||||
session.setEndedAt(LocalDateTime.now());
|
||||
return sessionRepository.save(session);
|
||||
}
|
||||
|
||||
public Session renameSession(String id, String newName) {
|
||||
if (newName == null || newName.isBlank()) {
|
||||
throw new IllegalArgumentException("Le nom de la session ne peut pas être vide.");
|
||||
}
|
||||
Session session = sessionRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Session introuvable : " + id));
|
||||
session.setName(newName.trim());
|
||||
return sessionRepository.save(session);
|
||||
}
|
||||
|
||||
public Optional<Session> getById(String id) {
|
||||
return sessionRepository.findById(id);
|
||||
}
|
||||
|
||||
public Optional<Session> getActive() {
|
||||
return sessionRepository.findActive();
|
||||
}
|
||||
|
||||
public Optional<Session> getActiveByPlaythrough(String playthroughId) {
|
||||
return sessionRepository.findActiveByPlaythroughId(playthroughId);
|
||||
}
|
||||
|
||||
public List<Session> getAll() {
|
||||
return sessionRepository.findAll();
|
||||
}
|
||||
|
||||
public List<Session> getByPlaythroughId(String playthroughId) {
|
||||
return sessionRepository.findByPlaythroughId(playthroughId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteSession(String id) {
|
||||
if (!sessionRepository.existsById(id)) {
|
||||
throw new IllegalArgumentException("Session introuvable : " + id);
|
||||
}
|
||||
entryRepository.deleteBySessionId(id);
|
||||
sessionRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private String generateDefaultName(LocalDateTime startedAt) {
|
||||
return "Session du " + startedAt.format(DATE_FORMATTER);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,14 @@ public class Arc {
|
||||
private String campaignId; // Référence vers la Campaign parente
|
||||
private int order; // Ordre de l'arc dans la campagne
|
||||
|
||||
/**
|
||||
* Type structurel de l'arc. Détermine son rendu UI et la sémantique de ses chapitres
|
||||
* (séquence narrative LINEAR vs. quêtes parallèles d'un HUB).
|
||||
* Défaut LINEAR pour rétro-compatibilité avec les arcs existants.
|
||||
*/
|
||||
@Builder.Default
|
||||
private ArcType type = ArcType.LINEAR;
|
||||
|
||||
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||
private String icon;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Type structurel d'un Arc.
|
||||
* - LINEAR : narration séquentielle classique (chapitres joués dans l'ordre).
|
||||
* - HUB : narration non linéaire ; les chapitres sont des "quêtes" satellites
|
||||
* potentiellement parallèles, soumises à des prérequis pour être débloquées.
|
||||
*
|
||||
* Value Object du domaine (Bounded Context : Campaign).
|
||||
*/
|
||||
public enum ArcType {
|
||||
LINEAR,
|
||||
HUB
|
||||
}
|
||||
@@ -6,8 +6,12 @@ import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Entité de domaine représentant une Campaign.
|
||||
* Conteneur global pour organiser la narration d'une campagne.
|
||||
* Entité pure du domaine, sans dépendance technique.
|
||||
* Conteneur du SCÉNARIO (générique, ré-utilisable par plusieurs tables).
|
||||
*
|
||||
* <p>Toute donnée dynamique propre à une table jouée (progression des quêtes,
|
||||
* flags narratifs, sessions, PJ) vit dans un {@link com.loremind.domain.playcontext.Playthrough}.</p>
|
||||
*
|
||||
* <p>Entité pure du domaine, sans dépendance technique.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -21,17 +25,13 @@ public class Campaign {
|
||||
private int arcsCount;
|
||||
|
||||
/**
|
||||
* Référence faible (weak reference) vers un Lore.
|
||||
* Nullable : une campagne peut exister sans univers associé (one-shot, test, pitch libre).
|
||||
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context
|
||||
* (respect des Bounded Contexts en DDD).
|
||||
* Référence faible vers un Lore. Nullable.
|
||||
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
|
||||
*/
|
||||
private String loreId;
|
||||
|
||||
/**
|
||||
* Référence faible (weak reference) vers un GameSystem.
|
||||
* Nullable : une campagne peut être "générique" (pas de système de JDR déclaré).
|
||||
* Weak reference pour respecter la séparation des Bounded Contexts.
|
||||
* Référence faible vers un GameSystem. Nullable.
|
||||
*/
|
||||
private String gameSystemId;
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Évènement d'avancement émis pendant l'import streamé d'un PDF de campagne.
|
||||
* <p>
|
||||
* {@code total} = nombre de morceaux à traiter (0 pendant l'extraction).
|
||||
* {@code current} = morceaux traités. Les compteurs arc/chapitre/scène donnent
|
||||
* un aperçu de l'arbre trouvé jusqu'ici (affichage « au fil de l'eau »).
|
||||
*/
|
||||
public record CampaignImportProgress(
|
||||
int current,
|
||||
int total,
|
||||
int pageCount,
|
||||
int ocrPageCount,
|
||||
int arcCount,
|
||||
int chapterCount,
|
||||
int sceneCount,
|
||||
int npcCount) {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Proposition d'arborescence narrative extraite d'un PDF de campagne.
|
||||
* <p>
|
||||
* PROPOSITION non persistée : l'UI laisse l'utilisateur réviser/éditer l'arbre
|
||||
* avant la création effective des arcs/chapitres/scènes. Records purs (domaine).
|
||||
*/
|
||||
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
|
||||
|
||||
/**
|
||||
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
|
||||
* campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le
|
||||
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer.
|
||||
*/
|
||||
|
||||
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
|
||||
public record ArcProposal(
|
||||
String name, String description, String type,
|
||||
List<ChapterProposal> chapters, String existingId) {
|
||||
}
|
||||
|
||||
public record ChapterProposal(
|
||||
String name, String description, List<SceneProposal> scenes, String existingId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code rooms} non vide => lieu explorable (donjon). {@code playerNarration}
|
||||
* = encadré « à lire aux joueurs », {@code gmNotes} = secrets/développement MJ.
|
||||
*/
|
||||
public record SceneProposal(
|
||||
String name, String description, String playerNarration, String gmNotes,
|
||||
List<RoomProposal> rooms, String existingId) {
|
||||
}
|
||||
|
||||
public record RoomProposal(String name, String description, String enemies, String loot) {
|
||||
}
|
||||
|
||||
/**
|
||||
* PNJ/creature notable detecte dans le PDF (PNJ nommes, boss). Propose a la
|
||||
* revue (coche par defaut) ; cree comme Npc de la campagne a l''apply avec
|
||||
* sa description dans values["Description"] (meme convention que les cartes
|
||||
* d''action des ateliers).
|
||||
*/
|
||||
public record NpcProposal(String name, String description) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Un objet d'un {@link ItemCatalog} (boutique, butin, trésor…).
|
||||
* Value object possédé par le catalogue : remplacé en bloc à chaque mise à jour.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class CatalogItem {
|
||||
|
||||
private String name;
|
||||
|
||||
/** Prix libre (ex. « 50 po », « 2 pa »). Nullable. */
|
||||
private String price;
|
||||
|
||||
/** Catégorie de regroupement (ex. « Armes », « Potions »). Nullable. */
|
||||
private String category;
|
||||
|
||||
/** Description / effet (markdown). Nullable. */
|
||||
private String description;
|
||||
}
|
||||
@@ -21,6 +21,14 @@ public class Chapter {
|
||||
private String arcId; // Référence vers l'Arc parent
|
||||
private int order; // Ordre du chapitre dans l'arc
|
||||
|
||||
/**
|
||||
* Conditions de déblocage (combinées en ET). Vide => quête immédiatement AVAILABLE.
|
||||
* Pertinent surtout pour les chapitres d'un Arc HUB ; ignoré pour LINEAR.
|
||||
* Donnée de SCÉNARIO — partagée par toutes les Parties de la campagne.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<Prerequisite> prerequisites = new ArrayList<>();
|
||||
|
||||
/** Cle d'icone choisie par l'utilisateur (cf. CAMPAIGN_ICON_OPTIONS cote front). */
|
||||
private String icon;
|
||||
|
||||
|
||||
@@ -4,18 +4,26 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||
* <p>
|
||||
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
|
||||
* backstory, équipement). Évolution prévue vers un système templaté par
|
||||
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
||||
* Champs universels hard-codes : {@code name}, {@code portraitImageId},
|
||||
* {@code headerImageId}. Tout le reste est piloté par le template PJ du
|
||||
* GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
|
||||
* <p>
|
||||
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
|
||||
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
|
||||
* Évolution prévue : système de templating partagé PJ/PNJ piloté par
|
||||
* GameSystem pour adapter les blocs aux différents systèmes de JDR.
|
||||
* Les valeurs des champs templates sont stockées dans deux maps :
|
||||
* - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
|
||||
* parsé à l'usage cote presentation)
|
||||
* - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
|
||||
* <p>
|
||||
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
|
||||
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
|
||||
* <p>
|
||||
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -24,18 +32,59 @@ public class Character {
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
|
||||
private String portraitImageId;
|
||||
|
||||
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
|
||||
private String headerImageId;
|
||||
|
||||
/**
|
||||
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
|
||||
* renseigné progressivement par le MJ.
|
||||
* Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
|
||||
* (sensible a la casse cote stockage mais comparaison case-insensitive
|
||||
* dans le domaine GameSystem). Jamais null apres construction.
|
||||
*/
|
||||
private String markdownContent;
|
||||
private Map<String, String> values;
|
||||
|
||||
/** Référence vers la Campaign parente. */
|
||||
private String campaignId;
|
||||
/**
|
||||
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
|
||||
* liste ordonnee d'IDs d'images. Jamais null apres construction.
|
||||
*/
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Ordre d'affichage dans la liste des PJ de la campagne. */
|
||||
/**
|
||||
* Valeurs des champs KEY_VALUE_LIST du template PJ. Cle externe = nom du
|
||||
* champ template (ex: "Caracteristiques"), cle interne = label predefini
|
||||
* dans le template (ex: "FOR"), valeur = valeur saisie (ex: "16").
|
||||
* Les labels suivent l'ordre defini dans TemplateField.labels.
|
||||
*/
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
|
||||
/**
|
||||
* Référence vers le Playthrough (= la partie / table) auquel ce PJ appartient.
|
||||
* Les PJ sont propres à une table jouée, pas au scénario générique de la campagne.
|
||||
* Weak reference cross-context.
|
||||
*/
|
||||
private String playthroughId;
|
||||
|
||||
/** Ordre d'affichage dans la liste des PJ de la Partie. */
|
||||
private int order;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/** Garantit que les maps ne sont jamais null cote consommateur. */
|
||||
public Map<String, String> getValues() {
|
||||
if (values == null) values = new HashMap<>();
|
||||
return values;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getImageValues() {
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
return imageValues;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||
return keyValueValues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fiche d'ennemi (monstre/créature) d'une campagne — le bestiaire du MJ.
|
||||
* <p>
|
||||
* Même principe de templating que {@link Npc} : champs universels hard-codés
|
||||
* (nom, niveau, dossier, portrait, bandeau) + champs pilotés par le template
|
||||
* ENNEMI du GameSystem ({@code GameSystem.enemyTemplate} : CA, PV, attaques…).
|
||||
* Classement libre par dossier (« Démons », « Humanoïdes »…).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Enemy {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** Niveau / FP / dangerosité — texte libre (« 5 », « FP 8 », « Boss »). Nullable. */
|
||||
private String level;
|
||||
|
||||
/** Dossier de classement (texte libre). Null = non classé. */
|
||||
private String folder;
|
||||
|
||||
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
|
||||
private String portraitImageId;
|
||||
|
||||
/** ID de l'image header/bannière (champ universel hard-codé). Nullable. */
|
||||
private String headerImageId;
|
||||
|
||||
/** Valeurs TEXT/NUMBER du template ennemi. Jamais null après construction. */
|
||||
private Map<String, String> values;
|
||||
|
||||
/** Valeurs IMAGE du template ennemi (listes d'IDs ordonnées par champ). Jamais null. */
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||
private String campaignId;
|
||||
|
||||
/** Ordre d'affichage dans la liste. */
|
||||
private int order;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public Map<String, String> getValues() {
|
||||
if (values == null) values = new HashMap<>();
|
||||
return values;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getImageValues() {
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
return imageValues;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||
return keyValueValues;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Catalogue d'objets prédéfinis d'une campagne : une boutique, un butin, un trésor…
|
||||
* Le MJ le remplit à la main ou via l'IA, et le consulte (notamment en session) quand
|
||||
* les joueurs visitent une échoppe. Scope campagne (cross-aggregate via ID).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class ItemCatalog {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** Description libre (à quoi sert ce catalogue / cette boutique). Nullable. */
|
||||
private String description;
|
||||
|
||||
/** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */
|
||||
private String icon;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||
private String campaignId;
|
||||
|
||||
/** Ordre d'affichage dans la liste des catalogues de la campagne. */
|
||||
private int order;
|
||||
|
||||
/** Objets ordonnés du catalogue. Jamais null après construction. */
|
||||
private List<CatalogItem> items;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public List<CatalogItem> getItems() {
|
||||
if (items == null) items = new ArrayList<>();
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Atelier d'adaptation (« notebook ») d'une campagne : une ou plusieurs sources
|
||||
* PDF indexées (RAG) + une conversation, persistés pour y revenir.
|
||||
* <p>
|
||||
* Les SOURCES ({@link NotebookSource}) et les MESSAGES ({@link NotebookMessage})
|
||||
* sont gérés comme entités liées par {@code notebookId} (chargées séparément).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class Notebook {
|
||||
private String id;
|
||||
private String name;
|
||||
private String campaignId;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Un message de la conversation d'un {@link Notebook}. {@code role} = "user" ou
|
||||
* "assistant". Persisté pour recharger l'historique de l'atelier.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class NotebookMessage {
|
||||
private String id;
|
||||
private String notebookId;
|
||||
private String role;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
/** Null = conversation active ; sinon horodatage du « vider » (lot d'archive). */
|
||||
private LocalDateTime archivedAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Une source (PDF) d'un {@link Notebook}. Son {@code id} sert de clé d'indexation
|
||||
* vectorielle côté Brain (les vecteurs vivent sur le volume du Brain).
|
||||
* <p>
|
||||
* {@code status} : INDEXING (en cours), READY (interrogeable), FAILED (échec).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class NotebookSource {
|
||||
private String id;
|
||||
private String notebookId;
|
||||
private String filename;
|
||||
private String status;
|
||||
private int chunkCount;
|
||||
private int pageCount;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -4,21 +4,23 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||
* <p>
|
||||
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé —
|
||||
* un PNJ a vocation à porter à terme des invariants métier propres (faction,
|
||||
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
|
||||
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
|
||||
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
|
||||
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
|
||||
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
|
||||
* <p>
|
||||
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé
|
||||
* PJ/PNJ piloté par GameSystem.
|
||||
* Mêmes champs universels hard-codés et meme structure de templating que Character,
|
||||
* pilotée par le template PNJ du GameSystem
|
||||
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
|
||||
* <p>
|
||||
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent
|
||||
* gérés via le système Page/Template du LoreContext.
|
||||
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
|
||||
* via le système Page/Template du LoreContext.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@@ -27,15 +29,57 @@ public class Npc {
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */
|
||||
private String markdownContent;
|
||||
/** ID de l'image portrait (champ universel hard-code). Nullable. */
|
||||
private String portraitImageId;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */
|
||||
/** ID de l'image header/banniere (champ universel hard-code). Nullable. */
|
||||
private String headerImageId;
|
||||
|
||||
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
|
||||
private Map<String, String> values;
|
||||
|
||||
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Valeurs KEY_VALUE_LIST : fieldName -> label -> value. Jamais null. */
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||
private String campaignId;
|
||||
|
||||
/**
|
||||
* IDs de Pages de Lore référencées par ce PNJ (sa ville, sa faction, sa
|
||||
* région…). Référence faible cross-context, même principe que sur
|
||||
* Arc/Chapter/Scene — alimente notamment le graphe du Lore.
|
||||
*/
|
||||
private List<String> relatedPageIds;
|
||||
|
||||
/** Dossier de classement (texte libre, ex. « Bard's Gate »). Nullable = non classé. */
|
||||
private String folder;
|
||||
|
||||
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
||||
private int order;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public Map<String, String> getValues() {
|
||||
if (values == null) values = new HashMap<>();
|
||||
return values;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> getImageValues() {
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
return imageValues;
|
||||
}
|
||||
|
||||
public Map<String, Map<String, String>> getKeyValueValues() {
|
||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||
return keyValueValues;
|
||||
}
|
||||
|
||||
public List<String> getRelatedPageIds() {
|
||||
if (relatedPageIds == null) relatedPageIds = new ArrayList<>();
|
||||
return relatedPageIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
|
||||
*
|
||||
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
|
||||
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
|
||||
* {@link PrerequisiteEvaluator}.
|
||||
*
|
||||
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
|
||||
* (pas de OR pour le moment).
|
||||
*/
|
||||
public sealed interface Prerequisite
|
||||
permits Prerequisite.QuestCompleted,
|
||||
Prerequisite.SessionReached,
|
||||
Prerequisite.FlagSet {
|
||||
|
||||
/** La quête référencée par {@code questId} doit être en COMPLETED. */
|
||||
record QuestCompleted(String questId) implements Prerequisite {}
|
||||
|
||||
/** Le compteur de sessions de la campagne doit avoir atteint {@code minSessionNumber}. */
|
||||
record SessionReached(int minSessionNumber) implements Prerequisite {}
|
||||
|
||||
/** Le flag campagne nommé {@code flagName} doit être à true. */
|
||||
record FlagSet(String flagName) implements Prerequisite {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
|
||||
* et en dérive le {@link QuestStatus} effectif.
|
||||
*
|
||||
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
|
||||
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
|
||||
* compile-time est compensée par le throw final qui fait crasher tout nouvel
|
||||
* implémentant non câblé.
|
||||
*/
|
||||
public final class PrerequisiteEvaluator {
|
||||
|
||||
/**
|
||||
* Contexte minimal nécessaire à l'évaluation. On ne passe pas la Campaign entière
|
||||
* pour ne pas créer de couplage fort ; juste les faits nécessaires.
|
||||
*/
|
||||
public record EvaluationContext(
|
||||
Set<String> completedQuestIds,
|
||||
int currentSessionCount,
|
||||
Map<String, Boolean> campaignFlags
|
||||
) {}
|
||||
|
||||
/** True si TOUS les prérequis sont satisfaits (ET logique). Vide => true. */
|
||||
public boolean areAllSatisfied(List<Prerequisite> prerequisites, EvaluationContext ctx) {
|
||||
if (prerequisites == null || prerequisites.isEmpty()) return true;
|
||||
return prerequisites.stream().allMatch(p -> isSatisfied(p, ctx));
|
||||
}
|
||||
|
||||
/** Évalue un seul prérequis. */
|
||||
public boolean isSatisfied(Prerequisite prereq, EvaluationContext ctx) {
|
||||
if (prereq instanceof Prerequisite.QuestCompleted q) {
|
||||
return ctx.completedQuestIds().contains(q.questId());
|
||||
}
|
||||
if (prereq instanceof Prerequisite.SessionReached s) {
|
||||
return ctx.currentSessionCount() >= s.minSessionNumber();
|
||||
}
|
||||
if (prereq instanceof Prerequisite.FlagSet f) {
|
||||
return Boolean.TRUE.equals(ctx.campaignFlags().get(f.flagName()));
|
||||
}
|
||||
throw new IllegalStateException("Prerequisite non géré : " + prereq.getClass().getName());
|
||||
}
|
||||
|
||||
/** Dérive le statut effectif à partir de la progression manuelle + des prérequis. */
|
||||
public QuestStatus computeStatus(
|
||||
ProgressionStatus progression,
|
||||
List<Prerequisite> prerequisites,
|
||||
EvaluationContext ctx
|
||||
) {
|
||||
switch (progression) {
|
||||
case COMPLETED: return QuestStatus.COMPLETED;
|
||||
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
|
||||
case NOT_STARTED:
|
||||
return areAllSatisfied(prerequisites, ctx)
|
||||
? QuestStatus.AVAILABLE
|
||||
: QuestStatus.LOCKED;
|
||||
default:
|
||||
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
|
||||
*
|
||||
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
|
||||
* selon les prérequis — voir {@link QuestStatus}.
|
||||
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
|
||||
* COMPLETED : marquée terminée par le MJ.
|
||||
*
|
||||
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
|
||||
*/
|
||||
public enum ProgressionStatus {
|
||||
NOT_STARTED,
|
||||
IN_PROGRESS,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
|
||||
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
|
||||
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
|
||||
*
|
||||
* Table de vérité :
|
||||
* NOT_STARTED + prérequis non remplis -> LOCKED
|
||||
* NOT_STARTED + prérequis remplis -> AVAILABLE
|
||||
* IN_PROGRESS -> IN_PROGRESS
|
||||
* COMPLETED -> COMPLETED
|
||||
*/
|
||||
public enum QuestStatus {
|
||||
LOCKED,
|
||||
AVAILABLE,
|
||||
IN_PROGRESS,
|
||||
COMPLETED
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Table aléatoire d'une campagne : on jette un dé ({@code diceFormula}) et la
|
||||
* valeur tombée désigne une {@link RandomTableEntry} (par sa plage) → un résultat.
|
||||
* <p>
|
||||
* Outil MJ classique (rencontres, butin, complications, noms…). Le JET lui-même
|
||||
* est effectué côté client (instantané, comme le panneau de dés) ; le domaine ne
|
||||
* fait que stocker la table et ses entrées. Scope campagne (cross-aggregate via ID).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class RandomTable {
|
||||
|
||||
private String id;
|
||||
private String name;
|
||||
|
||||
/** Description libre (à quoi sert la table). Nullable. */
|
||||
private String description;
|
||||
|
||||
/** Formule du dé à lancer : "1d20", "2d6", "d100"… */
|
||||
private String diceFormula;
|
||||
|
||||
/** Clé d'icône (lucide) pour la sidebar/fiche. Nullable. */
|
||||
private String icon;
|
||||
|
||||
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||
private String campaignId;
|
||||
|
||||
/** Ordre d'affichage dans la liste des tables de la campagne. */
|
||||
private int order;
|
||||
|
||||
/** Entrées ordonnées (par plage de jet). Jamais null après construction. */
|
||||
private List<RandomTableEntry> entries;
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public List<RandomTableEntry> getEntries() {
|
||||
if (entries == null) entries = new ArrayList<>();
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Une entrée d'une {@link RandomTable} : une PLAGE de jet (minRoll..maxRoll, bornes
|
||||
* incluses) qui mappe vers un résultat. Les plages permettent les tables PONDÉRÉES
|
||||
* (un résultat couvrant 1–10 est plus probable qu'un couvrant 11–12).
|
||||
* <p>
|
||||
* Value object possédé par la table (pas d'identité propre côté domaine) : à chaque
|
||||
* mise à jour, les entrées sont remplacées en bloc.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class RandomTableEntry {
|
||||
|
||||
/** Borne basse du jet (incluse). */
|
||||
private int minRoll;
|
||||
|
||||
/** Borne haute du jet (incluse). Pour une entrée unitaire, min == max. */
|
||||
private int maxRoll;
|
||||
|
||||
/** Résultat court affiché (ex. "Embuscade de gobelins"). */
|
||||
private String label;
|
||||
|
||||
/** Détail markdown : « ce que c'est » (effet, description). Nullable. */
|
||||
private String detail;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Pièce d'un lieu explorable (donjon, crypte…) attaché à une Scene.
|
||||
*
|
||||
* <p>Une Scene devient « explorable » dès qu'elle a au moins une Room. Tant
|
||||
* qu'elle n'en a pas, elle se comporte comme un beat narratif classique.</p>
|
||||
*
|
||||
* <p>Pas un record Java parce que la liste {@code branches} est mutable côté
|
||||
* builder ; on garde la classe Lombok pour la cohérence avec le reste du
|
||||
* domaine (Arc, Chapter, Scene). L'ID est généré côté front (UUID) au moment
|
||||
* de la création — pas d'auto-increment DB puisque c'est un Value Object
|
||||
* sérialisé en JSONB sur Scene.</p>
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Room {
|
||||
|
||||
/** ID stable (UUID généré côté client). Sert de cible aux {@link RoomBranch}. */
|
||||
private String id;
|
||||
|
||||
/** Nom de la pièce (« Antichambre », « Salle du trône »). */
|
||||
private String name;
|
||||
|
||||
/** Narration / description lue ou résumée aux joueurs en entrant. */
|
||||
private String description;
|
||||
|
||||
/** Énemis, créatures, boss éventuels (markdown libre). */
|
||||
private String enemies;
|
||||
|
||||
/**
|
||||
* IDs des fiches du bestiaire ({@link Enemy}) présentes dans la pièce
|
||||
* (weak refs). Complète le texte libre {@code enemies}, comme sur Scene.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> enemyIds = new ArrayList<>();
|
||||
|
||||
/** Loot / récompenses présentes dans la pièce. */
|
||||
private String loot;
|
||||
|
||||
/** Pièges / dangers environnementaux. */
|
||||
private String traps;
|
||||
|
||||
/** Notes privées du MJ (cachées des joueurs). */
|
||||
private String gmNotes;
|
||||
|
||||
/** Étage / niveau de la pièce. 0 = rez-de-chaussée. Nullable = pas d'étage défini. */
|
||||
private Integer floor;
|
||||
|
||||
/** Ordre d'affichage dans la liste (au sein d'un même étage le cas échéant). */
|
||||
private int order;
|
||||
|
||||
/** IDs d'images d'illustration / ambiance. */
|
||||
@Builder.Default
|
||||
private List<String> illustrationImageIds = new ArrayList<>();
|
||||
|
||||
/** ID de l'image « plan » de la pièce (1 image dédiée, schéma tactique). */
|
||||
private String mapImageId;
|
||||
|
||||
/**
|
||||
* Sorties vers d'autres pièces. {@link RoomBranch#targetRoomId()} doit pointer
|
||||
* vers une Room de la même Scene.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<RoomBranch> branches = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.loremind.domain.campaigncontext;
|
||||
|
||||
/**
|
||||
* Sortie d'une pièce vers une autre pièce de la même Scene explorable.
|
||||
* Équivalent inter-pièces de {@link SceneBranch}.
|
||||
*
|
||||
* <p>Record Java immuable, sérialisé via Jackson dans la liste JSONB
|
||||
* {@code rooms} de la Scene.</p>
|
||||
*
|
||||
* <p>Règle métier : {@code targetRoomId} doit pointer vers une Room de la
|
||||
* MÊME Scene (validation côté service).</p>
|
||||
*
|
||||
* @param label Libellé visible (« Porte nord », « Trappe au sol »).
|
||||
* @param targetRoomId ID stable de la Room de destination (UUID Room.id).
|
||||
* @param condition Condition optionnelle (« si les PJ ont la clé en argent »).
|
||||
*/
|
||||
public record RoomBranch(String label, String targetRoomId, String condition) {
|
||||
|
||||
public static RoomBranch of(String label, String targetRoomId) {
|
||||
return new RoomBranch(label, targetRoomId, null);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,15 @@ public class Scene {
|
||||
|
||||
// === Combat ou rencontre ===
|
||||
private String combatDifficulty; // Difficulté estimée
|
||||
private String enemies; // Liste des ennemis et créatures
|
||||
private String enemies; // Liste des ennemis et créatures (texte libre)
|
||||
|
||||
/**
|
||||
* IDs des fiches du bestiaire ({@link Enemy}) engagées dans cette rencontre
|
||||
* (weak cross-aggregate references). Complète le texte libre `enemies` :
|
||||
* l'utilisateur peut référencer ses fiches, ou tout écrire à la main, ou les deux.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<String> enemyIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* IDs des pages du Lore associées à cette scène (weak cross-context references).
|
||||
@@ -72,6 +80,15 @@ public class Scene {
|
||||
@Builder.Default
|
||||
private List<SceneBranch> branches = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Pièces du lieu explorable représenté par cette scène (donjon, crypte, manoir…).
|
||||
* Vide => scène classique « beat narratif » (comportement inchangé).
|
||||
* Non vide => la scène devient explorable, l'UI affiche un layout dédié pièce-par-pièce.
|
||||
* Sérialisé en JSONB.
|
||||
*/
|
||||
@Builder.Default
|
||||
private List<Room> rooms = new ArrayList<>();
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user