Ctrl K
ComunidadGuías…

src/commands/swipe_generator.py

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

Archivo original ↗Preguntar a KiBot
"""Swipe 候选叙事生成。"""

from __future__ import annotations

import logging
from collections.abc import Callable
from copy import deepcopy
from typing import Any

from src.adventures.graph_v2 import ADVENTURE_GRAPH_FORMAT_V2
from src.commands.economy_effects import (
    currency_labels_for_rule,
    defer_narrative_effects,
    has_economy_proposal,
    pending_decision_notice,
    unbacked_purchase_notice,
    unbacked_payment_notice,
    should_warn_unbacked_payment,
)
from src.engine.economy import filter_unconfirmed_purchase_grants
from src.engine import combat_narrative, module_hooks, progression
from src.commands.protocol_repair import repair_malformed_protocol_response
from src.commands.round_actions import format_check_results_constraint
from src.commands.state_update_applier import StateUpdateApplier, discard_unresolved_player_damage
from src.commands.tag_parser import parse_tag_state
from src.commands.story_output import ensure_story_response, bound_story_response
from src.engine.game_instance import GameInstance, restore_players
from src.engine.modules import adventure_runtime_state, progression_state, session_stats
from src.engine.world_state import ensure_world_state
from src.engine.economy import queue_effect_group, reconcile_rollback_snapshot, reverse_round_economy
from src.imagegen.storyboards import normalize_scene_panels, storyboard_panel_metadata, storyboard_source_revision
from src.llm.context_builder import lore_char_budget
from src.llm.parser import normalize_tag_protocol, sanitize_narration
from src.lorebook.retrieval import LoreRetriever

logger = logging.getLogger("trpg")


