Ctrl K
ComunidadGuías…

src/llm/protocol.py

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

Archivo original ↗Preguntar a KiBot
"""Shared model state-tag protocol definitions and tolerant line parsing."""

from __future__ import annotations

import re
from dataclasses import dataclass


KNOWN_PROTOCOL_TAGS = frozenset({
    "HP", "REWARD", "GOLD", "PAY", "TEAM_PAY", "SCENE", "SCENE_IMAGE", "SCENE_PANEL", "NPC", "LOOT", "KEY_ITEM", "DECISION",
    "QUEST", "USE", "WEAPON", "WEAPON_GAIN", "EQUIP", "EQUIP_ITEM", "UNEQUIP_ITEM", "PRIVATE",
    "XP", "MILESTONE", "SAN", "SAN_CHECK", "LUCK", "SKILL_GROWTH", "PUSH", "PUZZLE", "MANA",
    "SPELL", "QUICK_ACTIONS", "COMBAT", "REVIVE", "CONFIRMED", "MEMORY", "STAT", "ADVENTURE_COMPLETE",
    "FREE_GRANT", "AFFECTION",
})
PROTOCOL_SENTINELS = frozenset({"NONE"})

# 曾在规则模板或模型输出中出现、但核心从未支持执行的伪标签。
# 只用于玩家可见文本的泄漏识别,绝不进入状态执行路径。
NON_EXECUTABLE_PROTOCOL_NAMES = frozenset({"STATE"})
RETIRED_PROTOCOL_TAGS = frozenset({"PAY", "TEAM_PAY"})


def _name_key(value: str) -> str:
    return re.sub(r"[^A-Z0-9]", "", str(value or "").upper())


_CANONICAL_BY_KEY = {
    _name_key(tag): tag
    for tag in KNOWN_PROTOCOL_TAGS | PROTOCOL_SENTINELS
}
_NON_EXECUTABLE_KEYS = {_name_key(tag) for tag in NON_EXECUTABLE_PROTOCOL_NAMES}


@dataclass(frozen=True)
class ProtocolLine:
    tag: str
    value: str
    trailing_text: str = ""
    executable: bool = True


_PROTOCOL_LINE_RE = re.compile(
    r"^\s*(?:(?:[-+*]\s+)|(?:>\s*))?"
    r"(?P<open>\*\*|__|`)?\s*"
    r"(?P<tag>[A-Za-z][A-Za-z0-9_-]{1,31})\s*[::]\s*"
    r"(?P<body>.*)$",
    re.IGNORECASE,
)
_SENTINEL_LINE_RE = re.compile(
    r"^\s*(?:(?:[-+*]\s+)|(?:>\s*))?(?:\*\*|__|`)?\s*NONE\s*(?:\*\*|__|`)?\s*$",
    re.IGNORECASE,
)
_INLINE_MARKDOWN_TAG_RE = re.compile(
    r"(?P<open>\*\*|__|`)\s*"
    r"(?P<tag>[A-Za-z][A-Za-z0-9_-]{1,31})\s*[::]"
    r"(?P<value>[^\n]*?)\s*(?P=open)",
    re.IGNORECASE,
)


def canonical_protocol_tag(value: str) -> str | None:
    """Return the canonical executable tag name, accepting underscore/case variants."""
    return _CANONICAL_BY_KEY.get(_name_key(value))


def parse_protocol_line(
    line: str,
    *,
    include_non_executable: bool = False,
) -> ProtocolLine | None:
    """Parse one tag-shaped line, tolerating bullets and Markdown wrappers.

    `SANCheck`, `san_check`, and `SAN_CHECK` all canonicalize to `SAN_CHECK`.
    Unsupported historical names such as `STATE` are returned only when explicitly
    requested for display filtering and are never executable.
    """
    source = str(line or "")
    if _SENTINEL_LINE_RE.match(source):
        return ProtocolLine("NONE", "")
    match = _PROTOCOL_LINE_RE.match(source)
    if not match:
        return None
    raw_tag = match.group("tag")
    tag = canonical_protocol_tag(raw_tag)
    executable = tag is not None
    if tag is None:
        if not include_non_executable or _name_key(raw_tag) not in _NON_EXECUTABLE_KEYS:
            return None
        tag = raw_tag.upper()
    if tag in RETIRED_PROTOCOL_TAGS:
        executable = False

    body = match.group("body").strip()
    trailing = ""
    opener = match.group("open") or ""
    if opener:
        closing_at = body.find(opener)
        if closing_at >= 0:
            trailing = body[closing_at + len(opener):].strip()
            body = body[:closing_at].strip()
    if tag != "NONE" and not body:
        return None
    return ProtocolLine(tag, body, trailing, executable)


def normalize_protocol_line(line: str) -> str | None:
    """Return a canonical executable protocol line, or None for ordinary text."""
    parsed = parse_protocol_line(line)
    if not parsed:
        return None
    if not parsed.executable:
        return None
    if parsed.tag == "NONE":
        return "NONE"
    return f"{parsed.tag}:{parsed.value}"


def leaked_protocol_line_start(text: str) -> int | None:
    """Locate the first line that begins with an executable or historical protocol tag."""
    source = str(text or "")
    offset = 0
    for line in source.splitlines(keepends=True):
        if parse_protocol_line(line.rstrip("\r\n"), include_non_executable=True):
            return offset
        offset += len(line)
    return None


def strip_protocol_markup_from_public_line(line: str) -> str:
    """Remove a leaked protocol token while preserving any explicit prose after it."""
    source = str(line or "")
    parsed = parse_protocol_line(source, include_non_executable=True)
    if parsed:
        trailing = parsed.trailing_text
        if not trailing and parsed.tag == "SAN_CHECK" and "|" in parsed.value:
            _value, trailing = parsed.value.split("|", 1)
        return trailing.lstrip(" \t||::—–-").strip()

    def replace_inline(match: re.Match[str]) -> str:
        tag = match.group("tag")
        if canonical_protocol_tag(tag) or _name_key(tag) in _NON_EXECUTABLE_KEYS:
            return ""
        return match.group(0)

    cleaned = _INLINE_MARKDOWN_TAG_RE.sub(replace_inline, source)
    return cleaned.strip() if cleaned != source else source


def contains_protocol_markup(text: str) -> bool:
    """Whether player-facing text still contains a known/internal protocol marker."""
    source = str(text or "")
    if leaked_protocol_line_start(source) is not None:
        return True
    for match in _INLINE_MARKDOWN_TAG_RE.finditer(source):
        tag = match.group("tag")
        if canonical_protocol_tag(tag) or _name_key(tag) in _NON_EXECUTABLE_KEYS:
            return True
    return False