Files

274 lines
11 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""RAG 知识库模板化 —— 换行业只改模板配置,内核零改动(PRD 5.4 / Issue #46)。
领域 RAG 知识库按**模板配置**(对齐 data-bus 的模板化思想,见 PRD 5.4
「知识库范围(由模板RAG库提供)」与 7.3「领域RAG知识库(一期)」):
- 知识源分类固定为三类,模板配置声明每类收录的文档:
- `process` 工艺规范(沸腾氯化工艺规范等);
- `sop` 标准作业程序 / 操作手册 / 异常处置 SOP;
- `standard` 国标 / 行业标准 / 文献(氯、钛相关国标等)。
- 全部外部资源命名由模板名推导(向量库 collection / 索引 / namespace),
命名清洗规则对齐 data-bus(小写 + 非安全字符替换为 `_`),但**保留中文**
(文档对象键 / doc_id 面向中文标题,MinIO 对象键支持 UTF-8)。
配置即「模板 RAG 库」资产(`config/kb.template.yaml`),运行时绑定模板
版本,保证检索结果可复现(对齐 PRD 5.4 Prompt/知识库版本化)。
"""
from __future__ import annotations
import re
from enum import Enum
from typing import Dict, List, Tuple
# 保留字符集:小写字母、数字、`-` `_` `.` 与中文(对齐 data-bus 清洗规则,
# 但保留中文以便文档对象键 / doc_id 直接使用中文标题)
_KEEP = re.compile(r"[^a-z0-9_.\u4e00-\u9fff-]+")
def sanitize(name: str) -> str:
"""通用命名清洗:小写 + 非安全字符(空格/标点等)替换为 `_`。
RAG 命名保留中文(文档标题即对象键),仅清洗 ASCII 不安全字符;
与 data-bus 的 sanitize 不同点即在此。
"""
s = (name or "").strip().lower()
s = _KEEP.sub("_", s)
return s.strip("._") or "kb"
class KnowledgeSourceKind(str, Enum):
"""知识源分类(模板 RAG 库的三类知识范围,PRD 7.3)。"""
PROCESS = "process" # 工艺规范
SOP = "sop" # 标准作业程序 / 操作手册 / 异常处置 SOP
STANDARD = "standard" # 国标 / 行业标准 / 文献
@property
def label(self) -> str:
return {
KnowledgeSourceKind.PROCESS: "工艺规范",
KnowledgeSourceKind.SOP: "SOP/操作手册",
KnowledgeSourceKind.STANDARD: "国标/标准",
}[self]
# 知识源分类注册表:模板配置中类别名 → 枚举(未知类别名直接拒绝,避免拼写漂移)
SOURCE_KINDS: Dict[str, KnowledgeSourceKind] = {k.value: k for k in KnowledgeSourceKind}
class KbTemplateNaming:
"""按模板推导 RAG 知识库全部外部资源命名(向量库 / 索引 / namespace)。
命名对齐 data-bus(`tpl_{tpl}` schema 风格):collection 名用下划线形态,
namespace 用连字符形态(对象存储桶风格)。
"""
def __init__(self, template: str, index_suffix: str = "kb"):
self.template = sanitize(template)
# SQL/向量库标识符用下划线形态(避免 `-` 需引号转义)
self.tpl_sql = self.template.replace("-", "_")
self.index_suffix = sanitize(index_suffix)
def collection(self) -> str:
"""向量库 collection:`{tpl}_kb`(对齐 `tpl_{tpl}` schema 命名)。"""
return f"{self.tpl_sql}_{self.index_suffix}"
def index_name(self) -> str:
"""检索索引名:`{tpl}_kb_idx`。"""
return f"{self.collection()}_idx"
def namespace(self) -> str:
"""对象存储 namespace:`tpl-{template}-kb`(S3 桶风格,允许 `-`)。"""
return f"tpl-{self.template}-{self.index_suffix}"
def doc_object_key(self, doc_id: str, ext: str = "md") -> str:
"""源文档对象键:`kb/{doc_id}.{ext}`(保留中文标题)。"""
return f"kb/{sanitize(doc_id)}.{sanitize(ext) or 'md'}"
class KbSourceConfig:
"""单个知识源的配置(模板 RAG 库声明某类知识源收录哪些文档)。"""
def __init__(self, kind: KnowledgeSourceKind, documents: List[str]):
self.kind = kind
self.documents = list(documents)
def __repr__(self) -> str: # pragma: no cover - 调试辅助
return f"KbSourceConfig({self.kind.value}, docs={len(self.documents)})"
class KbTemplateConfig:
"""模板 RAG 库配置:模板名 + 三类知识源文档清单。"""
def __init__(self, template: str, sources: List[KbSourceConfig], version: str = "1.0.0"):
self.template = template
self.sources = sources
self.version = version
def documents_for(self, kind: KnowledgeSourceKind) -> List[str]:
"""返回某类知识源收录的文档标题列表。"""
for src in self.sources:
if src.kind is kind:
return list(src.documents)
return []
def all_documents(self) -> List[str]:
"""全部知识源文档标题(去重保序)。"""
seen, out = set(), []
for src in self.sources:
for d in src.documents:
if d not in seen:
seen.add(d)
out.append(d)
return out
# ---------------------------------------------------------------------------
# 轻量 YAML 子集解析(零第三方依赖,递归下降):map / list / scalar / 注释。
# 足以解析 `config/kb.template.yaml` 这类模板资产;运行时零依赖可复现。
# ---------------------------------------------------------------------------
def _parse_scalar(text: str) -> str:
"""去掉标量两侧引号与行内注释(`key: value # comment`)。"""
t = text.split(" #", 1)[0].strip()
if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
return t[1:-1]
return t
def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]:
"""剔除空行与整行注释,保留行号(1 起)用于报错定位。"""
out = []
for i, ln in enumerate(lines):
s = ln.strip()
if not s or s.startswith("#"):
continue
out.append((ln, i + 1))
return out
def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int):
"""递归解析从 lines[i] 开始、缩进为 `indent` 的一个节点。
返回 `(value, next_i)`:value 为 dict / list / str,next_i 为下一个
未消费行的下标。
"""
text, no = lines[i]
lead = len(text) - len(text.lstrip(" "))
# ---- list 节点:`- item` 或 `- key: val`(map 项) ----
if text.lstrip(" ").startswith("- "):
items: List[object] = []
while i < len(lines):
t, no2 = lines[i]
stripped = t.lstrip(" ")
if not stripped.startswith("- "):
break
lead_j = len(t) - len(t.lstrip(" "))
if lead_j != indent:
break # 缩进不同的 `-` 项属于外层块,交还上层处理
item_text = stripped[2:].strip()
if not item_text:
raise ValueError(f"kb.yaml 第 {no2} 行:list 项为空")
if ":" in item_text:
# map 项:把 `- key: val` 规范化为缩进 `map_indent` 的 map 首行
map_indent = len(t) - len(t.lstrip(" ")) + 2
lines[i] = (" " * map_indent + item_text, no2)
v, i = _parse_node(lines, i, map_indent)
items.append(v)
else:
items.append(_parse_scalar(item_text))
i += 1
return items, i
# ---- map 节点:`key: value` / `key:`(嵌套值) ----
result: Dict[str, object] = {}
while i < len(lines):
t, no = lines[i]
lead_j = len(t) - len(t.lstrip(" "))
if lead_j < indent or t.lstrip(" ").startswith("- "):
break
if lead_j > indent:
raise ValueError(f"kb.yaml 第 {no} 行缩进异常(期望 {indent},实际 {lead_j})")
if ":" not in t:
raise ValueError(f"kb.yaml 第 {no} 行不是合法键值对:{t!r}")
key, _, rest = t.partition(":")
key = key.strip()
rest = rest.strip()
if rest:
result[key] = _parse_scalar(rest)
i += 1
continue
# `key:` —— 值在后续行嵌套(list 或 map),缩进必须更深
if i + 1 >= len(lines):
raise ValueError(f"kb.yaml 第 {no} 行 {key!r} 缺少值")
sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" "))
if sub_indent <= indent:
raise ValueError(f"kb.yaml 第 {no} 行 {key!r} 缺少值(无嵌套内容)")
v, i = _parse_node(lines, i + 1, sub_indent)
result[key] = v
return result, i
def _load_yaml_text(text: str) -> Dict[str, object]:
"""解析 YAML 子集 → 嵌套 dict/list。顶层必须为 map。"""
lines = _strip_comments(text.splitlines())
if not lines:
return {}
top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" "))
value, next_i = _parse_node(lines, 0, top_indent)
if not isinstance(value, dict):
raise ValueError("kb.yaml 顶层必须是 map")
if next_i < len(lines):
raise ValueError(
f"kb.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点(缩进不一致)"
)
return value
def load_kb_config(path: str) -> KbTemplateConfig:
"""从模板 RAG 库 YAML 资产加载配置(零第三方依赖)。
期望结构(详见 `config/kb.template.yaml`):
```yaml
template: ti-cl4
version: 1.0.0
sources:
- kind: process
documents: [...]
- kind: sop
documents: [...]
- kind: standard
documents: [...]
```
"""
with open(path, "r", encoding="utf-8") as fh:
data = _load_yaml_text(fh.read())
template = str(data.get("template", "")).strip()
if not template:
raise ValueError("kb.yaml 缺少 template 字段")
version = str(data.get("version", "1.0.0")).strip() or "1.0.0"
sources: List[KbSourceConfig] = []
raw_sources = data.get("sources") or []
if not isinstance(raw_sources, list):
raise ValueError("kb.yaml sources 必须是 list")
for item in raw_sources:
if not isinstance(item, dict):
raise ValueError(f"kb.yaml sources 项必须是 map,实际 {item!r}")
kind_name = str(item.get("kind", "")).strip()
if kind_name not in SOURCE_KINDS:
raise ValueError(
f"kb.yaml 未知知识源类别 {kind_name!r}(应为 {sorted(SOURCE_KINDS)})"
)
docs = item.get("documents") or []
if not isinstance(docs, list):
raise ValueError(f"kb.yaml {kind_name} 的 documents 必须是 list")
sources.append(
KbSourceConfig(SOURCE_KINDS[kind_name], [str(d) for d in docs])
)
return KbTemplateConfig(template=template, sources=sources, version=version)