"""W21B-W7 (2026-06-12) — visual_continuity_anchor LLM IO boundary.

스파이크 2개(VISUAL_ACCEPTANCE_PASS)의 anchor 추출 프롬프트 production 포팅:

  - ``extract_zoom_anchor`` — 스파이크1 (S12 zoom) ANCHOR_SYS. 같은 순간의
    wide/zoom continuity group 에서 양쪽에 동일해야 하는 시각 요소를
    evidence quote 와 함께 추출한다.
  - ``extract_prop_anchor`` — 스파이크2 (S25/S29 prop) ANCHOR_SYS 의 generic 화.
    printed/displayed content 를 가진 물리 prop 의 단일 결정판 identity 를
    원문 전체에서 통합 추출한다. "photograph" 고정 표현 제거 — prop 종류는
    입력 데이터가 결정한다. 인쇄 문구의 자구(텍스트 의미)는 고정하지 않고
    printed content 의 구조(장소+인물 구성)만 고정한다 (Codex 가이드 7).

규칙 (CLAUDE.md / 절대 규칙):
  - 입력 시나리오/씬 텍스트는 절대 자르지 않는다 — 원문 전체 전달.
  - 프롬프트에 작품 고유명사/특정 시나리오 토큰 0 — 모든 내용은 caller 가
    조립한 데이터로만 들어온다.
  - 출력 품질은 deterministic 테스트 비대상 — canary + 육안 gate.

모델 라우팅은 ``call_structured(step="visual_continuity_anchor")`` —
manifest default_model(gpt=gpt-5.5) + project_config override 를 따른다.
"""
from __future__ import annotations

import json
import logging
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)

PROVIDER_VERSION: str = "visual_continuity_anchor_v1"
# 3.x (2026-06-19): prop anchor carries_printed_content gate — PROP_ANCHOR_SYSTEM
# /SCHEMA semantics changed (default-deny printed-content classification + own-prop
# -only). Bumped so config_hash invalidates pre-fix (conflation-polluted) checkpoints.
# 4.x (2026-06-20): P8 immobilized_subject anchor added (new anchor_type + provider).
# Additive — printed_prop / zoom semantics unchanged; bump keeps config_hash drift
# detection honest when the immobilized flag is on.
PROMPT_VERSION: str = "4.202606201500"
ANCHOR_MAX_TOKENS: int = 4000

_STEP = "visual_continuity_anchor"

# ───────────────────────── schemas (strict) ─────────────────────────

_LOCKED_ELEMENT_SCHEMA = {
    "type": "object",
    "properties": {
        "kind": {
            "type": "string",
            "enum": ["subject_pose", "held_prop", "prop_state", "surface_state"],
        },
        "description": {
            "type": "string",
            "description": "one English sentence stating the locked visual fact "
                           "(position, orientation, which hand, etc.)",
        },
        "evidence_quote": {
            "type": "string",
            "description": "verbatim quote from the scene text or staging that supports it",
        },
        "confidence": {"type": "string", "enum": ["high", "medium", "low"]},
    },
    "required": ["kind", "description", "evidence_quote", "confidence"],
    "additionalProperties": False,
}

ZOOM_ANCHOR_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "locked_elements": {"type": "array", "items": _LOCKED_ELEMENT_SCHEMA},
        "wide_shot_contract": {
            "type": "array",
            "items": {"type": "string"},
            "description": "English sentences: what the WIDE shot must visibly "
                           "include so that the zoom shot is a true enlargement of it",
        },
        # W21B-W7 Cinematography 축 (2026-06-12 사용자 지시): crop 은 source 의
        # 카메라 축을 그대로 물려받으므로, 두 샷의 staging 이 같은 축의 punch-in
        # 일 때만 pixel crop 이 zoom 의 camera plan 을 실현할 수 있다.
        "camera_relation": {
            "type": "string",
            "enum": [
                "same_axis_punch_in",
                "different_camera_same_moment",
                "uncertain",
            ],
            "description": "whether the zoom frame is a sub-region of the wide "
                           "frame as staged (same camera position/axis, only "
                           "tighter) or staged from a different camera "
                           "position/angle/height",
        },
        "camera_relation_evidence": {
            "type": "string",
            "description": "verbatim quotes from the two shots' staging/"
                           "descriptions that support the camera_relation verdict",
        },
    },
    "required": [
        "locked_elements", "wide_shot_contract",
        "camera_relation", "camera_relation_evidence",
    ],
    "additionalProperties": False,
}

