"""DiceFrame 公开文档的轻量检索索引。 优先加载随程序打包的预建索引包(scripts/build_assistant_knowledge.py 生成), 不联网、不解析 markdown,秒检索。预建索引不存在时(开发环境)回退到运行时 解析本地 docs。便携版 / 源码发布包通过预建索引获得文档,无需打包原始 docs。 diceframe-content 仓库提供通用用户手册和部署文档;本仓库维护 README、 更新、当前功能流程与翻译政策,作为版本相关的内置补充。运行时后台从其 GitHub raw 拉取 docs/{lang}/*.md(TTL 缓存,失败静默回退内置索引),文档推送后无需发版, 各实例的助手在 TTL 内自动跟进。README 属于主程序仓库,只走内置索引。 DF Agent 使用 current_version_only 检索:只读当前本地版本说明或版本匹配的 打包索引,不查询移动远程指南。其能力与操作以工具接口和实时观察为准。 """ from __future__ import annotations import asyncio import hashlib import json import logging import math import os import re import time from collections import Counter from dataclasses import dataclass from pathlib import Path from typing import Any from src.version import __version__ logger = logging.getLogger("trpg") PROJECT_ROOT = Path(__file__).resolve().parents[2] INDEX_FILE = Path(__file__).parent / "assistant_knowledge_index.json" _DOCUMENTS = { "zh": ( "README_zh-CN.md", "docs/zh/updates.md", "docs/zh/content-and-play.md", "docs/zh/translations.md", ), "en": ( "README.md", "docs/en/updates.md", "docs/en/content-and-play.md", "docs/en/translations.md", ), } # diceframe-content 仓库 docs/ 下的文档清单(相对 docs/{lang}/),运行时拉取。 _CONTENT_DOC_PATHS = { "zh": ( "guide.md", "deploy.md", "standalone-webui.md", "plugin-development.md", "plugin-registry.md", "scene-images.md", "voice-pack-publishing.md", "website.md", "bot-bridge-core.md", ), "en": ( "guide.md", "deploy.md", "standalone-webui.md", "plugin-development.md", "plugin-registry.md", "voice-pack-publishing.md", "bot-bridge-core.md", ), } GITHUB_DOCS_BASE_URL = "https://raw.githubusercontent.com/diceframe/diceframe-content/main/docs" _DOCS_BASE_URLS = tuple(dict.fromkeys(filter(None, ( os.getenv("DICEFRAME_DOCS_BASE_URL", "").strip().rstrip("/"), GITHUB_DOCS_BASE_URL, )))) _REMOTE_TTL_SECONDS = 3600 _REMOTE_FETCH_TIMEOUT = 8 _HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)\s*$") _ASCII_WORD_RE = re.compile(r"[a-z0-9][a-z0-9_.:/+-]*", re.IGNORECASE) _CJK_RE = re.compile(r"[㐀-鿿]+") _MAX_CHUNK_CHARS = 3600 _DEFAULT_CONTEXT_CHARS = 6_000 @dataclass(frozen=True) class DocumentChunk: source: str heading: str text: str tokens: Counter[str] heading_tokens: frozenset[str] @dataclass(frozen=True) class KnowledgeResult: context: str sources: list[dict[str, str]] _CACHE: dict[str, tuple[str, tuple[DocumentChunk, ...]]] = {} _CACHE_LOCK = asyncio.Lock() # 远程文档索引缓存: lang -> (过期时间戳, chunks 或 None)。None 也缓存, # 离线时避免每条助手消息都重新撞网络。TTL 过期后下一次查询再试。 _REMOTE_CACHE: dict[str, tuple[float, tuple[DocumentChunk, ...] | None]] = {} def _language_key(language: str) -> str: return "en" if (language or "").lower().startswith("en") else "zh" def _tokens(text: str) -> list[str]: normalized = text.lower() result = _ASCII_WORD_RE.findall(normalized) for sequence in _CJK_RE.findall(normalized): result.append(sequence) if len(sequence) > 1: result.extend(sequence[index : index + 2] for index in range(len(sequence) - 1)) return result def _split_long_text(text: str, limit: int = _MAX_CHUNK_CHARS) -> list[str]: if len(text) <= limit: return [text] paragraphs = re.split(r"\n{2,}", text) parts: list[str] = [] current = "" for paragraph in paragraphs: if not paragraph: continue candidate = f"{current}\n\n{paragraph}" if current else paragraph if len(candidate) <= limit: current = candidate continue if current: parts.append(current) while len(paragraph) > limit: parts.append(paragraph[:limit]) paragraph = paragraph[limit:] current = paragraph if current: parts.append(current) return parts def _parse_markdown(source: str, text: str) -> list[DocumentChunk]: chunks: list[DocumentChunk] = [] headings: list[str] = [] body: list[str] = [] def flush() -> None: value = "\n".join(body).strip() if not value: return heading = " > ".join(headings) or source for part in _split_long_text(value): chunks.append( DocumentChunk( source=source, heading=heading, text=part, tokens=Counter(_tokens(f"{heading}\n{part}")), heading_tokens=frozenset(_tokens(heading)), ) ) for line in text.splitlines(): match = _HEADING_RE.match(line) if not match: body.append(line) continue flush() body = [] level = len(match.group(1)) title = match.group(2).strip() headings = headings[: level - 1] headings.append(title) flush() return chunks def _load_built_index(lang_key: str, *, current_version_only: bool = False) -> tuple[DocumentChunk, ...] | None: """加载预建索引包;不存在或损坏返回 None,由调用方回退到运行时解析。""" if not INDEX_FILE.exists(): return None try: data = json.loads(INDEX_FILE.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None if current_version_only: metadata = data.get("_meta") if isinstance(data, dict) else None if not isinstance(metadata, dict) or metadata.get("schema") != 1 or metadata.get("application_version") != __version__: return None items = data.get(lang_key) if isinstance(data, dict) else None if not isinstance(items, list): return None chunks: list[DocumentChunk] = [] for item in items: if not isinstance(item, dict): continue tokens = item.get("tokens") heading_tokens = item.get("heading_tokens") chunks.append( DocumentChunk( source=str(item.get("source") or ""), heading=str(item.get("heading") or ""), text=str(item.get("text") or ""), tokens=Counter(tokens if isinstance(tokens, dict) else {}), heading_tokens=frozenset(heading_tokens if isinstance(heading_tokens, list) else []), ) ) return tuple(chunks) def _current_version_index(language: str) -> tuple[DocumentChunk, ...]: """Use current checkout files, or verified bundled files; never moving guides.""" lang_key = _language_key(language) local = _build_index_from_local(lang_key) available = {relative for relative in _DOCUMENTS[lang_key] if (PROJECT_ROOT / relative).is_file()} bundled = _load_built_index(lang_key, current_version_only=True) or () return local + tuple(chunk for chunk in bundled if chunk.source in _DOCUMENTS[lang_key] and chunk.source not in available) def _build_index_from_local(lang_key: str) -> tuple[DocumentChunk, ...]: """兜底:预建索引不存在时(开发环境),运行时解析本地 docs。""" chunks: list[DocumentChunk] = [] for relative in _DOCUMENTS[lang_key]: try: text = (PROJECT_ROOT / relative).read_text(encoding="utf-8") except OSError: continue chunks.extend(_parse_markdown(relative, text)) return tuple(chunks) def _local_signature(lang_key: str) -> str: """本地 docs 内容签名,内容变化才重建索引。""" digest = hashlib.sha256() for relative in _DOCUMENTS[lang_key]: try: text = (PROJECT_ROOT / relative).read_text(encoding="utf-8") except OSError: text = "" digest.update(relative.encode("utf-8")) digest.update(b"\0") digest.update(text.encode("utf-8")) digest.update(b"\0") return digest.hexdigest() def _doc_source_name(lang_key: str, path: str) -> str: """远程文档在索引里的来源名,与预建索引中 trpg/docs 的相对路径写法一致。""" return f"docs/{lang_key}/{path}" async def _fetch_remote_docs(lang_key: str) -> dict[str, str] | None: """从 diceframe-content 仓库拉取 docs/{lang}/*.md;全部失败返回 None。 单个文件 404(文档被上游删除)不影响其他文件,按实际拉到的集合替换。 aiohttp 延迟到函数内导入:本模块被 scripts/build_assistant_knowledge.py 在无依赖的构建环境加载,顶层 import 会让发布打包直接崩(CI 不装 aiohttp)。 """ if not _DOCS_BASE_URLS: return None paths = _CONTENT_DOC_PATHS.get(lang_key) or () if not paths: return None try: import aiohttp except ImportError: return None async def fetch_one(session: aiohttp.ClientSession, base: str, path: str) -> str | None: try: async with session.get(f"{base}/{lang_key}/{path}") as resp: if resp.status != 200: return None text = await resp.text() return text if text.strip() else None except (aiohttp.ClientError, asyncio.TimeoutError): return None try: timeout = aiohttp.ClientTimeout(total=_REMOTE_FETCH_TIMEOUT) async with aiohttp.ClientSession(timeout=timeout) as session: for base in _DOCS_BASE_URLS: results = await asyncio.gather( *(fetch_one(session, base, path) for path in paths) ) texts = { path: text for path, text in zip(paths, results) if text is not None } if texts: return texts except Exception: logger.debug("助手远程文档拉取异常", exc_info=True) return None async def _remote_index(lang_key: str) -> tuple[DocumentChunk, ...] | None: """带 TTL 缓存的远程文档索引;离线/失败返回 None(用内置索引兜底)。""" now = time.monotonic() cached = _REMOTE_CACHE.get(lang_key) if cached and cached[0] > now: return cached[1] texts = await _fetch_remote_docs(lang_key) chunks: tuple[DocumentChunk, ...] | None = None if texts is not None: parsed: list[DocumentChunk] = [] for path, text in texts.items(): parsed.extend(_parse_markdown(_doc_source_name(lang_key, path), text)) chunks = tuple(parsed) logger.info( "助手远程文档已加载 (%s): %d 个文件, %d 个片段", lang_key, len(texts), len(chunks), ) _REMOTE_CACHE[lang_key] = (now + _REMOTE_TTL_SECONDS, chunks) return chunks async def _index(language: str) -> tuple[DocumentChunk, ...]: lang_key = _language_key(language) built = _load_built_index(lang_key) if built is not None: signature = "built" else: signature = _local_signature(lang_key) cached = _CACHE.get(lang_key) if cached and cached[0] == signature: base = cached[1] else: async with _CACHE_LOCK: cached = _CACHE.get(lang_key) if cached and cached[0] == signature: base = cached[1] else: base = built if built is not None else _build_index_from_local(lang_key) _CACHE[lang_key] = (signature, base) remote = await _remote_index(lang_key) if remote is None: return base # 远程文档按 source 全量替换内置快照:上游删改即时生效,README 等 # 主程序自有文档不受影响。 replaced = {chunk.source for chunk in remote} return tuple(chunk for chunk in base if chunk.source not in replaced) + remote async def prefetch_remote_indexes() -> None: """启动时预热远程文档缓存,避免首个助手问题在线等拉取。""" await asyncio.gather( *(_remote_index(lang_key) for lang_key in ("zh", "en")), return_exceptions=True, ) def _score(chunk: DocumentChunk, query_tokens: Counter[str], normalized_query: str) -> float: score = 0.0 for token, query_count in query_tokens.items(): count = chunk.tokens.get(token, 0) if not count: continue score += query_count * (1.0 + math.log1p(count)) if token in chunk.heading_tokens: score += query_count * 3.5 compact_query = re.sub(r"\s+", "", normalized_query.lower()) compact_text = re.sub(r"\s+", "", f"{chunk.heading}\n{chunk.text}".lower()) if len(compact_query) >= 3 and compact_query in compact_text: score += 8.0 return score async def search_knowledge( query: str, language: str, *, limit: int = 4, max_chars: int = _DEFAULT_CONTEXT_CHARS, current_version_only: bool = False, ) -> KnowledgeResult: query_tokens = Counter(_tokens(query)) if not query_tokens: return KnowledgeResult(context="", sources=[]) index = _current_version_index(language) if current_version_only else await _index(language) scored = [ (_score(chunk, query_tokens, query), chunk) for chunk in index ] scored = [(score, chunk) for score, chunk in scored if score >= 2.0] scored.sort(key=lambda item: (-item[0], item[1].source, item[1].heading)) selected: list[DocumentChunk] = [] seen: set[tuple[str, str]] = set() used_chars = 0 for _, chunk in scored: identity = (chunk.source, chunk.heading) if identity in seen: continue rendered = f"[来源: {chunk.source} > {chunk.heading}]\n{chunk.text}" if selected and used_chars + len(rendered) > max_chars: continue if not selected and len(rendered) > max_chars: rendered = rendered[:max_chars] chunk = DocumentChunk( source=chunk.source, heading=chunk.heading, text=rendered.split("\n", 1)[-1], tokens=chunk.tokens, heading_tokens=chunk.heading_tokens, ) selected.append(chunk) seen.add(identity) used_chars += len(rendered) if len(selected) >= limit: break context_parts = [ f"[来源: {chunk.source} > {chunk.heading}]\n{chunk.text}" for chunk in selected ] sources = [{"source": chunk.source, "heading": chunk.heading} for chunk in selected] return KnowledgeResult(context="\n\n".join(context_parts), sources=sources)