Ctrl K
ComunidadGuías…

src/llm/parser.py

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

Archivo original ↗Preguntar a KiBot
"""LLM 输出解析器 —— 从 GM 回复末尾提取结构化 JSON 块。"""

from __future__ import annotations

import json
import logging
import re
from dataclasses import dataclass, field

from src.llm.protocol import (
    contains_protocol_markup,
    normalize_protocol_line,
    parse_protocol_line,
    strip_protocol_markup_from_public_line,
)

logger = logging.getLogger("trpg")


@dataclass
class ParsedResult:
    """解析后的 LLM 输出。"""
    narration: str                            # 叙事文本(给玩家看的)
    state_update: dict | None = None          # 状态更新
    memory_delta: dict | None = None          # 记忆变更
    info_asymmetry: dict | None = None        # 信息不对称(私聊消息)
    plot_update: dict | None = None           # 剧情推进(任务/关系/决策)
    is_narration_only: bool = False           # JSON 解析失败,降级为纯叙事


# 匹配末尾 ```json ... ``` 块的模式
_JSON_BLOCK_RE = re.compile(r"```json\s*\n(.*?)```", re.DOTALL)

# LLM 常见的元文本(不应出现在叙事中)
_META_PATTERNS = [
    re.compile(r"^明白了[,。]\s*", re.MULTILINE),
    re.compile(r"^好的[,。]\s*", re.MULTILINE),
    re.compile(r"^了解[了]?[,。]\s*", re.MULTILINE),
    re.compile(r"^以下是.*回复[::]?\s*", re.MULTILINE),
    re.compile(r"^修正后.*[::]\s*", re.MULTILINE),
    re.compile(r"^我会.*输出[。!]\s*", re.MULTILINE),
    re.compile(r"^严格遵循[。,].*\s*", re.MULTILINE),
]

_INTERNAL_CONTEXT_HEADINGS = (
    "【游戏状态】",
    "【对话历史】",
    "【玩家发言】",
    "【局势分析(内部参考)】",
    "【局势分析(内部参考)】",
)

_INTERNAL_ANALYSIS_PREFIXES = (
    "我们被要求",
    "玩家动作是",
    "上面已经有",
    "但注意这是",
    "现在用户",
    "实际上,用户",
    "看起来像是",
    "可能用户",
    "我应该做什么",
)

_INTERNAL_CHECK_HEADING_RE = re.compile(
    r"^(?:【系统[^】]*检定[^】]*必须遵循】|\[System Check\s*[-–—]\s*Must Follow\])$",
    re.IGNORECASE,
)
_INTERNAL_CHECK_LINE_RE = re.compile(
    r"^(?:角色|机制|检定|成功等级阈值|结果|要求|幸运(?:提示|选项)|"
    r"actor|mechanic|check|success thresholds?|result|requirement|luck (?:hint|option))\s*[::]",
    re.IGNORECASE,
)
_INTERNAL_GM_DIRECTIVE_RE = re.compile(
    r"^(?:【GM(?:私密)?指令】|【GMプライベート指示】|【权限裁定·必须遵循】|【権限裁定・必ず従うこと】|"
    r"\[Private GM Directives?\]|## Authority Adjudication)",
    re.IGNORECASE,
)

_STATE_HEADING_LINE_RE = re.compile(
    r"(?im)^[ \t#>*_`【\[]*"
    r"(?:状态[\s*_`]*(?:变更|变化|更新)|state[\s*_`-]*changes?)"
    r"[\s#>*_`】\]::-]*$"
)


