Skip to content

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

  1. Phase 0 Prerequisite: Provider Abstraction Expansion
  2. Executive Summary
  3. Design Principles
  4. maid ai Command Group (Proposed)
  5. Structured Output Engine
  6. Content Generation Templates
  7. Human-in-the-Loop Workflow
  8. Copilot Agent Integration
  9. World-Aware Generation
  10. Balance Analysis Engine
  11. Batch Generation Pipeline
  12. In-Game AI Building Commands (Proposed)
  13. Quality Assurance
  14. Cost & Budget Management
  15. Mix-and-Match Flexibility
  16. Implementation Plan
  17. 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 generate is a proof-of-concept stub. It is NOT deprecated — it continues to work as-is. The proposed maid ai group 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., exits and zone for 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 in loader/entity_types.py:

Entity type Extra top-level fields
Room exits, zone
NPC / Item location

All AI-generated YAML in this document uses only fields from these whitelists. The exits and zone fields 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 _meta blocks with correct schema references
  • Proper _id fields and UUID generation
  • Component payloads matching Pydantic model schemas
  • @ref:type/name references 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:

  1. Extracts JSON Schema from Component Pydantic models
  2. Provides the schema to the LLM via its native structured output mechanism
  3. Parses and validates the response against the schema
  4. 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:

  1. Draft — Initial generation with full prompt context
  2. Validate — Run through Tier 1 PreparePhase validation
  3. Repair — Auto-fix common issues (missing fields, type coercion)
  4. Review — Present to human with diff annotations
  5. 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 PricingConfig and calculate_cost() from observability/ai_metrics.py
  • Daily/monthly budgets are enforced via new GenerationBudget infrastructure (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 RateLimiter and TokenBudgetManager handle 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 ai Typer command group, no packages/maid-engine/src/maid_engine/cli/ai.py, and no runtime @ai.* command registration. The existing maid dev generate is 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=true is not set and no valid admin secret is configured (the default secret is intentionally rejected). The maid ai commands must handle this by constructing only the AI-relevant settings subset (AISettings, AIGenerationSettings) without triggering the full Settings validation. 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 filtering maid_stdlib.api.admin.world
Area (Tier 2 generation) A bundle document containing multiple related entities for a zone maid ai generate area
Zone The formal world-grouping concept, managed via @zone commands, stored in the room's top-level zone field loader/entity_types.py

"Area" is not a first-class entity type. The maid ai generate area command 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).

maid ai cache stats              Show cache statistics
maid ai cache clear              Clear all cached responses

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 CompletionOptions with response_format, tools, and tool_choice fields. Until Phase 0 lands, the current MAID codebase has no structured-output adapter. The planned fallback path is GenericJSONAdapter (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 AnthropicStructuredAdapter and OpenAIStructuredAdapter require Phase 0 changes to CompletionOptions. The GenericJSONAdapter does 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 proposed maid ai command 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 .yaml and .yml extensions
  • 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 --context file 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 @ai command 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.accept creates entities in the running world but does NOT write canonical YAML files. To persist AI-generated content as canonical source-of-truth YAML, use maid 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 RateLimiter handles NPC dialogue RPM/token budgets only. The existing TokenBudgetManager allocates context-provider budgets, not generation budgets. The existing MemoryCache caches NPC memory context sections, not generation responses. There is no AIGenerationSettings in the current settings.

However, the existing observability/ai_metrics.py module provides PricingConfig, PricingEntry, calculate_cost(), and load_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 ai command group exists. The current MockProvider is 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_host defaults to http://localhost:11434 — Ollama is always registered if its import succeeds, even without a running Ollama server
  • chatjimmy_enabled defaults to true — ChatJimmy is always registered
  • MockProvider only appears when all other providers fail to register

This means a machine with no API keys will attempt to call the local Ollama endpoint (which fails with ConnectError if Ollama isn't running) and then fall back to ChatJimmy (a remote service). The "graceful no-provider" path that reaches MockProvider is 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 ai command 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=ollama but do NOT disable ChatJimmy, the registry's complete_with_fallback() may fall back to the remote ChatJimmy service if Ollama fails. Set MAID_AI_CHATJIMMY_ENABLED=false for 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 CompletionOptions and CompletionResult.

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, toolstools Expanded options
OpenAI adapter update Map response_formatresponse_format, toolstools Expanded options
Gemini adapter update Map response_formatgeneration_config.response_mime_type Expanded options
Ollama adapter update Map response_formatformat: "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 generate command 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.