Ctrl K
ComunidadGuías…

src/commands/round_llm.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""回合中的 LLM 调用、重试与回复解析。"""

from __future__ import annotations

import logging
import time
from typing import Any

from src.commands.narrative_application import apply_parsed_data_to_response as _apply_parsed
from src.commands.round_helpers import should_multi_step, validate_dice_constraint
from src.commands.protocol_repair import append_protocol_repair_instruction
from src.commands.tag_json import safe_parse_json
from src.commands.tag_parser import parse_tag_state
from src.engine.game_instance import GameInstance
from src.engine.health import record_health_event
from src.engine.language import localized_text, normalize_language
from src.engine.modules import progression_state
from src.engine.story import StoryFormatError, StorySink, StoryStream, read_story_mode
from src.commands.story_output import bound_story_response, decode_story_response
from src.llm.client import OutputTruncatedError, length_retry_budgets
from src.llm.parser import (
    find_protocol_suffix_start,
    has_malformed_protocol_leak,
    normalize_tag_protocol,
    sanitize_narration,
)
from src.llm.protocol import leaked_protocol_line_start, strip_protocol_markup_from_public_line

logger = logging.getLogger("trpg")
# 叙事压缩目标,按语言归一后的 key(normalize_language 返回值)取配置。
# 中文按字符数(普通 260 / 战斗 400),英文按词数换算成字符(普通 ≈150 词、
# 战斗 ≈200 词,触发线设在超过提示词上限才压)。新增语言时在此加一行配置,
# 并同步补充该语言的压缩 prompt 文案;未登记的语言回退中文配置。
_NARRATION_LIMITS = {
    "zh-CN": {"trigger": 500, "soft": 260, "combat": 400},
    "en": {"trigger": 1200, "soft": 900, "combat": 1100},
    "de": {"trigger": 1200, "soft": 900, "combat": 1100},
}
_NARRATION_COMPRESS_MIN_TOKENS = 1024
_NARRATION_COMPRESS_MAX_TOKENS = 2048
# GM prompt 中叙事风格 section 的多语言标题(由 src/content/gm_style.py 渲染)。
_NARRATION_STYLE_HEADINGS = (
    "## GM Narration Style",
    "## GM 叙事风格",
    "## GM ナラティブスタイル",
    "## GM-Erzählstil",
)


def _narration_len(text: str) -> int:
    return len(str(text or "").replace("\n", "").strip())


def _replace_narration_in_content(content: str, narration: str) -> str:
    if "---" not in content:
        return narration
    return f"{narration.strip()}\n---{content.split('---', 1)[1]}"


_THINK_OPEN_TAG = "<think>"
_THINK_CLOSE_TAG = "</think>"


def _partial_think_tag_len(text: str, tag: str) -> int:
    """返回 text 尾部恰好是 tag 真前缀的最长长度(0 表示没有)。"""
    for length in range(min(len(tag) - 1, len(text)), 0, -1):
        if text.endswith(tag[:length]):
            return length
    return 0


class _NarrationDeltaFilter:
    """流式转发叙事正文,遇到 '---' 分隔符后停止转发。

    LLM 叙事输出形如 ``<叙事>\n---\n<结构化标签>``,--- 之后的标签是给解析器用的,
    不能推给前端。本类逐段接收 call_stream 的 delta,只把分隔符之前的部分经 on_delta
    推出;为避免 '---' 被拆到多个 chunk 中间,最多暂存 len(SEPARATOR)-1 个字符,
    flush() 时把剩余暂存一次性发出(纯叙事、无分隔符的场景)。

    同时剔除模型混入正文流中的 reasoning 思考块:``<think>…</think>``(含多个块、
    标签跨 chunk)与孤立 ``</think>`` 一律不转发;未闭合的 think 内容直到流结束
    都不会经 flush() 吐给玩家。
    """

    SEPARATOR = "---"
    PROTOCOL_HOLD_CHARS = 128

    def __init__(self, on_delta):
        self._on_delta = on_delta
        self._buf = ""
        self._sent = 0
        self._sealed = False
        self._inside_think = False
        self._think_probe = ""

    def _strip_think(self, text: str) -> str:
        """剔除增量中的 <think>…</think> 思考内容,标签允许跨 chunk。

        无法判定是否属于标签开头的尾部前缀暂存在 _think_probe,与下一批增量
        拼接后再判定;未闭合 think 的内容一律丢弃,不进入下游缓冲。
        """
        text = self._think_probe + text
        self._think_probe = ""
        parts: list[str] = []
        i = 0
        while i < len(text):
            if self._inside_think:
                close_idx = text.find(_THINK_CLOSE_TAG, i)
                if close_idx < 0:
                    hold = _partial_think_tag_len(text[i:], _THINK_CLOSE_TAG)
                    if hold:
                        self._think_probe = text[len(text) - hold:]
                    break
                self._inside_think = False
                i = close_idx + len(_THINK_CLOSE_TAG)
                continue
            open_idx = text.find(_THINK_OPEN_TAG, i)
            close_idx = text.find(_THINK_CLOSE_TAG, i)
            if open_idx < 0 and close_idx < 0:
                hold = max(
                    _partial_think_tag_len(text[i:], _THINK_OPEN_TAG),
                    _partial_think_tag_len(text[i:], _THINK_CLOSE_TAG),
                )
                parts.append(text[i:len(text) - hold] if hold else text[i:])
                if hold:
                    self._think_probe = text[len(text) - hold:]
                break
            if open_idx >= 0 and (close_idx < 0 or open_idx < close_idx):
                parts.append(text[i:open_idx])
                self._inside_think = True
                i = open_idx + len(_THINK_OPEN_TAG)
            else:
                # 孤立 </think>:只丢弃标签本身,不改变普通正文状态
                parts.append(text[i:close_idx])
                i = close_idx + len(_THINK_CLOSE_TAG)
        return "".join(parts)

    async def feed(self, text: str) -> None:
        if self._sealed or not text:
            return
        cleaned = self._strip_think(text)
        if not cleaned:
            return
        self._buf += cleaned
        separator_idx = self._buf.find(self.SEPARATOR)
        protocol_idx = find_protocol_suffix_start(self._buf)
        candidates = [
            index
            for index in (separator_idx, protocol_idx)
            if index is not None and index >= 0
        ]
        idx = min(candidates) if candidates else -1
        if idx != -1:
            head = self._buf[self._sent:idx]
            self._sent = idx
            self._sealed = True
            if head:
                await self._on_delta(head)
            return

        # A single malformed tag line cannot be treated as an executable suffix
        # safely, but it must still never reach the player. Hold that line until
        # complete, remove the protocol token, and preserve explicit prose after it.
        while True:
            pending = self._buf[self._sent:]
            leaked_at = leaked_protocol_line_start(pending)
            if leaked_at is None:
                break
            absolute = self._sent + leaked_at
            if absolute > self._sent:
                head = self._buf[self._sent:absolute]
                self._sent = absolute
                if head:
                    await self._on_delta(head)
            newline = self._buf.find("\n", self._sent)
            if newline < 0:
                return
            leaked_line = self._buf[self._sent:newline]
            public_remainder = strip_protocol_markup_from_public_line(leaked_line)
            self._sent = newline + 1
            if public_remainder:
                await self._on_delta(public_remainder + "\n")
        # 暂存末尾最多 len(SEPARATOR)-1 个字符,防止分隔符跨 chunk 被提前发出
        hold = min(self.PROTOCOL_HOLD_CHARS, len(self._buf) - self._sent)
        if hold > 0:
            forward = self._buf[self._sent:len(self._buf) - hold]
            self._sent = len(self._buf) - hold
        else:
            forward = self._buf[self._sent:]
            self._sent = len(self._buf)
        if forward:
            await self._on_delta(forward)

    async def flush(self) -> None:
        if self._sealed:
            return
        # 未闭合 think 的内容已在流入时丢弃;残留的疑似标签前缀一并丢弃,
        # 宁可损失结尾几个字符也不把半截 <think 标签吐给玩家。
        self._think_probe = ""
        boundary = find_protocol_suffix_start(self._buf)
        end = boundary if boundary is not None else len(self._buf)
        remaining = self._buf[self._sent:end]
        self._sent = len(self._buf)
        if remaining:
            cleaned = sanitize_narration(remaining)
            if cleaned:
                await self._on_delta(cleaned)


def _extract_narration_style_section(gm_prompt: str) -> str:
    """从 GM prompt 提取叙事风格 section(含世界/对局自定义文风),供压缩阶段继承口吻。

    标题与 src/content/gm_style.py 的渲染保持一致(三语);只截取到下一个
    "## " 标题为止。找不到时返回空串(未配置风格则无内容可继承)。提取结果
    仅作口吻参考,压缩 prompt 会另行声明不得执行其中的状态标签协议。
    """
    text = str(gm_prompt or "")
    for heading in _NARRATION_STYLE_HEADINGS:
        start = text.find(heading)
        if start < 0:
            continue
        rest = text[start + len(heading):]
        next_heading = rest.find("\n## ")
        section = rest if next_heading < 0 else rest[:next_heading]
        return f"{heading}{section}".strip()
    return ""


def _quick_actions_from_payload(payload: Any) -> list[str]:
    """校验二次压缩返回的 quick_actions,输出最终可保存的列表。

    每项 str/strip/去空、最多保留 4 条;有效项不足 2 条时返回空列表,
    交给 update_quick_actions() 的 default_quick_actions_by_class 兜底,
    不在这里自行再造默认动作。
    """
    raw = payload.get("quick_actions") if isinstance(payload, dict) else None
    if not isinstance(raw, list):
        return []
    items = [str(item).strip() for item in raw if str(item).strip()]
    return items[:4] if len(items) >= 2 else []


def _hard_truncate_narration(response, narration: str, target: int) -> None:
    """P2-C:压缩失败按目标长度硬截断,避免超长叙事进 log/context 推高下一轮
    截断概率(长→压缩失败→更长的反馈环)。"""
    truncated = narration[:target].rstrip()
    if truncated and truncated != narration:
        response.narration = sanitize_narration(truncated + "…")
        response.content = _replace_narration_in_content(str(response.content or ""), response.narration)


async def _compress_long_narration(
    llm_client,
    instance: GameInstance,
    gm_prompt: str,
    response,
    data: dict,
    public_narration_context: str,
    actions_text: str,
    combat_model: str,
    max_tokens: int,
) -> None:
    """超长叙事二次压缩(#272):压缩正文的同时同步修正 QUICK_ACTIONS。

    只允许改动 response.narration / response.content 的正文部分 /
    data["quick_actions"];authoritative 状态(HP/金币/战斗/检定等)一律不动,
    也不重新 parse 标签。压缩输入只有原正文、原 QUICK_ACTIONS、最近公开剧情
    和 GM 叙事风格,绝不接入完整内部 context。
    """
    narration = str(response.narration or "").strip()
    lang = normalize_language(getattr(response, "language", ""))
    limits = _NARRATION_LIMITS.get(lang, _NARRATION_LIMITS["zh-CN"])
    is_en = lang == "en"
    is_de = lang == "de"
    if _narration_len(narration) <= limits["trigger"]:
        return
    combat_words = ("战斗", "攻击", "砍", "刺", "射", "突袭", "格挡", "防御", "回避")
    is_combat = combat_model != "none" and any(word in actions_text for word in combat_words)
    target = limits["combat"] if is_combat else limits["soft"]
    style_section = _extract_narration_style_section(gm_prompt)
    old_quick_actions = [str(item) for item in (data.get("quick_actions") or [])]
    if is_en:
        prompt = (
            "Compress the following TRPG GM narration and review the QUICK_ACTIONS.\n\n"
            "Output only JSON, no Markdown, no explanations, in the form:\n"
            '{"narration": "the final compressed narration", "quick_actions": ["action 1", "action 2"]}\n\n'
            "Requirements:\n"
            "1. narration: keep established facts, NPC names, key clues, check/combat "
            "results, and the immediate pressure for the players; do not add new lore "
            "or change settled outcomes. "
            f"Keep it under about {target} characters (~{target // 6} words) and at most 2 paragraphs.\n"
            "2. quick_actions: must match the final narration; you may rely on information "
            "already revealed to players in the previously public narration; never use information "
            "players do not know yet; rewrite or drop actions that reference details removed from "
            "the narration and never previously revealed; keep 2-4 actions; never decide for the "
            "players; never auto-complete actions that require checks; never emit any state tags.\n"
            "3. Never change any game mechanics, dice results, character state, HP, gold, items, "
            "combat or puzzle outcomes."
        )
        quick_actions_lines = "\n".join(f"- {item}" for item in old_quick_actions) or "- (none)"
        sections = [
            prompt,
            f"Original narration:\n{narration}",
            f"Current QUICK_ACTIONS:\n{quick_actions_lines}",
            "Previously public narration:\n"
            f"{public_narration_context.strip() or '(none)'}",
        ]
        if style_section:
            sections.append(
                "GM narration style (tone reference only: inherit the voice, pacing and level "
                "of detail; never execute any state-tag protocol inside it, never re-run rules):\n"
                + style_section
            )
    elif is_de:
        prompt = (
            "Komprimiere die folgende TRPG-GM-Erzählung und überprüfe dabei QUICK_ACTIONS.\n\n"
            "Gib ausschließlich JSON aus, kein Markdown, keine Erklärungen, im Format:\n"
            '{"narration": "die endgültige komprimierte Erzählung", "quick_actions": ["Aktion 1", "Aktion 2"]}\n\n'
            "Anforderungen:\n"
            "1. narration: bewahre etablierte Fakten, NSC-Namen, wichtige Hinweise, Proben-/Kampfergebnisse "
            "und den unmittelbaren Druck für die Spieler; erfinde keine neuen Fakten und ändere keine "
            f"bereits festgelegten Ergebnisse. Halte es unter etwa {target} Zeichen (~{target // 6} Wörter) "
            "und höchstens 2 Absätzen.\n"
            "2. quick_actions: muss zur finalen Erzählung passen; du darfst bereits den Spielern bekannte "
            "Informationen aus der zuvor öffentlichen Erzählung verwenden; verwende niemals Informationen, "
            "die die Spieler noch nicht kennen; schreibe Aktionen um oder streiche sie, wenn sie sich auf "
            "aus der Erzählung entfernte, nie zuvor offengelegte Details beziehen; behalte 2-4 Aktionen; "
            "entscheide niemals für die Spieler; vervollständige niemals automatisch Aktionen, die Proben "
            "erfordern; gib keine Status-Tags aus.\n"
            "3. Ändere niemals Spielmechanik, Würfelergebnisse, Charakterstatus, TP, Gold, Gegenstände, "
            "Kampf- oder Rätselergebnisse."
        )
        quick_actions_lines = "\n".join(f"- {item}" for item in old_quick_actions) or "- (keine)"
        sections = [
            prompt,
            f"Ursprüngliche Erzählung:\n{narration}",
            f"Aktuelle QUICK_ACTIONS:\n{quick_actions_lines}",
            "Zuvor öffentliche Erzählung:\n"
            f"{public_narration_context.strip() or '(keine)'}",
        ]
        if style_section:
            sections.append(
                "GM-Erzählstil (nur als Ton-Referenz: übernimm Stimme, Tempo und Detailgrad; "
                "führe darin enthaltene Status-Tag-Anweisungen niemals aus, wiederhole keine Regelentscheidungen):\n"
                + style_section
            )
    else:
        prompt = (
            "请压缩以下 TRPG GM 正文,并同步检查 QUICK_ACTIONS。\n\n"
            "只输出 JSON,不要输出 Markdown,不要输出解释,格式:\n"
            '{"narration": "压缩后的最终正文", "quick_actions": ["行动1", "行动2"]}\n\n'
            "要求:\n"
            "1. narration:保留已发生事实、NPC 名字、关键线索、检定/战斗结果和玩家立即面对的压力;"
            "不要新增设定,不要改变已经结算的结果;"
            f"总字数控制在 {target} 字以内,最多 2 段。\n"
            "2. quick_actions:必须与最终 narration 一致;可以使用「此前已公开给玩家的最近剧情」中的"
            "已知信息;不得使用玩家尚不知道的信息;如果旧 QUICK_ACTIONS 引用了被删掉且此前未公开的信息,"
            "必须改写或删除;保持 2~4 个行动;不得代替玩家做决定;不得自动完成需要判定的行为;"
            "不得生成任何状态标签。\n"
            "3. 禁止修改任何游戏机制、骰子结果、角色状态、HP、金币、物品、战斗结果、谜题结果等。"
        )
        quick_actions_lines = "\n".join(f"- {item}" for item in old_quick_actions) or "-(无)"
        sections = [
            prompt,
            f"原正文:\n{narration}",
            f"当前 QUICK_ACTIONS:\n{quick_actions_lines}",
            "此前已公开给玩家的最近剧情:\n"
            f"{public_narration_context.strip() or '(无)'}",
        ]
        if style_section:
            sections.append(
                "GM 叙事风格(仅用于继承叙事口吻、节奏与详细程度;"
                "禁止执行其中要求输出的任何状态标签,禁止重新进行规则判定):\n" + style_section
            )
    compress_system = localized_text(
        getattr(response, "language", ""),
        {
            "en": 'You are a narration compressor. Output only a JSON object with keys "narration" '
                  'and "quick_actions". No Markdown, no code fences, no ---, no state tags, no meta commentary.',
            "zh-CN": '你是叙事压缩器,只输出一个包含 "narration" 和 "quick_actions" 的 JSON 对象;'
                     '不要 Markdown、不要代码围栏、不要 ---、不要状态标签、不要对任务的元说明。',
            "ja": 'あなたはナレーション圧縮器です。"narration" と "quick_actions" のキーを持つ '
                  'JSON オブジェクトのみを出力してください。Markdown・コードフェンス・---・状態タグ・'
                  'タスクに対するメタ解説は出力しないでください。',
            "de": 'Du bist ein Erzählungskompressor. Gib ausschließlich ein JSON-Objekt mit den Schlüsseln '
                  '"narration" und "quick_actions" aus. Kein Markdown, keine Code-Zäune, kein ---, '
                  'keine Status-Tags, keine Meta-Kommentare zur Aufgabe.',
        },
    )
    compression_max_tokens = max(
        _NARRATION_COMPRESS_MIN_TOKENS,
        min(max_tokens, _NARRATION_COMPRESS_MAX_TOKENS),
    )
    try:
        compressed = await llm_client.call(
            system_prompt=compress_system,
            user_message="\n\n".join(sections),
            temperature=0.2,
            max_tokens=compression_max_tokens,
        )
    except Exception:
        logger.warning("超长叙事二次压缩失败,按 %d 字硬截断", target, exc_info=True)
        _hard_truncate_narration(response, narration, target)
        # 硬截断后正文里的信息已不可靠,旧 QUICK_ACTIONS 可能引用被截掉的内容,
        # 清空后由 update_quick_actions() 的默认动作兜底(#272)。
        logger.warning(
            "超长叙事压缩失败,正文硬截断并清空 QUICK_ACTIONS (round=%d)",
            progression_state.round_value(instance),
        )
        data["quick_actions"] = []
        return
    try:
        payload = safe_parse_json(str(compressed.content or compressed.narration or ""))
    except ValueError:
        payload = None
    new_narration = str(payload.get("narration") or "").strip() if isinstance(payload, dict) else ""
    if not new_narration:
        # JSON 无效或空 narration 视同压缩失败:硬截断兜底,且不保留旧 QUICK_ACTIONS。
        logger.warning(
            "超长叙事压缩返回无效,正文硬截断并清空 QUICK_ACTIONS (round=%d)",
            progression_state.round_value(instance),
        )
        _hard_truncate_narration(response, narration, target)
        data["quick_actions"] = []
        return
    if _narration_len(new_narration) >= _narration_len(narration):
        logger.info("超长叙事压缩未变短,保留原文与原 QUICK_ACTIONS (round=%d)", progression_state.round_value(instance))
        return
    response.narration = sanitize_narration(new_narration)
    response.content = _replace_narration_in_content(str(response.content or ""), response.narration)
    new_quick_actions = _quick_actions_from_payload(payload)
    if new_quick_actions != old_quick_actions:
        logger.info(
            "超长叙事压缩后同步 QUICK_ACTIONS (round=%d, before=%d, after=%d)",
            progression_state.round_value(instance),
            len(old_quick_actions),
            len(new_quick_actions),
        )
    data["quick_actions"] = new_quick_actions


async def append_multistep_analysis(
    llm_client: Any,
    instance: GameInstance,
    gm_prompt: str,
    context: str,
    actions_text: str,
    analysis_max_tokens: int,
) -> str:
    """WebUI 多步推理:先分析局势,再把分析摘要追加到上下文。"""
    started = time.perf_counter()
    if not should_multi_step(instance, actions_text):
        # 供验收日志证明:本轮没有额外局势分析调用。
        logger.info("局势分析: 未触发,跳过 (round=%d)", progression_state.round_value(instance))
        return context

    try:
        analyze_context = context + "\n\n请用 JSON 分析当前局势,格式: {\"situation\":\"...\",\"npc_intents\":{},\"environment\":\"...\",\"risks\":[],\"key_details\":[]}"
        analyze_res = await llm_client.call(
            system_prompt=gm_prompt,
            user_message=analyze_context,
            temperature=0.3,
            max_tokens=analysis_max_tokens,
        )
        analysis_text = analyze_res.content
        logger.info("局势分析: 完成 (round=%d, len=%d, 耗时=%dms)",
                    progression_state.round_value(instance),
                    len(analysis_text),
                    int((time.perf_counter() - started) * 1000))
        return context + "\n\n【局势分析(内部参考)】\n" + analysis_text[:400]
    except Exception:
        logger.exception("局势分析: 失败,降级为单次调用 (round=%d)", progression_state.round_value(instance))
        return context


async def _call_stream_with_length_retry(
    llm_client: Any,
    instance: GameInstance,
    gm_prompt: str,
    context: str,
    narrative_max_tokens: int,
    on_delta,
    on_reset,
):
    """流式调用被截断时,按 1×、2×、4× 的预算独立重试。"""
    budgets = length_retry_budgets(narrative_max_tokens)
    for budget_index, current_max_tokens in enumerate(budgets):
        attempt_started = time.perf_counter()
        story_stream = StoryStream(instance, on_delta) if read_story_mode(instance) == "dialogue" else None
        filt = _NarrationDeltaFilter(story_stream.feed if story_stream else on_delta)
        try:
            response = await llm_client.call_stream(
                system_prompt=gm_prompt,
                user_message=context,
                temperature=0.7,
                max_tokens=current_max_tokens,
                on_delta=filt.feed,
            )
            await filt.flush()
            if story_stream is not None:
                await story_stream.flush()
                response.story = story_stream.last_story
            response.token_budget_initial = narrative_max_tokens
            response.token_budget_used = current_max_tokens
            return response
        except OutputTruncatedError:
            if budget_index + 1 >= len(budgets):
                logger.warning(
                    "流式输出截断且已达放大上限 (max_tokens=%d, round=%d)",
                    current_max_tokens,
                    progression_state.round_value(instance),
                )
                raise
            bumped = budgets[budget_index + 1]
            logger.info(
                "叙事重试: 流式输出被截断,提高 max_tokens %d -> %d (round=%d, 本次耗时=%dms)",
                current_max_tokens,
                bumped,
                progression_state.round_value(instance),
                int((time.perf_counter() - attempt_started) * 1000),
            )
            if on_reset:
                await on_reset()


async def call_llm_with_tag_retry(
    llm_client: Any,
    instance: GameInstance,
    gm_prompt: str,
    context: str,
    combat_model: str,
    dice_block: str,
    narrative_max_tokens: int,
    actions_text: str = "",
    *,
    on_delta=None,
    on_reset=None,
    public_narration_context: str = "",
) -> tuple[Any, dict]:
    """调用 LLM,解析标签;若叙事违反骰子约束则最多重试 1 次。

    传入 on_delta 时走流式调用(call_stream),逐段叙事经 _NarrationDeltaFilter 过滤掉
    --- 之后的结构化标签后推给前端;骰子矛盾或截断重试前调 on_reset 让前端清空已显示
    的流式文本。流式与非流式截断均按 1×、2×、4× 预算重试,且不占用骰子矛盾重试次数。
    """
    response = None
    data: dict = {}
    stream = on_delta is not None
    max_budget_used = narrative_max_tokens
    dice_retry = 0
    retry_kind = ""
    protocol_retry_used = False
    story_retry_used = False
    dialogue = read_story_mode(instance) == "dialogue"
    streamed_story = None
    started = time.perf_counter()
    while True:
        attempt_started = time.perf_counter()
        retry_context = context
        if retry_kind == "protocol":
            retry_context = append_protocol_repair_instruction(
                context,
                getattr(instance, "language", "zh-CN"),
            )
        elif retry_kind == "dice":
            retry_context = context + "\n\n" + localized_text(
                getattr(instance, "language", ""),
                {
                    "en": "Previous response contradicted the required dice/check result. Rewrite the narration and strictly follow the check outcome.",
                    "zh-CN": "⚠️ 上一轮回复与【系统检定·必须遵循】矛盾,请严格遵循检定结果重新叙述。",
                    "ja": "⚠️ 前の応答が【システム判定・必須遵守】の判定結果に矛盾しています。判定結果を厳守してナレーションを書き直してください。",
                    "de": "⚠️ Die vorherige Antwort widersprach dem erforderlichen Würfel-/Probenergebnis. Schreibe die Erzählung neu und folge dabei strikt dem Probenergebnis.",
                },
            )
        elif retry_kind == "story":
            retry_context = context + (
                "\n\nThe previous public body did not follow character-dialogue format. "
                "Rewrite it as one narration/dialogue JSON object per line, using only roster IDs "
                "or new:local_handle, followed by the required --- state-tag block."
            )
        if stream:
            response = await _call_stream_with_length_retry(
                llm_client,
                instance,
                gm_prompt,
                retry_context,
                narrative_max_tokens,
                on_delta,
                on_reset,
            )
        else:
            response = await llm_client.call(
                system_prompt=gm_prompt,
                user_message=retry_context,
                temperature=0.7,
                max_tokens=narrative_max_tokens,
            )
        max_budget_used = max(
            max_budget_used,
            int(getattr(response, "token_budget_used", 0) or 0),
        )
        response.language = getattr(instance, "language", "zh-CN")
        malformed_protocol = has_malformed_protocol_leak(response.content)
        if malformed_protocol and not protocol_retry_used:
            protocol_retry_used = True
            retry_kind = "protocol"
            logger.warning(
                "叙事重试: 检测到模型协议标签泄漏,按严格格式重试 (round=%d, 本次耗时=%dms)",
                progression_state.round_value(instance),
                int((time.perf_counter() - attempt_started) * 1000),
            )
            if on_reset:
                await on_reset()
            continue
        response.content = normalize_tag_protocol(response.content)

        if "---" in response.content:
            narration_part = response.content.split("---", 1)[0].strip()
            response.narration = narration_part or response.narration or response.content
        response.narration = sanitize_narration(response.narration or response.content)
        data = parse_tag_state(response.content, combat_model)
        if dialogue:
            streamed_story = getattr(response, "story", None) if stream else None
            try:
                decode_story_response(instance, response, strict=not story_retry_used)
            except StoryFormatError:
                if story_retry_used:
                    raise
                story_retry_used = True
                retry_kind = "story"
                if on_reset:
                    await on_reset()
                continue
        if not data.get("state_update") and not data.get("plot_update"):
            try:
                json_data = safe_parse_json(response.content)
                if json_data:
                    logger.info("标签无结果,JSON 回退成功 (round=%d)", progression_state.round_value(instance))
                    data["state_update"] = json_data.get("state_update", {})
                    # ``evidence_validated`` is an internal marker produced by
                    # the tag parser. JSON fallback is model-authored input, so
                    # never let it mint the marker and unlock auto rewards.
                    fallback_proposals = (
                        data["state_update"].get("economy_proposals")
                        if isinstance(data["state_update"], dict)
                        else None
                    )
                    if isinstance(fallback_proposals, list):
                        for proposal in fallback_proposals:
                            if isinstance(proposal, dict):
                                proposal.pop("evidence_validated", None)
                    data["memory_delta"] = json_data.get("memory_delta", {})
                    data["info_asymmetry"] = json_data.get("info_asymmetry", {})
                    data["plot_update"] = json_data.get("plot_update", {})
            except ValueError:
                record_health_event(
                    instance,
                    component="llm_parser",
                    code="JSON_FALLBACK_FAILED",
                    severity="info",
                    title="JSON 回退解析失败",
                    message="标签解析无结构化结果后,JSON 回退解析也未成功。",
                    fallback="continue_tag_result",
                    repair_hint="如果连续发生,检查模型是否遵守标签或 JSON 输出格式。",
                )

        narration = response.narration or response.content
        if not dice_block or validate_dice_constraint(dice_block, narration):
            break
        if dice_retry >= 1:
            logger.error("骰子约束连续2次矛盾,接受最后输出 (round=%d)", progression_state.round_value(instance))
            break
        dice_retry += 1
        retry_kind = "dice"
        logger.warning(
            "叙事重试: 骰子约束矛盾,第%d次重试 (round=%d, 本次耗时=%dms)",
            dice_retry,
            progression_state.round_value(instance),
            int((time.perf_counter() - attempt_started) * 1000),
        )
        if on_reset:
            await on_reset()

    logger.info(
        "GM 叙事: 完成 (round=%d, 总耗时=%dms)",
        progression_state.round_value(instance),
        int((time.perf_counter() - started) * 1000),
    )
    if dialogue:
        limits = _NARRATION_LIMITS.get(normalize_language(instance.language), _NARRATION_LIMITS["zh-CN"])
        bound_story_response(response, data, trigger=limits["trigger"], limit=limits["combat"])
        if stream and streamed_story != response.story:
            if on_reset:
                await on_reset()
            await on_delta(response.narration)
            if isinstance(on_delta, StorySink):
                await on_delta.story_update(response.story)
    else:
        await _compress_long_narration(
            llm_client, instance, gm_prompt, response, data,
            public_narration_context, actions_text, combat_model, narrative_max_tokens,
        )
    response.token_budget_initial = narrative_max_tokens
    response.token_budget_used = max_budget_used
    return response, data


def apply_parsed_data_to_response(instance: GameInstance, response: Any, data: dict) -> None:
    """把解析出的标签数据落到 response 对象,供后续状态应用阶段使用。

    实现已泛化到 `narrative_application`(GAL 轨道 P5),此处 re-export
    维持回合管线的既有 import 路径。
    """
    return _apply_parsed(instance, response, data)