Files
LoreMind/brain/app/api/routers/tables.py
IETM_FIXE\ietm6 1fb4563557 Redécoupage des fichiers et sortie des prompts dans leurs propre fichiers pour ne pas tout mélanger ensemble.
On garde malgrès tout les promps à coté des parseurs car ils évoluent généralement ensemble.
2026-06-15 10:16:01 +02:00

188 lines
6.6 KiB
Python

"""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.application.prompts import tables as prompts
from app.core.language import get_user_language
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)],
language: Annotated[str, Depends(get_user_language)],
) -> 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
prompt = prompts.random_table_prompt(
body.description, body.dice_formula, lo, hi, body.context, 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 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)],
language: Annotated[str, Depends(get_user_language)],
) -> ImproviseRollResponseDTO:
"""Brode un court récit (2-3 phrases) sur un résultat tiré, pour lancer la scène."""
prompt = prompts.improvise_roll_prompt(
body.table_name, body.result_label, body.result_detail, body.context, language)
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)],
language: Annotated[str, Depends(get_user_language)],
) -> GenerateCatalogResponseDTO:
"""Génère un catalogue d'objets (boutique, butin…) — nom, prix, catégorie, description."""
prompt = prompts.item_catalog_prompt(body.description, body.context, 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 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,
)