class SwipeGenerator:
    """为指定历史轮次重新生成一个候选叙事。"""

    def __init__(
        self,
        llm_client: Any,
        matcher: Any,
        prompt: Any,
        state_applier: StateUpdateApplier,
        load_world_template: Callable[[str, str], dict | None],
        ensure_matcher_for_world: Callable[[str, str], None],
        narrative_max_tokens: int,
        get_instance: Callable[[tuple], GameInstance | None] | None = None,
        save_instance: Callable[[GameInstance], Any] | None = None,
        lore_retriever: Any | None = None,
    ):
        self.llm_client = llm_client
        self.matcher = matcher
        self.lore_retriever = lore_retriever or LoreRetriever(matcher)
        self.prompt = prompt
        self.state_applier = state_applier
        self.load_world_template = load_world_template
        self.ensure_matcher_for_world = ensure_matcher_for_world
        self.narrative_max_tokens = narrative_max_tokens
        self.get_instance = get_instance
        self.save_instance = save_instance
        # 生图调度回调(GameHandler 注入):swipe 叙事带新 SCENE_IMAGE 时重新生成该回合图片
        self._scene_image_hook = None

    def set_scene_image_hook(self, hook) -> None:
        self._scene_image_hook = hook

    async def generate(self, instance: GameInstance, round_num: int) -> str | None:
        """Run the complete historical rewrite under the shared process barrier."""

        if instance._process_lock.locked():
            logger.warning(
                "process_round/swipe 进行中,跳过并发 generate_swipe: %s",
                instance.game_key,
            )
            return None
        async with instance.historical_rewrite() as rewrite_entered:
            if not rewrite_entered:
                return None
            async with instance._process_lock:
                session_stats.require_writable(instance)
                adventure_runtime_state.require_writable(instance)
                module_hooks.preflight(instance, module_hooks.NARRATIVE_VIEW)
                expected_run_id = instance.run_id
                before = type(instance).from_dict(deepcopy(instance.to_dict()))
                before.log = deepcopy(instance.log)
                staged = type(instance).from_dict(deepcopy(instance.to_dict()))
                staged.log = deepcopy(instance.log)
                narration, scene_payload = await self._generate_locked(staged, round_num)
                if narration is None:
                    return None
                if self.get_instance:
                    current = self.get_instance(instance.game_key)
                    if current is not instance or getattr(current, "run_id", None) != expected_run_id:
                        return None
                instance.replace_persisted_state_from(staged)
                try:
                    if self.save_instance is not None:
                        await self.save_instance(instance)
                except Exception:
                    instance.replace_persisted_state_from(before)
                    raise
                if scene_payload and self._scene_image_hook:
                    try:
                        self._scene_image_hook(
                            instance,
                            str(scene_payload.get("prompt") or ""),
                            int(scene_payload.get("round", round_num) or round_num),
                            force=True,
                            panels=scene_payload.get("panels") or [],
                            compressed_count=int(scene_payload.get("compressed_count") or 0),
                        )
                    except Exception:
                        logger.exception("Swipe 场景图调度失败 (round=%d)", round_num)
                return narration

    async def _generate_locked(
        self, instance: GameInstance, round_num: int,
    ) -> tuple[str | None, dict[str, Any] | None]:
        """为指定轮生成一个新 swipe(最多 5 个)。"""
        target_entry = None
        target_idx = -1
        for i, entry in enumerate(instance.log):
            if entry.get("round") == round_num:
                target_entry = entry
                target_idx = i
                break
        if not target_entry:
            return None, None
        from src.engine.modules import resource_recovery, skill_growth
        skill_growth.rewind_preflight(instance, round_num)
        resource_recovery.rewind_preflight(instance, round_num)

        swipes = target_entry.get("swipes", [])
        if len(swipes) >= 5:
            logger.warning("Swipe 已达上限 (5), round=%d", round_num)
            return None, None
        progression.require_writable(instance)
        session_stats.require_writable(instance)
        if not swipes:
            swipes = [target_entry.get("gm_response", "")]
            target_entry["swipes"] = swipes

        progress_snapshot = target_entry.get("pre_adventure_progress")
        snapshot = target_entry.get("pre_state_snapshot", {})
        if ((instance.adventure_binding or {}).get("format") == ADVENTURE_GRAPH_FORMAT_V2
                and (not isinstance(progress_snapshot, dict) or not isinstance(snapshot, dict) or not snapshot)):
            logger.warning("Cannot rewrite v2 adventure without its pre-round snapshots: %s round=%s",
                           instance.game_key, round_num)
            return None, None
        if snapshot:
            # ADR 0003 branch cut: rounds after the rewritten target belong to
            # the discarded branch. Their log entries are dropped together
            # with their economy (reverse_round_economy withdraws every
            # settlement at or after the target round), and the replay
            # continues from the target round itself.
            instance.log = [
                entry for entry in instance.log
                if int(entry.get("round", 0) or 0) <= round_num
            ]
            module_hooks.run(module_hooks.ROUND_REWIND_BEGIN, instance, round_num)
            progression.rewind_for_replay(instance, round_num)
            reverse_round_economy(instance, round_num)
            restore_players(instance, reconcile_rollback_snapshot(instance, snapshot, round_num))
            module_hooks.run(
                module_hooks.ROUND_REWIND_RESTORE, instance, target_entry, round_num,
                module_hooks.REWIND_SOURCE_SWIPE,
            )
            # 世界真相同属被丢弃的分支:swipe 切回目标轮时,本轮之后写入的
            # world ops 一起撤销(ADR 0003 整轮语义)。
            world_snapshot = target_entry.get("pre_world_state")
            if isinstance(world_snapshot, dict) and world_snapshot:
                instance.world_state = ensure_world_state(world_snapshot)
            logger.info("Swipe: 已恢复 pre-state snapshot (round=%d)", round_num)

        actions_text = "; ".join(
            a.get("text", "") for a in target_entry.get("actions", [])
            if a.get("user_id") in instance.players
        )
        actions_text += format_check_results_constraint(
            instance, list(target_entry.get("check_results") or [])
        )
        if instance.world_id:
            self.ensure_matcher_for_world(instance.world_id, instance.language)
        # 与正常回合同一个 LoreRetriever:swipe 在 staged 克隆上重放同一轮,计时器
        # 语义与正常回合一致(匹配结果随后经 replace_persisted_state_from 写回)。
        action_actor_uids = sorted({
            str(action.get("user_id") or "")
            for action in target_entry.get("actions", [])
            if str(action.get("user_id") or "") in instance.players
        })
        lorebook_matches = await self.lore_retriever.retrieve(
            instance, actions_text, action_actor_uids=action_actor_uids,
            overall_budget=lore_char_budget(
                self.llm_client.default if getattr(self, "llm_client", None) else ""
            ),
        )

        rule_ctx = self.prompt.load_swipe_rule_context(instance, self.load_world_template)
        combat_model_s = rule_ctx.combat_model
        world_data = rule_ctx.world_data
        currency_labels = currency_labels_for_rule(rule_ctx.rule)

        gm_prompt = self.prompt.compose_gm_prompt(
            instance, rule_ctx.rule_appendix, world_data=rule_ctx.world_data,
        )
        pending_combat_event_ids = combat_narrative.pending_event_ids(instance)
        pending_combat_events_text = combat_narrative.format_pending_events(instance)

        # 构建上下文(仅使用目标轮之前的日志),不临时改写共享 instance.log。
        provider_name = self.llm_client.default if self.llm_client else ""
        context = await self.prompt.build_user_context(
            instance,
            gm_prompt,
            lorebook_matches,
            actions_text,
            provider_name=provider_name,
            world_data=world_data,
            history_override=instance.log[:target_idx],
            authoritative_events_text=pending_combat_events_text,
        )

        response = await self.llm_client.call(
            system_prompt=gm_prompt,
            user_message=context,
            temperature=0.9,
            max_tokens=self.narrative_max_tokens,
        )
        response = await repair_malformed_protocol_response(
            self.llm_client,
            response,
            system_prompt=gm_prompt,
            user_message=context,
            language=instance.language,
            temperature=0.9,
            max_tokens=self.narrative_max_tokens,
        )
        response.content = normalize_tag_protocol(response.content)
        response = await ensure_story_response(
            instance, response, self.llm_client, system_prompt=gm_prompt,
            user_message=context, max_tokens=self.narrative_max_tokens,
            round_number=round_num, revision=len(swipes),
        )

        narration = response.content
        if "---" in response.content:
            narration = response.content.split("---", 1)[0].strip()
        narration = sanitize_narration(narration)
        data = parse_tag_state(response.content, combat_model_s)
        if getattr(response, "story", None):
            bound_story_response(response, data, trigger=500 if instance.language == "zh-CN" else 1200,
                                 limit=400 if instance.language == "zh-CN" else 1100)
            narration = response.narration
        system_changes: list[str] = []
        if should_warn_unbacked_payment(
            narration, data, instance.language,
            currency_labels=currency_labels,
        ):
            system_changes.append(unbacked_payment_notice(instance.language))
        dropped_purchase_items = filter_unconfirmed_purchase_grants(instance, data)
        if dropped_purchase_items:
            system_changes.append(unbacked_purchase_notice(instance.language))
        deferred_effects = defer_narrative_effects(
            data, response,
            defer_state_update=True,
        )
        economy_pending = has_economy_proposal(data)
        if economy_pending:
            system_changes.append(pending_decision_notice(instance.language))

        queued_proposals: list[dict[str, Any]] = []
        if data.get("state_update"):
            discard_unresolved_player_damage(instance, data.get("state_update", {}))
            queued_proposals = self.state_applier.apply_state_update(
                instance, data.get("state_update", {}),
            )
        if deferred_effects:
            deferred_effects["allowed_player_uids"] = None
            queue_effect_group(instance, queued_proposals, deferred_effects)
        plot_update = data.get("plot_update")
        if isinstance(plot_update, dict) and "adventure_completion" in plot_update:
            plot_update = dict(plot_update)
            plot_update.pop("adventure_completion", None)
        if plot_update and instance.plot_tracker:
            try:
                instance.plot_tracker.apply_update(
                    plot_update, progression_state.round_value(instance))
            except Exception:
                logger.exception("Swipe 剧情更新异常,已跳过 (round=%d)", round_num)

        await instance.finish_judgment_with_swipe(
            narration, round_num,
            state_changes=system_changes if system_changes else None,
            story=getattr(response, "story", None),
            adventure_completion_candidate=(
                data.get("plot_update", {}).get("adventure_completion")
                if isinstance(data.get("plot_update"), dict) else None
            ),
        )
        combat_narrative.consume_pending_events(
            instance, pending_combat_event_ids,
        )
        scene_payload = None
        swipe_prompt = str(data.get("scene_image_prompt") or "").strip()
        swipe_panels = data.get("scene_panels")
        normalized_panels, compressed_count = normalize_scene_panels(
            swipe_panels, merge_same_location=False,
        )
        # A swipe is a new public-story revision. Keep its storyboard draft
        # beside the narration (never derive it later from an old image).
        original_panels = deepcopy(target_entry.get("scene_panels") or [])
        original_prompt = str(target_entry.get("scene_image_prompt") or "")
        target_entry["scene_panels"] = normalized_panels
        target_entry["scene_image_prompt"] = swipe_prompt[:300]
        target_entry["scene_panel_meta"] = storyboard_panel_metadata(
            normalized_panels,
            narration=str(target_entry.get("gm_response") or ""),
            actions=target_entry.get("actions") or [],
            current_scene=str(module_hooks.narrative_view(instance)["scene"] or ""),
            source_revision=storyboard_source_revision(target_entry),
        )
        prompt_history = target_entry.setdefault("swipe_scene_image_prompts", [])
        if not isinstance(prompt_history, list):
            prompt_history = []
            target_entry["swipe_scene_image_prompts"] = prompt_history
        while len(prompt_history) < len(target_entry.get("swipes", [])) - 1:
            prompt_history.append(original_prompt if not prompt_history else "")
        prompt_history.append(swipe_prompt[:300])
        swipe_panel_history = target_entry.setdefault("swipe_scene_panels", [])
        if not isinstance(swipe_panel_history, list):
            swipe_panel_history = []
            target_entry["swipe_scene_panels"] = swipe_panel_history
        # The first item corresponds to the original swipe; subsequent items
        # follow the same order as the public ``swipes`` text list.
        while len(swipe_panel_history) < len(target_entry.get("swipes", [])) - 1:
            swipe_panel_history.append(deepcopy(original_panels) if not swipe_panel_history else [])
        swipe_panel_history.append(deepcopy(normalized_panels))
        if not normalized_panels:
            target_entry.pop("scene_panel_meta", None)
        if swipe_prompt or normalized_panels:
            scene_payload = {
                "prompt": swipe_prompt,
                "round": round_num,
                "panels": normalized_panels,
                "compressed_count": compressed_count,
            }
        logger.info("Swipe 生成: round=%d swipe=%d/%d", round_num,
                    len(swipes) + 1, len(swipes) + 1)
        return narration, scene_payload