PROP_ANCHOR_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "carries_printed_content": {
            "type": "boolean",
            "description": "true ONLY if THIS prop is itself an object that bears its "
                           "own printed or displayed content whose composition must "
                           "stay consistent across shots (e.g. a photograph, map, "
                           "document, screen, printed sheet/sign). false for a painted/"
                           "drawn mark or symbol, an emblem, a tool, fabric, a plant, a "
                           "vessel, or any object that has no printed/displayed matter "
                           "of its own. Default to false when unsure.",
        },
        "applicability_evidence": {
            "type": "string",
            "description": "one English sentence (with a verbatim scene-text quote when "
                           "carries_printed_content is true) justifying the verdict; for "
                           "false, state briefly why the prop bears no printed content",
        },
        "printed_content": {
            "type": "string",
            "description": "when carries_printed_content is true: one or two English "
                           "sentences fully describing what is printed/displayed on THIS "
                           "prop — the place AND every person visible in it, integrated "
                           "into one coherent image. When false: empty string.",
        },
        "physical_form": {
            "type": "string",
            "description": "English: object type, real-world physical size class, "
                           "material/wear state, stains",
        },
        "scale_contract": {
            "type": "string",
            "description": "one English sentence usable in any shot prompt stating "
                           "the prop must keep its real physical size relative to "
                           "the environment",
        },
        "locked_elements": {"type": "array", "items": _LOCKED_ELEMENT_SCHEMA},
    },
    "required": ["carries_printed_content", "applicability_evidence",
                 "printed_content", "physical_form", "scale_contract", "locked_elements"],
    "additionalProperties": False,
}

# ───────────────────────── prompts (generic — no scenario tokens) ─────────────────────────

ZOOM_ANCHOR_SYSTEM = (
    "You are a film continuity supervisor. Two shots form a CONTINUITY GROUP: they depict "
    "the SAME MOMENT in the same space — one wide framing, one zoomed-in detail of that frame.\n\n"
    "From the scene text (source of truth) and the two shot descriptions/staging, extract the "
    "visual elements that MUST be identical across both shots.\n"
    "Rules:\n"
    "- Only elements grounded in the provided text (no invention). Every locked element carries "
    "a verbatim evidence quote.\n"
    "- The zoom shot's focal subject MUST exist in the wide shot's contract.\n"
    "- Keep it minimal — only elements whose mismatch would break continuity.\n\n"
    "Also judge the CAMERA RELATION between the two shots from their staging and "
    "descriptions. The question is practical: could a tighter CROP of the wide frame "
    "serve as this zoom shot without violating the zoom's ESSENTIAL staging? Essential "
    "staging means which side of the subject is seen (frontal vs profile vs back), "
    "above vs below relation, and what occupies the frame — NOT exact camera position; "
    "minor differences in distance, height or lateral position do not matter.\n"
    "- 'same_axis_punch_in': a crop of the wide frame can serve the zoom — the zoom's "
    "focal region is visible inside the wide frame from a compatible side/angle.\n"
    "- 'different_camera_same_moment': the zoom's essential staging cannot come from the "
    "wide camera's viewpoint (e.g., the zoom needs a frontal face but the wide sees a "
    "profile, or the opposite side of the subject, or looking up where the wide looks "
    "down) — the same moment seen by a genuinely different camera.\n"
    "- 'uncertain': the staging does not give enough to decide.\n"
    "Quote the staging passages that support your verdict in camera_relation_evidence."
)

