# Prompt engine — shot/storyboard data → model-ready prompt strings (CP-3 SSOT).
# Capability state/canonical live in ssot_manifest.yaml (prompt_building,
# state: active, canonical recoil/pipeline/_lib/prompt_engine.py — "One prompt
# engine, all builders via get_builder(). Do not create parallel builders.").
#
# THE registry is BUILDERS: dict[(model_id, modality) -> builder fn] resolved by
# get_builder(); there is NO literal "directed_prose / start_end_frame" strategy
# object in this module (that REC-72 framing is realized structurally):
#   - directed-prose  = the per-model builders (seedance/kling/wan/veo/gpt-image-2).
#   - cede-control / start_end_frame = the has_end_frame=True In-Between transition
#     mode threaded through build_wan_i2v_prompt (BUILDERS-less "wan_between" slot in
#     compile_all_prompts), NOT a registered key.
# Two length guards diverge (prompt-side truncate vs Flora hard-fail) — see
# divergence prompt_length_split. Identity/name binding is enforced by the
# bind_named_prose / _assert_bound_prompt junction (modality-forked invariant).
subsystem: prompt_engine

# Capability node 'prompt_building' is declared canonically in dispatch_payload.topo.yaml;
# this file only FK-references it by string (entrypoint.capabilities / route.capability /
# divergence.capability / flag.forks_capability). State lives in ssot_manifest.yaml.

entrypoints:
  - id: prompt_engine_smoke_cli
    file: recoil/pipeline/_lib/prompt_engine.py
    symbol: __main__
    invoke: "python -m recoil.pipeline._lib.prompt_engine"
    kind: cli
    summary: >
      __main__ smoke harness: loads RECOIL_ROOT/DEFAULT_PROJECT storyboard_ep_001.json
      + project_config.json + breakdown.json and prints compiled prompts. Dev smoke
      only — production callers import get_builder()/compile_all_prompts() directly
      (EpisodeRunner → build_dispatch_payload → builders). Not the live dispatch path.
    capabilities: [prompt_building]
    reduced_tier: never
    risk_level: low

routes:
  # Each route is a NAMED traversal into prompt_building via get_builder((model,modality)).
  # resolves_via cites the concrete builder the BUILDERS table dispatches to.
  - id: seedance_i2v_route
    capability: prompt_building
    surface: video
    entry: recoil/pipeline/_lib/prompt_engine.py::get_builder
    resolves_via: recoil/pipeline/_lib/prompt_engine.py::build_seeddance_i2v_prompt
    ref_kind: composite_sheet
    gated_by: null
    fallback_route: null
    risk_level: high

  - id: seedance_r2v_multi_route
    capability: prompt_building
    surface: r2v
    entry: recoil/pipeline/_lib/prompt_engine.py::get_builder
    resolves_via: recoil/pipeline/_lib/prompt_engine.py::build_seeddance_r2v_prompt_multi
    ref_kind: composite_sheet
    gated_by: null
    fallback_route: null
    risk_level: high

  - id: kling_i2v_route
    capability: prompt_building
    surface: video
    entry: recoil/pipeline/_lib/prompt_engine.py::get_builder
    resolves_via: recoil/pipeline/_lib/prompt_engine.py::build_kling_i2v_prompt
    ref_kind: composite_sheet
    gated_by: null
    fallback_route: null
    risk_level: medium

  - id: kling_t2v_route
    capability: prompt_building
    surface: video
    entry: recoil/pipeline/_lib/prompt_engine.py::get_builder
    resolves_via: recoil/pipeline/_lib/prompt_engine.py::build_kling_t2v_prompt
    ref_kind: individual
    gated_by: null
    fallback_route: null
    risk_level: medium

  - id: wan_inbetween_route
    capability: prompt_building
    surface: f2v
    entry: recoil/pipeline/_lib/prompt_engine.py::compile_all_prompts
    resolves_via: recoil/pipeline/_lib/prompt_engine.py::build_wan_i2v_prompt
    ref_kind: turn_view            # cede-control: has_end_frame=True transition mode
    gated_by: has_end_frame
    fallback_route: null
    risk_level: medium

  - id: gpt_image_2_storyboard_route
    capability: prompt_building
    surface: storyboard
    entry: recoil/pipeline/_lib/prompt_engine.py::get_builder
    resolves_via: recoil/pipeline/_lib/prompt_engine.py::build_storyboard_strip_prompt
    ref_kind: individual           # _indexed_ref_entries → "REFERENCE MAPPING:" block
    gated_by: null
    fallback_route: null
    risk_level: medium

  - id: previz_keyframe_route
    capability: prompt_building
    surface: previz
    entry: recoil/pipeline/_lib/prompt_engine.py::get_builder
    resolves_via: recoil/pipeline/_lib/prompt_engine.py::build_previs_prompt
    ref_kind: individual           # nbp/flash/krea-2/gemini-image: keyframe+previz both map here
    gated_by: null
    fallback_route: null
    risk_level: low

