Ctrl K
ComunidadGuías…

src/commands/tag_parser.py

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

Archivo original ↗Preguntar a KiBot
"""LLM 标签 / JSON 输出解析工具。

负责把 GM 回复中的 `---` 后状态标签解析为结构化 state_update、plot_update、
memory_delta 等数据;同时提供 JSON 回退解析与标签摘要。

parse_tag_state 按标签类型分派到 tag_handlers 中的 typed handler:
玩家字段、世界/剧情、战利品、行动/谜题。
"""

from __future__ import annotations

from src.llm.parser import normalize_tag_protocol
from src.llm.protocol import normalize_protocol_line
from src.commands.tag_handlers import (
    ACTION_TAGS,
    ADVENTURE_TAGS,
    KNOWN_TAGS,
    LIMITS_BY_COMBAT_MODEL,
    LOOT_TAGS,
    PLAYER_TAGS,
    WORLD_TAGS,
    parse_action_tag,
    parse_adventure_tag,
    parse_loot_tag,
    parse_player_tag,
    parse_world_tag,
)


def _new_result() -> dict:
    return {
        "state_update": {"players": {}, "npcs": {}, "scene_change": "", "loot": []},
        "memory_delta": {"add": [], "update": [], "forget": []},
        "info_asymmetry": {},
        "plot_update": {"quests": [], "relations": [], "decisions": [], "adventure_completion": None},
        "xp_rewards": {},  # uid -> xp_amount (LLM 可选标签)
        "milestone_grants": [],  # D&D live advancement entitlement targets
        "growth_skills": [],  # [{uid, skill}] 用于 CoC 技能成长检定
        "scene_image_prompt": "",  # GM 请求为本回合生成场景图(需图像生成插件)
        "scene_panels": [],  # 多人公共分镜:[{participants, location, description}]
    }


def _extract_tag_lines(text: str, result: dict) -> list[str]:
    """切出标签行;只有 `---` 之后的内容会被当作可执行标签。"""
    if "---" in text:
        parts = text.split("---", 1)
        tag_block = parts[1].strip()
        # ``NONE`` is a valid marker for "no other state changes", but it
        # must not hide a required SCENE_PANEL that follows it.  Older
        # parsing stopped at any block beginning with NONE, so a model that
        # emitted ``NONE`` before its storyboard silently lost the panels.
        # Skip standalone NONE lines and continue parsing the remaining
        # protocol records.  A block containing only NONE still yields no
        # executable tags as before.
        return [
            line for line in tag_block.splitlines()
            if line.strip().upper() != "NONE"
        ]
    result.setdefault("_missing_tag_separator", True)
    return []


def _split_tag_line(line: str) -> tuple[str | None, str | None]:
    """解析单行 'TAG: value';无效或未知标签返回 (None, None)。"""
    line = normalize_protocol_line(line) or line.strip()
    if not line or ":" not in line:
        return None, None
    tag, _, value = line.partition(":")
    tag, value = tag.strip().upper(), value.strip()
    if tag not in KNOWN_TAGS or not value:
        return None, None
    return tag, value


def parse_tag_state(text: str, combat_model: str = "hp_based") -> dict:
    """从 LLM 输出的标签格式中提取结构化状态。
    根据规则体系(combat_model)动态调整数值上限。
    """
    text = normalize_tag_protocol(text)
    limits = LIMITS_BY_COMBAT_MODEL.get(combat_model, LIMITS_BY_COMBAT_MODEL["hp_based"])
    result = _new_result()
    if not text:
        return result

    for line in _extract_tag_lines(text, result):
        tag, value = _split_tag_line(line)
        if tag is None:
            continue
        if tag in PLAYER_TAGS:
            parse_player_tag(tag, value, result, limits)
        elif tag in WORLD_TAGS:
            parse_world_tag(tag, value, result)
        elif tag in LOOT_TAGS:
            parse_loot_tag(tag, value, result)
        elif tag in ACTION_TAGS:
            parse_action_tag(tag, value, result)
        elif tag in ADVENTURE_TAGS:
            parse_adventure_tag(tag, value, result)

    return result