Jordancole21's picture
Deploy native Gradio SEO page and tool
1d5c3e4 verified
Raw
History Blame Contribute Delete
3.68 kB
"""NPC generator: input handling, generation, rendering, export."""
import html
import random
import tempfile
from pathlib import Path
from .shared import ai_client
from .shared.ratelimit import RateLimiter
from .prompts import SCHEMA_SPEC, SYSTEM_PROMPT, IMPORTANCE_GUIDE, build_user_prompt
from .fixtures import FIXTURE_LOCK_KEEPER
limiter = RateLimiter()
RACES = [
"Human", "Elf", "Half-Elf", "Dwarf", "Halfling", "Dragonborn", "Tiefling",
"Half-Orc", "Orc", "Gnome", "Goliath", "Aasimar", "Tabaxi", "Kenku", "Firbolg",
]
IMPORTANCE = list(IMPORTANCE_GUIDE.keys()) # Background, Recurring, Major
DEMEANORS = [
"Friendly", "Gruff", "Nervous", "Scheming", "Weary", "Cheerful",
"Menacing", "Aloof",
]
ROLES = [
"canal lock-keeper", "harbor-master", "back-alley fence", "village priest",
"caravan quartermaster", "lighthouse keeper", "guild debt-collector",
"street surgeon", "gravedigger who hears things", "tavern cook and informant",
"border toll-taker", "royal rat-catcher", "pit-fight promoter",
"traveling tooth-puller", "archive clerk with a grudge",
"ferryman on the night crossing", "disgraced court astrologer",
"reliquary guard", "salt-mine overseer", "letter-carrier between enemy houses",
]
MAX_FIELD = 300
MAX_NOTES = 800
def randomize():
"""Fill the core inputs with a random playable combination."""
return (
random.choice(ROLES),
random.choice(RACES),
random.choice(IMPORTANCE),
random.choice(DEMEANORS),
)
def _clip(value: str, limit: int) -> str:
return (value or "").strip()[:limit]
def generate(
role, race, importance, location, demeanor, setting, party_need, notes,
request=None,
):
"""Returns (markdown, plain_text, download_path, error_message)."""
allowed, message = limiter.check(request)
if not allowed:
return None, None, None, message
importance = importance if importance in IMPORTANCE_GUIDE else "Recurring"
fields = {
"Role / occupation": _clip(role, MAX_FIELD),
"Race / species": _clip(race, MAX_FIELD),
"Importance to the campaign": importance,
"Location / context where the party meets them": _clip(location, MAX_FIELD),
"Demeanor": _clip(demeanor, 40),
"Campaign setting": _clip(setting, MAX_FIELD),
"What the party needs from them": _clip(party_need, MAX_FIELD),
"DM notes": _clip(notes, MAX_NOTES),
}
user_prompt = build_user_prompt(fields) + "\n\n" + IMPORTANCE_GUIDE[importance]
try:
data = ai_client.generate_json(
SYSTEM_PROMPT, user_prompt, SCHEMA_SPEC,
fixture=FIXTURE_LOCK_KEEPER,
)
except ai_client.AIUnavailable as exc:
return None, None, None, str(exc)
md = render_markdown(data)
path = Path(tempfile.mkdtemp()) / "dnd-npc.md"
path.write_text(md, encoding="utf-8")
return md, md, str(path), None
def render_markdown(d: dict) -> str:
hooks = "\n".join(f"{i}. {h}" for i, h in enumerate(d["plot_hooks"], 1))
quote = d["quote"].strip().strip('"')
return f"""## {d['name']}
*{d['role_title']}*
### Appearance
{d['appearance']}
### Personality
{d['personality']}
### Voice & Mannerism
{d['voice_and_mannerism']}
### Motivation
{d['motivation']}
### Secret
{d['secret']}
### Relationships
{d['relationships']}
> "{quote}"
### Three Plot Hooks
{hooks}
### Stat Suggestion
{d['stat_suggestion']}
### Quick Reference
{d['quick_reference']}
"""
def render_error(message: str) -> str:
return (
'<div class="lf-output" role="alert" style="border-color:#A7343B;">'
f"<p>{html.escape(message)}</p></div>"
)