PROP_ANCHOR_SYSTEM = (
    "You are a film props master. One physical prop (THIS prop, given by its canon) recurs "
    "across multiple shots. First CLASSIFY it, then — only if it qualifies — lock its "
    "displayed-content identity.\n\n"
    "STEP 1 — carries_printed_content (default false):\n"
    "Set true ONLY if THIS prop is itself an object that bears its own printed or displayed "
    "content whose composition must stay consistent (a photograph, map, document, screen, "
    "printed sheet or printed sign). Set false for a painted/drawn mark or symbol, an emblem, "
    "a tattoo, a tool, fabric, a plant, a vessel, or any object that has no printed/displayed "
    "matter of its own. When unsure, choose false. Justify in applicability_evidence.\n\n"
    "STEP 2 — if false: set printed_content, physical_form and scale_contract to empty strings "
    "and locked_elements to []. Invent nothing.\n\n"
    "STEP 2 — if true: produce the SINGLE definitive identity of THIS prop, grounded ONLY in "
    "THIS prop's own canon and its own appearances in the scene texts.\n"
    "Rules:\n"
    "- Describe ONLY this prop. NEVER borrow, merge, or describe the content of a DIFFERENT "
    "co-occurring prop, even if a similar shape, colour or word appears on another object. If "
    "the texts only describe a similar feature on a different object, this prop is NOT that "
    "object — prefer false.\n"
    "- printed_content merges ALL grounded facts about what THIS prop shows (place + people), "
    "integrated into one coherent image. No invention beyond the texts.\n"
    "- If a person shown on the prop matches one of the provided character canons, describe them "
    "consistently with that canon.\n"
    "- Do NOT fix the literal wording of any text visible on the prop — fix only the "
    "STRUCTURE of the printed content (the place and the people composition). Written characters "
    "are allowed to vary between renders.\n"
    "- Every locked element carries a verbatim evidence quote from the scene texts."
)


# ───────────────────────── P8 immobilized_subject schema/prompt ─────────────────────────

