Ctrl K
ComunidadGuías…

src/commands/narrative_application.py

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

Archivo original ↗Preguntar a KiBot
"""回合管线之外的叙事响应应用链(GAL 轨道 P5 泛化)。

`apply_parsed_data_to_response` 把解析出的标签数据落到 response 对象;
`apply_narrative_effects` 应用一段与回合机制无关的通用尾部链(成长奖励、
快捷行动、记忆、剧情、私信)。两者最初都长在回合管线
(`round_llm.py` / `round_processor.process_round_impl`)里,行为保持不变地
抽出,供链外的叙事消费方(如节点激活后的场景过渡叙事)复用。

注意 `RoundProcessor.commit_deferred_economy_effects` 仍保留自己的手工链:
那边的步骤顺序(plot → private → memory)与"记忆失败必须抛出以中止结算
事务"的语义是刻意设计,不迁移到这里统一。
"""

from __future__ import annotations

import logging
from typing import Any

from src.commands.round_effects import (
    apply_growth_rewards,
    apply_memory_delta,
    apply_plot_update,
    store_private_messages,
    update_quick_actions,
)
from src.engine.game_instance import GameInstance
from src.engine.health import record_health_event
from src.engine.language import localized_text
from src.engine.modules import progression_state

# 与回合管线(round_llm)共用 "trpg" 日志通道,保持搬家后的日志路由不变。
logger = logging.getLogger("trpg")


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

    ``context_label`` 仅用于日志与健康事件的上下文描述(链外调用时传
    如 "scene-transition");缺省时回退到当前回合号,与回合管线内行为一致。
    """
    round_context = (
        context_label if context_label is not None
        else str(progression_state.round_value(instance))
    )
    if data.get("state_update") or data.get("plot_update"):
        response.is_narration_only = False
        instance.set_tag_failure_streak(0)  # 成功解析即清零,防止 streak 累积误触发提示
        response.state_update = data["state_update"]
        response.memory_delta = data["memory_delta"]
        response.info_asymmetry = data["info_asymmetry"]
        response.plot_update = data["plot_update"]
        state_update = data.get("state_update", {})
        players_changed = list(state_update.get("players", {}).keys())
        scene = state_update.get("scene_change", "")
        loot = state_update.get("loot", [])
        logger.info(
            "标签解析成功 (round=%s): 玩家=%s, 场景=%s, 战利品=%d",
            round_context,
            players_changed if players_changed else "无变化",
            scene or "不变",
            len(loot),
        )
        return

    if not response.state_update:
        response.is_narration_only = True
        streak = instance._tag_fail_streak + 1
        instance.set_tag_failure_streak(streak)
        if streak >= 3:
            logger.error("标签连续%d轮解析失败!建议:检查模型是否支持当前prompt格式,或更换模型", streak)
            record_health_event(
                instance,
                component="llm_parser",
                code="TAG_PARSE_STREAK",
                severity="error",
                title="结构化解析连续失败",
                message=f"标签已连续 {streak} 轮解析失败。",
                impact="HP、资源、物品、任务和记忆等结构化状态可能持续未更新。",
                fallback="narration_only",
                repair_hint="建议暂停并检查模型、prompt 标签格式,或重新生成本轮。",
            )
            # P2-B:连续失败时给玩家可见提示,避免"叙事里受伤但 HP 没扣"的
            # 状态漂移无声无息(health_event 仅 GM 可见)。作为系统事件展示。
            _sync_notice = localized_text(
                getattr(instance, "language", ""),
                {
                    "en": "⚠️ System: state sync has failed for several rounds; HP/resources/items "
                          "may be out of date. Ask the GM to check, or regenerate this round.",
                    "zh-CN": "⚠️ 系统提示:连续多轮状态同步失败,HP/资源/物品可能未更新。"
                              "请告知 GM 检查,或重新生成本轮。",
                    "ja": "⚠️ システム通知:複数ラウンドにわたり状態同期に失敗しています。"
                          "HP/資源/アイテムが最新でない可能性があります。GM に確認を依頼するか、"
                          "このラウンドを再生成してください。",
                    "de": "⚠️ Systemhinweis: Die Statussynchronisation ist mehrere Runden in Folge "
                          "fehlgeschlagen; TP/Ressourcen/Gegenstände sind möglicherweise veraltet. "
                          "Bitte den GM um eine Prüfung oder generiere diese Runde neu.",
                },
            )
            system_notices = getattr(response, "system_notices", None)
            if not isinstance(system_notices, list):
                system_notices = []
                response.system_notices = system_notices
            system_notices.append(_sync_notice)
        else:
            logger.warning(
                "标签解析失败,本轮仅保留叙事 (round=%s, streak=%d)",
                round_context, streak,
            )
            record_health_event(
                instance,
                component="llm_parser",
                code="NARRATION_ONLY_FALLBACK",
                severity="warning",
                title="结构化解析失败",
                message="本轮 AI 回复未解析出状态标签,系统仅保留叙事。",
                impact="HP、资源、物品、任务和记忆等结构化状态可能未更新。",
                fallback="narration_only",
                repair_hint="可重新生成本轮,或检查模型是否遵守 prompt 标签格式。",
            )
        response.state_update = {}
        response.memory_delta = {"add": [], "update": [], "forget": []}
        response.info_asymmetry = {}
        response.plot_update = {"quests": [], "relations": [], "decisions": []}
    else:
        instance.set_tag_failure_streak(0)


async def apply_narrative_effects(
    instance: GameInstance,
    data: dict,
    response: Any,
    *,
    rule: Any = None,
    progression: Any = None,
    runtime: Any | None = None,
    apply_growth: bool = True,
    apply_quick_actions: bool = True,
    memory_store: Any = None,
) -> list[str]:
    """应用叙事响应的通用尾部链,返回成长奖励产生的系统提示。

    链序与回合管线(process_round_impl)保持一致:成长奖励 → 快捷行动 →
    记忆增量(best-effort)→ embedding 队列冲刷 → 剧情更新 → 私信存储。
    回合特有项(检定、战斗信号、谜题、经济决策屏障、回合提交)不在其中。
    """
    notices: list[str] = []
    if apply_growth:
        notices.extend(apply_growth_rewards(
            instance, data, response, rule, progression, runtime,
        ))
    if apply_quick_actions:
        update_quick_actions(instance, data)
    await apply_memory_delta(instance, response, memory_store)
    # 消费待处理 embedding 队列,让新记忆在运行中也能获得向量(此前从未被调用)
    if memory_store and memory_store.embedding_client:
        try:
            await memory_store.flush_pending_embeddings()
        except Exception:
            logger.warning(
                "flush_pending_embeddings 失败 (round=%d)",
                progression_state.round_value(instance), exc_info=True,
            )
    apply_plot_update(instance, response)
    store_private_messages(instance, response)
    return notices