loops:
  - id: flash_enrichment_lockterm_retry
    kind: retry
    capability: prompt_building
    trigger: "enrich_prompt: Flash output dropped a locked_term (missing != [])"
    driver: recoil/pipeline/_lib/prompt_engine.py::enrich_prompt
    bound:
      max: 1                       # single retry at temperature=0.0, then give up
      source: "hardcoded second _call_flash_enrichment(temp=0.0) in enrich_prompt"
    exit:
      - "all locked_terms present → return (enriched, 'v{version}')"
      - "retry still missing terms → return (base_prompt, 'fallback_validation')"
      - "Flash returns empty → return (base_prompt, 'fallback_error'/'fallback_retry_error')"
    retries_into: previz_keyframe_route
    reads_artifact: flash_system_prompt_files
    risk_level: medium

flags:
  - id: skip_flash_enrichment
    defined: recoil/pipeline/_lib/prompt_engine.py::enrich_prompt
    default: false                 # project_config.skip_flash_enrichment:true → bypass
    forks_capability: prompt_building
    gates_routes: []
    summary: >
      enrich_prompt early-returns the raw builder prompt (tag 'bypass_config') when
      project_config.skip_flash_enrichment is set, OR (tag 'bypass_env') when is_env=True
      — Flash hallucinates humans into empty rooms, so ENV shots never enrich.

  - id: has_end_frame
    defined: recoil/pipeline/_lib/prompt_engine.py::build_wan_i2v_prompt
    default: false                 # compile_all_prompts wires wan_i2v (False) + wan_between (True)
    forks_capability: prompt_building
    gates_routes: [wan_inbetween_route]
    summary: >
      has_end_frame=True switches build_wan_i2v_prompt to In-Between transition prose
      (start→end interpolation, cede-control); False is standard single-anchor i2v.

artifacts:
  - id: prompt_bible
    path: recoil/**/prompt_bible*.{json,yaml}   # loaded via load_bible()/get_prompt_rules()/get_optimal_word_range()
    written_by: []
    read_by:
      - prompt_engine_smoke_cli
      - seedance_i2v_route
      - kling_i2v_route
    schema: "per-model {optimal_word_range, max_chars, shared_lexicon, formatter_limits}"
    reduced_tier: auto
    risk_level: medium

  - id: flash_system_prompt_files
    path: "flash_base_instructions.txt + flash_to_{model_target}_v{ver}.txt"  # load_prompt_file()
    written_by: []
    read_by:
      - flash_enrichment_lockterm_retry
    schema: "free-text system-prompt templates for Flash enrichment per target model"
    reduced_tier: never
    risk_level: low