def find_protocol_suffix_start(text: str) -> int | None:
    """Locate a leaked state-tag suffix when the model omitted ``---``."""
    source = str(text or "")
    heading = _STATE_HEADING_LINE_RE.search(source)
    if heading:
        for line in source[heading.end():].splitlines():
            if not line.strip():
                continue
            if parse_protocol_line(line):
                return heading.start()
            break

    # Some models omit both separator and heading. Only accept a trailing
    # block containing at least two protocol lines to avoid false positives.
    offset = 0
    lines_with_offsets: list[tuple[int, str]] = []
    for raw_line in source.splitlines(keepends=True):
        line = raw_line.rstrip("\r\n")
        if line.strip():
            lines_with_offsets.append((offset, line))
        offset += len(raw_line)
    for index, (line_offset, line) in enumerate(lines_with_offsets):
        if not parse_protocol_line(line):
            continue
        suffix_lines = [item[1] for item in lines_with_offsets[index:]]
        if len(suffix_lines) >= 2 and all(parse_protocol_line(item) for item in suffix_lines):
            return line_offset
    return None


def _normalize_protocol_block(text: str) -> str:
    lines: list[str] = []
    for line in str(text or "").splitlines():
        if _STATE_HEADING_LINE_RE.match(line):
            continue
        normalized = normalize_protocol_line(line)
        lines.append(normalized if normalized is not None else line)
    return "\n".join(lines)


def normalize_tag_protocol(text: str) -> str:
    """Repair common model variants of the narration/tag separator."""
    source = str(text or "")
    if "---" in source:
        narration, tag_block = source.split("---", 1)
        return narration + "---" + _normalize_protocol_block(tag_block)
    boundary = find_protocol_suffix_start(source)
    if boundary is None:
        return source
    suffix = source[boundary:]
    heading = _STATE_HEADING_LINE_RE.match(suffix)
    if heading:
        suffix = suffix[heading.end():]
    return source[:boundary].rstrip() + "\n---\n" + _normalize_protocol_block(suffix.lstrip())


def has_malformed_protocol_leak(text: str) -> bool:
    """Whether a response exposes tag-shaped protocol text without a safe separator."""
    source = str(text or "")
    normalized = normalize_tag_protocol(source)
    return "---" not in normalized and contains_protocol_markup(source)


def sanitize_narration(text: str) -> str:
    """Remove leaked prompt/context blocks from player-facing narration."""
    if not text:
        return ""
    text = normalize_tag_protocol(text)
    if "---" in text:
        text = text.split("---", 1)[0]
    boundary = find_protocol_suffix_start(text)
    if boundary is not None:
        text = str(text)[:boundary]
    lines = str(text).splitlines()
    kept: list[str] = []
    skipping = False
    skipping_check = False
    skipping_directive = False
    for line in lines:
        stripped = line.strip()
        protocol_line = parse_protocol_line(stripped, include_non_executable=True)
        if protocol_line:
            public_remainder = strip_protocol_markup_from_public_line(stripped)
            if public_remainder:
                kept.append(public_remainder)
            continue
        scrubbed_line = strip_protocol_markup_from_public_line(line)
        if scrubbed_line != line:
            line = scrubbed_line
            stripped = line.strip()
            if not stripped:
                continue
        normalized_heading = stripped.replace("**", "").strip("*_ ")
        if _INTERNAL_CHECK_HEADING_RE.match(normalized_heading):
            skipping_check = True
            continue
        if _INTERNAL_GM_DIRECTIVE_RE.match(normalized_heading):
            skipping_directive = True
            continue
        if skipping_directive:
            if not normalized_heading:
                skipping_directive = False
                continue
            if normalized_heading.startswith((
                "-", "以下内容只用于", "Apply these directives", "これらの指示は",
                "以下声明被裁判", "The referee flagged", "以下の宣言は",
            )):
                continue
            skipping_directive = False
        if normalized_heading.startswith("要求:下一次判定必须优先遵循这条 GM 指令"):
            continue
        if skipping_check:
            if not normalized_heading or _INTERNAL_CHECK_LINE_RE.match(normalized_heading):
                continue
            skipping_check = False
        if any(stripped.startswith(heading) for heading in _INTERNAL_CONTEXT_HEADINGS):
            skipping = True
            continue
        if any(stripped.startswith(prefix) for prefix in _INTERNAL_ANALYSIS_PREFIXES):
            continue
        if skipping and stripped.startswith("【") and stripped.endswith("】"):
            if any(stripped.startswith(heading) for heading in _INTERNAL_CONTEXT_HEADINGS):
                continue
            skipping = False
        if not skipping:
            kept.append(line)
    cleaned = "\n".join(kept).strip()
    for pattern in _META_PATTERNS:
        cleaned = pattern.sub("", cleaned).strip()
    return cleaned