IMMOBILIZED_SUBJECT_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "shared_state_contract": {
            "type": "string",
            "description": "one or two English sentences stating the ONE physical "
                           "configuration of the immobilized subject that every shot "
                           "must keep consistent: body posture/orientation, where the "
                           "body lies/sits, which way the head/limbs point, and the "
                           "state of any held or directly-adjacent object. Refer to the "
                           "subject only structurally (e.g. 'the immobilized subject', "
                           "'the body') and by screen position — NEVER by a proper name "
                           "or entity ID.",
        },
        "locked_elements": {"type": "array", "items": _LOCKED_ELEMENT_SCHEMA},
        "per_shot_visible_focus": {
            "type": "array",
            "description": "for EACH member shot, what part of the immobilized subject "
                           "(and its held/adjacent props) is actually inside that shot's "
                           "frame — so a tight insert is not forced to show the whole "
                           "body. Whatever is off-frame still obeys shared_state_contract.",
            "items": {
                "type": "object",
                "properties": {
                    "shot_index": {"type": "integer"},
                    "visible_focus": {
                        "type": "string",
                        "description": "English: which portion of the subject/props is "
                                       "visible in this shot's framing",
                    },
                },
                "required": ["shot_index", "visible_focus"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["shared_state_contract", "locked_elements", "per_shot_visible_focus"],
    "additionalProperties": False,
}

IMMOBILIZED_SUBJECT_SYSTEM = (
    "You are a film continuity supervisor. One character is in an IMMOBILIZED state "
    "(dead, unconscious, or severely injured — they cannot move on their own) and "
    "appears across several shots of the SAME scene. Because the subject cannot move, "
    "its body posture, position, orientation and the state of anything it holds or that "
    "lies against it MUST stay identical from shot to shot — only the camera framing "
    "changes (a later shot may crop in to a hand or a detail).\n\n"
    "From the scene text (source of truth) and the shot descriptions/staging (each shot "
    "carries its own independently-written body_pose, which is exactly what drifts), "
    "produce the SINGLE shared physical configuration of this subject.\n"
    "Rules:\n"
    "- shared_state_contract: the one configuration all shots share. Ground it ONLY in "
    "the provided text — no invention.\n"
    "- Refer to the subject structurally ('the immobilized subject', 'the body', 'the "
    "left hand') and by screen position. NEVER use a proper name or an entity ID token "
    "(like C01) in any output field — the renderer identifies the subject from its "
    "reference image, not from a name.\n"
    "- locked_elements: the minimal set of visual facts whose mismatch would break "
    "continuity (posture, which hand, held/adjacent prop state, surface). Every locked "
    "element carries a verbatim evidence quote from the scene text or staging.\n"
    "- Do NOT lock camera, framing, lens or composition — those are free per shot. Lock "
    "only the subject's physical state.\n"
    "- per_shot_visible_focus: for each member shot, state which part of the subject is "
    "actually in frame for that shot's framing, so a tight insert is not forced to "
    "render the whole body.\n"
    "- If prior continuity evidence from a related zoom group is given, treat it as a "
    "hint only — the scene text remains the source of truth."
)


def build_immobilized_subject_user_prompt(
    scene_text: str,
    subject_canon: Dict[str, Any],
    member_shots: List[Tuple[int, int, str, str, str]],
    prior_zoom_locked: Optional[List[Dict[str, Any]]] = None,
) -> str:
    """scene_text: 원문 전체(자르지 않음). subject_canon: {short_id, name} (식별용 —
    출력엔 이름 금지). member_shots: [(scene_index, shot_index, description,
    body_pose, framing_scale)]. prior_zoom_locked: 같은 순간 zoom 그룹 locked_elements
    (참고자료, SOT 아님)."""
    parts = ["SCENE TEXT (full):\n" + scene_text]
    parts.append(
        "\nIMMOBILIZED SUBJECT (identity for your understanding only — do NOT name it "
        "in output):\n"
        + json.dumps(
            {k: subject_canon.get(k) for k in ("short_id", "name")},
            ensure_ascii=False,
        )
    )
    parts.append("\nSHOTS WHERE THIS SUBJECT IS IMMOBILIZED:")
    for si, shi, desc, body_pose, framing in member_shots:
        parts.append(
            f"- scene {si} shot {shi} [framing={framing or 'n/a'}]:\n"
            f"    description: {desc or ''}\n"
            f"    this shot's written body_pose (may drift — reconcile into ONE contract): "
            f"{body_pose or 'n/a'}"
        )
    if prior_zoom_locked:
        parts.append(
            "\nPRIOR CONTINUITY EVIDENCE from a related zoom group (HINT only, not "
            "authoritative):\n" + json.dumps(prior_zoom_locked, ensure_ascii=False)
        )
    return "\n".join(parts)


def extract_immobilized_subject_anchor(
    scene_text: str,
    subject_canon: Dict[str, Any],
    member_shots: List[Tuple[int, int, str, str, str]],
    prior_zoom_locked: Optional[List[Dict[str, Any]]] = None,
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict[str, Any]:
    from app.modules.llm.llm_client import call_structured

    return call_structured(
        step=_STEP,
        system_prompt=IMMOBILIZED_SUBJECT_SYSTEM,
        user_prompt=build_immobilized_subject_user_prompt(
            scene_text, subject_canon, member_shots, prior_zoom_locked
        ),
        response_schema=IMMOBILIZED_SUBJECT_SCHEMA,
        project_config=project_config,
        schema_name="immobilized_subject_anchor",
        opik_metadata=opik_metadata,
        max_tokens=ANCHOR_MAX_TOKENS,
    )


REVISED_WIDE_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "revised_prompts": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "variation_index": {"type": "integer"},
                    "revised_prompt": {"type": "string"},
                },
                "required": ["variation_index", "revised_prompt"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["revised_prompts"],
    "additionalProperties": False,
}

# W-C1 (Codex W_C_NARROW_REVIEW 안전안): 스파이크1 ② REWRITE_SYS 포팅 + contract
# guard — 새 entity ID 토큰 / 새 reference-image 요구 추가 금지 (ref-contract
# SOT 는 scene_detail required_refs 불변; revised 는 text-only 로 계약 표현).
# 2026-06-12 S29 fix ②: 인물은 ID 토큰뿐 아니라 이름/평문 묘사로도 추가 금지 —
# 원본에 없는 인물은 ref 가 부착되지 않아 모델이 얼굴을 발명한다(외국인 현상).
REWRITE_WIDE_SYSTEM = (
    "You revise T2I prompts for the WIDE shot of a zoom-continuity group. Keep each "
    "original prompt's camera position, framing, mood and environment description AS-IS. "
    "Only weave in the wide_shot_contract elements and locked_elements so every locked "
    "visual fact is explicitly visible in the wide frame. One moment, one space.\n"
    "Hard rules:\n"
    "- Do NOT add new characters, events or locations.\n"
    "- Do NOT add any person who is not already present in the original prompt — not by "
    "entity ID, not by name, not by plain-text description. A person added without their "
    "reference image gets an invented face. If a locked element or contract item could "
    "only be satisfied by adding such a person, leave that element out and keep the "
    "original framing instead.\n"
    "- Do NOT introduce any entity ID tokens (like C01, P02, L03) that are not already "
    "present in the original prompt, and do NOT add new requirements to use reference "
    "images — express the locked facts in plain text only.\n"
    "- Revise EVERY prompt given, keeping its variation_index."
)

# Cinematography 축 (2026-06-12): camera_relation 이 punch-in 이 아닌 zoom 멤버는
# crop 하지 않고 자기 camera plan(원본 t2i)을 유지한 채 locked facts 만 weave.
REWRITE_ZOOM_SYSTEM = (
    "You revise T2I prompts for the ZOOM shot of a zoom-continuity group. This shot is "
    "staged from its own camera position/angle (NOT a pixel crop of the wide shot), so "
    "keep each original prompt's camera position, angle, height, framing, composition, "
    "lighting and mood AS-IS — they are this shot's camera plan. Only weave in the "
    "locked_elements so every locked visual fact (poses, held props, prop/surface "
    "states, costumes) matches the wide shot of the same moment. One moment, one space.\n"
    "Hard rules:\n"
    "- Do NOT add new characters, events or locations.\n"
    "- Do NOT add any person who is not already present in the original prompt — not by "
    "entity ID, not by name, not by plain-text description. If a locked element could "
    "only be satisfied by adding such a person, leave that element out.\n"
    "- Do NOT introduce any entity ID tokens (like C01, P02, L03) that are not already "
    "present in the original prompt, and do NOT add new requirements to use reference "
    "images — express the locked facts in plain text only.\n"
    "- Revise EVERY prompt given, keeping its variation_index."
)


def build_rewrite_wide_user_prompt(
    variations: List[Tuple[int, str]],
    anchor: Dict[str, Any],
) -> str:
    parts = ["ORIGINAL WIDE T2I PROMPTS:"]
    for idx, prompt in variations:
        parts.append(f"[variation_index {idx}]\n{prompt}")
    parts.append("\nANCHOR:\n" + json.dumps(anchor, ensure_ascii=False))
    return "\n".join(parts)


def _rewrite_prompts(
    system_prompt: str,
    schema_name: str,
    variations: List[Tuple[int, str]],
    anchor: Dict[str, Any],
    project_config: Optional[Dict],
    opik_metadata: Optional[Dict],
    step: str,
) -> Dict[int, str]:
    from app.modules.llm.llm_client import call_structured

    result = call_structured(
        step=step,
        system_prompt=system_prompt,
        user_prompt=build_rewrite_wide_user_prompt(variations, anchor),
        response_schema=REVISED_WIDE_SCHEMA,
        project_config=project_config,
        schema_name=schema_name,
        opik_metadata=opik_metadata,
        max_tokens=ANCHOR_MAX_TOKENS,
    )
    return {
        int(r["variation_index"]): r["revised_prompt"]
        for r in result.get("revised_prompts", [])
        if isinstance(r, dict) and isinstance(r.get("revised_prompt"), str)
    }


def rewrite_wide_prompts(
    variations: List[Tuple[int, str]],
    anchor: Dict[str, Any],
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
    *,
    step: str = "zoom_continuity_anchor",
) -> Dict[int, str]:
    """wide 멤버의 t2i variations 일괄 재작문 — {variation_index: revised}."""
    return _rewrite_prompts(
        REWRITE_WIDE_SYSTEM, "revised_wide_prompts",
        variations, anchor, project_config, opik_metadata, step,
    )


def rewrite_zoom_prompts(
    variations: List[Tuple[int, str]],
    anchor: Dict[str, Any],
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
    *,
    step: str = "zoom_continuity_anchor",
) -> Dict[int, str]:
    """different_camera zoom 멤버의 t2i variations 재작문 — camera plan 보존 +
    locked facts weave (Cinematography 축). {variation_index: revised}."""
    return _rewrite_prompts(
        REWRITE_ZOOM_SYSTEM, "revised_zoom_prompts",
        variations, anchor, project_config, opik_metadata, step,
    )


# ───────────────────────── user prompt assembly ─────────────────────────


def build_zoom_anchor_user_prompt(
    scene_text: str,
    wide: Dict[str, Any],
    zoom: Dict[str, Any],
) -> str:
    """wide/zoom: {description, staging, t2i_prompt(optional)} — 원문/입력 전체 전달."""
    parts = ["SCENE TEXT (full):\n" + scene_text]
    for label, shot in (("WIDE", wide), ("ZOOM", zoom)):
        parts.append(f"\n{label} SHOT description: " + (shot.get("description") or ""))
        if shot.get("staging") is not None:
            parts.append(f"{label} SHOT staging: "
                         + json.dumps(shot["staging"], ensure_ascii=False))
        if shot.get("t2i_prompt"):
            parts.append(f"{label} SHOT current t2i prompt:\n" + shot["t2i_prompt"])
    return "\n".join(parts)


def build_prop_anchor_user_prompt(
    scene_texts: List[Tuple[int, str]],
    prop_canon: Dict[str, Any],
    character_canons: List[Dict[str, Any]],
    member_shot_descriptions: List[Tuple[int, int, str]],
) -> str:
    """scene_texts: [(scene_index, 원문 전체)]. canon 은 {short_id, name, t2i_prompt}."""
    parts: List[str] = []
    for si, text in scene_texts:
        parts.append(f"SCENE {si} TEXT (full):\n{text}\n")
    parts.append(
        "CURRENT PROP CANON (may be incomplete):\n"
        + json.dumps(
            {k: prop_canon.get(k) for k in ("short_id", "name", "t2i_prompt")},
            ensure_ascii=False,
        )
    )
    if character_canons:
        parts.append(
            "\nCHARACTER CANONS (use for consistent identity of any person shown on the prop):\n"
            + json.dumps(
                [
                    {k: c.get(k) for k in ("short_id", "name", "t2i_prompt")}
                    for c in character_canons
                ],
                ensure_ascii=False,
            )
        )
    if member_shot_descriptions:
        parts.append("\nSHOTS WHERE THE PROP APPEARS:")
        for si, shi, desc in member_shot_descriptions:
            parts.append(f"- scene {si} shot {shi}: {desc}")
    return "\n".join(parts)


# ───────────────────────── LLM calls ─────────────────────────


def extract_zoom_anchor(
    scene_text: str,
    wide: Dict[str, Any],
    zoom: Dict[str, Any],
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
    *,
    step: str = "zoom_continuity_anchor",
) -> Dict[str, Any]:
    from app.modules.llm.llm_client import call_structured

    return call_structured(
        step=step,
        system_prompt=ZOOM_ANCHOR_SYSTEM,
        user_prompt=build_zoom_anchor_user_prompt(scene_text, wide, zoom),
        response_schema=ZOOM_ANCHOR_SCHEMA,
        project_config=project_config,
        schema_name="zoom_anchor",
        opik_metadata=opik_metadata,
        max_tokens=ANCHOR_MAX_TOKENS,
    )


def extract_prop_anchor(
    scene_texts: List[Tuple[int, str]],
    prop_canon: Dict[str, Any],
    character_canons: List[Dict[str, Any]],
    member_shot_descriptions: List[Tuple[int, int, str]],
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict[str, Any]:
    from app.modules.llm.llm_client import call_structured

    return call_structured(
        step=_STEP,
        system_prompt=PROP_ANCHOR_SYSTEM,
        user_prompt=build_prop_anchor_user_prompt(
            scene_texts, prop_canon, character_canons, member_shot_descriptions
        ),
        response_schema=PROP_ANCHOR_SCHEMA,
        project_config=project_config,
        schema_name="prop_anchor",
        opik_metadata=opik_metadata,
        max_tokens=ANCHOR_MAX_TOKENS,
    )