divergences:
  - id: builder_dispatch_table
    capability: prompt_building
    forks:
      - seedance_i2v_route
      - kling_i2v_route
      - gpt_image_2_storyboard_route
      - previz_keyframe_route
    on_flag: null
    invariant: >
      EVERY production prompt MUST resolve through get_builder((model_id, modality)) against
      the single BUILDERS table (prompt_engine.py ~L7956-8042) — no parallel builder module,
      no direct-import that bypasses the (model,modality) key. get_builder raises KeyError
      (never silently substitutes) on an unregistered key. New (model,modality) pairs are
      added ONLY by appending to BUILDERS, kept in sync with provider_strategy.json (asserted
      by test_keys_match_provider_strategy_snapshot).
    risk: >
      The table aliases distinct models onto shared builders (happy-horse-* → build_wan_*,
      gpt-image-2 → build_previs_prompt) whose token syntax differs (HappyHorse uses
      character1/character2 positional, not @Image1). An engineer adding a model by reusing a
      builder slot can ship a syntactically-wrong prompt that passes get_builder and only fails
      at the provider. The build_seeddance_i2v_multishot_prompt key is intentionally OMITTED —
      a tools/ import references a non-existent symbol (single-d "seedance" typo).
    reduced_tier: always
    risk_level: high

  - id: prompt_length_split
    capability: prompt_building
    forks:
      - seedance_i2v_route
      - kling_i2v_route
    on_flag: null
    invariant: >
      Prompt-side _enforce_prompt_length (prompt_engine.py L443-495) TRUNCATES at the bible's
      per-model max_chars (last sentence boundary); the Flora video adapter
      (recoil/execution/providers/flora.py L489-498, _FLORA_MAX_PROMPT_CHARS=2500) RAISES
      ValueError on any video prompt >2500 chars rather than truncating. These two guards MUST
      stay reconciled: if a model's bible max_chars is unset or >2500, prompt_engine emits an
      over-length prompt that flora.py hard-fails. The seedance builders must keep prompts under
      2500 organically (boards+sheets carry the visual), never rely on back-truncation.
    risk: >
      REC-123: Flora's preprocessing silently REPLACES any >2500-char prompt with '. _ .'
      (blankPrompt) and bills the run with no prompt. flora.py fails loud to prevent the silent
      burn — but if _enforce_prompt_length truncates a seedance prompt to a bible max_chars that
      is LARGER than 2500, the run reaches flora.py over-length and aborts; if smaller, the cinema
      tail is lopped. The two limits live in different files and can drift apart unnoticed.
    reduced_tier: always
    risk_level: high

  - id: name_binding_modality_fork
    capability: prompt_building
    forks:
      - seedance_r2v_multi_route
      - seedance_i2v_route
    on_flag: null
    invariant: >
      bind_named_prose / _assert_bound_prompt (prompt_engine.py L5399-5530) forks by modality:
      'r2v_multi' bound prose MUST carry @ImageN tokens, every one backed by the ref manifest;
      'video_i2v' bound prose MUST NOT contain ANY @ImageN token (raises BindAssertionError). No
      bound prompt of either modality may leak a configured character proper noun, and beat count
      MUST equal len(timing_segments).
    risk: >
      A2 content-policy leak: single-shot i2v/r2v had never run through the name-strip ("Option C")
      path. Mixing the two contracts — emitting @ImageN tokens on the i2v path, or leaving a
      character name un-stripped on either — fails the bind assertion at build time, but a builder
      change that routes i2v prose through the r2v binder would inject illegal @ImageN tokens that
      the provider mis-maps.
    reduced_tier: always
    risk_level: high

symbols:
  - id: get_builder_junction
    file: recoil/pipeline/_lib/prompt_engine.py
    symbol: get_builder
    role: >
      THE single resolution point for prompt_building — (model_id, modality) → builder via the
      BUILDERS table. Every production prompt traverses here; the SSOT chokepoint that forbids
      parallel builders. Raises KeyError (never silent) on an unregistered key.
    reduced_tier: always
    risk_level: high

  - id: enforce_prompt_length_junction
    file: recoil/pipeline/_lib/prompt_engine.py
    symbol: _enforce_prompt_length
    role: >
      The prompt-side length guard called as the LAST step of nearly every builder. Reads bible
      max_chars + optimal_word_range; truncates at sentence boundary. Counterpart to flora.py's
      2500-char hard-fail (see divergence prompt_length_split).
    reduced_tier: always
    risk_level: high

  - id: assert_bound_prompt_junction
    file: recoil/pipeline/_lib/prompt_engine.py
    symbol: _assert_bound_prompt
    role: >
      Post-bind invariant gate for named prose — enforces the modality-forked @ImageN contract,
      no-character-name-leak, and beat==timing_segments. Raises BindAssertionError. The junction
      that keeps r2v_multi and i2v prose from cross-contaminating (see name_binding_modality_fork).
    reduced_tier: always
    risk_level: high

  - id: indexed_ref_entries_junction
    file: recoil/pipeline/_lib/prompt_engine.py
    symbol: _indexed_ref_entries
    role: >
      Normalizes ref_layout sublocation_refs/prop_refs (dict OR list) into index-sorted entries
      that build_storyboard_strip_prompt / build_gpt_image_2_storyboard_finish_prompt render into
      the "REFERENCE MAPPING:" block — the bridge from resolved ref spans to prompt @ImageN text.
    reduced_tier: auto
    risk_level: medium
