Tier 2: AI Content Generation Pipeline¶
Status: Draft
Author: MAID Core Team
Created: 2025-07-15
Depends on: Tier 1 — YAML Content Pipeline (6-phase loader)
Package:maid-engine(core),maid-stdlib(templates), content packs (integration)
Table of Contents¶
- Phase 0 Prerequisite: Provider Abstraction Expansion
- Executive Summary
- Design Principles
maid aiCommand Group (Proposed)- Structured Output Engine
- Content Generation Templates
- Human-in-the-Loop Workflow
- Copilot Agent Integration
- World-Aware Generation
- Balance Analysis Engine
- Batch Generation Pipeline
- In-Game AI Building Commands (Proposed)
- Quality Assurance
- Cost & Budget Management
- Mix-and-Match Flexibility
- Implementation Plan
- Appendices
0. Phase 0 Prerequisite: Provider Abstraction Expansion¶
This is a BLOCKER. Nothing in this design is implementable without Phase 0.
The entire structured output engine (Section 4) assumes that the LLM provider
layer can carry schema constraints, tool definitions, and response-format
directives. The current CompletionOptions cannot do this. Today it only has:
@dataclass
class CompletionOptions:
model: str | None = None
max_tokens: int = 500
temperature: float = 0.7
top_p: float = 1.0
stop_sequences: list[str] = field(default_factory=list)
presence_penalty: float = 0.0
frequency_penalty: float = 0.0
There is no way to pass a JSON Schema, Anthropic tool definitions, OpenAI
response_format, a reproducibility seed, or provider-specific structured
output configuration.
0.1 Required Additions to CompletionOptions¶
@dataclass
class CompletionOptions:
# --- Existing fields (unchanged) ---
model: str | None = None
max_tokens: int = 500
temperature: float = 0.7
top_p: float = 1.0
stop_sequences: list[str] = field(default_factory=list)
presence_penalty: float = 0.0
frequency_penalty: float = 0.0
# --- New fields for Tier 2 ---
response_format: ResponseFormat | None = None
tools: list[ToolDefinition] | None = None
tool_choice: str | ToolChoice | None = None # "auto", "required", or specific
seed: int | None = None # Reproducibility (provider support varies)
extra: dict[str, Any] = field(default_factory=dict) # Provider-specific passthrough
@dataclass
class ResponseFormat:
"""Structured output format specification."""
type: str = "text" # "text", "json_object", "json_schema"
json_schema: dict[str, Any] | None = None # For type="json_schema"
strict: bool = False
@dataclass
class ToolDefinition:
"""Tool/function definition for constrained generation."""
name: str
description: str
input_schema: dict[str, Any]
@dataclass
class ToolChoice:
"""Force the model to use a specific tool."""
type: str = "tool" # "tool" or "function"
name: str = ""
0.2 Required Provider-Side Changes¶
Each provider's _do_complete() must be updated to forward the new fields:
| Provider | Change Required |
|---|---|
| Anthropic | Pass tools and tool_choice to messages.create() |
| OpenAI | Pass response_format to chat.completions.create() |
| Gemini | Pass response_schema to generate_content() |
| Ollama | Pass format: "json" in payload when response_format.type != "text" |
| ChatJimmy | No structured output API — text only; routed through GenericJSONAdapter |
| MockProvider | Return static structured responses keyed by tool/schema name |
0.3 Required Addition to CompletionResult¶
@dataclass
class CompletionResult:
content: str
model: str
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
# --- New for Tier 2 ---
tool_calls: list[ToolCallResult] | None = None # Parsed tool call responses
@dataclass
class ToolCallResult:
"""A tool call returned by the model."""
tool_name: str
arguments: dict[str, Any] # Parsed JSON arguments
raw: str = "" # Raw string before parsing
0.4 Scope and Risk¶
Phase 0 is an additive, non-breaking change — all new fields default to
None or empty, so existing callers (NPC dialogue, maid dev generate) are
unaffected. Provider implementations that do not yet handle the new fields
simply ignore them.
Phase 0 must be completed before any other Tier 2 work begins. Estimated effort: 1-2 weeks.
1. Executive Summary¶
MAID's Tier 1 content pipeline provides a robust, validated path from YAML files to
live game entities through six deterministic phases (Discover → Parse → Prepare →
ResolveRefs → Instantiate → PostLoad). The current maid dev generate command is a
minimal proof-of-concept that sends a hard-coded prompt to whatever LLM provider
is registered, prints the raw response (requested as JSON, but never validated),
and optionally writes it to a file. It has no schema awareness, no structured
output, no validation, and no integration with the loader pipeline.
Tier 2 bridges the gap. It proposes new infrastructure — starting with
expanding the provider abstraction (Phase 0) — and a new maid ai command group
that wraps the existing AI provider infrastructure (LLMProviderRegistry, circuit
breakers, rate limiters) with a structured output engine. That engine constrains
LLM responses to match the exact YAML schemas consumed by the Tier 1 pipeline.
The result: AI-generated content that loads through the same validation, reference
resolution, and instantiation path as hand-authored content — no special cases.
Current state: None of the commands, modules, or infrastructure described in this document exist today. This is a design for proposed new work. The existing
maid dev generateis a proof-of-concept stub. It is NOT deprecated — it continues to work as-is. The proposedmaid aigroup described here would eventually supersede it.
What Tier 2 Delivers¶
| Capability | Description |
|---|---|
| Phase 0: Provider expansion | New fields on CompletionOptions/CompletionResult for structured output (prerequisite) |
maid ai CLI (proposed) |
New command group for AI-assisted content creation |
| Structured Output | LLM output constrained to pipeline-compatible YAML via schema injection |
| Content Templates | Prompt templates for rooms, NPCs, items, monsters, areas, quests, dungeons, lore |
| Human-in-the-Loop | Generate → Preview → Edit → Validate → Load workflow |
| World-Aware | Context injection from existing world state |
| Balance Analysis | AI + rule-based analysis of combat, economy, progression |
| Batch Generation | Hierarchical world generation from high-level descriptions |
| In-Game Commands (proposed) | @ai generate, @ai describe, @ai populate for live building |
| Copilot Integration | Standardized bridge for lore-writer, monster-designer, npc-creator, world-mapper agents |
| Cost Management | New token tracking, cost estimation, daily budgets, response caching infrastructure |
What Tier 2 Does NOT Do¶
- Replace human creativity. AI generates drafts; humans curate.
- Bypass validation. All output flows through the Tier 1 pipeline.
- Require API keys. Features degrade to template-based generation (see Section 14.3 for caveats).
- Introduce new entity formats. Output matches existing loader-compatible YAML schemas — component-centric with
_meta.schema, plus entity-type-specific top-level conveniences allowed by the loader (e.g.,exitsandzonefor rooms — see note below).
Loader top-level field rules. The loader assembler (
loader/assembler.py) enforces a whitelist of allowed top-level fields. The universal set is:_id,_uuid,_template,_use,_extends,_vars,_append,components,tags,type. Entity-type-specific extras are configured inloader/entity_types.py:
Entity type Extra top-level fields Room exits,zoneNPC / Item locationAll AI-generated YAML in this document uses only fields from these whitelists. The
exitsandzonefields visible in room examples are not author-friendly sugar requiring an assembly layer — they are already accepted by the loader as-is.
2. Design Principles¶
2.1 AI as Assistant, Not Author¶
The AI generates drafts. Every piece of AI-generated content passes through human review before becoming part of the canonical world. The pipeline is designed for iteration: generate, review, edit, regenerate, approve.
Human Intent ──→ AI Draft ──→ Human Review ──→ Tier 1 Pipeline ──→ Live Entity
↑ │
└────── Feedback Loop ───────┘
2.2 Pipeline-Compatible Output¶
AI output is YAML that passes the Tier 1 pipeline without modification. This means:
- Valid
_metablocks with correct schema references - Proper
_idfields and UUID generation - Component payloads matching Pydantic model schemas
@ref:type/namereferences to existing entities- Template inheritance via
_use,_extends,_vars
If the AI produces output that fails Tier 1 validation, the system retries with error context, repairs common issues, or falls back to a minimal valid template.
2.3 Provider-Agnostic¶
All generation routes through LLMProviderRegistry.complete_with_fallback(),
which provides circuit-breaker protection and automatic fallback across
registered providers. Content quality varies by provider, but output format
is identical.
Current state: Today's provider abstraction (
CompletionOptions) does not support structured output directives. Phase 0 (Section 0) must land first. The table below shows both current capabilities and what Phase 0 enables.
| Provider | Current Capability | After Phase 0 |
|---|---|---|
| Anthropic | Plain text completion only | Tool use with JSON schema via tools + tool_choice |
| OpenAI | Plain text completion only | response_format: { type: "json_schema" } |
| Gemini | Plain text completion only | response_schema in generation config |
| Ollama | Plain text completion only | format: "json" flag + post-validation |
| ChatJimmy | Plain text completion only | Text only — no JSON mode API; always uses GenericJSONAdapter |
| MockProvider | Canned responses | Template-based structured responses (no API call) |
Until Phase 0 lands, all providers would use the GenericJSONAdapter path: the
schema is injected as text in the prompt, and the response is parsed and validated
post-hoc. ⚠️ GenericJSONAdapter does not exist yet — it is a new adapter
proposed as part of Phase 1 (see Section 4.3). It requires no Phase 0 provider
changes, but the adapter itself must be implemented. Once built, it produces
lower-quality structured output than native provider mechanisms but works with
any provider.
2.4 Structured Output Over Free-Form¶
The system never asks an LLM to "write YAML." Instead, it:
- Extracts JSON Schema from Component Pydantic models
- Provides the schema to the LLM via its native structured output mechanism
- Parses and validates the response against the schema
- Converts validated data to pipeline-compatible YAML
This eliminates the class of errors where LLMs produce syntactically invalid YAML (bad indentation, unquoted strings, incorrect types).
2.5 Iterative Refinement¶
Generation is a multi-pass process:
- Draft — Initial generation with full prompt context
- Validate — Run through Tier 1 PreparePhase validation
- Repair — Auto-fix common issues (missing fields, type coercion)
- Review — Present to human with diff annotations
- Refine — Optionally send back to AI with review feedback
2.6 Budget-Aware¶
Every generation operation has a known cost ceiling:
- Token counts are estimated before sending requests (using existing
estimate_tokens()) - Cost is calculated per-provider using the existing
PricingConfigandcalculate_cost()fromobservability/ai_metrics.py - Daily/monthly budgets are enforced via new
GenerationBudgetinfrastructure (Section 13) - Cached responses are reused for identical prompts via a new
GenerationCache(Section 13) - Batch operations provide cost estimates before execution
Current state: The existing
RateLimiterandTokenBudgetManagerhandle NPC dialogue rate limiting and context-provider token allocation respectively. They are not designed for content generation budgets. Tier 2 introduces new budget tracking that integrates with the existing observability pricing system rather than duplicating it.
3. maid ai Command Group (Proposed)¶
These commands do not exist today. There is no
maid aiTyper command group, nopackages/maid-engine/src/maid_engine/cli/ai.py, and no runtime@ai.*command registration. The existingmaid dev generateis a minimal stub that sends a hard-coded prompt, prints raw output, and has no pipeline integration.This section describes the proposed command surface. Implementation requires Phase 0 (provider expansion) and new modules described throughout this document.
The proposed maid ai command group will be registered as a Typer command group in
a new file packages/maid-engine/src/maid_engine/cli/ai.py.
Boot requirement: The current CLI can fail during settings initialization if
MAID_DEBUG=trueis not set and no valid admin secret is configured (the default secret is intentionally rejected). Themaid aicommands must handle this by constructing only the AI-relevant settings subset (AISettings,AIGenerationSettings) without triggering the fullSettingsvalidation. This mirrors how a future offline-capable CLI should work.
3.1 Command Overview¶
maid ai generate <type> <name> [options] Generate content from description
maid ai review <path> AI review of content files
maid ai balance <path> Analyze combat/economy balance
maid ai describe <entity> Generate/improve descriptions
maid ai populate <area> Add NPCs/items to an area
maid ai connect <area1> <area2> Suggest connections between areas
maid ai estimate <type> <name> [options] Estimate cost without generating
maid ai validate-output <path> Validate AI/agent-generated YAML
maid ai schema <type> Show current schema for a type
maid ai cache [stats|clear] Manage response cache
3.2 maid ai generate¶
Generate pipeline-compatible YAML content from a natural language description.
maid ai generate <type> <name> [options]
Types:
room Single room with exits, descriptions, attributes
npc NPC with dialogue, schedule, backstory
item Item with stats, descriptions, variants
monster Monster with behavior, loot, ecology
area Connected set of rooms (3-20 rooms)
quest Quest with stages, rewards, dialogue
dungeon Multi-room dungeon with encounters, loot, boss
lore Lore document (history, culture, legend)
Options:
-o, --output PATH Output file (default: stdout)
-p, --provider TEXT LLM provider name (default: from settings)
-m, --model TEXT Model override
--temperature FLOAT Temperature override (0.0-2.0)
--style TEXT Tone/style preset (epic, gritty, whimsical, horror, pastoral)
--zone TEXT Zone name for room assignment (sets top-level `zone` field)
--level-range TEXT Level range, e.g. "5-10"
--tags TEXT Comma-separated tags
--theme TEXT Thematic guidance
--context PATH Additional context file(s), repeatable
--world-context / --no-world-context
Inject existing world state (default: true)
--few-shot / --no-few-shot
Include few-shot examples (default: true)
--template TEXT Custom prompt template name
--validate / --no-validate
Run Tier 1 validation (default: true)
--interactive / --no-interactive
Interactive review mode (default: false)
--max-tokens INT Max response tokens (default: per-type)
--retries INT Max retry attempts on failure (default: 2)
--seed INT Random seed (requires Phase 0; provider support varies)
--dry-run Show prompt without sending to LLM
--format [yaml|json] Output format (default: yaml)
--pack TEXT Content pack context for templates/style
Example: Generate a Room¶
$ maid ai generate room "Abandoned Library" \
--zone millbrook \
--style gritty \
--level-range "3-5" \
--tags indoor,dungeon,dark \
--output data/rooms/abandoned_library.yaml
⠋ Generating room: Abandoned Library...
✓ Generated in 2.3s (847 tokens, ~$0.003)
✓ Tier 1 validation passed (0 errors, 1 warning)
⚠ MAID-S005: Room has only one exit (consider adding more)
Output saved to: data/rooms/abandoned_library.yaml
Generated output:
# Auto-generated by maid ai generate
# Provider: anthropic/claude-sonnet-4-20250514
# Timestamp: 2025-07-15T14:23:01Z
_meta:
schema: maid:room:v1
generated_by: maid-ai
generation_id: gen_a1b2c3d4
rooms:
- _id: abandoned_library
components:
DescriptionComponent:
name: "Abandoned Library"
short_desc: "a dust-choked library with collapsed shelves"
long_desc: |
Decades of neglect have reduced this once-grand library to ruin.
Collapsed bookshelves lean against one another like drunken sentinels,
their contents scattered across a floor thick with dust and mouse
droppings. A few intact tomes cling to a shelf near the eastern wall,
their leather bindings cracked but legible. Pale light filters through
a single grimy window, illuminating motes of dust that swirl with each
footstep. The air is heavy with the smell of mildew and old paper.
ExtendedRoomComponent:
descriptions:
time_variants:
night: "Darkness swallows the library whole. Only the faintest moonlight through the grimy window reveals the shapes of ruined shelves."
dawn: "Grey predawn light seeps through the window, casting long shadows between the toppled shelves."
weather_effects:
rain: "The sound of rain on the roof is oddly soothing, though a steady drip from a crack in the ceiling pools on the floor."
mood: eerie
atmosphere_text: "The silence here feels watchful, as if the books themselves are listening."
random_details:
- text: "A mouse skitters behind a fallen shelf."
weight: 3
- text: "Dust motes dance in a beam of light."
weight: 5
- text: "A page detaches from a book and drifts to the floor."
weight: 2
exits:
south: "@ref:room/millbrook_north_road"
zone: millbrook
tags:
- room
- indoor
- dungeon
- dark
- explorable
Example: Generate an NPC¶
$ maid ai generate npc "Mirela the Herbalist" \
--zone millbrook \
--style pastoral \
--theme "wise woman, hedge witch, village healer" \
--level-range "1-5" \
--interactive
⠋ Generating NPC: Mirela the Herbalist...
✓ Generated in 3.1s (1,204 tokens, ~$0.005)
── Preview ──────────────────────────────────────────────────
Name: Mirela the Herbalist
Type: merchant / quest_giver
Level: 4
Spawn: village_herbalist_shop
AI Dialogue: enabled (DialogueComponent)
Schedule: 5 blocks (dawn to night) (ScheduleComponent)
─────────────────────────────────────────────────────────────
[A]ccept [E]dit [R]egenerate [V]iew full YAML [Q]uit
>
Generated output (full YAML on V):
_meta:
schema: maid:npc:v1
generated_by: maid-ai
generation_id: gen_e5f6g7h8
npcs:
- _id: mirela_herbalist
components:
DescriptionComponent:
name: "Mirela the Herbalist"
short_desc: "a weathered woman with herb-stained fingers and knowing eyes"
long_desc: |
Mirela is a wiry woman of indeterminate age, her sun-browned skin
creased with laugh lines and her grey-streaked hair perpetually escaping
from a loose braid. Her hands are stained green from years of grinding
herbs, and she smells faintly of lavender and something sharper —
perhaps foxglove. She moves with the quiet confidence of someone who
knows exactly where every jar on her shelves belongs.
NPCComponent:
behavior_type: friendly
faction_id: village_millbrook
spawn_point_id: "@ref:room/village_herbalist_shop"
is_merchant: true
is_quest_giver: true
wander_radius: 0
DialogueComponent:
ai_enabled: true
personality: |
Mirela is patient and observant, with a dry wit that catches people off
guard. She speaks plainly but chooses her words with care. She genuinely
wants to help people but has no tolerance for foolishness. She believes
strongly in the old ways — respecting the forest, reading the signs of
nature, never taking more than you need.
Backstory: Mirela arrived in Millbrook fifteen years ago with nothing
but a satchel of seeds and a well-thumbed herbal. No one knows where
she came from — she earned the village's trust by curing a fever that
swept through one winter and has been the unofficial healer ever since.
speaking_style: |
Speaks in a calm, measured cadence. Uses herbalism metaphors naturally.
Occasionally pauses mid-sentence to sniff the air or examine a plant.
Addresses strangers as "dear" but not condescendingly. Never raises her
voice — when angry, she gets quieter and more precise.
knowledge_domains:
- herbalism
- local flora and fauna
- folk remedies
- village history
- forest lore
secret_knowledge:
- "She knows the location of a moonpetal grove deep in the forest, but guards it fiercely."
- "She once treated a wounded orc in secret, and they left a carved token she still keeps."
- "She suspects the mayor is being poisoned slowly, but lacks proof."
wont_discuss:
- "Her life before coming to the village."
- "Making poisons, even when offered large sums."
greeting: "Ah, another one the forest sent my way. What ails you, dear?"
farewell: "Mind the thorns on the path back. And drink that tea I gave you."
npc_role: "village healer and herbalist"
faction: "village_millbrook"
ScheduleComponent:
blocks:
- start_hour: 5
end_hour: 7
activity: CRAFT
location: "@ref:room/village_herb_garden"
priority: 1.0
- start_hour: 7
end_hour: 12
activity: WORK
location: "@ref:room/village_herbalist_shop"
priority: 1.0
- start_hour: 12
end_hour: 13
activity: WANDER
location: "@ref:room/village_square"
priority: 0.5
- start_hour: 13
end_hour: 19
activity: WORK
location: "@ref:room/village_herbalist_shop"
priority: 1.0
- start_hour: 19
end_hour: 22
activity: SOCIALIZE
location: "@ref:room/village_tavern"
priority: 0.8
schedule_adherence: 0.85
# NPC relationships are managed at runtime via RelationshipManager, not in
# entity YAML. Seed initial relationships in the content pack's on_load()
# method or via a separate relationship data file.
location: "@ref:room/village_herbalist_shop"
tags:
- npc
- merchant
- quest_giver
- healer
- village_millbrook
Example: Generate a Monster¶
$ maid ai generate monster "Thornback Spider" \
--level-range "4-6" \
--theme "forest ambush predator, web traps" \
--output data/monsters/thornback_spider.yaml
⠋ Generating monster: Thornback Spider...
✓ Generated in 1.8s (632 tokens, ~$0.002)
✓ Tier 1 validation passed
Output saved to: data/monsters/thornback_spider.yaml
Generated output:
_meta:
schema: maid:monster:v1
generated_by: maid-ai
generation_id: gen_i9j0k1l2
monster_templates:
- _id: thornback_spider
name: "Thornback Spider"
monster_type: beast
level: 5
description: |
A spider the size of a large dog, its carapace bristling with thorn-like
spines that rattle when it moves. Its eight eyes gleam with predatory
cunning, and its mandibles drip with a paralytic venom. Silk strands
trail from its spinnerets, ready to anchor the next web trap.
stats:
base_health: 85
base_mana: 0
attack_power: 18
defense: 12
speed: 22
evasion: 15
behavior:
behavior_type: ambush
aggro_range: 1
flee_health_pct: 0.15
pack_size: [1, 3]
preferred_terrain: [forest, cave]
active_hours: [20, 6]
special_abilities:
- name: "Web Trap"
cooldown: 30
description: "Lays a sticky web that immobilizes targets for 2 rounds."
- name: "Venomous Bite"
cooldown: 15
description: "Injects paralytic venom. Target's speed reduced by 50% for 3 rounds."
- name: "Thorn Volley"
cooldown: 45
description: "Launches thorns from its back in a cone. Hits up to 3 targets."
ecology:
habitat: "Dense forest canopy and cave entrances"
diet: "Ambush predator — birds, small mammals, unwary travelers"
predators: ["forest_drake", "giant_owl"]
prey: ["forest_rabbit", "cave_bat"]
territorial: true
territory_radius: 5
nest_description: "A dome of thick silk anchored between trees, studded with the husks of past meals."
loot_table:
guaranteed: []
common:
- item: "spider_silk"
quantity: [1, 3]
weight: 0.6
- item: "thorn_spine"
quantity: [1, 2]
weight: 0.5
uncommon:
- item: "venom_sac"
quantity: [1, 1]
weight: 0.2
rare:
- item: "thornback_carapace"
quantity: [1, 1]
weight: 0.05
spawn_config:
rooms: ["@ref:room/dark_forest_path", "@ref:room/forest_clearing"]
max_per_room: 3
respawn_seconds: 600
condition: "night_or_overcast"
tags:
- monster
- beast
- forest
- ambush_predator
- venomous
Example: Generate an Area (Multi-Entity Bundle)¶
Terminology — Area vs Zone:
Term Meaning Where Area (runtime) Room metadata label in admin API ( area_id,area_name) used for UI filteringmaid_stdlib.api.admin.worldArea (Tier 2 generation) A bundle document containing multiple related entities for a zone maid ai generate areaZone The formal world-grouping concept, managed via @zonecommands, stored in the room's top-levelzonefieldloader/entity_types.py"Area" is not a first-class entity type. The
maid ai generate areacommand produces a multi-entity YAML file whose rooms are assigned to the specified zone via--zone. This matches Tier 1 and Tier 3 definitions.
$ maid ai generate area "Thornwood Forest" \
--zone thornwood \
--style gritty \
--level-range "3-8" \
--theme "dark forest, spider infestation, ancient ruins" \
--tags outdoor,forest,dangerous \
--output data/areas/thornwood_forest.yaml
⠋ Generating area: Thornwood Forest (estimating 8-12 rooms)...
⠸ Phase 1/3: Generating area layout...
⠼ Phase 2/3: Generating room details...
⠴ Phase 3/3: Validating connections...
✓ Generated in 12.4s (4,218 tokens, ~$0.016)
✓ 10 rooms, 14 exits, 3 NPCs, 5 monster spawns
✓ Tier 1 validation passed (0 errors, 2 warnings)
⚠ MAID-S005: Room "spider_nest" has only one exit
⚠ MAID-S005: Room "ruined_shrine" has only one exit
Output saved to: data/areas/thornwood_forest.yaml
3.3 maid ai review¶
AI-powered review of existing content files for quality, consistency, and completeness.
maid ai review <path> [options]
Arguments:
path File or directory to review
Options:
-p, --provider TEXT LLM provider
--focus TEXT Review focus (quality, consistency, lore, balance, completeness)
--style TEXT Expected style/tone
--context PATH Additional context files
--output PATH Write review to file
--format [text|json|md] Output format (default: text)
--severity [all|warn|error]
Minimum severity to report (default: all)
Example¶
$ maid ai review data/areas/thornwood_forest.yaml --focus quality,consistency
⠋ Reviewing thornwood_forest.yaml...
✓ Review complete (1,847 tokens, ~$0.007)
── AI Review: data/areas/thornwood_forest.yaml ──────────────
Quality: ★★★★☆ (4/5)
✓ Strengths:
• Rich atmospheric descriptions with sensory details
• Consistent dark forest theme throughout
• Good variety in room types (paths, clearings, landmarks)
• Spider ecology feels natural and interconnected
⚠ Suggestions:
• Room "forest_edge" description is generic — add unique landmarks
• NPC "old_woodsman" backstory doesn't explain why he stays in
dangerous territory — add motivation
• Three rooms use the word "gnarled" — vary the vocabulary
• Consider adding a water feature (stream/pond) for variety
✗ Issues:
• Room "spider_nest" is a dead end with level 7 monsters —
new players could wander in from the level 3 "forest_path"
with no warning. Add a warning sign or level gate.
── Lore Consistency ─────────────────────────────────────────
⚠ The "ruined_shrine" references "the old gods" but no other
content in this world defines that religion. Consider:
• Adding lore entries for "the old gods"
• Or connecting to existing pantheon if one exists
3.4 maid ai balance¶
Analyze combat encounters, economy, and progression balance.
maid ai balance <path> [options]
Arguments:
path File or directory to analyze
Options:
--type [combat|economy|progression|density|all]
Analysis type (default: all)
-p, --provider TEXT LLM provider (rule-based analysis needs no provider)
--output PATH Write analysis to file
--format [text|json|md] Output format (default: text)
--party-size INT Assumed party size for combat (default: 1)
--level INT Assumed player level for analysis
Example¶
$ maid ai balance data/areas/thornwood_forest.yaml --type combat --level 5
⠋ Analyzing combat balance...
✓ Analysis complete
── Combat Balance: Thornwood Forest ─────────────────────────
Overview: Level range 3-8, analyzed for level 5 solo player
Room-by-Room Threat Assessment:
forest_edge (level 3) ██░░░░░░░░ Easy ✓
forest_path (level 4) ████░░░░░░ Moderate ✓
dark_clearing (level 5) ██████░░░░ Fair ✓
spider_grove (level 6) ████████░░ Hard ✓
ancient_ruins (level 6) ████████░░ Hard ✓
spider_nest (level 7) ██████████ Deadly ⚠
⚠ Balance Issues:
• spider_nest: Thornback Spider x3 (level 5) in enclosed space
with web traps. Expected damage: 54/round vs player HP ~120.
Solo players at level 5 will likely die.
Suggestion: Reduce max_per_room to 2 or add escape route.
• No healing available between forest_path and spider_nest.
Consider placing an herb node or safe room.
✓ Progression curve is otherwise smooth (difficulty increases
gradually from edge → center).
3.5 maid ai describe¶
Generate or improve descriptions for existing entities.
maid ai describe <entity> [options]
Arguments:
entity Entity reference (@ref:type/name, UUID, or path:key)
Options:
-p, --provider TEXT LLM provider
--style TEXT Description style
--length [short|medium|long]
Description length (default: medium)
--component TEXT Which description to generate
(long_desc, short_desc, time, weather, mood)
--replace / --no-replace Replace existing description (default: false)
--output PATH Write to file instead of updating in-place
Example¶
$ maid ai describe @ref:room/village_square \
--component time,weather,mood \
--style pastoral
⠋ Loading entity: village_square...
⠋ Generating descriptions...
✓ Generated 3 description components (423 tokens, ~$0.002)
── Time Descriptions ────────────────────────────────────────
dawn: "Pale gold light spills across the cobblestones as the
village wakes. A rooster crows from somewhere near the
inn, and the smell of baking bread drifts from the west."
morning: "The square bustles with morning activity. Merchants set
up stalls, children chase each other between the well
and the notice board."
...
Write to data/rooms/village.yaml? [y/N]
3.6 maid ai populate¶
Add NPCs, items, and monsters to an existing area.
maid ai populate <area> [options]
Arguments:
area Area name or path to area YAML
Options:
-p, --provider TEXT LLM provider
--types TEXT What to add (npcs, items, monsters, all) (default: all)
--density [sparse|normal|dense]
Population density (default: normal)
--level-range TEXT Level range for spawned content
--theme TEXT Thematic guidance
--output PATH Output file for new entities
--merge / --no-merge Merge into existing area file (default: false)
--interactive Review each entity before adding
Example¶
$ maid ai populate thornwood_forest \
--types npcs,monsters \
--density normal \
--theme "spider-infested forest" \
--interactive
⠋ Loading area: thornwood_forest (10 rooms)...
⠋ Analyzing area theme and gaps...
⠋ Generating population plan...
── Population Plan ──────────────────────────────────────────
NPCs (3):
1. Wounded Scout — forest_edge — quest hook, warns of spiders
2. Hermit Alchemist — hidden_grove — merchant, buys spider parts
3. Lost Child — dark_clearing — rescue quest target
Monsters (4):
1. Thornback Spider — spider_grove, spider_nest — ambush pack
2. Web Lurker — dark_clearing, ancient_ruins — solo stalker
3. Broodmother — spider_nest — boss encounter
4. Forest Rat — forest_edge, forest_path — ambient wildlife
[A]ccept plan [E]dit plan [R]egenerate [Q]uit
> A
⠋ Generating NPC 1/3: Wounded Scout...
✓ Wounded Scout generated
── Preview: Wounded Scout ───────────────────────────────────
A militia scout with a bandaged leg and haunted eyes...
Quest: "Clear the spider nest" (combat, level 5-7)
[A]ccept [S]kip [R]egenerate [E]dit
> A
...
3.7 maid ai connect¶
Suggest and create connections between areas.
maid ai connect <area1> <area2> [options]
Arguments:
area1 First area name or path
area2 Second area name or path
Options:
-p, --provider TEXT LLM provider
--type [path|portal|hidden|conditional]
Connection type (default: path)
--bidirectional / --no-bidirectional
Create exits in both directions (default: true)
--transition-rooms INT Number of transition rooms (default: 1)
--output PATH Output file
--interactive Review before applying
Example¶
$ maid ai connect millbrook thornwood_forest \
--type path \
--transition-rooms 2 \
--interactive
⠋ Loading areas...
⠋ Analyzing area boundaries...
✓ Suggestion ready
── Connection Proposal ──────────────────────────────────────
millbrook/village_north_gate
→ (new) forest_approach: "A dirt road narrows as trees close
in overhead. The cheerful sounds of the village fade..."
→ (new) forest_threshold: "The last farmstead sits here, its
fence marking the boundary between civilization and wild..."
→ thornwood_forest/forest_edge
Creates: 2 new rooms, 4 new exits
Updates: 2 existing rooms (adds exits)
[A]ccept [E]dit [R]egenerate [Q]uit
>
3.8 maid ai estimate¶
Estimate cost without generating content. Useful for batch planning.
Uses the PricingConfig from observability/ai_metrics.py for cost calculation.
$ maid ai estimate area "Dwarven Mines" --level-range "10-15"
── Cost Estimate ────────────────────────────────────────────
Content type: area
Estimated rooms: 12-18
Estimated tokens: 5,000-8,000
Provider costs (estimated, from data/ai_pricing.yml):
anthropic/claude-sonnet: $0.02 - $0.03
openai/gpt-4o: $0.02 - $0.04
ollama/llama3.2: $0.00 (local)
chatjimmy/llama3.1-8B: $0.001 - $0.002
Daily budget remaining: $0.47 / $0.50
3.9 maid ai validate-output¶
Validate AI-generated or Copilot-agent-generated YAML against the Tier 1 pipeline. This is the primary integration point for external tools (see Section 7).
maid ai validate-output <path> [options]
Arguments:
path YAML file to validate
Options:
--type TEXT Entity type (room, npc, item, monster) — inferred from _meta.schema if present
--fix-minor / --no-fix-minor
Auto-fix minor issues: missing _id, missing tags (default: false)
--strict / --no-strict Treat unresolved @ref: as errors (default: false)
--format [text|json] Output format (default: text)
Example¶
$ maid ai validate-output data/npcs/generated_guards.yaml --type npc --fix-minor
✓ 4 NPCs validated
⚠ 2 warnings:
npcs[1]: missing short_desc (auto-generated from name)
npcs[3]: exit references unknown room "hidden_passage" (unresolved @ref)
✗ 1 error:
npcs[2]: HealthComponent.current (150) > maximum (100)
Auto-fixed: 2 issues (missing _id, missing tags)
3.10 maid ai schema¶
Show the current JSON Schema for a content type. Useful for Copilot agents and external tooling that needs the canonical schema.
maid ai schema <type> [options]
Arguments:
type Content type (room, npc, item, monster)
Options:
--format [yaml|json-schema|example]
Output format (default: json-schema)
--component TEXT Show schema for a specific component only
Example¶
$ maid ai schema npc --format example
# Example NPC entity (from registered Pydantic models)
_meta:
schema: maid:npc:v1
npcs:
- _id: example_npc
components:
DescriptionComponent:
name: "Example NPC"
short_desc: ""
long_desc: ""
keywords: []
NPCComponent:
template_id: null
behavior_type: "passive"
dialogue_id: null
spawn_point_id: null
respawn_time: 300.0
wander_radius: 0
faction_id: null
is_merchant: false
is_quest_giver: false
location: "@ref:room/example_room"
3.11 maid ai cache¶
PROPOSED — NOT YET IMPLEMENTED. See Section 3 preamble.
Manage the generation response cache (see Section 13.2).
Example¶
Proposed UX — not yet implemented.
$ maid ai cache stats
── Generation Cache ─────────────────────────────────────────
Entries: 47
Total size: 128 KB
Cache directory: .maid_cache/ai_generation/
TTL: 24 hours
Hit rate (est): 23% (based on duplicate prompt detection)
4. Structured Output Engine¶
Depends on Phase 0. The adapters in this section assume the expanded
CompletionOptionswithresponse_format,tools, andtool_choicefields. Until Phase 0 lands, the current MAID codebase has no structured-output adapter. The planned fallback path isGenericJSONAdapter(prompt-based JSON injection + post-hoc validation), which requires no Phase 0 provider changes but is itself new code to be written as part of Phase 1 — see §4.3.
The structured output engine is the core innovation of Tier 2. Instead of asking LLMs to produce free-form text and hoping it parses, we constrain output to match exact schemas derived from the codebase's own Pydantic component models.
4.1 Architecture¶
┌─────────────────────────────────────────────────────────────────┐
│ Structured Output Engine │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ Schema │ │ Prompt │ │ Output │ │
│ │ Extractor │──│ Assembler │──│ Parser │ │
│ │ │ │ │ │ │ │
│ │ Component ──→│ │ System msg →│ │ Raw response →│ │
│ │ Pydantic │ │ Schema hint │ │ JSON parse │ │
│ │ models │ │ Few-shot │ │ Schema validate │ │
│ │ ↓ │ │ Context │ │ YAML serialize │ │
│ │ JSON Schema │ │ Constraints │ │ Pipeline validate │ │
│ └─────────────┘ └──────────────┘ └───────────────────────┘ │
│ │ │ │
│ │ ┌──────────────┐ │ │
│ └────────→│ Provider │←──────────────┘ │
│ │ Adapter │ │
│ │ │ │
│ │ Anthropic: │ ┌───────────────────────┐ │
│ │ tool_use │ │ Retry / Repair │ │
│ │ OpenAI: │──│ │ │
│ │ json_schema │ │ Parse error → retry │ │
│ │ Ollama: │ │ Validation → repair │ │
│ │ json + post │ │ Timeout → fallback │ │
│ └──────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
4.2 Schema Extraction from Component Models¶
Since all MAID components extend Component(BaseModel) with Pydantic v2, we can
extract JSON Schema directly from the model definitions.
# packages/maid-engine/src/maid_engine/ai/structured/schema.py
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
from maid_engine.core.ecs.component import Component, ComponentRegistry
class ContentSchema(BaseModel):
"""JSON Schema wrapper for a content type."""
content_type: str
schema: dict[str, Any]
required_components: list[str]
optional_components: list[str]
example: dict[str, Any] | None = None
def to_json_schema(self) -> dict[str, Any]:
"""Return the schema in JSON Schema format for LLM consumption."""
return self.schema
def to_prompt_hint(self) -> str:
"""Return a human-readable schema description for prompt injection."""
lines = [f"Content type: {self.content_type}"]
lines.append(f"Required components: {', '.join(self.required_components)}")
if self.optional_components:
lines.append(f"Optional components: {', '.join(self.optional_components)}")
return "\n".join(lines)
def extract_component_schema(component_cls: type[Component]) -> dict[str, Any]:
"""Extract JSON Schema from a Component subclass.
Uses Pydantic v2's model_json_schema() which respects field types,
defaults, validators, and constraints.
"""
schema = component_cls.model_json_schema()
# Remove Pydantic internal fields that confuse LLMs
schema.pop("title", None)
for prop in schema.get("properties", {}).values():
prop.pop("title", None)
return schema
def build_room_schema() -> ContentSchema:
"""Build the complete schema for room generation."""
from maid_stdlib.components import (
DescriptionComponent,
ExtendedRoomComponent,
)
desc_schema = extract_component_schema(DescriptionComponent)
ext_schema = extract_component_schema(ExtendedRoomComponent)
return ContentSchema(
content_type="room",
schema={
"type": "object",
"properties": {
"_id": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"},
"components": {
"type": "object",
"properties": {
"DescriptionComponent": desc_schema,
"ExtendedRoomComponent": ext_schema,
},
"required": ["DescriptionComponent"],
},
"exits": {
"type": "object",
"additionalProperties": {"type": "string"},
"description": "Direction → room reference (@ref:room/name or UUID)",
},
"attributes": {
"type": "object",
"additionalProperties": True,
},
"tags": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["_id", "components"],
},
required_components=["DescriptionComponent"],
optional_components=["ExtendedRoomComponent"],
)
def build_content_schema(content_type: str) -> ContentSchema:
"""Build schema for any supported content type.
Dispatches to type-specific builders that know which components
are required/optional for each content type.
"""
builders: dict[str, Any] = {
"room": build_room_schema,
"npc": build_npc_schema,
"item": build_item_schema,
"monster": build_monster_schema,
"quest": build_quest_schema,
"lore": build_lore_schema,
}
builder = builders.get(content_type)
if builder is None:
raise ValueError(
f"Unknown content type: {content_type}. "
f"Valid types: {', '.join(builders)}"
)
return builder()
4.3 Provider-Specific Structured Output Adapters¶
All adapters in this section are PROPOSED — none exist in the codebase today. The
AnthropicStructuredAdapterandOpenAIStructuredAdapterrequire Phase 0 changes toCompletionOptions. TheGenericJSONAdapterdoes not need Phase 0 provider changes but is itself new code that must be written as part of Phase 1. It is the simplest adapter and the recommended starting point for implementation.
Each LLM provider has a different mechanism for structured output. The adapter
layer normalizes this behind a common interface. Crucially, all adapters
route through LLMProviderRegistry.complete_with_fallback() rather than
calling providers directly — this ensures circuit-breaker protection, fallback
routing, and consistent metrics.
# packages/maid-engine/src/maid_engine/ai/structured/adapters.py
from __future__ import annotations
import json
from abc import ABC, abstractmethod
from typing import Any
from maid_engine.ai.providers.base import (
CompletionOptions,
CompletionResult,
Message,
ResponseFormat,
ToolDefinition,
)
from maid_engine.ai.registry import LLMProviderRegistry
from .schema import ContentSchema
class StructuredOutputAdapter(ABC):
"""Adapter that wraps the LLMProviderRegistry to produce structured output.
All adapters route through the registry's complete_with_fallback() to
get circuit-breaker protection and provider fallback.
"""
def __init__(self, registry: LLMProviderRegistry) -> None:
self._registry = registry
@abstractmethod
async def generate_structured(
self,
messages: list[Message],
schema: ContentSchema,
options: CompletionOptions | None = None,
) -> StructuredResult:
"""Generate content conforming to the given schema."""
...
class StructuredResult:
"""Result of a structured generation."""
def __init__(
self,
data: dict[str, Any],
raw_response: str,
completion_result: CompletionResult,
validation_errors: list[str] | None = None,
repair_applied: bool = False,
) -> None:
self.data = data
self.raw_response = raw_response
self.completion_result = completion_result
self.validation_errors = validation_errors or []
self.repair_applied = repair_applied
@property
def is_valid(self) -> bool:
return len(self.validation_errors) == 0
@property
def tokens_used(self) -> int:
"""Total tokens used. Handles providers that don't set total_tokens."""
usage = self.completion_result.usage
total = usage.get("total_tokens", 0)
if total == 0:
# Many providers only populate prompt_tokens + completion_tokens
total = usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
return total
class AnthropicStructuredAdapter(StructuredOutputAdapter):
"""Uses Anthropic's tool_use feature to constrain output.
Requires Phase 0: CompletionOptions.tools and .tool_choice fields.
"""
async def generate_structured(
self,
messages: list[Message],
schema: ContentSchema,
options: CompletionOptions | None = None,
) -> StructuredResult:
opts = options or CompletionOptions()
# Phase 0 fields: pass tool definition via CompletionOptions
tool = ToolDefinition(
name=f"create_{schema.content_type}",
description=f"Create a {schema.content_type} entity",
input_schema=schema.to_json_schema(),
)
opts.tools = [tool]
opts.tool_choice = "required"
# Route through registry for circuit-breaker + fallback
result = await self._registry.complete_with_fallback(
messages, opts, preferred_provider="anthropic"
)
# Parse tool call from response
if result.tool_calls:
data = result.tool_calls[0].arguments
else:
data = json.loads(result.content)
return StructuredResult(
data=data,
raw_response=result.content,
completion_result=result,
)
class OpenAIStructuredAdapter(StructuredOutputAdapter):
"""Uses OpenAI's native json_schema response format.
Requires Phase 0: CompletionOptions.response_format field.
"""
async def generate_structured(
self,
messages: list[Message],
schema: ContentSchema,
options: CompletionOptions | None = None,
) -> StructuredResult:
opts = options or CompletionOptions()
# Phase 0 fields: pass JSON schema via response_format
opts.response_format = ResponseFormat(
type="json_schema",
json_schema={
"name": f"{schema.content_type}_output",
"schema": schema.to_json_schema(),
"strict": True,
},
)
result = await self._registry.complete_with_fallback(
messages, opts, preferred_provider="openai"
)
data = json.loads(result.content)
return StructuredResult(
data=data,
raw_response=result.content,
completion_result=result,
)
class GeminiStructuredAdapter(StructuredOutputAdapter):
"""Uses Gemini's response_schema for structured output.
Requires Phase 0: CompletionOptions.response_format field.
"""
async def generate_structured(
self,
messages: list[Message],
schema: ContentSchema,
options: CompletionOptions | None = None,
) -> StructuredResult:
opts = options or CompletionOptions()
opts.response_format = ResponseFormat(
type="json_schema",
json_schema=schema.to_json_schema(),
)
result = await self._registry.complete_with_fallback(
messages, opts, preferred_provider="gemini"
)
data = json.loads(result.content)
return StructuredResult(
data=data,
raw_response=result.content,
completion_result=result,
)
class GenericJSONAdapter(StructuredOutputAdapter):
"""Fallback for providers without native structured output.
PROPOSED — does not exist yet. This is the first adapter to implement
in Phase 1. Requires no Phase 0 provider changes. Used by Ollama,
ChatJimmy, and as the universal fallback. Instructs the LLM to output
JSON via prompt text and validates post-hoc.
"""
async def generate_structured(
self,
messages: list[Message],
schema: ContentSchema,
options: CompletionOptions | None = None,
) -> StructuredResult:
schema_hint = json.dumps(schema.to_json_schema(), indent=2)
constrained_messages = [
*messages,
Message.user(
f"Output your response as a single JSON object matching "
f"this exact schema:\n\n```json\n{schema_hint}\n```\n\n"
f"Output ONLY the JSON object. No markdown, no explanation."
),
]
# Route through registry for circuit-breaker + fallback
result = await self._registry.complete_with_fallback(
constrained_messages,
options or CompletionOptions(),
)
data = self._parse_json(result.content)
errors = self._validate_against_schema(data, schema)
return StructuredResult(
data=data,
raw_response=result.content,
completion_result=result,
validation_errors=errors,
)
def _parse_json(self, content: str) -> dict[str, Any]:
"""Extract JSON from potentially wrapped response."""
text = content.strip()
# Strip markdown code fences
if text.startswith("```"):
lines = text.split("\n")
text = "\n".join(lines[1:-1])
return json.loads(text)
def _validate_against_schema(
self, data: dict[str, Any], schema: ContentSchema
) -> list[str]:
"""Validate data against JSON Schema. Returns error messages."""
import jsonschema
errors = []
validator = jsonschema.Draft7Validator(schema.to_json_schema())
for error in validator.iter_errors(data):
errors.append(f"{error.json_path}: {error.message}")
return errors
def get_adapter(
registry: LLMProviderRegistry,
preferred_provider: str | None = None,
) -> StructuredOutputAdapter:
"""Select the best structured output adapter for the preferred provider.
Falls back to GenericJSONAdapter if the provider doesn't have a
native structured output adapter, or if Phase 0 hasn't landed yet.
"""
provider_name = preferred_provider or registry.default
# Post-Phase 0 adapters (require expanded CompletionOptions)
adapter_map: dict[str, type[StructuredOutputAdapter]] = {
"anthropic": AnthropicStructuredAdapter,
"openai": OpenAIStructuredAdapter,
"gemini": GeminiStructuredAdapter,
}
adapter_cls = adapter_map.get(provider_name or "", GenericJSONAdapter)
return adapter_cls(registry)
4.4 Output Parsing and Validation Pipeline¶
After the LLM produces structured data, it passes through a multi-stage validation pipeline before being serialized to YAML.
# packages/maid-engine/src/maid_engine/ai/structured/validator.py
from __future__ import annotations
import uuid
from typing import Any
from .schema import ContentSchema
class ValidationPipeline:
"""Multi-stage validation for AI-generated content."""
def __init__(self, schema: ContentSchema) -> None:
self._schema = schema
self._stages: list[ValidationStage] = [
SchemaValidationStage(),
IDNormalizationStage(),
ReferenceValidationStage(),
ComponentValidationStage(),
DefaultInjectionStage(),
]
def validate(
self, data: dict[str, Any]
) -> tuple[dict[str, Any], list[str], list[str]]:
"""Run all validation stages.
Returns:
Tuple of (cleaned_data, errors, warnings).
"""
errors: list[str] = []
warnings: list[str] = []
current = data
for stage in self._stages:
current, stage_errors, stage_warnings = stage.process(
current, self._schema
)
errors.extend(stage_errors)
warnings.extend(stage_warnings)
return current, errors, warnings
class ValidationStage:
"""Base class for validation pipeline stages."""
def process(
self, data: dict[str, Any], schema: ContentSchema
) -> tuple[dict[str, Any], list[str], list[str]]:
raise NotImplementedError
class SchemaValidationStage(ValidationStage):
"""Validate against JSON Schema."""
def process(
self, data: dict[str, Any], schema: ContentSchema
) -> tuple[dict[str, Any], list[str], list[str]]:
import jsonschema
errors = []
validator = jsonschema.Draft7Validator(schema.to_json_schema())
for error in validator.iter_errors(data):
errors.append(f"Schema: {error.json_path}: {error.message}")
return data, errors, []
class IDNormalizationStage(ValidationStage):
"""Ensure _id fields are valid, generate UUIDs where needed."""
def process(
self, data: dict[str, Any], schema: ContentSchema
) -> tuple[dict[str, Any], list[str], list[str]]:
warnings = []
if "_id" not in data:
name = data.get("components", {}).get(
"DescriptionComponent", {}
).get("name", "unnamed")
data["_id"] = self._slugify(name)
warnings.append(f"Generated _id from name: {data['_id']}")
if "id" not in data:
data["id"] = str(uuid.uuid4())
return data, [], warnings
@staticmethod
def _slugify(name: str) -> str:
import re
slug = name.lower().strip()
slug = re.sub(r"[^a-z0-9]+", "_", slug)
slug = slug.strip("_")
return slug or "unnamed"
class ReferenceValidationStage(ValidationStage):
"""Validate @ref: references are well-formed."""
def process(
self, data: dict[str, Any], schema: ContentSchema
) -> tuple[dict[str, Any], list[str], list[str]]:
import re
errors = []
warnings = []
ref_pattern = re.compile(r"@ref:(\w+)/(\w+)")
def check_refs(obj: Any, path: str = "") -> None:
if isinstance(obj, str) and obj.startswith("@ref:"):
if not ref_pattern.match(obj):
errors.append(
f"Malformed reference at {path}: {obj}"
)
elif isinstance(obj, dict):
for k, v in obj.items():
check_refs(v, f"{path}.{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
check_refs(v, f"{path}[{i}]")
check_refs(data)
return data, errors, warnings
class ComponentValidationStage(ValidationStage):
"""Validate component payloads against registered Pydantic models."""
def process(
self, data: dict[str, Any], schema: ContentSchema
) -> tuple[dict[str, Any], list[str], list[str]]:
from maid_engine.core.ecs.component import ComponentRegistry
errors = []
warnings = []
components = data.get("components", {})
for comp_name, comp_data in components.items():
comp_cls = ComponentRegistry.get(comp_name)
if comp_cls is None:
errors.append(f"Unknown component: {comp_name}")
continue
try:
comp_cls.model_validate(comp_data)
except Exception as e:
errors.append(
f"Component {comp_name} validation failed: {e}"
)
return data, errors, warnings
class DefaultInjectionStage(ValidationStage):
"""Inject required defaults for fields the LLM omitted."""
def process(
self, data: dict[str, Any], schema: ContentSchema
) -> tuple[dict[str, Any], list[str], list[str]]:
warnings = []
if "tags" not in data:
data["tags"] = [schema.content_type]
warnings.append("Injected default tags")
if "attributes" not in data:
data["attributes"] = {}
return data, [], warnings
4.5 Handling Malformed Output¶
LLMs sometimes produce output that doesn't parse. The system uses a three-tier recovery strategy:
Attempt 1: Generate with schema constraints
↓ (parse error?)
Attempt 2: Retry with error context appended to prompt
↓ (still fails?)
Attempt 3: Repair — extract partial data + fill defaults
↓ (catastrophic failure?)
Fallback: Return minimal template with name/description only
# packages/maid-engine/src/maid_engine/ai/structured/retry.py
from __future__ import annotations
import logging
from typing import Any
from maid_engine.ai.providers.base import CompletionOptions, Message
from .adapters import StructuredOutputAdapter, StructuredResult
from .schema import ContentSchema
logger = logging.getLogger(__name__)
class RetryStrategy:
"""Handles retry and repair for structured generation."""
def __init__(
self,
adapter: StructuredOutputAdapter,
max_retries: int = 2,
) -> None:
self._adapter = adapter
self._max_retries = max_retries
async def generate_with_retry(
self,
messages: list[Message],
schema: ContentSchema,
options: CompletionOptions | None = None,
) -> StructuredResult:
"""Attempt generation with automatic retry and repair."""
last_error: str | None = None
for attempt in range(self._max_retries + 1):
try:
if attempt > 0 and last_error:
# Append error context for retry
retry_messages = [
*messages,
Message.user(
f"Your previous response had errors:\n"
f"{last_error}\n\n"
f"Please fix these issues and try again."
),
]
else:
retry_messages = messages
result = await self._adapter.generate_structured(
retry_messages, schema, options
)
if result.is_valid:
return result
last_error = "\n".join(result.validation_errors)
logger.warning(
"Generation attempt %d/%d had validation errors: %s",
attempt + 1,
self._max_retries + 1,
last_error,
)
except Exception as e:
last_error = str(e)
logger.warning(
"Generation attempt %d/%d failed: %s",
attempt + 1,
self._max_retries + 1,
e,
)
# All retries exhausted — attempt repair
logger.warning("All retries exhausted, attempting repair")
return self._repair_or_fallback(messages, schema, last_error)
def _repair_or_fallback(
self,
messages: list[Message],
schema: ContentSchema,
last_error: str | None,
) -> StructuredResult:
"""Create a minimal valid result from available data."""
# Extract name from the original prompt
name = self._extract_name_from_messages(messages)
minimal: dict[str, Any] = {
"_id": name.lower().replace(" ", "_"),
"components": {
"DescriptionComponent": {
"name": name,
"short_desc": f"a {schema.content_type}",
"long_desc": f"A {schema.content_type} called {name}. "
f"(AI generation failed — please edit manually.)",
},
},
"tags": [schema.content_type, "ai_fallback", "needs_review"],
}
from .adapters import StructuredResult as SR
return SR(
data=minimal,
raw_response="(fallback template)",
completion_result=_empty_completion_result(),
validation_errors=[
f"Fell back to template after {self._max_retries + 1} "
f"failed attempts. Last error: {last_error}"
],
repair_applied=True,
)
@staticmethod
def _extract_name_from_messages(messages: list[Message]) -> str:
for msg in reversed(messages):
if msg.role.value == "user" and msg.content:
return msg.content.split("\n")[0][:80]
return "Unnamed"
4.6 YAML Serialization¶
The final step converts validated structured data to pipeline-compatible YAML.
# packages/maid-engine/src/maid_engine/ai/structured/serializer.py
from __future__ import annotations
import datetime
from typing import Any
import yaml
class ContentSerializer:
"""Serialize structured content to pipeline-compatible YAML."""
def __init__(
self,
provider_name: str = "unknown",
model_name: str = "unknown",
) -> None:
self._provider = provider_name
self._model = model_name
def to_yaml(
self,
content_type: str,
entities: list[dict[str, Any]],
generation_id: str | None = None,
) -> str:
"""Serialize entities to a complete YAML document.
Produces output compatible with the Tier 1 pipeline's
DiscoverPhase → ParsePhase → PreparePhase path.
"""
doc: dict[str, Any] = {
"_meta": {
"schema": f"maid:{content_type}:v1",
"generated_by": "maid-ai",
"provider": f"{self._provider}/{self._model}",
"timestamp": datetime.datetime.now(
tz=datetime.timezone.utc
).isoformat(),
},
}
if generation_id:
doc["_meta"]["generation_id"] = generation_id
# Use the standard top-level key for entity type
type_key = self._get_type_key(content_type)
doc[type_key] = entities
return self._dump_yaml(doc)
@staticmethod
def _get_type_key(content_type: str) -> str:
key_map = {
"room": "rooms",
"npc": "npcs",
"item": "items",
"monster": "monster_templates",
"quest": "quests",
"lore": "lore_entries",
}
return key_map.get(content_type, f"{content_type}s")
@staticmethod
def _dump_yaml(data: dict[str, Any]) -> str:
"""Dump YAML with style choices that match hand-authored content."""
return yaml.dump(
data,
default_flow_style=False,
allow_unicode=True,
sort_keys=False,
width=80,
)
5. Content Generation Templates¶
Each content type has a dedicated prompt template. Templates are composed from reusable fragments: a system identity block, a world context block, a schema constraint block, a style guide block, and few-shot examples.
5.1 Template Architecture¶
┌──────────────────────────────────────────────────────┐
│ Final Prompt │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ SYSTEM MESSAGE │ │
│ │ ├─ Identity block (who you are) │ │
│ │ ├─ World context block (existing world state) │ │
│ │ ├─ Schema constraint block (output format) │ │
│ │ └─ Style guide block (tone & quality rules) │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ FEW-SHOT EXAMPLES (optional) │ │
│ │ ├─ User: "Generate a room: Village Square" │ │
│ │ └─ Assistant: { valid YAML example } │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ USER MESSAGE │ │
│ │ ├─ Content request (type, name, description) │ │
│ │ ├─ Constraints (level range, tags, theme) │ │
│ │ └─ Additional context (area, connections) │ │
│ └────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
5.2 Shared Template Fragments¶
System Identity Block¶
SYSTEM_IDENTITY = """You are a content designer for a fantasy MUD (Multi-User Dungeon) game engine called MAID. You create game content in a structured YAML format that will be loaded directly into the game engine's entity pipeline.
Your content must be:
- Atmospheric: Rich sensory descriptions (sight, sound, smell, touch)
- Consistent: Match the tone and lore of the existing world
- Mechanically sound: Stats, levels, and rewards must be balanced
- Pipeline-compatible: Output must match the exact YAML schema provided
You never break character. You never include meta-commentary. You output only the requested structured data."""
World Context Block¶
WORLD_CONTEXT_TEMPLATE = """== EXISTING WORLD STATE ==
World: {world_name}
Theme: {world_theme}
Existing Areas ({area_count}):
{area_list}
Existing NPCs in {target_area} ({npc_count}):
{npc_list}
Existing Items in {target_area} ({item_count}):
{item_list}
Level Range for {target_area}: {level_range}
Tags: {area_tags}
== IMPORTANT: REFERENCE EXISTING ENTITIES ==
When referring to existing rooms, NPCs, or items, use @ref:type/name syntax:
@ref:room/village_square
@ref:npc/aldric_shopkeeper
@ref:item/iron_sword
Available room references in {target_area}:
{room_refs}
Available NPC references in {target_area}:
{npc_refs}"""
Style Guide Block¶
STYLE_GUIDES = {
"epic": """== STYLE GUIDE: EPIC ==
Write in a grand, sweeping style. Use vivid imagery and powerful verbs.
Descriptions should evoke wonder and scale. NPCs speak with gravitas.
Combat encounters are dramatic. Items feel legendary even at low levels.
Vocabulary: ancient, vast, towering, thunder, blazing, sworn, destiny.""",
"gritty": """== STYLE GUIDE: GRITTY ==
Write in a harsh, realistic style. Focus on survival, scarcity, and danger.
Descriptions emphasize wear, decay, and the mundane details of a hard world.
NPCs are pragmatic and wary. Combat is brutal and short.
Vocabulary: rusted, cracked, stained, grim, scarred, bitter, mud.""",
"whimsical": """== STYLE GUIDE: WHIMSICAL ==
Write in a playful, fairy-tale style. Include humor and surprise.
Descriptions should charm and delight. NPCs have quirky personalities.
Even dangerous encounters have a sense of wonder.
Vocabulary: curious, gleaming, tumbling, peculiar, sparkle, mischief.""",
"horror": """== STYLE GUIDE: HORROR ==
Write in an unsettling, atmospheric style. Build dread through implication.
Descriptions focus on wrongness — things that are almost normal but not quite.
NPCs are evasive or unsettlingly calm. Combat encounters are terrifying.
Vocabulary: writhing, hollow, seeping, pale, whisper, damp, cold.""",
"pastoral": """== STYLE GUIDE: PASTORAL ==
Write in a warm, gentle style. Emphasize beauty, community, and peace.
Descriptions highlight nature, craftsmanship, and simple pleasures.
NPCs are welcoming and have rich daily lives. Danger is distant.
Vocabulary: golden, meadow, hearth, fragrant, laughter, harvest, gentle.""",
}
5.3 Room Generation Template¶
ROOM_SYSTEM_PROMPT = """{identity}
{world_context}
{style_guide}
== OUTPUT SCHEMA ==
You must output a JSON object matching this exact schema:
{json_schema}
== ROOM-SPECIFIC GUIDELINES ==
1. The `long_desc` should be 3-6 sentences. Use second person ("You see...").
2. The `short_desc` should be a brief noun phrase (5-10 words), lowercase.
3. Include at least 2 exits unless the room is intentionally a dead end.
4. Exits should reference existing rooms with @ref:room/name when possible.
5. For new exits that don't connect to existing rooms yet, use a descriptive
placeholder name like "dark_tunnel_north" — the pipeline will flag it as
an unresolved reference for manual connection later.
6. If ExtendedRoomComponent is included:
- Nest fields under `descriptions` (e.g., `descriptions.time_variants`)
- Add at least 2 time_variants (dawn, day, dusk, night)
- Add at least 1 weather_effect
- Add 2-4 random_details with varying weights (1-10)
- Set mood from: peaceful, eerie, tense, bustling, mysterious, oppressive
7. Set `zone` as a top-level field to assign the room to a zone.
8. Use tags for room properties: indoor/outdoor, light_dim/light_dark, etc.
9. Include relevant tags: room, indoor/outdoor, safe_zone/dangerous, etc.
10. The _id must be a unique snake_case identifier."""
ROOM_USER_PROMPT = """Generate a room: "{name}"
Description/theme: {theme}
Parent area: {area}
Level range: {level_range}
Tags: {tags}
Additional context: {additional_context}
{constraints}"""
ROOM_FEW_SHOT_EXAMPLE = {
"user": 'Generate a room: "Village Blacksmith"\n\nDescription/theme: A working smithy in a small village\nParent area: millbrook\nLevel range: 1-5\nTags: indoor, safe_zone, shop',
"assistant": """{
"_id": "village_blacksmith",
"components": {
"DescriptionComponent": {
"name": "Village Blacksmith",
"short_desc": "a sweltering smithy ringing with hammer blows",
"long_desc": "Heat rolls over you in waves as you step into the smithy. The forge dominates the far wall, its coals glowing a fierce orange-white. Racks of half-finished blades and horseshoes line the walls, and the stone floor is scorched black near the anvil. The rhythmic clang of metal on metal fills the air, punctuated by the hiss of quenched steel."
},
"ExtendedRoomComponent": {
"descriptions": {
"time_variants": {
"dawn": "The forge is cold and dark. A thin trail of smoke rises from yesterday's coals as the smith prepares to start the day.",
"night": "The forge still radiates warmth. Banked coals cast a dim red glow across the empty workshop."
},
"weather_effects": {
"rain": "Rain drums on the tin roof, and the open doorway lets in a cool breeze that makes the forge coals flare."
},
"mood": "bustling",
"atmosphere_text": "The air tastes of iron and charcoal.",
"random_details": [
{"text": "Sparks leap from the anvil with each hammer strike.", "weight": 5},
{"text": "A cat sleeps on a warm brick near the forge, unbothered by the noise.", "weight": 3},
{"text": "A sword blank glows cherry-red in the tongs.", "weight": 4}
]
}
}
},
"exits": {
"south": "@ref:room/village_square",
"back": "@ref:room/blacksmith_storeroom"
},
"zone": "millbrook",
"tags": ["room", "indoor", "safe_zone", "shop", "craft_station"]
}""",
}
5.4 NPC Generation Template¶
NPC_SYSTEM_PROMPT = """{identity}
{world_context}
{style_guide}
== OUTPUT SCHEMA ==
{json_schema}
== NPC-SPECIFIC GUIDELINES ==
1. Give NPCs a distinct personality voice. They should feel like individuals.
2. The `personality` field for AI dialogue should be 2-4 sentences capturing
how the NPC thinks, what they value, and what frustrates them.
3. The `speaking_style` field should describe HOW they talk, not WHAT they say.
Include verbal tics, vocabulary level, accent notes, emotional baseline.
4. Include 2-5 `knowledge_domains` — things the NPC can discuss intelligently.
5. Include 1-3 `secret_knowledge` items — things the NPC knows but doesn't
freely share. These drive quests and intrigue.
6. Include 1-2 `boundaries` — topics the NPC will NOT discuss.
7. The `schedule` should have 3-6 entries covering dawn (5-7), work (7-17),
evening (17-22). Valid ActivityType values: WORK, SLEEP, EAT, SOCIALIZE,
PATROL, GUARD, TRADE, CRAFT, WORSHIP, TRAIN, WANDER, CUSTOM.
8. Include at least 2 `relationships` to existing NPCs (use @ref).
9. The `backstory` should be 3-5 sentences, hinting at secrets without
revealing everything.
10. Set `spawn_point_id` to an existing room reference.
11. Set `behavior_type` to one of: passive, aggressive, defensive, merchant,
quest_giver, patrol, wander, guard, ambient, scripted.
12. NPC subtypes are expressed via `is_merchant`, `is_quest_giver` booleans
and `faction_id` for faction membership.
13. Stats must be appropriate for the level range."""
NPC_USER_PROMPT = """Generate an NPC: "{name}"
Description/theme: {theme}
Parent area: {area}
Level range: {level_range}
Behavior type: {behavior_type}
Role flags: is_merchant={is_merchant}, is_quest_giver={is_quest_giver}
Tags: {tags}
Additional context: {additional_context}
{constraints}"""
5.5 Item Generation Template¶
ITEM_SYSTEM_PROMPT = """{identity}
{world_context}
{style_guide}
== OUTPUT SCHEMA ==
{json_schema}
== ITEM-SPECIFIC GUIDELINES ==
1. Item names should be evocative but concise (2-4 words).
2. Descriptions should hint at the item's history or craftsmanship.
3. Stats must be level-appropriate:
- Level 1-5: value 1-50, damage/armor 1-10
- Level 5-10: value 25-200, damage/armor 8-20
- Level 10-15: value 100-500, damage/armor 15-35
- Level 15-20: value 300-1000, damage/armor 30-50
4. Include quality_variants if the item naturally comes in different grades
(e.g., crude, standard, fine, masterwork).
5. For weapons: include damage_type (slashing, piercing, blunt, magical).
6. For armor: include armor_type (light, medium, heavy) and slot.
7. For consumables: include effect with duration_seconds and magnitude.
8. Set weight realistically (dagger: 1, sword: 3, plate armor: 15).
9. Item types: weapon, armor, potion, scroll, food, material, quest_item,
container, key, tool, jewelry, clothing."""
ITEM_USER_PROMPT = """Generate an item: "{name}"
Description/theme: {theme}
Item type: {item_type}
Level range: {level_range}
Rarity: {rarity}
Tags: {tags}
Additional context: {additional_context}
{constraints}"""
5.6 Monster Generation Template¶
MONSTER_SYSTEM_PROMPT = """{identity}
{world_context}
{style_guide}
== OUTPUT SCHEMA ==
{json_schema}
== MONSTER-SPECIFIC GUIDELINES ==
1. Monsters should feel like living creatures with ecology, not just stat blocks.
2. Include `ecology` with habitat, diet, predators, prey, and territory info.
3. Behavior types: aggressive, defensive, ambush, pack, territorial,
passive, fleeing, boss.
4. Stats should scale with level:
- Level 1-5: HP 30-100, attack 8-18
- Level 5-10: HP 80-200, attack 15-30
- Level 10-15: HP 150-400, attack 25-45
- Level 15-20: HP 300-800, attack 40-70
5. Include 1-3 special_abilities with cooldowns and descriptions.
6. The loot_table should have guaranteed (always drops), common (>50%),
uncommon (10-30%), and rare (<10%) tiers.
7. Loot should make ecological sense (wolves drop pelts, not gold).
8. Set active_hours for day/night behavior (24h format, [start, end]).
9. Set aggro_range: 0 (passive), 1-2 (cautious), 3-5 (aggressive).
10. Pack monsters should have pack_size as [min, max]."""
MONSTER_USER_PROMPT = """Generate a monster: "{name}"
Description/theme: {theme}
Monster type: {monster_type}
Level range: {level_range}
Habitat: {habitat}
Tags: {tags}
Additional context: {additional_context}
{constraints}"""
5.7 Area Generation Template¶
Area generation is a multi-step process. The LLM first generates a layout (room names, connections), then individual rooms are generated in parallel.
AREA_LAYOUT_SYSTEM_PROMPT = """{identity}
{world_context}
{style_guide}
== AREA LAYOUT TASK ==
You are designing the layout of a new area. Generate a JSON object that
defines the rooms and their connections. Individual room details will be
generated separately — focus on the topology.
== OUTPUT SCHEMA ==
{{
"area_id": "string (snake_case)",
"area_name": "string",
"description": "string (1-2 sentences)",
"level_range": [min_level, max_level],
"rooms": [
{{
"id": "string (snake_case)",
"name": "string",
"brief": "string (one sentence concept)",
"difficulty": 1-10,
"tags": ["string"],
"connections": {{
"direction": "target_room_id"
}}
}}
],
"entry_points": {{
"room_id": "direction from external area"
}}
}}
== AREA LAYOUT GUIDELINES ==
1. Generate {room_count_min}-{room_count_max} rooms.
2. Ensure all rooms are reachable from at least one entry point.
3. Avoid linear chains of more than 3 rooms. Use branches and loops.
4. Difficulty should increase as players move deeper into the area.
5. Include 1-2 landmark rooms (distinctive, memorable).
6. Include at least 1 entry point connecting to an adjacent area.
7. Dead ends should contain something rewarding (loot, NPC, lore).
8. Vary room types: paths, clearings, interiors, landmarks, chokepoints."""
AREA_LAYOUT_USER_PROMPT = """Design a layout for area: "{name}"
Description/theme: {theme}
Level range: {level_range}
Room count: {room_count_min}-{room_count_max}
Connects to: {connected_areas}
Tags: {tags}
Additional context: {additional_context}"""
5.8 Quest Generation Template¶
QUEST_SYSTEM_PROMPT = """{identity}
{world_context}
{style_guide}
== OUTPUT SCHEMA ==
{json_schema}
== QUEST-SPECIFIC GUIDELINES ==
1. Quests need a clear narrative arc: hook → objectives → climax → resolution.
2. Include 2-5 stages with clear completion conditions.
3. Each stage should have description text shown to the player.
4. Rewards must be level-appropriate (see item stat guidelines).
5. Include dialogue snippets for quest giver and key NPCs.
6. Quest types: fetch, kill, escort, explore, puzzle, dialogue, craft.
7. Optional objectives add replayability.
8. Consider failure states — what happens if the player gives up?
9. Reference existing NPCs, rooms, and items via @ref syntax.
10. Include `prerequisites` if the quest requires prior completion."""
QUEST_USER_PROMPT = """Generate a quest: "{name}"
Description/theme: {theme}
Quest type: {quest_type}
Quest giver: {quest_giver}
Level range: {level_range}
Area: {area}
Tags: {tags}
Additional context: {additional_context}
{constraints}"""
5.9 Lore Generation Template¶
LORE_SYSTEM_PROMPT = """{identity}
{world_context}
{style_guide}
== LORE DOCUMENT TASK ==
Generate a lore entry that enriches the world's history and culture.
Lore entries are reference documents used by AI NPCs to ground their
knowledge and by builders to maintain consistency.
== OUTPUT SCHEMA ==
{{
"_id": "string (snake_case)",
"title": "string",
"category": "history | culture | religion | legend | geography | politics | magic | nature",
"summary": "string (2-3 sentences, used in NPC knowledge injection)",
"full_text": "string (the complete lore document, 200-500 words)",
"related_entities": ["@ref:type/name"],
"knowledge_domains": ["string (which NPCs should know this)"],
"secrecy_level": "common | uncommon | rare | secret",
"tags": ["string"]
}}
== LORE GUIDELINES ==
1. Write in-world — this is a document that exists in the game world.
2. Common lore is known by most NPCs in the region.
3. Rare/secret lore is known only by specific NPCs (listed in knowledge_domains).
4. Reference existing entities to create connections.
5. Include hooks for quests and NPC dialogue.
6. Contradictions with established lore are bugs — check world context.
7. The `summary` is injected into NPC system prompts, so keep it concise."""
LORE_USER_PROMPT = """Generate a lore entry: "{name}"
Category: {category}
Description/theme: {theme}
Related area: {area}
Secrecy level: {secrecy_level}
Tags: {tags}
Additional context: {additional_context}
{constraints}"""
5.10 Dungeon Generation Template¶
Dungeon generation combines area layout with monster placement, loot tables, puzzle rooms, and a boss encounter. It is the most complex generation type.
DUNGEON_SYSTEM_PROMPT = """{identity}
{world_context}
{style_guide}
== DUNGEON GENERATION TASK ==
Generate a complete dungeon with rooms, encounters, loot, and a boss.
This is a multi-entity generation — produce rooms, monsters, items,
and NPCs as a cohesive package.
== OUTPUT SCHEMA ==
{{
"dungeon": {{
"id": "string",
"name": "string",
"description": "string",
"level_range": [min, max],
"theme": "string",
"entry_room": "room_id"
}},
"rooms": [ ... room objects per room schema ... ],
"monsters": [ ... monster objects per monster schema ... ],
"loot": [ ... item objects per item schema ... ],
"boss": {{ ... single monster object, boss type ... }},
"lore": {{ ... optional lore entry ... }}
}}
== DUNGEON GUIDELINES ==
1. 5-15 rooms with clear progression from entrance to boss.
2. Include at least 1 puzzle/trap room.
3. Include at least 1 safe room (no monsters, possible rest point).
4. Boss room should be the deepest/most dramatic room.
5. Loot quality increases with depth.
6. Monster difficulty scales from entrance to boss.
7. Include environmental storytelling (room descriptions that tell a story).
8. At least 1 shortcut or secret passage for exploration reward.
9. The boss should have unique mechanics, not just higher stats."""
DUNGEON_USER_PROMPT = """Generate a dungeon: "{name}"
Description/theme: {theme}
Level range: {level_range}
Room count: {room_count_min}-{room_count_max}
Boss concept: {boss_concept}
Connects to: {connected_area}
Tags: {tags}
Additional context: {additional_context}"""
6. Human-in-the-Loop Workflow¶
AI content generation is inherently imperfect. The human-in-the-loop workflow ensures that every piece of generated content is reviewed before it enters the game world.
6.1 The Generate → Validate → Review → Load Cycle¶
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Generate │────→│ Validate │────→│ Preview │────→│ Edit │────→│ Load │
│ │ │ │ │ │ │ │ │ │
│ AI draft │ │ Tier 1 │ │ Human │ │ Manual │ │ Pipeline │
│ produced │ │ schema │ │ reviews │ │ fixes │ │ ingest │
└──────────┘ │ checks │ │ diff │ │ or AI │ └──────────┘
└──────────┘ │ view │ │ refine │
└──────────┘ └──────────┘
│ │
│ ┌───────────┘
▼ ▼
┌──────────┐
│Regenerate│ (optional, with feedback)
└──────────┘
6.2 Interactive Review Mode¶
When --interactive is passed, the CLI enters an interactive review session
after generation. This uses Rich's console UI for a terminal-native experience.
# packages/maid-engine/src/maid_engine/ai/workflow/interactive.py
from __future__ import annotations
import asyncio
from enum import Enum
from typing import Any
from rich.console import Console
from rich.panel import Panel
from rich.syntax import Syntax
from rich.table import Table
import yaml
class ReviewAction(Enum):
ACCEPT = "accept"
EDIT = "edit"
REGENERATE = "regenerate"
VIEW_YAML = "view_yaml"
VIEW_DIFF = "view_diff"
VALIDATE = "validate"
QUIT = "quit"
class InteractiveReviewer:
"""Terminal-based interactive review for AI-generated content."""
def __init__(self, console: Console | None = None) -> None:
self._console = console or Console()
def review(
self,
content_type: str,
yaml_content: str,
validation_errors: list[str],
validation_warnings: list[str],
generation_stats: dict[str, Any],
) -> ReviewAction:
"""Present generated content for human review."""
# Show summary panel
self._show_summary(
content_type, yaml_content, generation_stats
)
# Show validation results
if validation_errors:
self._console.print(
f"\n[red]✗ {len(validation_errors)} validation errors:[/red]"
)
for err in validation_errors:
self._console.print(f" [red]• {err}[/red]")
if validation_warnings:
self._console.print(
f"\n[yellow]⚠ {len(validation_warnings)} warnings:[/yellow]"
)
for warn in validation_warnings:
self._console.print(f" [yellow]• {warn}[/yellow]")
if not validation_errors and not validation_warnings:
self._console.print("\n[green]✓ Validation passed[/green]")
# Action prompt
return self._prompt_action()
def _show_summary(
self,
content_type: str,
yaml_content: str,
stats: dict[str, Any],
) -> None:
"""Show a condensed summary of the generated content."""
data = yaml.safe_load(yaml_content)
table = Table(title=f"Generated {content_type.title()}")
table.add_column("Field", style="cyan")
table.add_column("Value")
# Extract key fields based on content type
entities = data.get(
f"{content_type}s",
data.get("monster_templates", []),
)
if isinstance(entities, list) and entities:
entity = entities[0]
comps = entity.get("components", {})
desc = comps.get("DescriptionComponent", {})
table.add_row("Name", desc.get("name", "N/A"))
table.add_row("ID", entity.get("_id", "N/A"))
table.add_row(
"Short Desc",
desc.get("short_desc", "N/A"),
)
table.add_row(
"Components",
", ".join(comps.keys()),
)
table.add_row(
"Tags",
", ".join(entity.get("tags", [])),
)
table.add_row("Tokens", str(stats.get("tokens_used", "N/A")))
table.add_row(
"Cost",
f"${stats.get('estimated_cost', 0):.4f}",
)
table.add_row("Provider", stats.get("provider", "N/A"))
self._console.print(table)
def _prompt_action(self) -> ReviewAction:
"""Prompt for user action."""
self._console.print(
"\n[bold][A]ccept [E]dit [R]egenerate "
"[V]iew YAML [D]iff [Q]uit[/bold]"
)
choice = input("> ").strip().lower()
action_map = {
"a": ReviewAction.ACCEPT,
"e": ReviewAction.EDIT,
"r": ReviewAction.REGENERATE,
"v": ReviewAction.VIEW_YAML,
"d": ReviewAction.VIEW_DIFF,
"q": ReviewAction.QUIT,
}
return action_map.get(choice, ReviewAction.VIEW_YAML)
def show_yaml(self, yaml_content: str) -> None:
"""Display the full YAML with syntax highlighting."""
syntax = Syntax(yaml_content, "yaml", theme="monokai", line_numbers=True)
self._console.print(syntax)
def show_diff(self, original: str, modified: str) -> None:
"""Show a diff between original and modified content."""
import difflib
diff = difflib.unified_diff(
original.splitlines(keepends=True),
modified.splitlines(keepends=True),
fromfile="original",
tofile="modified",
)
diff_text = "".join(diff)
if diff_text:
syntax = Syntax(diff_text, "diff", theme="monokai")
self._console.print(syntax)
else:
self._console.print("[dim]No changes.[/dim]")
6.3 Edit Workflow¶
When the user chooses Edit, the system opens the generated YAML in the
user's configured editor ($EDITOR, defaulting to vi). After editing,
the modified content is re-validated through the Tier 1 pipeline.
async def edit_and_revalidate(
yaml_content: str,
content_type: str,
reviewer: InteractiveReviewer,
) -> tuple[str, list[str], list[str]]:
"""Open content in editor, then re-validate."""
import os
import subprocess
from pathlib import Path
editor = os.environ.get("EDITOR", "vi")
# Write to a named file in the output directory
edit_path = Path(f".maid_ai_edit_{content_type}.yaml")
edit_path.write_text(yaml_content)
try:
subprocess.run([editor, str(edit_path)], check=True)
modified = edit_path.read_text()
finally:
edit_path.unlink(missing_ok=True)
# Re-validate through Tier 1 PreparePhase
errors, warnings = await validate_through_pipeline(modified)
reviewer.show_diff(yaml_content, modified)
return modified, errors, warnings
6.4 Batch Generation with Approval Queue¶
For large batch operations (e.g., maid ai populate or maid ai generate area),
individual review of each entity is impractical. The approval queue collects
all generated entities and presents them as a batch.
class ApprovalQueue:
"""Batch approval queue for multi-entity generation."""
def __init__(self) -> None:
self._items: list[QueueItem] = []
def add(
self,
content_type: str,
name: str,
yaml_content: str,
errors: list[str],
warnings: list[str],
) -> None:
self._items.append(
QueueItem(
content_type=content_type,
name=name,
yaml_content=yaml_content,
errors=errors,
warnings=warnings,
status="pending",
)
)
def review_batch(self, console: Console) -> list[QueueItem]:
"""Present batch for review. Returns accepted items."""
table = Table(title="Generated Content Batch")
table.add_column("#", style="dim")
table.add_column("Type", style="cyan")
table.add_column("Name")
table.add_column("Status")
table.add_column("Issues")
for i, item in enumerate(self._items, 1):
status = (
"[green]✓ Valid[/green]"
if not item.errors
else f"[red]✗ {len(item.errors)} errors[/red]"
)
issues = (
f"{len(item.warnings)} warnings"
if item.warnings
else "none"
)
table.add_row(
str(i), item.content_type, item.name, status, issues
)
console.print(table)
console.print(
"\n[bold][A]ccept all valid [R]eview individually "
"[S]ave for later [Q]uit[/bold]"
)
return [
item for item in self._items if not item.errors
]
7. Copilot Agent Integration¶
PROPOSED. The validation bridge (
maid ai validate-output) and schema helper (maid ai schema) described below are part of the proposedmaid aicommand group and do not exist yet. The agent architecture description (§7.1) reflects the current state; everything from §7.2 onward is new infrastructure.
MAID already has four custom Copilot agents (lore-writer, monster-designer,
npc-creator, world-mapper) defined in .github/agents/. These agents produce
content-rich YAML, but their output is currently disconnected from the Tier 1
validation pipeline. Tier 2 provides a bridge.
7.1 Current Agent Architecture¶
┌──────────────────────────────────────────────────────────┐
│ Copilot CLI Environment │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ lore-writer │ │ npc-creator │ │ world-mapper│ ... │
│ │ │ │ │ │ │ │
│ │ Markdown │ │ Markdown │ │ Markdown │ │
│ │ agent spec │ │ agent spec │ │ agent spec │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ YAML Output (unvalidated) │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Problem: Agent output goes to files but is never validated against engine schemas. Format inconsistencies are discovered only when attempting to load.
7.2 Validation Bridge¶
Tier 2 adds a validation bridge that agents can invoke after generating content. This is exposed as a CLI command that agents can call:
# Agent calls this after generating content
maid ai validate-output <path> --type room --fix-minor
# Returns structured feedback the agent can act on
✓ 8 rooms validated
⚠ 2 warnings:
rooms[3]: missing short_desc (auto-generated from name)
rooms[7]: exit references unknown room "hidden_passage"
✗ 1 error:
rooms[5]: HealthComponent.current (150) > maximum (100)
7.3 Standardized Output Format¶
To ensure agent output is pipeline-compatible, agent specifications are updated with explicit schema constraints. The following section is added to each agent's markdown specification:
## Output Format Requirements
All YAML output MUST include:
1. A `_meta` block:
```yaml
_meta:
schema: maid:<type>:v1
generated_by: copilot-agent/<agent-name>
```
2. Entity IDs as `_id` fields (snake_case, unique within file)
3. Component payloads matching registered Pydantic models exactly:
- `DescriptionComponent`: `name`, `short_desc`, `long_desc`, `keywords`
- `NPCComponent`: `behavior_type`, `faction_id`, `spawn_point_id`,
`respawn_time`, `wander_radius`, `is_merchant`, `is_quest_giver`,
`dialogue_id`, `template_id`
- `ItemComponent` (common fields — not exhaustive): `item_type`, `quality`, `weight`,
`value` (not `base_value`), `stack_count`, `max_stack`, `durability`,
`max_durability`, `wear_slots`, `effects`, `keywords`. Additional fields
exist (`template_id`, `is_bound`, `owner_id`, `container_capacity`,
`contents`) — use `maid ai schema item` for the complete model.
- `HealthComponent`: `current`, `maximum` (where `current <= maximum`),
`regeneration_rate`
4. References using `@ref:type/name` syntax
5. After generating, validate with:
```bash
maid ai validate-output <output-file> --type <entity-type>
```
6. To get the current schema with all field names and defaults:
```bash
maid ai schema <type> --format example
```
7.4 Agent-Pipeline Integration Flow¶
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Copilot │────→│ YAML File │────→│ maid ai │
│ Agent │ │ (generated) │ │ validate- │
│ │ │ │ │ output │
│ lore-writer │ │ _meta block │ │ │
│ npc-creator │ │ _id fields │ │ Schema check │
│ world-mapper │ │ components │ │ Pydantic val │
│ monster- │ │ @ref: links │ │ Rule engine │
│ designer │ │ │ │ │
└─────────────┘ └──────────────┘ └──────┬───────┘
│
┌──────────────────────┤
│ │
┌─────▼─────┐ ┌─────▼──────┐
│ Errors │ │ Validated │
│ returned │ │ YAML │
│ to agent │ │ │
│ for fix │ │ Ready for │
└───────────┘ │ maid data │
│ load │
└────────────┘
7.5 Agent-Specific Template Injection¶
Each Copilot agent's markdown spec references canonical examples from the tutorial world content pack. Tier 2 makes this more robust by providing an agent helper command:
# Get the current schema for a content type (for agent context injection)
maid ai schema room --format yaml
maid ai schema npc --format json-schema
maid ai schema monster --examples
This lets agent specs point to a live, always-current schema rather than hard-coding examples that may drift from the actual engine requirements.
8. World-Aware Generation¶
The most powerful aspect of Tier 2 is its ability to generate content that is aware of and consistent with the existing world state.
8.1 Context Window Management¶
LLM context windows are finite. A rich world may have thousands of entities. The context manager selects the most relevant subset for injection.
# packages/maid-engine/src/maid_engine/ai/generation/context.py
from __future__ import annotations
from typing import Any
from maid_engine.ai.tokens import estimate_tokens
from maid_engine.core.world import World
class GenerationContextManager:
"""Manages world context injection for content generation.
Selects the most relevant world state for the generation task,
fitting within the provider's context window budget.
"""
# Reserve tokens for system prompt + schema + response
DEFAULT_CONTEXT_BUDGET = 2000
MAX_CONTEXT_BUDGET = 8000
def __init__(
self,
world: World | None = None,
budget_tokens: int = DEFAULT_CONTEXT_BUDGET,
) -> None:
self._world = world
self._budget = min(budget_tokens, self.MAX_CONTEXT_BUDGET)
def build_context(
self,
content_type: str,
target_area: str | None = None,
related_entities: list[str] | None = None,
) -> str:
"""Build world context string within token budget.
Prioritizes:
1. Target area rooms and NPCs (most relevant)
2. Adjacent area names and connections
3. Global world theme and settings
4. Related entity details
"""
if self._world is None:
return self._build_offline_context()
sections: list[tuple[int, str]] = [] # (priority, text)
remaining = self._budget
# Priority 1: Target area details
if target_area:
area_ctx = self._build_area_context(target_area)
tokens = estimate_tokens(area_ctx)
if tokens <= remaining:
sections.append((1, area_ctx))
remaining -= tokens
# Priority 2: Adjacent areas (names only)
if target_area and remaining > 100:
adj_ctx = self._build_adjacent_areas(target_area)
tokens = estimate_tokens(adj_ctx)
if tokens <= remaining:
sections.append((2, adj_ctx))
remaining -= tokens
# Priority 3: World overview
if remaining > 100:
world_ctx = self._build_world_overview()
tokens = estimate_tokens(world_ctx)
if tokens <= remaining:
sections.append((3, world_ctx))
remaining -= tokens
# Priority 4: Related entities
if related_entities and remaining > 100:
rel_ctx = self._build_related_context(related_entities)
tokens = estimate_tokens(rel_ctx)
if tokens <= remaining:
sections.append((4, rel_ctx))
remaining -= tokens
# Sort by priority and join
sections.sort(key=lambda x: x[0])
return "\n\n".join(text for _, text in sections)
def _build_area_context(self, area: str) -> str:
"""Build context for a specific area."""
rooms = self._get_area_rooms(area)
npcs = self._get_area_npcs(area)
items = self._get_area_items(area)
lines = [f"== Area: {area} =="]
lines.append(f"Rooms ({len(rooms)}):")
for room in rooms:
lines.append(f" - {room['id']}: {room['name']}")
exits = room.get("exits", {})
if exits:
lines.append(f" Exits: {', '.join(exits.keys())}")
if npcs:
lines.append(f"\nNPCs ({len(npcs)}):")
for npc in npcs:
lines.append(
f" - @ref:npc/{npc['id']}: {npc['name']} "
f"({npc.get('type', 'unknown')})"
)
if items:
lines.append(f"\nItems ({len(items)}):")
for item in items[:10]: # Limit to avoid context bloat
lines.append(
f" - @ref:item/{item['id']}: {item['name']}"
)
lines.append(
f"\nRoom references available (use these in exits):"
)
for room in rooms:
lines.append(f" @ref:room/{room['id']}")
return "\n".join(lines)
def _build_world_overview(self) -> str:
"""Build high-level world overview."""
areas = self._get_all_areas()
lines = ["== World Overview =="]
lines.append(f"Total areas: {len(areas)}")
for area in areas[:20]:
lines.append(
f" - {area['name']}: "
f"{area.get('description', 'No description')[:80]}"
)
return "\n".join(lines)
def _build_offline_context(self) -> str:
"""Context when no world is loaded (offline generation)."""
return (
"== No World Context Available ==\n"
"Generating in offline mode. Use placeholder references\n"
"like @ref:room/placeholder_name that can be resolved later."
)
def _build_adjacent_areas(self, area: str) -> str:
"""List areas adjacent to the target."""
# Implementation queries world for areas connected via exits
return ""
def _build_related_context(
self, entity_refs: list[str]
) -> str:
"""Build context for specifically referenced entities."""
# Implementation resolves @ref: strings and includes entity details
return ""
def _get_area_rooms(self, area: str) -> list[dict[str, Any]]:
"""Query world for rooms in an area."""
# Implementation uses World.query_entities with area tag
return []
def _get_area_npcs(self, area: str) -> list[dict[str, Any]]:
return []
def _get_area_items(self, area: str) -> list[dict[str, Any]]:
return []
def _get_all_areas(self) -> list[dict[str, Any]]:
return []
8.2 Cross-Entity Consistency¶
When generating multiple entities for the same area, the system maintains a generation session that tracks what has been created so far:
class GenerationSession:
"""Tracks state across a multi-entity generation session.
Ensures consistency: if room A has an exit to room B, room B
must exist. If an NPC references an item, the item must exist.
"""
def __init__(self) -> None:
self._generated: dict[str, dict[str, Any]] = {}
self._references: set[str] = set()
self._unresolved: set[str] = set()
def register(
self, content_type: str, entity_id: str, data: dict[str, Any]
) -> None:
"""Register a generated entity."""
key = f"{content_type}/{entity_id}"
self._generated[key] = data
self._references.add(f"@ref:{key}")
# Check if this resolves any previously unresolved refs
resolved = {
ref
for ref in self._unresolved
if ref == f"@ref:{key}"
}
self._unresolved -= resolved
def track_reference(self, ref: str) -> None:
"""Track a reference used in generated content."""
if ref not in self._references:
self._unresolved.add(ref)
@property
def unresolved_references(self) -> set[str]:
return self._unresolved.copy()
def get_context_for_next(self) -> str:
"""Build context from previously generated entities."""
lines = ["== Previously Generated (this session) =="]
for key, data in self._generated.items():
name = data.get("components", {}).get(
"DescriptionComponent", {}
).get("name", key)
lines.append(f" @ref:{key}: {name}")
return "\n".join(lines)
8.3 Reference Resolution Strategy¶
Generated content uses @ref:type/name references. These fall into three
categories:
| Reference Type | Resolution | Example |
|---|---|---|
| Existing | Resolves to a loaded entity | @ref:room/village_square |
| Session | Resolves to another generated entity | @ref:room/spider_nest (generated earlier in batch) |
| Placeholder | Flagged for manual resolution | @ref:room/future_expansion |
The pipeline handles placeholders gracefully — they become warnings (not errors) in non-strict mode, allowing content to be loaded and connected later.
8.4 Security Considerations for World-Aware Generation¶
World context injection and --context PATH files create trust boundaries that
must be handled explicitly.
Prompt injection. World state and context files are injected into LLM prompts. Malicious or corrupted content could manipulate generation. Mitigations:
- Context files are limited to
.yamland.ymlextensions - File paths are validated against
MAID_AI_GENERATION_ALLOWED_PATHS(defaults to the current working directory) - Symlink traversal is blocked (same logic as
ContentFilter.validate_profanity_file_path()) - World context is extracted as structured summaries (names, IDs, brief descriptions), not raw file content — reducing the surface for injection
PII redaction. The existing PIIRedactor from maid_engine.ai.pii is applied to:
- All world context before prompt injection
- All
--contextfile content before prompt injection - Generated output before it is displayed or saved
- AI review output (Section 3.3)
Content safety filtering. The existing ContentFilter from maid_engine.ai.safety
is applied to AI-generated content (not just dialogue):
- Generated descriptions are checked for harmful content, profanity, and real-world information leakage
- Generated NPC dialogue prompts (personality, speaking_style, secret_knowledge) are filtered before being written
- Blocked content triggers a retry with sanitized context
Audit logging. All generation operations emit AuditEntry records with:
- The requesting user (CLI user or in-game builder)
- The content type and target
- Token usage and provider
- Whether content was accepted, rejected, or modified
9. Balance Analysis Engine¶
The balance analysis engine combines rule-based heuristics (fast, deterministic, no API cost) with AI-powered analysis (nuanced, contextual, costs tokens).
9.1 Analysis Types¶
| Analysis | Rule-Based | AI-Powered |
|---|---|---|
| Combat | DPS vs HP calculations, level scaling | Encounter feel, strategy depth |
| Economy | Gold input/output flow, inflation check | Reward meaningfulness, player motivation |
| Progression | XP curves, level gating, dead ends | Pacing feel, narrative arc quality |
| Content Density | Rooms per area, NPCs per room | Variety, atmosphere, world feel |
9.2 Rule-Based Analyzers¶
These run without an LLM and provide instant, deterministic feedback.
# packages/maid-engine/src/maid_engine/ai/balance/rules.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class BalanceIssue:
"""A single balance issue found by analysis."""
severity: str # "info", "warning", "error"
category: str # "combat", "economy", "progression", "density"
message: str
entity_id: str | None = None
suggestion: str | None = None
metrics: dict[str, Any] = field(default_factory=dict)
class CombatAnalyzer:
"""Rule-based combat balance analysis."""
# Expected DPS ranges by level (solo player)
EXPECTED_PLAYER_DPS = {
(1, 5): (8, 15),
(5, 10): (14, 28),
(10, 15): (24, 42),
(15, 20): (38, 65),
}
# Expected HP pools by level (solo player)
EXPECTED_PLAYER_HP = {
(1, 5): (80, 150),
(5, 10): (130, 250),
(10, 15): (220, 400),
(15, 20): (350, 650),
}
def analyze_encounter(
self,
monsters: list[dict[str, Any]],
room: dict[str, Any],
player_level: int,
party_size: int = 1,
) -> list[BalanceIssue]:
"""Analyze a single encounter for balance issues."""
issues: list[BalanceIssue] = []
total_monster_dps = sum(
m.get("stats", {}).get("attack_power", 0)
for m in monsters
)
total_monster_hp = sum(
m.get("stats", {}).get("base_health", 0)
for m in monsters
)
player_hp_range = self._get_range(
self.EXPECTED_PLAYER_HP, player_level
)
player_dps_range = self._get_range(
self.EXPECTED_PLAYER_DPS, player_level
)
if player_hp_range and player_dps_range:
avg_player_hp = sum(player_hp_range) / 2 * party_size
avg_player_dps = sum(player_dps_range) / 2 * party_size
# Time to kill monsters
ttk_monsters = (
total_monster_hp / avg_player_dps
if avg_player_dps > 0
else float("inf")
)
# Time for monsters to kill players
ttk_players = (
avg_player_hp / total_monster_dps
if total_monster_dps > 0
else float("inf")
)
# Deadly if monsters kill player before player kills monsters
if ttk_players < ttk_monsters * 0.7:
issues.append(
BalanceIssue(
severity="error",
category="combat",
message=(
f"Deadly encounter: monsters deal "
f"{total_monster_dps} DPS vs estimated player "
f"HP {avg_player_hp:.0f}. Players die in "
f"{ttk_players:.1f}s but need {ttk_monsters:.1f}s "
f"to win."
),
entity_id=room.get("_id"),
suggestion=(
f"Reduce monster count or attack power, or "
f"add an escape route."
),
metrics={
"monster_dps": total_monster_dps,
"monster_hp": total_monster_hp,
"ttk_monsters": ttk_monsters,
"ttk_players": ttk_players,
},
)
)
elif ttk_monsters < ttk_players * 0.3:
issues.append(
BalanceIssue(
severity="warning",
category="combat",
message=(
f"Trivial encounter: players kill monsters in "
f"{ttk_monsters:.1f}s with minimal risk."
),
entity_id=room.get("_id"),
suggestion="Increase monster stats or count.",
)
)
return issues
@staticmethod
def _get_range(
table: dict[tuple[int, int], tuple[int, int]],
level: int,
) -> tuple[int, int] | None:
for (lo, hi), values in table.items():
if lo <= level <= hi:
return values
return None
class EconomyAnalyzer:
"""Rule-based economy balance analysis."""
def analyze_area_economy(
self,
area_data: dict[str, Any],
) -> list[BalanceIssue]:
"""Check gold/item flow balance in an area."""
issues: list[BalanceIssue] = []
# Calculate total gold input (loot, quest rewards)
gold_input = self._calculate_gold_input(area_data)
# Calculate total gold sinks (shops, services)
gold_sinks = self._calculate_gold_sinks(area_data)
if gold_input > 0 and gold_sinks == 0:
issues.append(
BalanceIssue(
severity="warning",
category="economy",
message=(
f"Area produces {gold_input} gold but has no gold "
f"sinks (merchants, services). May cause inflation."
),
suggestion="Add a merchant or service provider.",
)
)
return issues
def _calculate_gold_input(self, data: dict[str, Any]) -> int:
return 0 # Implementation sums loot table values
def _calculate_gold_sinks(self, data: dict[str, Any]) -> int:
return 0 # Implementation sums merchant item costs
class ProgressionAnalyzer:
"""Rule-based progression analysis."""
def analyze_level_curve(
self,
rooms: list[dict[str, Any]],
) -> list[BalanceIssue]:
"""Check that difficulty progresses smoothly."""
issues: list[BalanceIssue] = []
difficulties = []
for room in rooms:
diff = room.get("attributes", {}).get("difficulty")
if diff is not None:
difficulties.append((room.get("_id", "?"), diff))
# Check for sharp jumps (more than 3 levels between connected rooms)
# Implementation checks exit graph for difficulty spikes
for i in range(len(difficulties) - 1):
room_a, diff_a = difficulties[i]
room_b, diff_b = difficulties[i + 1]
gap = abs(diff_b - diff_a)
if gap > 3:
issues.append(
BalanceIssue(
severity="warning",
category="progression",
message=(
f"Sharp difficulty jump from {room_a} "
f"(difficulty {diff_a}) to {room_b} "
f"(difficulty {diff_b})."
),
suggestion=(
"Add a transition room or adjust difficulty."
),
)
)
return issues
class ContentDensityAnalyzer:
"""Rule-based content density analysis."""
# Recommended ranges
NPCS_PER_ROOM = (0.3, 2.0) # Average across area
ITEMS_PER_ROOM = (0.5, 3.0)
EXITS_PER_ROOM = (1.5, 4.0)
def analyze_density(
self,
rooms: list[dict[str, Any]],
npcs: list[dict[str, Any]],
items: list[dict[str, Any]],
) -> list[BalanceIssue]:
"""Check content density metrics."""
issues: list[BalanceIssue] = []
if not rooms:
return issues
room_count = len(rooms)
npc_ratio = len(npcs) / room_count
item_ratio = len(items) / room_count
total_exits = sum(
len(r.get("exits", {})) for r in rooms
)
exit_ratio = total_exits / room_count
if npc_ratio < self.NPCS_PER_ROOM[0]:
issues.append(
BalanceIssue(
severity="info",
category="density",
message=(
f"Low NPC density: {npc_ratio:.1f} NPCs/room "
f"(recommended: {self.NPCS_PER_ROOM[0]}-"
f"{self.NPCS_PER_ROOM[1]})"
),
suggestion="Consider adding NPCs for atmosphere.",
)
)
if exit_ratio < self.EXITS_PER_ROOM[0]:
issues.append(
BalanceIssue(
severity="warning",
category="density",
message=(
f"Low connectivity: {exit_ratio:.1f} exits/room. "
f"Area may feel too linear."
),
suggestion=(
"Add shortcuts, loops, or secret passages."
),
)
)
return issues
9.3 AI-Powered Analysis¶
The AI analyzer uses an LLM to evaluate subjective qualities that rules cannot capture: narrative pacing, atmospheric consistency, quest design quality, and theme coherence.
AI_BALANCE_REVIEW_PROMPT = """You are analyzing game content for balance and quality issues in a fantasy MUD.
== CONTENT TO ANALYZE ==
{content_yaml}
== RULE-BASED FINDINGS ==
{rule_findings}
== ANALYSIS REQUESTED ==
{analysis_type}
Evaluate the content and provide:
1. An overall quality score (1-5 stars)
2. Specific issues found (severity: info/warning/error)
3. Concrete suggestions for improvement
4. Things that are done well (positive reinforcement)
For combat analysis, consider:
- Is the encounter interesting (not just a stat check)?
- Are there tactical options (positioning, abilities, terrain)?
- Is there a risk/reward trade-off?
For economy analysis, consider:
- Do rewards feel meaningful for the effort required?
- Is there a reason to spend gold in this area?
- Are rare drops genuinely exciting?
For progression analysis, consider:
- Does the difficulty curve feel natural?
- Is there a sense of growing power?
- Are there satisfying milestone moments?
Output as JSON:
{{
"score": 1-5,
"issues": [
{{"severity": "...", "category": "...", "message": "...", "suggestion": "..."}}
],
"strengths": ["..."],
"summary": "..."
}}"""
10. Batch Generation Pipeline¶
PROPOSED — NOT YET IMPLEMENTED. The commands and workflows in this section (
maid ai generate-world, hierarchical generation, cost estimation) are all proposed features that depend on the Phase 1 foundation being complete.
The batch pipeline would generate entire worlds from high-level descriptions, handling the complexity of hierarchical generation, reference resolution, and cost management.
10.1 World Generation from Description¶
Proposed UX — not yet implemented.
$ maid ai generate-world "A frontier mining town called Ironvale, nestled
in mountains rich with iron and darker things. Recently, miners broke
through to ancient tunnels and awakened something. The town is divided
between those who want to seal the mines and those driven by greed." \
--level-range "1-15" \
--style gritty \
--output data/worlds/ironvale/
⠋ Planning world structure...
✓ World plan generated:
── World Plan: Ironvale ─────────────────────────────────────
Areas (5):
1. Ironvale Town (level 1-3) 8 rooms, 6 NPCs
2. The Mines (level 3-7) 12 rooms, 2 NPCs
3. Ancient Tunnels (level 7-10) 10 rooms, 1 NPC
4. Mountain Trails (level 2-5) 6 rooms, 3 NPCs
5. The Deep (level 10-15) 8 rooms, 1 boss
Quests (4):
1. "Seal the Breach" (main quest, level 5-15)
2. "Missing Miners" (side quest, level 3-5)
3. "Iron for the Smith" (repeatable, level 1-3)
4. "Voices in the Dark" (hidden quest, level 7-10)
Lore (3):
1. "History of Ironvale" (common knowledge)
2. "The Ancient Builders" (rare, found in tunnels)
3. "The Awakening" (secret, boss-related)
Estimated cost: 15,000-22,000 tokens (~$0.06-$0.09)
─────────────────────────────────────────────────────────────
[P]roceed [E]dit plan [Q]uit
>
10.2 Hierarchical Generation Strategy¶
World generation follows a top-down approach:
Phase 1: World Plan
├─ Parse description into areas, themes, level ranges
├─ Determine area count and connectivity
└─ Generate overall narrative arc
Phase 2: Area Layouts (parallel)
├─ Generate room layout per area
├─ Establish inter-area connections
└─ Assign difficulty curves
Phase 3: Room Details (parallel, batched)
├─ Generate room descriptions
├─ Add ExtendedRoomComponent details
└─ Validate exit connections
Phase 4: Population (parallel)
├─ Generate NPCs per area theme
├─ Generate monsters per area difficulty
├─ Generate items and loot tables
└─ Generate merchant inventories
Phase 5: Quests and Lore
├─ Generate quest chains using NPC/room refs
├─ Generate lore entries
└─ Connect lore to NPC knowledge domains
Phase 6: Validation and Assembly
├─ Run full Tier 1 pipeline validation
├─ Resolve all cross-references
├─ Fix any remaining issues
└─ Write final YAML files
10.3 Cost Estimation¶
Before executing a batch, the system estimates total cost using the existing
PricingConfig from observability/ai_metrics.py (loaded from data/ai_pricing.yml
or MAID_AI__PRICING_JSON). This avoids duplicating pricing data.
# packages/maid-engine/src/maid_engine/ai/generation/cost.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from maid_engine.ai.providers.base import TokenUsage
from maid_engine.observability.ai_metrics import (
PricingConfig,
calculate_cost,
get_pricing,
)
@dataclass
class CostEstimate:
"""Estimated cost for a generation operation."""
estimated_input_tokens: int
estimated_output_tokens: int
estimated_total_tokens: int
estimated_cost_usd: float
provider: str
model: str
breakdown: dict[str, Any]
def __str__(self) -> str:
return (
f"{self.estimated_total_tokens:,} tokens "
f"(~${self.estimated_cost_usd:.4f}) "
f"via {self.provider}/{self.model}"
)
# Estimated tokens per content type (input + output).
# These are empirical estimates based on prompt template sizes and
# typical LLM response lengths.
CONTENT_TYPE_ESTIMATES: dict[str, dict[str, int]] = {
"room": {"input": 800, "output": 400},
"npc": {"input": 1000, "output": 600},
"item": {"input": 600, "output": 300},
"monster": {"input": 800, "output": 500},
"area": {"input": 1500, "output": 3000}, # Multi-room
"quest": {"input": 1200, "output": 800},
"dungeon": {"input": 2000, "output": 5000}, # Multi-room + monsters
"lore": {"input": 600, "output": 400},
}
def estimate_generation_cost(
content_type: str,
count: int,
model: str,
pricing: PricingConfig | None = None,
) -> CostEstimate:
"""Estimate the cost of generating content.
Uses PricingConfig from observability/ai_metrics.py rather than
hardcoded pricing tables. Falls back to PricingConfig defaults
for unknown models.
"""
if pricing is None:
pricing = get_pricing()
type_est = CONTENT_TYPE_ESTIMATES.get(
content_type, {"input": 800, "output": 400}
)
total_input = type_est["input"] * count
total_output = type_est["output"] * count
# Use the existing calculate_cost() with a synthetic TokenUsage
usage = TokenUsage(
prompt_tokens=total_input,
completion_tokens=total_output,
)
cost = calculate_cost(usage, model, pricing)
return CostEstimate(
estimated_input_tokens=total_input,
estimated_output_tokens=total_output,
estimated_total_tokens=total_input + total_output,
estimated_cost_usd=cost,
provider="(from pricing config)",
model=model,
breakdown={
"input_tokens": total_input,
"output_tokens": total_output,
"per_entity_tokens": type_est["input"] + type_est["output"],
},
)
10.4 Parallelism and Rate Limiting¶
Batch generation uses asyncio.gather() with a semaphore for true concurrency,
respecting the existing rate limiter infrastructure:
class BatchGenerator:
"""Orchestrates parallel batch generation with rate limiting."""
def __init__(
self,
registry: LLMProviderRegistry,
max_concurrent: int = 3,
) -> None:
self._registry = registry
self._semaphore = asyncio.Semaphore(max_concurrent)
self._session = GenerationSession()
self._results: list[GenerationResult] = []
async def generate_batch(
self,
items: list[GenerationRequest],
progress_callback: Callable | None = None,
) -> list[GenerationResult]:
"""Generate multiple items with true concurrency control."""
async def _generate_with_semaphore(
index: int, item: GenerationRequest
) -> GenerationResult:
async with self._semaphore:
result = await self._generate_single(item)
if result.success:
self._session.register(
item.content_type,
result.entity_id,
result.data,
)
if progress_callback:
progress_callback(index + 1, len(items), result)
return result
# Launch all tasks — semaphore limits actual concurrency
tasks = [
_generate_with_semaphore(i, item)
for i, item in enumerate(items)
]
results = await asyncio.gather(*tasks)
return list(results)
11. In-Game AI Building Commands (Proposed)¶
These commands do not exist today. There is no
@aicommand registration file in the codebase. This section describes proposed in-game commands that would use the same generation pipeline as the CLI commands.
For builders working in-game, Tier 2 proposes @ai commands that invoke
the same generation pipeline through the game's command system.
11.1 Command Registration¶
# packages/maid-engine/src/maid_engine/ai/commands/ai_commands.py
from maid_engine.commands.registry import CommandRegistry
def register_ai_commands(registry: CommandRegistry) -> None:
"""Register @ai building commands."""
registry.register("@ai", cmd_ai_help, locks="perm(builder)")
registry.register("@ai.generate", cmd_ai_generate, locks="perm(builder)")
registry.register("@ai.describe", cmd_ai_describe, locks="perm(builder)")
registry.register("@ai.populate", cmd_ai_populate, locks="perm(builder)")
registry.register("@ai.connect", cmd_ai_connect, locks="perm(builder)")
registry.register("@ai.quest", cmd_ai_quest, locks="perm(builder)")
registry.register("@ai.review", cmd_ai_review, locks="perm(admin)")
registry.register("@ai.budget", cmd_ai_budget, locks="perm(admin)")
11.2 @ai generate¶
@ai generate <type> <name> [= description]
type: room, npc, item, monster
name: entity name
description: optional theme/description after =
Examples:
@ai generate room Dark Cellar = a damp underground storage room
@ai generate npc Guard Captain = stern veteran who patrols the walls
@ai generate item Healing Herb = a common restorative plant
@ai generate monster Cave Bat = small nocturnal creature
The generated entity is created in a staging area. Use @ai.accept or
@ai.reject to finalize or discard.
In-Game Session Example¶
> @ai generate room Abandoned Mine Shaft = a collapsed mine tunnel,
dangerous and dark, with signs of recent digging
[AI] Generating room: Abandoned Mine Shaft...
[AI] ✓ Generated in 2.1s
── Abandoned Mine Shaft ─────────────────────────────────────
A narrow mine shaft that ends in a wall of collapsed rock and splintered
timbers. Pick marks scar the walls, and a trickle of rusty water runs
along a groove in the floor. The air is thick with dust and the faint,
sweet smell of decay. Something scratches behind the rubble.
Exits: south (back to mine entrance)
Tags: room, indoor, dark, dangerous, mine
─────────────────────────────────────────────────────────────
Type @ai.accept to create, @ai.edit to modify, @ai.reject to discard.
> @ai.accept
[AI] Room "Abandoned Mine Shaft" created.
ID: abandoned_mine_shaft
Exit south linked to: mine_entrance (current room)
Live state only.
@ai.acceptcreates entities in the running world but does NOT write canonical YAML files. To persist AI-generated content as canonical source-of-truth YAML, usemaid data export(or the visual editor export) after accepting. This matches the Tier 1 and Tier 3 principle that live-world edits become canonical only through explicit export.
11.3 @ai describe¶
@ai describe [target]
target: entity name, here (current room), or self
Generates/replaces the long_desc for the target.
Examples:
@ai describe here — redescribe current room
@ai describe Old Bookshelf — describe an item in the room
@ai describe Grumpy Innkeeper — describe an NPC in the room
11.4 @ai populate¶
@ai populate [here|<area>] [types=npcs,items,monsters] [density=normal]
Generates and places entities in the current room or area.
Examples:
@ai populate here types=items
@ai populate millbrook types=npcs density=sparse
@ai populate here types=monsters
[AI] Populating current room with items...
[AI] Generated 3 items:
1. Dusty Lantern — a battered tin lantern, still functional
2. Frayed Rope — 20 feet of rope, looks like it could snap
3. Miner's Pick — a worn but serviceable mining pick
Accept all? [y/n/review]
11.5 @ai connect¶
@ai connect <direction> [= area_name or room description]
Creates a new room in the specified direction with AI-generated content.
Examples:
@ai connect north = a winding forest path
@ai connect down = a hidden cellar beneath the tavern
[AI] Creating connection north from Village Square...
[AI] ✓ New room: Winding Forest Path
Exit north created from Village Square
Exit south created from Winding Forest Path → Village Square
11.6 @ai quest¶
@ai quest <quest_giver_npc> [= quest description]
Generates a quest for an NPC, including stages, dialogue, and rewards.
Examples:
@ai quest Mirela = find rare moonpetal flowers in the forest
@ai quest Guard Captain = investigate disappearances at the mine
[AI] Generating quest for Mirela the Herbalist...
[AI] ✓ Quest: "Moonpetal Gathering"
Type: fetch
Stages: 3
Rewards: 50 gold, Moonpetal Salve (potion)
Stage 1: Talk to Mirela about moonpetals
Stage 2: Find the moonpetal grove (forest_clearing)
Stage 3: Return with 3 moonpetal flowers
Accept? [y/n/review]
11.7 @ai budget¶
@ai budget
Shows current AI token usage and remaining budget.
[AI] === AI Generation Budget ===
Today's usage: 3,847 / 50,000 tokens
Cost today: $0.015 / $0.50 limit
Requests today: 12 / unlimited
Provider: anthropic/claude-sonnet-4-20250514
Cache hit rate: 23%
11.8 @ai.accept and @ai.reject¶
After any @ai generate or @ai populate command, the generated entity is
held in a staging buffer. The builder must explicitly accept or reject it.
@ai.accept Accept the staged entity and create it in the world
@ai.reject Discard the staged entity
@ai.edit Open the staged entity for text editing before accepting
Staged entities are per-builder and per-session. They do not persist across logouts. Only one entity can be staged at a time per builder — generating a new entity while one is staged will prompt the builder to accept or reject the existing one first.
12. Quality Assurance¶
12.1 Automated Review Pipeline¶
Every piece of AI-generated content passes through an automated review pipeline before being presented to the human reviewer. This catches common LLM failure modes.
# packages/maid-engine/src/maid_engine/ai/quality/review.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class QualityIssue:
"""A quality issue found by automated review."""
checker: str
severity: str # "info", "warning", "error"
message: str
auto_fixable: bool = False
class QualityReviewPipeline:
"""Automated quality checks for AI-generated content."""
def __init__(self) -> None:
self._checkers: list[QualityChecker] = [
DescriptionQualityChecker(),
ConsistencyChecker(),
ReferenceIntegrityChecker(),
NameQualityChecker(),
StatBoundsChecker(),
DuplicateDetector(),
ToneChecker(),
]
def review(
self,
content_type: str,
data: dict[str, Any],
context: dict[str, Any] | None = None,
) -> list[QualityIssue]:
"""Run all quality checks on generated content."""
issues: list[QualityIssue] = []
for checker in self._checkers:
if checker.applies_to(content_type):
issues.extend(
checker.check(data, context or {})
)
return issues
class QualityChecker:
"""Base class for quality checkers."""
def applies_to(self, content_type: str) -> bool:
return True
def check(
self,
data: dict[str, Any],
context: dict[str, Any],
) -> list[QualityIssue]:
raise NotImplementedError
class DescriptionQualityChecker(QualityChecker):
"""Check description quality: length, sensory detail, clichés."""
CLICHES = [
"you find yourself",
"nothing special",
"an ordinary",
"a room",
"you are in",
"this is a",
"a dark and stormy",
]
MIN_LONG_DESC_WORDS = 20
MAX_LONG_DESC_WORDS = 200
MIN_SHORT_DESC_WORDS = 3
MAX_SHORT_DESC_WORDS = 15
def check(
self,
data: dict[str, Any],
context: dict[str, Any],
) -> list[QualityIssue]:
issues: list[QualityIssue] = []
desc = data.get("components", {}).get(
"DescriptionComponent", {}
)
long_desc = desc.get("long_desc", "")
short_desc = desc.get("short_desc", "")
# Length checks
word_count = len(long_desc.split())
if word_count < self.MIN_LONG_DESC_WORDS:
issues.append(
QualityIssue(
checker="description",
severity="warning",
message=(
f"Long description too short ({word_count} words, "
f"minimum {self.MIN_LONG_DESC_WORDS})"
),
)
)
elif word_count > self.MAX_LONG_DESC_WORDS:
issues.append(
QualityIssue(
checker="description",
severity="info",
message=(
f"Long description is very long ({word_count} words)"
),
)
)
# Cliché detection
lower_desc = long_desc.lower()
for cliche in self.CLICHES:
if cliche in lower_desc:
issues.append(
QualityIssue(
checker="description",
severity="info",
message=f'Cliché detected: "{cliche}"',
)
)
# Sensory detail check (at least 2 senses)
sense_keywords = {
"sight": ["see", "light", "dark", "glow", "shimmer", "shadow", "bright", "dim", "color"],
"sound": ["hear", "sound", "echo", "silence", "ring", "hum", "creak", "whisper"],
"smell": ["smell", "scent", "odor", "fragrance", "stench", "aroma", "reek"],
"touch": ["feel", "warm", "cold", "rough", "smooth", "damp", "dry", "breeze"],
"taste": ["taste", "bitter", "sweet", "sour", "salt"],
}
senses_found = sum(
1
for sense_words in sense_keywords.values()
if any(w in lower_desc for w in sense_words)
)
if senses_found < 2:
issues.append(
QualityIssue(
checker="description",
severity="info",
message=(
f"Only {senses_found} sensory detail(s) detected. "
f"Consider adding more senses (sight, sound, smell, touch)."
),
)
)
return issues
class NameQualityChecker(QualityChecker):
"""Check entity names for quality."""
def check(
self,
data: dict[str, Any],
context: dict[str, Any],
) -> list[QualityIssue]:
issues: list[QualityIssue] = []
desc = data.get("components", {}).get(
"DescriptionComponent", {}
)
name = desc.get("name", "")
if name and name == name.lower():
issues.append(
QualityIssue(
checker="name",
severity="info",
message="Name is all lowercase — consider title case.",
auto_fixable=True,
)
)
if name and len(name) > 50:
issues.append(
QualityIssue(
checker="name",
severity="warning",
message=f"Name is very long ({len(name)} chars).",
)
)
return issues
class DuplicateDetector(QualityChecker):
"""Detect potential duplicates of existing content."""
def check(
self,
data: dict[str, Any],
context: dict[str, Any],
) -> list[QualityIssue]:
issues: list[QualityIssue] = []
desc = data.get("components", {}).get(
"DescriptionComponent", {}
)
name = desc.get("name", "")
entity_id = data.get("_id", "")
existing_names = context.get("existing_names", [])
existing_ids = context.get("existing_ids", [])
if entity_id in existing_ids:
issues.append(
QualityIssue(
checker="duplicate",
severity="error",
message=f"ID '{entity_id}' already exists.",
auto_fixable=True,
)
)
# Fuzzy name match
name_lower = name.lower()
for existing in existing_names:
if name_lower == existing.lower():
issues.append(
QualityIssue(
checker="duplicate",
severity="warning",
message=(
f"Name '{name}' matches existing "
f"entity '{existing}'."
),
)
)
return issues
12.2 Style and Lore Consistency¶
For worlds with established lore, the AI reviewer checks generated content against existing lore documents:
LORE_CONSISTENCY_PROMPT = """You are a lore consistency checker for a fantasy MUD world.
== ESTABLISHED LORE ==
{existing_lore}
== NEW CONTENT TO CHECK ==
{new_content}
Check for:
1. Contradictions with established facts
2. Anachronisms or technology level mismatches
3. Geographic impossibilities
4. Character/faction behavior out of alignment
5. Naming convention mismatches (e.g., elvish names in dwarf territory)
For each issue found, provide:
- The specific contradiction
- The established fact it conflicts with
- A suggested fix
Output as JSON:
{{
"consistent": true/false,
"issues": [
{{
"type": "contradiction|anachronism|geographic|behavioral|naming",
"description": "...",
"established_fact": "...",
"suggestion": "..."
}}
]
}}"""
13. Cost & Budget Management¶
Current state: The infrastructure described in this section is entirely new. The existing
RateLimiterhandles NPC dialogue RPM/token budgets only. The existingTokenBudgetManagerallocates context-provider budgets, not generation budgets. The existingMemoryCachecaches NPC memory context sections, not generation responses. There is noAIGenerationSettingsin the current settings.However, the existing
observability/ai_metrics.pymodule providesPricingConfig,PricingEntry,calculate_cost(), andload_pricing()— Tier 2 integrates with these rather than duplicating pricing data.
13.1 Token Tracking¶
Every AI generation operation is tracked for cost accounting. Cost calculation
uses the existing PricingConfig and calculate_cost() from
maid_engine.observability.ai_metrics:
# packages/maid-engine/src/maid_engine/ai/generation/budget.py
from __future__ import annotations
import datetime
from dataclasses import dataclass, field
from typing import Any
from maid_engine.ai.providers.base import TokenUsage
from maid_engine.observability.ai_metrics import (
PricingConfig,
calculate_cost,
get_pricing,
)
@dataclass
class GenerationBudget:
"""Tracks and enforces generation budget limits.
This is NEW infrastructure — it does not extend or replace the
existing RateLimiter (which handles NPC dialogue only).
"""
daily_token_limit: int = 100_000
daily_cost_limit_usd: float = 1.00
monthly_token_limit: int = 2_000_000
monthly_cost_limit_usd: float = 20.00
# Running totals
_daily_tokens: int = 0
_daily_cost: float = 0.0
_monthly_tokens: int = 0
_monthly_cost: float = 0.0
_day_start: datetime.date = field(
default_factory=lambda: datetime.date.today()
)
_month_start: datetime.date = field(
default_factory=lambda: datetime.date.today().replace(day=1)
)
_history: list[dict[str, Any]] = field(default_factory=list)
def check_budget(self, estimated_tokens: int) -> BudgetCheckResult:
"""Check if a generation request fits within budget."""
self._maybe_reset()
if self._daily_tokens + estimated_tokens > self.daily_token_limit:
return BudgetCheckResult(
allowed=False,
reason=(
f"Daily token limit reached "
f"({self._daily_tokens:,}/{self.daily_token_limit:,})"
),
remaining_daily_tokens=(
self.daily_token_limit - self._daily_tokens
),
)
if self._monthly_tokens + estimated_tokens > self.monthly_token_limit:
return BudgetCheckResult(
allowed=False,
reason=(
f"Monthly token limit reached "
f"({self._monthly_tokens:,}/{self.monthly_token_limit:,})"
),
remaining_daily_tokens=(
self.daily_token_limit - self._daily_tokens
),
)
return BudgetCheckResult(
allowed=True,
remaining_daily_tokens=(
self.daily_token_limit - self._daily_tokens
),
)
def record_usage(
self,
usage: TokenUsage,
model: str,
content_type: str,
provider: str,
) -> None:
"""Record token usage. Cost is calculated via observability pricing."""
pricing = get_pricing()
cost_usd = calculate_cost(usage, model, pricing)
tokens = usage.prompt_tokens + usage.completion_tokens
self._daily_tokens += tokens
self._daily_cost += cost_usd
self._monthly_tokens += tokens
self._monthly_cost += cost_usd
self._history.append(
{
"timestamp": datetime.datetime.now(
tz=datetime.timezone.utc
).isoformat(),
"tokens": tokens,
"cost_usd": cost_usd,
"content_type": content_type,
"provider": provider,
"model": model,
}
)
def get_stats(self) -> dict[str, Any]:
"""Get current budget statistics."""
return {
"daily_tokens_used": self._daily_tokens,
"daily_token_limit": self.daily_token_limit,
"daily_cost_usd": self._daily_cost,
"daily_cost_limit_usd": self.daily_cost_limit_usd,
"monthly_tokens_used": self._monthly_tokens,
"monthly_token_limit": self.monthly_token_limit,
"monthly_cost_usd": self._monthly_cost,
"monthly_cost_limit_usd": self.monthly_cost_limit_usd,
"requests_today": len(
[
h
for h in self._history
if h["timestamp"].startswith(str(self._day_start))
]
),
}
def _maybe_reset(self) -> None:
"""Reset counters on day/month boundaries."""
today = datetime.date.today()
if today > self._day_start:
self._daily_tokens = 0
self._daily_cost = 0.0
self._day_start = today
if today.month != self._month_start.month:
self._monthly_tokens = 0
self._monthly_cost = 0.0
self._month_start = today.replace(day=1)
@dataclass
class BudgetCheckResult:
allowed: bool
reason: str | None = None
remaining_daily_tokens: int = 0
13.2 Response Caching¶
Identical prompts return cached responses without an API call:
# packages/maid-engine/src/maid_engine/ai/generation/cache.py
from __future__ import annotations
import hashlib
import json
import time
from pathlib import Path
from typing import Any
class GenerationCache:
"""Disk-backed cache for AI generation responses.
Caches are keyed by a hash of (content_type, prompt, schema, options).
Cache entries expire after a configurable TTL.
"""
def __init__(
self,
cache_dir: Path | None = None,
ttl_seconds: int = 86400, # 24 hours
max_entries: int = 1000,
) -> None:
self._cache_dir = cache_dir or Path(".maid_cache/ai_generation")
self._cache_dir.mkdir(parents=True, exist_ok=True)
self._ttl = ttl_seconds
self._max_entries = max_entries
def get(self, cache_key: str) -> dict[str, Any] | None:
"""Retrieve a cached response."""
path = self._cache_dir / f"{cache_key}.json"
if not path.exists():
return None
try:
data = json.loads(path.read_text())
if time.time() - data.get("timestamp", 0) > self._ttl:
path.unlink()
return None
return data.get("response")
except (json.JSONDecodeError, KeyError):
path.unlink(missing_ok=True)
return None
def put(
self,
cache_key: str,
response: dict[str, Any],
) -> None:
"""Store a response in cache."""
path = self._cache_dir / f"{cache_key}.json"
data = {
"timestamp": time.time(),
"response": response,
}
path.write_text(json.dumps(data))
@staticmethod
def make_key(
content_type: str,
prompt_hash: str,
provider: str,
model: str,
) -> str:
"""Generate a deterministic cache key."""
raw = f"{content_type}:{prompt_hash}:{provider}:{model}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def clear(self) -> int:
"""Clear all cached entries. Returns count cleared."""
count = 0
for path in self._cache_dir.glob("*.json"):
path.unlink()
count += 1
return count
def stats(self) -> dict[str, Any]:
"""Return cache statistics."""
entries = list(self._cache_dir.glob("*.json"))
total_size = sum(p.stat().st_size for p in entries)
return {
"entries": len(entries),
"total_size_bytes": total_size,
"cache_dir": str(self._cache_dir),
"ttl_seconds": self._ttl,
}
13.3 Configuration¶
AI generation budget is configured via environment variables:
# Generation budget (separate from dialogue budget)
MAID_AI_GENERATION_DAILY_TOKEN_LIMIT=100000
MAID_AI_GENERATION_DAILY_COST_LIMIT_USD=1.00
MAID_AI_GENERATION_MONTHLY_TOKEN_LIMIT=2000000
MAID_AI_GENERATION_MONTHLY_COST_LIMIT_USD=20.00
# Cache settings
MAID_AI_GENERATION_CACHE_ENABLED=true
MAID_AI_GENERATION_CACHE_TTL_SECONDS=86400
MAID_AI_GENERATION_CACHE_MAX_ENTRIES=1000
# Default generation settings
MAID_AI_GENERATION_DEFAULT_PROVIDER=anthropic
MAID_AI_GENERATION_DEFAULT_MODEL=claude-sonnet-4-20250514
MAID_AI_GENERATION_DEFAULT_TEMPERATURE=0.8
MAID_AI_GENERATION_MAX_RETRIES=2
MAID_AI_GENERATION_DEFAULT_STYLE=epic
# packages/maid-engine/src/maid_engine/config/settings.py (additions)
class AIGenerationSettings(BaseSettings):
"""Settings for AI content generation (Tier 2)."""
model_config = SettingsConfigDict(env_prefix="MAID_AI_GENERATION_")
daily_token_limit: int = 100_000
daily_cost_limit_usd: float = 1.00
monthly_token_limit: int = 2_000_000
monthly_cost_limit_usd: float = 20.00
cache_enabled: bool = True
cache_ttl_seconds: int = 86400
cache_max_entries: int = 1000
default_provider: str | None = None # Falls back to ai.default_provider
default_model: str | None = None
default_temperature: float = 0.8
max_retries: int = 2
default_style: str = "epic"
max_tokens_per_request: int = 4000
batch_max_concurrent: int = 3
14. Mix-and-Match Flexibility¶
14.1 Works With All Content Pack Styles¶
Tier 2 generates content compatible with every content pack style:
| Pack Style | How Tier 2 Helps |
|---|---|
| YAML-only pack | Generates YAML files directly loadable by the pipeline |
| Python pack | Generates YAML data that Python code references |
| Hybrid pack | Generates both YAML content and Python component stubs |
14.2 Custom Prompt Templates Per Content Pack¶
Content packs can optionally ship custom prompt templates. This uses duck-typing
(hasattr) rather than modifying the ContentPack Protocol, avoiding a breaking
change for all existing packs. This matches the approach used elsewhere in the
codebase for optional pack capabilities.
# In the generation engine:
def get_prompt_template(pack: ContentPack, content_type: str) -> str | None:
"""Get a custom prompt template from a content pack, if available.
Uses duck-typing — packs that don't define get_ai_prompt_templates()
simply use the default templates.
"""
if hasattr(pack, "get_ai_prompt_templates"):
templates = pack.get_ai_prompt_templates()
if templates and content_type in templates:
return templates[content_type]
return None
Example: A sci-fi content pack would override room templates:
class SciFiContentPack:
# ... standard ContentPack methods ...
def get_ai_prompt_templates(self) -> dict[str, str]:
"""Optional AI prompt overrides (duck-typed, not Protocol-required)."""
return {
"room": """You are designing rooms for a science fiction space
station MUD. Use technical terminology. Rooms have airlocks, conduits,
and viewports instead of doors, passages, and windows.
{standard_schema_block}
{standard_guidelines_block}""",
}
14.3 No-Provider Behavior (Realistic)¶
PROPOSED — NOT YET IMPLEMENTED. The UX shown below describes the target experience after Phase 0/1 lands and the
maid aicommand group exists. The currentMockProvideris a plain-text responder that returns a canned string; it cannot emit structured generation skeletons. A new generation-layer mock adapter would need to be built on top of it.Important caveat: The current provider registry defaults do NOT result in "no provider configured" on a typical machine. Specifically:
ollama_hostdefaults tohttp://localhost:11434— Ollama is always registered if its import succeeds, even without a running Ollama serverchatjimmy_enableddefaults totrue— ChatJimmy is always registeredMockProvideronly appears when all other providers fail to registerThis means a machine with no API keys will attempt to call the local Ollama endpoint (which fails with
ConnectErrorif Ollama isn't running) and then fall back to ChatJimmy (a remote service). The "graceful no-provider" path that reachesMockProvideris rare in practice.
The proposed maid ai generate command would handle this explicitly:
Proposed UX — not yet implemented.
$ maid ai generate room "Test Room"
⚠ Provider fallback chain:
1. anthropic — not configured (no API key)
2. ollama — connection refused (localhost:11434)
3. chatjimmy — attempting remote call...
✗ chatjimmy — request failed
No working AI provider available. Options:
1. Set MAID_AI_ANTHROPIC_API_KEY for Anthropic (recommended)
2. Set MAID_AI_OPENAI_API_KEY for OpenAI
3. Start a local model: ollama serve
4. Use --provider mock for template-based generation (no AI)
5. To prevent remote fallback: set MAID_AI_CHATJIMMY_ENABLED=false
$ maid ai generate room "Test Room" --provider mock
✓ Generated from template (no AI call)
Output is a valid skeleton — edit to add descriptions.
The MockProvider would generate valid YAML skeletons with placeholder
descriptions:
_meta:
schema: maid:room:v1
generated_by: maid-ai-mock
rooms:
- _id: test_room
components:
DescriptionComponent:
name: "Test Room"
short_desc: "a room (edit this description)"
long_desc: |
[TODO: Add room description]
This is a placeholder generated without an AI provider.
Edit this file to add atmospheric descriptions.
exits: {}
tags:
- room
- needs_description
14.4 Local LLM Support via Ollama¶
PROPOSED — NOT YET IMPLEMENTED. The examples below show the target UX after the
maid aicommand group is built. The Ollama provider itself works today for plain-text completions.
Ollama provides free, local generation with no API costs:
Proposed UX — not yet implemented.
# Start Ollama (one-time setup)
ollama pull llama3.2
ollama serve
# Configure MAID — IMPORTANT: also disable ChatJimmy to prevent
# remote fallback if you want truly local-only generation
export MAID_AI_DEFAULT_PROVIDER=ollama
export MAID_AI_CHATJIMMY_ENABLED=false
# Generate — identical interface, local execution
$ maid ai generate room "Mystic Cavern" --provider ollama
⠋ Generating room: Mystic Cavern (local/llama3.2)...
✓ Generated in 8.1s (local inference, $0.00)
Note: If you set
MAID_AI_DEFAULT_PROVIDER=ollamabut do NOT disable ChatJimmy, the registry'scomplete_with_fallback()may fall back to the remote ChatJimmy service if Ollama fails. SetMAID_AI_CHATJIMMY_ENABLED=falsefor a truly air-gapped setup.
Local models would use the proposed GenericJSONAdapter (see §4.3) with
post-hoc validation. Output quality is lower than cloud models but sufficient
for drafts that will be edited.
15. Implementation Plan¶
Phase 0: Provider API Expansion (Weeks 0-2) — PREREQUISITE¶
This phase must be completed before any Tier 2 work can begin. See Section 0 for the full description of changes required to
CompletionOptionsandCompletionResult.
| Task | Description | Dependencies |
|---|---|---|
ResponseFormat model |
type (text/json_object/json_schema), json_schema |
None |
ToolDefinition/ToolChoice |
Function-calling models for tool-use providers | None |
ToolCallResult on CompletionResult |
Capture tool_calls from provider response | None |
Expand CompletionOptions |
Add response_format, tools, tool_choice, seed fields |
Models above |
Expand CompletionResult |
Add tool_calls, typed usage with TokenUsage |
Models above |
| Anthropic adapter update | Map response_format → tool-use blocks, tools → tools |
Expanded options |
| OpenAI adapter update | Map response_format → response_format, tools → tools |
Expanded options |
| Gemini adapter update | Map response_format → generation_config.response_mime_type |
Expanded options |
| Ollama adapter update | Map response_format → format: "json" |
Expanded options |
| ChatJimmy adapter update | Ignore structured fields, return text only | Expanded options |
| MockProvider update | Return canned JSON matching requested schema | Expanded options |
| Unit tests | All providers handle new fields (or ignore gracefully) | All above |
Phase 1: Foundation (Weeks 3-5)¶
| Task | Description | Dependencies |
|---|---|---|
| Schema extractor | JSON Schema from Component Pydantic models | Component base class |
| Structured output adapters | Anthropic, OpenAI, Generic adapters | Phase 0 complete |
| Validation pipeline | 5-stage validation (Schema, ID, Ref, Component, Default) | Schema extractor |
| YAML serializer | Convert validated data → pipeline YAML | Validation pipeline |
| Retry/repair strategy | 3-tier recovery (retry, repair, fallback) | Adapters, validation |
maid ai generate room |
Single room generation, CLI command | All above |
| Unit tests | Schema extraction, validation, serialization | All above |
Phase 2: Content Types (Weeks 6-8)¶
| Task | Description | Dependencies |
|---|---|---|
| NPC generation template | Full NPC with dialogue, schedule, relationships | Phase 1 |
| Item generation template | Items with stats, variants, loot tables | Phase 1 |
| Monster generation template | Monsters with ecology, behavior, abilities | Phase 1 |
| Lore generation template | Lore documents with knowledge domains | Phase 1 |
| Few-shot example system | Extract examples from tutorial world | Template system |
| World context injection | Build context from loaded world state | World class |
maid ai generate for all types |
CLI for npc, item, monster, lore | Templates |
Phase 3: Human Workflow (Weeks 9-10)¶
| Task | Description | Dependencies |
|---|---|---|
| Interactive reviewer | Rich-based terminal review UI | Phase 2 |
| Edit workflow | $EDITOR integration, re-validation | Reviewer |
| Approval queue | Batch review for multi-entity generation | Reviewer |
| Style guides | 5 style presets (epic, gritty, whimsical, horror, pastoral) | Templates |
maid ai describe |
Description generation/improvement | Phase 2 |
maid ai review |
AI-powered content review | Phase 2 |
Phase 4: Multi-Entity (Weeks 11-13)¶
| Task | Description | Dependencies |
|---|---|---|
| Area generation | Multi-room layout + detail generation | Phase 2, 3 |
| Generation session | Cross-entity reference tracking | Area generation |
maid ai populate |
Add entities to existing areas | Area generation |
maid ai connect |
Create connections between areas | Area generation |
| Quest generation | Multi-stage quests with dialogue | NPC, room templates |
| Dungeon generation | Rooms + monsters + loot + boss | All templates |
maid ai balance |
Balance analysis (rule + AI) | Area generation |
Phase 5: Batch & Integration (Weeks 14-16)¶
| Task | Description | Dependencies |
|---|---|---|
| Batch generator | Parallel generation with rate limiting | Phase 4 |
| Cost estimator | Token/cost estimation using PricingConfig |
ai_metrics.py |
| Budget management | Daily/monthly limits, tracking, caching | Cost estimator |
| Response cache | Disk-backed cache for identical prompts | Budget |
| Copilot agent bridge | maid ai validate-output command |
Validation pipeline |
| Agent spec updates | Update agent markdown with schema requirements | Bridge |
| World generation | Full world from description | Batch, all templates |
Phase 6: In-Game & Polish (Weeks 17-19)¶
| Task | Description | Dependencies |
|---|---|---|
@ai commands |
In-game building commands | Phase 4 |
| Content pack templates | Per-pack prompt template override (duck-typed) | Template system |
| MockProvider templates | No-API skeleton generation | Serializer |
| Quality review pipeline | Automated quality checks | Phase 3 |
| Lore consistency checker | AI lore consistency review | Lore template |
| Documentation | User guide, API docs, examples | All |
| Integration tests | End-to-end pipeline tests | All |
Migration Path¶
PROPOSED. The
maid dev generatecommand exists today and is NOT deprecated. The migration below would happen only after Phase 1 ships.
Once maid ai generate is implemented, maid dev generate can be updated to
suggest the new command:
@dev_app.command("generate")
def dev_generate(...):
"""Basic AI content generation (proof-of-concept).
For pipeline-compatible output, use 'maid ai generate' instead
(requires Tier 2 AI pipeline).
"""
console.print(
"[dim]Tip: 'maid ai generate' produces pipeline-compatible "
"YAML with validation. See docs for setup.[/dim]"
)
# Existing behavior unchanged
...
16. Appendices¶
Appendix A: Complete CLI Session — Area Generation (Multi-Entity Bundle)¶
PROPOSED UX. This appendix shows the target CLI experience. "Area" here means a multi-entity bundle document, not a first-class entity type. See Section 3.2 for the terminology note.
$ maid ai generate area "Thornwood Forest" \
--zone thornwood \
--style gritty \
--level-range "3-8" \
--theme "dark forest, spider infestation, ancient ruins" \
--output data/areas/thornwood_forest.yaml \
--interactive
⠋ Building world context...
✓ World context loaded (millbrook: 12 rooms, 8 NPCs)
⠋ Phase 1/3: Generating area layout...
✓ Layout: 10 rooms, 14 exits
── Area Layout: Thornwood Forest ────────────────────────────
[forest_edge] ── north ──→ [forest_path]
│ │
east north
│ │
▼ ▼
[overgrown_trail] [dark_clearing]
│ ╱ ╲
north west east
│ ╱ ╲
▼ ▼ ▼
[hidden_grove] [spider_grove] [ancient_ruins]
│ │
north north
│ │
▼ ▼
[web_tunnels] [ruined_shrine]
│
north
│
▼
[spider_nest]
Entry: forest_edge ←→ millbrook/village_north_gate
─────────────────────────────────────────────────────────────
[A]ccept layout [R]egenerate [E]dit [Q]uit
> A
⠋ Phase 2/3: Generating room details (10 rooms)...
✓ forest_edge (1/10)
✓ forest_path (2/10)
✓ overgrown_trail (3/10)
✓ dark_clearing (4/10)
✓ hidden_grove (5/10)
✓ spider_grove (6/10)
✓ ancient_ruins (7/10)
✓ web_tunnels (8/10)
✓ ruined_shrine (9/10)
✓ spider_nest (10/10)
⠋ Phase 3/3: Validating and assembling...
✓ Tier 1 validation: 0 errors, 2 warnings
⚠ MAID-S005: Room "spider_nest" has only one exit
⚠ MAID-S005: Room "ruined_shrine" has only one exit
── Generation Summary ───────────────────────────────────────
Rooms: 10
Exits: 14 (12 bidirectional, 2 one-way)
Tokens: 4,218 (input: 2,810, output: 1,408)
Cost: $0.016
Time: 12.4s
Provider: anthropic/claude-sonnet-4-20250514
─────────────────────────────────────────────────────────────
[A]ccept [V]iew YAML [R]egenerate [E]dit [Q]uit
> V
[YAML output displayed with syntax highlighting — see Section 3.2 examples]
> A
✓ Saved to: data/areas/thornwood_forest.yaml
Next steps:
• Load into engine: maid data load data/areas/thornwood_forest.yaml
• Add NPCs/monsters: maid ai populate thornwood_forest
• Connect to world: maid ai connect millbrook thornwood_forest
• Review balance: maid ai balance data/areas/thornwood_forest.yaml
Appendix B: Configuration Reference¶
# =============================================================================
# AI Content Generation Settings (Tier 2)
# =============================================================================
# --- Provider Selection ---
MAID_AI_GENERATION_DEFAULT_PROVIDER=anthropic # anthropic|openai|ollama|chatjimmy
MAID_AI_GENERATION_DEFAULT_MODEL= # Uses provider default if unset
MAID_AI_GENERATION_DEFAULT_TEMPERATURE=0.8 # 0.0 (deterministic) - 2.0 (creative)
MAID_AI_GENERATION_DEFAULT_STYLE=epic # epic|gritty|whimsical|horror|pastoral
MAID_AI_GENERATION_MAX_RETRIES=2 # Retry on validation failure
MAID_AI_GENERATION_MAX_TOKENS_PER_REQUEST=4000 # Max output tokens per request
# --- Budget Limits ---
MAID_AI_GENERATION_DAILY_TOKEN_LIMIT=100000 # Daily token budget
MAID_AI_GENERATION_DAILY_COST_LIMIT_USD=1.00 # Daily cost cap
MAID_AI_GENERATION_MONTHLY_TOKEN_LIMIT=2000000 # Monthly token budget
MAID_AI_GENERATION_MONTHLY_COST_LIMIT_USD=20.00 # Monthly cost cap
# --- Cache ---
MAID_AI_GENERATION_CACHE_ENABLED=true # Enable response caching
MAID_AI_GENERATION_CACHE_TTL_SECONDS=86400 # Cache entry TTL (24h)
MAID_AI_GENERATION_CACHE_MAX_ENTRIES=1000 # Max cached responses
# --- Batch Generation ---
MAID_AI_GENERATION_BATCH_MAX_CONCURRENT=3 # Parallel generation limit
# --- In-Game Commands ---
MAID_AI_INGAME_ENABLED=true # Enable @ai commands
MAID_AI_INGAME_BUILDER_DAILY_LIMIT=50 # Per-builder daily limit
MAID_AI_INGAME_REQUIRE_APPROVAL=false # Require admin approval
Appendix C: Python Interface Summary¶
# --- Core Generation ---
from maid_engine.ai.structured.schema import (
ContentSchema,
build_content_schema,
extract_component_schema,
)
from maid_engine.ai.structured.adapters import (
StructuredOutputAdapter,
StructuredResult,
get_adapter,
)
from maid_engine.ai.structured.validator import (
ValidationPipeline,
)
from maid_engine.ai.structured.serializer import (
ContentSerializer,
)
from maid_engine.ai.structured.retry import (
RetryStrategy,
)
# --- Generation Workflow ---
from maid_engine.ai.generation.context import (
GenerationContextManager,
)
from maid_engine.ai.generation.session import (
GenerationSession,
)
from maid_engine.ai.generation.cost import (
CostEstimate,
estimate_generation_cost,
)
from maid_engine.ai.generation.budget import (
GenerationBudget,
)
from maid_engine.ai.generation.cache import (
GenerationCache,
)
from maid_engine.ai.generation.batch import (
BatchGenerator,
)
# --- Quality & Balance ---
from maid_engine.ai.quality.review import (
QualityReviewPipeline,
QualityIssue,
)
from maid_engine.ai.balance.rules import (
CombatAnalyzer,
EconomyAnalyzer,
ProgressionAnalyzer,
ContentDensityAnalyzer,
BalanceIssue,
)
# --- Human Workflow ---
from maid_engine.ai.workflow.interactive import (
InteractiveReviewer,
ReviewAction,
)
from maid_engine.ai.workflow.approval import (
ApprovalQueue,
)
# --- Templates ---
from maid_engine.ai.generation.templates import (
ROOM_SYSTEM_PROMPT,
NPC_SYSTEM_PROMPT,
ITEM_SYSTEM_PROMPT,
MONSTER_SYSTEM_PROMPT,
AREA_LAYOUT_SYSTEM_PROMPT,
QUEST_SYSTEM_PROMPT,
LORE_SYSTEM_PROMPT,
DUNGEON_SYSTEM_PROMPT,
STYLE_GUIDES,
)
Appendix D: Module Layout¶
packages/maid-engine/src/maid_engine/ai/
├── __init__.py # Existing
├── cache.py # Existing (NPC dialogue cache)
├── circuit_breaker.py # Existing
├── context_providers.py # Existing
├── conversation.py # Existing
├── metrics.py # Existing
├── pii.py # Existing
├── prompts.py # Existing (NPC dialogue prompts)
├── rate_limiter.py # Existing
├── registry.py # Existing
├── safety.py # Existing
├── token_budget.py # Existing
├── tokens.py # Existing
├── providers/ # Existing
│ ├── anthropic.py
│ ├── base.py
│ ├── chatjimmy.py
│ ├── gemini.py
│ ├── ollama.py
│ └── openai.py
│
├── structured/ # NEW — Structured output engine
│ ├── __init__.py
│ ├── schema.py # Schema extraction from Components
│ ├── adapters.py # Provider-specific structured output
│ ├── validator.py # Multi-stage validation pipeline
│ ├── serializer.py # YAML serialization
│ └── retry.py # Retry and repair strategies
│
├── generation/ # NEW — Content generation
│ ├── __init__.py
│ ├── templates.py # Prompt templates for all types
│ ├── context.py # World context manager
│ ├── session.py # Generation session tracker
│ ├── cost.py # Cost estimation
│ ├── budget.py # Budget tracking and enforcement
│ ├── cache.py # Generation response cache
│ └── batch.py # Batch generation orchestrator
│
├── quality/ # NEW — Quality assurance
│ ├── __init__.py
│ └── review.py # Automated quality checkers
│
├── balance/ # NEW — Balance analysis
│ ├── __init__.py
│ └── rules.py # Rule-based balance analyzers
│
├── workflow/ # NEW — Human-in-the-loop
│ ├── __init__.py
│ ├── interactive.py # Interactive review UI
│ └── approval.py # Batch approval queue
│
└── commands/ # NEW — In-game @ai commands
├── __init__.py
└── ai_commands.py # @ai command registration
packages/maid-engine/src/maid_engine/cli/
├── ai.py # NEW — maid ai CLI command group
└── app.py # Modified — register ai subcommand
Appendix E: Glossary¶
| Term | Definition |
|---|---|
| Tier 1 Pipeline | The 6-phase YAML content loader (Discover → PostLoad) |
| Tier 2 Pipeline | The AI content generation system (this document) |
| Structured Output | LLM output constrained to match a JSON Schema |
| Content Schema | JSON Schema derived from Component Pydantic models |
| Generation Session | Stateful tracker for multi-entity generation consistency |
| Provider Adapter | Abstraction over provider-specific structured output APIs |
| Few-Shot Example | Example input/output pair injected into prompts |
| World Context | Summary of existing world state injected into prompts |
| Approval Queue | Batch review workflow for multi-entity operations |
| Balance Analyzer | Rule-based or AI-powered game balance checker |
| Budget Manager | Tracks and enforces daily/monthly token spending limits |
| Generation Cache | Disk-backed cache avoiding duplicate API calls |
This document is a living design. Implementation details may evolve as Tier 1 pipeline features mature and LLM provider capabilities expand. The core principles — pipeline compatibility, human review, budget awareness — are stable.