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)
|
||||
|
||||
Reference in New Issue
Block a user