Co-MJ v1 : quêtes de première classe, guidage de préparation, assistant IA,
All checks were successful
All checks were successful
mode séance, battlemaps multiples et export Foundry ciblé
- Quêtes (Niveau 1) : entité Quest orthogonale à l'arbre, rattachée à un arc HUB
ou libre (migrations V9-V12, V18, V20 ; réconciliation des jumeaux V10).
Fusion quête/conteneur dans la sidebar, progression par partie (statuts
disponible/en cours/terminée), et quêtes libres avec espace de scènes créé
automatiquement (arc technique « Quêtes libres », V21 : levée de la
contrainte arcs.type héritée du baseline).
- Guidage (Pilier B) : bilan de préparation 100 % dérivé (règles arc/chapitre/
scène/quête), panneau « Prochaines étapes » avec boutons « Corriger », et
pastilles détaillées (tooltip des manques) dans l'arbre.
- Assistant IA (Pilier A) : étoffage champ par champ (scène, chapitre, arc) et
brouillons de scènes en propose→applique (brain : narrative-fields,
scene-drafts).
- Horloges & menaces (V15-V17) : clocks à segments avec déclencheurs, fronts.
- Mode séance : préparation de séance (readiness + quêtes dispo), scène
épinglée (V19), récap « précédemment » (brain : session-recap), onglet
« Partie » du panneau de référence, graphe amélioré (pan/zoom, éditeur de
liens).
- Perf : endpoint agrégé GET /api/campaigns/{id}/tree — la sidebar charge en
1 requête au lieu de ~15.
- Battlemaps multiples par scène (variantes jour/nuit, étages…) : liste JSON
étiquetée (V22, reprise automatique de la carte existante), rendu PDF avec
légendes, export/import rétro-compatible.
- Export Foundry ciblé : modale de périmètre (cartes+ennemis / journaux /
tables), bundle filtré côté serveur (zip allégé), module Foundry à jour
(respect du périmètre + une Scene Foundry par variante de carte,
rétro-compatible anciens bundles).
This commit is contained in:
@@ -6,7 +6,12 @@ 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.application.llm_json import load_json_object
|
||||
from app.application.llm_retry import generate_with_retry
|
||||
from app.application.prompts import conversation_title as title_prompts
|
||||
from app.application.prompts import narrative_fields as narrative_fields_prompts
|
||||
from app.application.prompts import scene_drafts as scene_drafts_prompts
|
||||
from app.application.prompts import session_recap as session_recap_prompts
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.language import get_user_language
|
||||
from app.domain.models import PageGenerationContext
|
||||
@@ -131,3 +136,157 @@ async def summarize_conversation_title(
|
||||
if not title:
|
||||
title = title_prompts.TITLE_FALLBACK.get(language, title_prompts.TITLE_FALLBACK["fr"])
|
||||
return SummarizeTitleResponseDTO(title=title)
|
||||
|
||||
|
||||
# --- Étoffer une entité narrative (Pilier A : co-MJ propose → l'humain valide) ----------
|
||||
|
||||
|
||||
class NarrativeFieldSpecDTO(BaseModel):
|
||||
"""Un champ autorisé : clé technique + libellé lisible (fourni par le Core)."""
|
||||
|
||||
key: str
|
||||
label: str = Field(default="")
|
||||
|
||||
|
||||
class NarrativeFieldsRequestDTO(BaseModel):
|
||||
"""Contexte envoyé par le Core pour proposer des valeurs de champs (arc/chapitre/scène)."""
|
||||
|
||||
entity_type: str = Field(default="")
|
||||
context: str = Field(default="")
|
||||
instruction: str = Field(default="")
|
||||
# Whitelist (clé + libellé) fournie par le Core, source de vérité.
|
||||
fields: list[NarrativeFieldSpecDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NarrativeFieldsResponseDTO(BaseModel):
|
||||
"""Retour : une valeur proposée par clé (uniquement des clés autorisées, non vides)."""
|
||||
|
||||
fields: dict[str, str]
|
||||
|
||||
|
||||
@router.post("/generate/narrative-fields", response_model=NarrativeFieldsResponseDTO)
|
||||
async def generate_narrative_fields(
|
||||
body: NarrativeFieldsRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
language: Annotated[str, Depends(get_user_language)],
|
||||
) -> NarrativeFieldsResponseDTO:
|
||||
"""Propose des valeurs pour ÉTOFFER une entité narrative (patch champ par champ, non appliqué).
|
||||
|
||||
Whitelist stricte : on ne retient que les clés autorisées, non vides. Un objet vide
|
||||
est une réponse VALIDE (le modèle n'a rien de pertinent à proposer — l'entité est
|
||||
peut-être déjà complète) ; seule une sortie non-JSON est une erreur.
|
||||
"""
|
||||
allowed = {f.key for f in body.fields if f.key}
|
||||
prompt = narrative_fields_prompts.narrative_fields_prompt(
|
||||
body.entity_type, body.context, body.instruction,
|
||||
[{"key": f.key, "label": f.label} for f in body.fields], language)
|
||||
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 champs exploitables.")
|
||||
|
||||
out: dict[str, str] = {}
|
||||
raw_fields = parsed.get("fields")
|
||||
if isinstance(raw_fields, dict):
|
||||
for key, value in raw_fields.items():
|
||||
if key not in allowed:
|
||||
continue
|
||||
if not isinstance(value, (str, int, float)):
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
out[str(key)] = text
|
||||
return NarrativeFieldsResponseDTO(fields=out)
|
||||
|
||||
|
||||
# --- Peupler un chapitre en scènes (Pilier A : capacité « create ») ----------
|
||||
|
||||
|
||||
class SceneDraftsRequestDTO(BaseModel):
|
||||
"""Contexte envoyé par le Core pour ébaucher des scènes d'un chapitre."""
|
||||
|
||||
context: str = Field(default="")
|
||||
instruction: str = Field(default="")
|
||||
count: int = Field(default=4)
|
||||
|
||||
|
||||
class SceneDraftDTO(BaseModel):
|
||||
name: str
|
||||
description: str = Field(default="")
|
||||
playerNarration: str = Field(default="")
|
||||
|
||||
|
||||
class SceneDraftsResponseDTO(BaseModel):
|
||||
scenes: list[SceneDraftDTO]
|
||||
|
||||
|
||||
@router.post("/generate/scene-drafts", response_model=SceneDraftsResponseDTO)
|
||||
async def generate_scene_drafts(
|
||||
body: SceneDraftsRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
language: Annotated[str, Depends(get_user_language)],
|
||||
) -> SceneDraftsResponseDTO:
|
||||
"""Propose des ébauches de scènes pour un chapitre (non créées). Un titre par scène
|
||||
est obligatoire ; on borne le nombre. Seule une sortie non-JSON est une erreur."""
|
||||
n = max(1, min(8, body.count))
|
||||
prompt = scene_drafts_prompts.scene_drafts_prompt(body.context, body.instruction, n, language)
|
||||
try:
|
||||
raw = await generate_with_retry(llm, prompt, output_format="json", temperature=0.8)
|
||||
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 scènes exploitables.")
|
||||
|
||||
scenes: list[SceneDraftDTO] = []
|
||||
for s in (parsed.get("scenes") or [])[:n]:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
name = str(s.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
scenes.append(SceneDraftDTO(
|
||||
name=name[:200],
|
||||
description=str(s.get("description") or "").strip(),
|
||||
playerNarration=str(s.get("playerNarration") or "").strip(),
|
||||
))
|
||||
return SceneDraftsResponseDTO(scenes=scenes)
|
||||
|
||||
|
||||
# --- Récap « précédemment… » d'une séance (mode cockpit) ---------------------
|
||||
|
||||
|
||||
class SessionRecapRequestDTO(BaseModel):
|
||||
"""Journal chronologique de la séance précédente + méta courte."""
|
||||
|
||||
transcript: str
|
||||
context: str = Field(default="")
|
||||
|
||||
|
||||
class SessionRecapResponseDTO(BaseModel):
|
||||
recap: str
|
||||
|
||||
|
||||
@router.post("/generate/session-recap", response_model=SessionRecapResponseDTO)
|
||||
async def generate_session_recap(
|
||||
body: SessionRecapRequestDTO,
|
||||
llm: Annotated[LLMProvider, Depends(get_llm_provider)],
|
||||
language: Annotated[str, Depends(get_user_language)],
|
||||
) -> SessionRecapResponseDTO:
|
||||
"""Rédige le récap « Précédemment… » à lire aux joueurs (texte libre, pas de JSON)."""
|
||||
if not body.transcript.strip():
|
||||
raise HTTPException(status_code=422, detail="Journal vide : rien à résumer.")
|
||||
prompt = session_recap_prompts.session_recap_prompt(body.transcript, body.context, language)
|
||||
try:
|
||||
raw = await generate_with_retry(llm, prompt, temperature=0.7)
|
||||
except LLMProviderError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
recap = raw.strip()
|
||||
if not recap:
|
||||
raise HTTPException(status_code=502, detail="Le modèle n'a renvoyé aucun récit.")
|
||||
return SessionRecapResponseDTO(recap=recap)
|
||||
|
||||
50
brain/app/application/prompts/narrative_fields.py
Normal file
50
brain/app/application/prompts/narrative_fields.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Prompt d'étoffage des champs d'une entité narrative (arc / chapitre / scène) — Pilier A.
|
||||
|
||||
Générique : le Core (Java) est la SOURCE DE VÉRITÉ des champs (clé + libellé) et les
|
||||
passe en entrée ; ce module ne fait que formuler le prompt. Le modèle ne renvoie que les
|
||||
clés fournies et OMET celles pour lesquelles il n'a rien de pertinent (pas de remplissage forcé).
|
||||
"""
|
||||
from app.core.language import language_name
|
||||
|
||||
# Étiquette lisible du type d'entité, pour la formulation du prompt.
|
||||
ENTITY_LABEL: dict[str, str] = {
|
||||
"arc": "cet arc narratif",
|
||||
"chapter": "ce chapitre",
|
||||
"scene": "cette scène",
|
||||
}
|
||||
|
||||
|
||||
def narrative_fields_prompt(entity_type: str, entity_context: str, instruction: str,
|
||||
fields: list[dict], language: str) -> str:
|
||||
"""Construit le prompt d'étoffage. `fields` = [{key, label}] (whitelist du Core)."""
|
||||
label = ENTITY_LABEL.get(entity_type or "", "cette entité narrative")
|
||||
lines = []
|
||||
for f in fields or []:
|
||||
key = str(f.get("key") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
flabel = str(f.get("label") or key).strip()
|
||||
lines.append(f'- "{key}" : {flabel}')
|
||||
fields_list = "\n".join(lines)
|
||||
instruction_block = (
|
||||
f"\nConsigne particulière du MJ : {instruction.strip()}\n"
|
||||
if instruction and instruction.strip() else ""
|
||||
)
|
||||
return (
|
||||
f"Tu es un co-Maître de Jeu. On te donne l'état ACTUEL d'{label} de jeu de rôle. "
|
||||
"Propose des valeurs pour l'ÉTOFFER, cohérentes avec ce qui existe déjà.\n\n"
|
||||
f"{entity_context.strip()}\n"
|
||||
f"{instruction_block}\n"
|
||||
"Champs que tu peux remplir (n'utilise QUE ces clés) :\n"
|
||||
f"{fields_list}\n\n"
|
||||
"Règles IMPÉRATIVES :\n"
|
||||
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
|
||||
'- Format exact : {"fields": {"cle": "valeur proposée", ...}}\n'
|
||||
"- N'inclus QUE des clés de la liste ci-dessus. N'invente AUCUNE autre clé.\n"
|
||||
"- Si un champ est déjà bien rempli ou si tu n'as rien de pertinent, OMETS-le "
|
||||
"(ne le renvoie pas) plutôt que de le remplir de force.\n"
|
||||
"- Reste cohérent avec le contexte : n'invente pas d'élément qui contredit "
|
||||
"l'entité ou la campagne.\n"
|
||||
f"- Rédige les valeurs en {language_name(language)}.\n"
|
||||
"Renvoie maintenant le JSON."
|
||||
)
|
||||
30
brain/app/application/prompts/scene_drafts.py
Normal file
30
brain/app/application/prompts/scene_drafts.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Prompt d'ébauche de scènes pour un chapitre (Pilier A — capacité « create »).
|
||||
|
||||
Le co-MJ propose plusieurs scènes cohérentes pour PEUPLER un chapitre vide (ou en manque).
|
||||
JSON structuré, une liste de scènes ; l'humain révise et ne crée que celles qu'il retient.
|
||||
"""
|
||||
from app.core.language import language_name
|
||||
|
||||
|
||||
def scene_drafts_prompt(context: str, instruction: str, count: int, language: str) -> str:
|
||||
instruction_block = (
|
||||
f"\nConsigne particulière du MJ : {instruction.strip()}\n"
|
||||
if instruction and instruction.strip() else ""
|
||||
)
|
||||
return (
|
||||
f"Tu es un co-Maître de Jeu. Propose {count} SCÈNES de jeu de rôle pour PEUPLER ce "
|
||||
"chapitre, cohérentes entre elles et avec le contexte.\n\n"
|
||||
f"{context.strip()}\n"
|
||||
f"{instruction_block}\n"
|
||||
"Règles IMPÉRATIVES :\n"
|
||||
"- Réponds UNIQUEMENT par un objet JSON valide, sans texte autour.\n"
|
||||
'- Format exact : {"scenes": [{"name": "...", "description": "...", "playerNarration": "..."}]}\n'
|
||||
f"- Propose AU PLUS {count} scènes, distinctes et complémentaires (une progression du chapitre).\n"
|
||||
"- 'name' : titre court et évocateur (OBLIGATOIRE).\n"
|
||||
"- 'description' : un résumé bref (une phrase).\n"
|
||||
"- 'playerNarration' : 2-3 phrases de mise en scène lues aux joueurs.\n"
|
||||
"- Ne DUPLIQUE pas les scènes déjà présentes ; reste cohérent avec le chapitre et la campagne "
|
||||
"(n'invente pas d'élément qui les contredit).\n"
|
||||
f"- Rédige en {language_name(language)}.\n"
|
||||
"Renvoie maintenant le JSON."
|
||||
)
|
||||
25
brain/app/application/prompts/session_recap.py
Normal file
25
brain/app/application/prompts/session_recap.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Prompt du récap « précédemment dans… » (mode séance).
|
||||
|
||||
Le Core envoie le journal chronologique de la séance PRÉCÉDENTE ; le modèle rédige un
|
||||
récapitulatif court, à lire aux joueurs à l'ouverture de la séance suivante. Texte libre
|
||||
(pas de JSON) : c'est de la narration.
|
||||
"""
|
||||
from app.core.language import language_name
|
||||
|
||||
|
||||
def session_recap_prompt(transcript: str, context: str, language: str) -> str:
|
||||
context_block = f"\n{context.strip()}\n" if context and context.strip() else ""
|
||||
return (
|
||||
"Tu es le Maître du Jeu. Voici le journal de la SÉANCE PRÉCÉDENTE de ta table "
|
||||
"(entrées chronologiques : notes, évènements, jets de dés, actions des joueurs).\n"
|
||||
f"{context_block}\n"
|
||||
"Journal :\n"
|
||||
f"{transcript.strip()}\n\n"
|
||||
"Rédige un récapitulatif « Précédemment… » à LIRE AUX JOUEURS pour ouvrir la "
|
||||
"nouvelle séance :\n"
|
||||
"- 4 à 8 phrases, ton narratif et vivant, au passé.\n"
|
||||
"- Uniquement ce qui s'est réellement passé dans le journal — n'invente RIEN, "
|
||||
"ne révèle aucun secret du MJ.\n"
|
||||
"- Termine sur la situation où les joueurs se sont arrêtés (le « cliffhanger »).\n"
|
||||
f"- Rédige en {language_name(language)}. Pas de préambule ni de méta : juste le récit."
|
||||
)
|
||||
Reference in New Issue
Block a user