def _build_result(narration: str, data: dict) -> ParsedResult:
    state_update = data.get("state_update")
    memory_delta = data.get("memory_delta")
    info_asymmetry = data.get("info_asymmetry")
    plot_update = data.get("plot_update")

    if memory_delta is not None:
        if not isinstance(memory_delta, dict):
            logger.warning("memory_delta 不是 dict,已忽略")
            memory_delta = None
        else:
            for key in ("add", "update", "forget"):
                if key not in memory_delta:
                    memory_delta[key] = []
                elif not isinstance(memory_delta[key], list):
                    memory_delta[key] = []

    return ParsedResult(
        narration=narration,
        state_update=state_update,
        memory_delta=memory_delta,
        info_asymmetry=info_asymmetry,
        plot_update=plot_update,
    )


def _repair_json(json_str: str) -> str:
    """自动修复 LLM 常见的 JSON 格式错误。"""
    s = json_str.strip()
    # 修复 0:字符串被 max_tokens 截断时,补上末尾引号
    if s.count('"') % 2 != 0:
        s += '"'

    # 修复 1:末尾多余逗号
    s = re.sub(r',\s*([}\]])', r'\1', s)
    # 修复 2:缺少末尾花括号
    missing = s.count('{') - s.count('}')
    if missing > 0:
        s += '}' * missing
    # 修复 3:缺少末尾方括号
    missing_b = s.count('[') - s.count(']')
    if missing_b > 0:
        s += ']' * missing_b
    # 修复 4:单引号字符串(全量替换时小心 value 内含单引号)
    if '"' not in s and "'" in s:
        s = re.sub(r"'([^']*)':", r'"\1":', s)
        s = re.sub(r":\s*'([^']*)'", r': "\1"', s)
    # 修复 5:LLM 在字符串值内塞了裸双引号 → 转义
    s = _escape_stray_quotes_in_values(s)
    # 修复 6:对象末尾的逗号前有注释 // comment
    s = re.sub(r'\s*//[^\n]*', '', s)
    # 修复 7:头尾有多余字符(LLM 可能在 JSON 前后夹了一句中文)
    s = _strip_surrounding_text(s)
    return s


def _escape_stray_quotes_in_values(json_str: str) -> str:
    """转义字符串值内部不该出现的裸双引号。

    LLM 有时会输出: "value": "他说"你好"世界"
    这会导致解析失败。策略:在已知的 key: 之后,成对引号之间如果出现
    第三个引号,将它转义。
    """
    result = []
    i = 0
    in_key = False
    expect_colon = False
    in_string = False
    string_start = 0
    while i < len(json_str):
        ch = json_str[i]
        if ch == '"' and not in_string:
            in_string = True
            string_start = i
            result.append(ch)
        elif ch == '"' and in_string:
            # 检查是否是合法的字符串结束:后面是 , } ] 或空白 followed by 这些
            after = json_str[i + 1:i + 10].lstrip()
            if after and after[0] in ',}:]':
                in_string = False
                result.append(ch)
            else:
                # 字符串内部的裸引号,转义
                result.append('\\"')
        elif ch == '\\' and in_string:
            result.append(ch)
            if i + 1 < len(json_str):
                i += 1
                result.append(json_str[i])
        else:
            result.append(ch)
        i += 1
    return ''.join(result)


def _strip_surrounding_text(s: str) -> str:
    """如果 JSON 前后被中文/英文文本包裹,尝试截取 JSON 部分。"""
    start = s.find('{')
    if start == -1:
        return s
    depth = 0
    for i in range(start, len(s)):
        if s[i] == '{':
            depth += 1
        elif s[i] == '}':
            depth -= 1
            if depth == 0:
                return s[start:i + 1]
    return s


