On garde malgrès tout les promps à coté des parseurs car ils évoluent généralement ensemble.
188 lines
6.6 KiB
Python
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,
|
|
)
|