Ctrl K
ComunidadGuías…

src/webui/services/logs.py

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

Archivo original ↗Preguntar a KiBot
"""剧情日志服务:日志分页 / 统计。"""

from __future__ import annotations

import copy
from dataclasses import dataclass
from typing import Any, Callable, Protocol

from src.engine import module_hooks
from src.engine.modules import checks, progression_state, session_stats
from src.engine.game_state import GameState
from src.llm.parser import sanitize_narration
from src.engine.story import public_story

GameKey = tuple[str, ...]

PUBLIC_LOG_FIELDS = frozenset({
    "round", "actions", "player_actions", "gm_response", "state_changes",
    "check_results", "swipes", "current_swipe", "timestamp",
    "story_recaps", "scene_image", "story",
})

# The image prompts are written by the GM model from GM context and may name
# unrevealed lore; players only get the picture and its public panels.
GM_ONLY_SCENE_IMAGE_FIELDS = frozenset({"prompt", "revised_prompt"})

# Historical action renderers use the actor identity and display text only.
# Live-action revision/dice status and internal adjudication are separate contracts.
PUBLIC_ACTION_FIELDS = frozenset({"user_id", "text"})


def _public_action(action: Any) -> Any:
    if not isinstance(action, dict):
        return action
    return {
        key: copy.deepcopy(value)
        for key, value in action.items()
        if key in PUBLIC_ACTION_FIELDS
    }


class LogRegistry(Protocol):
    def get(self, game_key: GameKey) -> Any | None: ...


@dataclass(frozen=True)
class LogDependencies:
    registry: LogRegistry
    parse_game_key: Callable[[str], GameKey]


def get_log(
    dependencies: LogDependencies,
    game_key: str,
    page: int = 1,
    per_page: int = 50,
    include_internal: bool = False,
) -> dict[str, Any]:
    inst = dependencies.registry.get(dependencies.parse_game_key(game_key))
    if not inst:
        return {"log": [], "total": 0, "page": page}
    log = inst.log
    total = len(log)
    start = (page - 1) * per_page
    end = start + per_page
    page_items = copy.deepcopy(log[-end:-start] if start else log[-end:])
    for entry in page_items:
        entry["gm_response"] = sanitize_narration(entry.get("gm_response", ""))
        if "story" in entry:
            story = public_story(inst, entry["story"])
            if story is None:
                entry.pop("story", None)
            else:
                entry["story"] = story
        swipes = entry.get("swipes")
        if isinstance(swipes, list):
            entry["swipes"] = [
                sanitize_narration(item) if isinstance(item, str) else item
                for item in swipes
            ]
    if not include_internal:
        for entry in page_items:
            for field in ("actions", "player_actions"):
                actions = entry.get(field)
                if not isinstance(actions, list):
                    continue
                entry[field] = [
                    _public_action(action) for action in actions
                    if not (
                        isinstance(action, dict)
                        and action.get("user_id") == "system"
                        and str(action.get("text") or "").lstrip().startswith(("【GM指令】", "[GM Directive]"))
                    )
                ]
        page_items = [
            {key: value for key, value in entry.items() if key in PUBLIC_LOG_FIELDS}
            for entry in page_items
        ]
        for entry in page_items:
            scene_image = entry.get("scene_image")
            if isinstance(scene_image, dict):
                entry["scene_image"] = {
                    key: value for key, value in scene_image.items()
                    if key not in GM_ONLY_SCENE_IMAGE_FIELDS
                }
    # 揭示标记与揭示方式是展示层信息:与日志同页返回,供时间线恢复
    # 已揭示/未揭示卡片状态。records 上限有界(模块内裁剪),整包返回。
    page_ids: set[str] = set()
    for entry in page_items:
        results = entry.get("check_results")
        if not isinstance(results, list):
            continue
        for check in results:
            if isinstance(check, dict) and str(check.get("check_id") or ""):
                page_ids.add(str(check.get("check_id")))
    # During the judgment phase, the current round checks are projected by the
    # game detail endpoint rather than the log. Include their reveal markers in
    # this companion map too, while keeping historical pagination unchanged.
    if (
        getattr(inst, "state", None) == GameState.ACTIVE_JUDGMENT
        and checks.round_checks_prepared(inst)
    ):
        for check in checks.last_checks(inst):
            if isinstance(check, dict) and str(check.get("check_id") or ""):
                page_ids.add(str(check.get("check_id")))
    reveal_records = module_hooks.read_value(inst, module_hooks.CHECK_REVEALS) or {}
    check_reveals_map = {
        check_id: copy.deepcopy(record)
        for check_id, record in reveal_records.items()
        if isinstance(record, dict) and (check_id in page_ids or not page_ids)
    }
    return {
        "log": page_items,
        "total": total,
        "page": page,
        "total_pages": max(1, (total + per_page - 1) // per_page),
        "check_reveals": check_reveals_map,
        "dice_reveal_mode": module_hooks.check_presentation(inst)["dice_reveal_mode"],
    }


def get_statistics(dependencies: LogDependencies, game_key: str) -> dict[str, Any]:
    inst = dependencies.registry.get(dependencies.parse_game_key(game_key))
    if not inst:
        return {}
    battles = sum(1 for e in inst.log if "combat" in e.get("actions", ""))

    player_stats: dict[str, dict] = {}
    for uid, p in inst.players.items():
        name = p.get("character_name", uid)
        player_stats[name] = {"name": name, "actions": 0}
    for entry in inst.log:
        for a in entry.get("actions", []):
            uid = a.get("user_id", "")
            if uid in inst.players:
                name = inst.players[uid].get("character_name", uid)
                player_stats.setdefault(name, {"name": name, "actions": 0})
                player_stats[name]["actions"] = player_stats[name].get("actions", 0) + 1

    return {
        "total_rounds": progression_state.round_value(inst),
        "total_battles": battles,
        "total_llm_calls": session_stats.total_llm_calls(inst),
        "total_tokens": session_stats.total_tokens(inst),
        "player_stats": list(player_stats.values()),
    }


class GameLogService:
    """Read-only public log and statistics projections."""

    def __init__(self, dependencies: LogDependencies) -> None:
        self._dependencies = dependencies

    def get_log(
        self,
        game_key: str,
        page: int = 1,
        per_page: int = 50,
        include_internal: bool = False,
    ) -> dict[str, Any]:
        return get_log(
            self._dependencies, game_key, page, per_page, include_internal,
        )

    def get_statistics(self, game_key: str) -> dict[str, Any]:
        return get_statistics(self._dependencies, game_key)