def _find_balanced_json(text: str) -> str | None:
    """用括号计数法从文本中提取首个完整 JSON 对象,支持任意深度嵌套。"""
    start = text.find('{')
    if start == -1:
        return None
    depth = 0
    for i in range(start, len(text)):
        ch = text[i]
        if ch == '{':
            depth += 1
        elif ch == '}':
            depth -= 1
            if depth == 0:
                return text[start:i + 1]
    return None


def parse_llm_response(raw: str) -> ParsedResult:
    """从 LLM 原始响应中提取叙事文本和结构化数据。

    算法:
    1. 找到所有 ```json ... ``` 块
    2. 取最后一个块解析为 JSON
    3. 叙事文本 = JSON 块之前的内容(所有 JSON 块都去掉)
    4. 如果没有任何 JSON 块或解析失败 → 整个响应视为叙事

    失败时 is_narration_only=True,上层调用方应触发 JSON 重试或降级处理。
    """
    if not raw or not raw.strip():
        return ParsedResult(narration="", is_narration_only=True)

    matches = list(_JSON_BLOCK_RE.finditer(raw))
    if not matches:
        # 兜底1:尝试匹配整个响应为裸 JSON(JSON 模式输出)
        try:
            data = json.loads(raw.strip())
            logger.info("直接 JSON 解析成功(JSON 模式输出)")
            return _build_result("", data)
        except json.JSONDecodeError:
            pass

        # 兜底2:括号计数法匹配嵌套 JSON(支持多层花括号)
        bare_match = _find_balanced_json(raw)
        if bare_match:
            try:
                data = json.loads(bare_match)
                narration = raw[:raw.index(bare_match)].strip()
                narration = sanitize_narration(narration)
                logger.info("通过括号计数匹配解析成功")
                return _build_result(narration, data)
            except (json.JSONDecodeError, ValueError):
                pass
        logger.debug("LLM 响应中未找到 JSON 块,使用标签或降级纯叙事")
        narration = raw.strip()
        narration = sanitize_narration(narration)
        return ParsedResult(narration=narration, is_narration_only=True)

    # 取最后一个 JSON 块
    last_match = matches[-1]
    json_str = last_match.group(1).strip()

    # 叙事文本 = 全部 JSON 块都被去掉后的残余
    narration = raw
    for m in reversed(matches):
        narration = narration[:m.start()] + narration[m.end():]
    narration = narration.strip()

    # 过滤 LLM 的元文本("明白了""好的" 等确认语)
    narration = sanitize_narration(narration)
    # 如果过滤后变空了,保留原文(至少玩家能看到点东西)
    if not narration:
        narration = raw.strip()
        for m in reversed(matches):
            narration = narration[:m.start()] + narration[m.end():]
        narration = sanitize_narration(narration)

    # 尝试解析(先尝试修复版,再原始版)
    try:
        data = json.loads(json_str)
    except json.JSONDecodeError:
        try:
            repaired = _repair_json(json_str)
            data = json.loads(repaired)
            logger.info("JSON 自动修复成功")
        except json.JSONDecodeError as exc:
            logger.warning("LLM 输出 JSON 解析失败: %s", exc)
            return ParsedResult(narration=narration, is_narration_only=True)

    return _build_result(narration, data)


def make_retry_message(original_prompt: str, previous_error: str) -> str:
    """构造重试提示:告诉 LLM 上次 JSON 格式哪里错了。"""
    return (
        f"你上次回复末尾的 JSON 格式有问题。\n"
        f"请重新输出完整 GM 回复,并在末尾添加一行紧凑 JSON:\n"
        f'{{"state_update":{{"players":{{}}}},"memory_delta":{{"add":[],"update":[],"forget":[]}},"info_asymmetry":{{}}}}\n'
        f"没有变动就传空对象。不要输出'明白了'这类确认文字,直接输出故事。\n\n"
        f"原来上下文:\n{original_prompt}"
    )