文件导入管道架构¶
文件导入管道负责将任意办公文档(PDF、XLSX/XLS、CSV、PPTX、DOCX)转换为经过校验的 KnowledgeDoc 并写入 Elasticsearch。整个流程强制人工审核:LLM 仅做结构抽取,未经人工确认的暂存文档不会进入可检索的索引。
与系统其它模块一致的零编造原则:LLM 仅做切分与标注,必须原文复制。所有接口位于 POST /api/v1/ingest/*;若未配置 KB_LLM__API_KEY 则返回 HTTP 503。
POST /api/v1/ingest/upload— multipart 上传,创建会话POST /api/v1/ingest/scan— 扫描服务端文件夹GET /api/v1/ingest/sessions[/{id}]— 列出 / 查询会话PUT /api/v1/ingest/sessions/{id}/documents/{idx}— 编辑暂存文档PATCH /api/v1/ingest/sessions/{id}/documents/{idx}— 接受 / 拒绝PATCH /api/v1/ingest/sessions/{id}/documents/{idx}/resolve— 解决冲突(保留 / 覆盖 / 合并)GET /api/v1/ingest/sessions/{id}/summary— 提交前的后果计数POST /api/v1/ingest/sessions/{id}/commit— 将已接受文档写入 ES
编排逻辑位于 src/kb/services/import_pipeline.py。
端到端流程¶
客户端(文件或文件夹路径 + 可选 hints)
│
▼
[0] 哈希与去重
│ 对文件字节计算 SHA-256 → 查询 kb_import_files 索引
│ 已经 committed → SKIPPED_DUPLICATE(除非 force=true),并附带
│ DuplicateInfo 摘要:说明 KB 已为该文件保存了什么
│ 否则 → tracker 记录 pending(原子 upsert),将文件写入 upload_dir
│
▼
[1] 文本抽取(按文件类型)— 在工作线程中运行(asyncio.to_thread)
│ PDF: pymupdf 直接抽取 → 图片型页面回退 PaddleOCR
│ XLSX/XLS: openpyxl,每个 sheet 一"页"
│ CSV: 标准库 csv,按行分块为虚拟页
│ PPTX: python-pptx,每张幻灯片一页
│ DOCX: python-docx,段落聚合为页
│ → list[(页码, 文本)]
│
▼
[2] 按块路由(提供 knowledge_type_hint 时锁定单一类型,跳过路由)
│ 按 ingest.segmentation_chunk_chars(默认 12000)切块
│ 每块由 LLM 路由器返回类型列表(默认仅返回单一主导类型):
│ {"types": ["alarm"]} ← 单一主导类型(默认)
│ {"types": ["alarm", "setup"]} ← 仅当两种类型各自独立成块、
│ 结构清晰时才返回多个
│ {"types": ["skip"]} ← 非正文(封面 / 目录 / 前言)
│ skip → 丢弃该块并生成友好的 SkippedChunk(reason、hint)
│
▼
[3] LLM 切分(同一块对每个检测到的类型各调用一次)
│ 提示词由 config/knowledge_types/<type>.yaml 渲染——是 LLM 契约
│ 与 pydantic 模型的唯一来源
│ 每次按类型的调用都带"忽略其他类型内容"以及"无条目则返回 []、
│ 禁止输出空字段占位条目"的规则
│ 超长单页按结构(标题/段落/换行)继续细分
│ 相邻块保留 1 页重叠;按知识类型对近重复条目分组(保留而非丢弃)
│ JSON 解析失败时:抢救最长合法前缀 → 对象扫描 →
│ 触发修复重试 → 按页二分递归(递归下限:1 页)
│ 条目校验:丢弃必填字段为空或 confidence < 0.3 的条目(路由误判防护)
│ 项目/机台:LLM 原文抽取 > 文件名/上传 hint > 所有项目
│ → (StagedDocument[],SkippedChunk[])
│ on_chunk_progress 上报*已完成*的块数(n/total → "Finalizing…")
│
▼
[3.5] 富化(services/cross_reference.py,尽力而为)
│ detect_collisions(): 用每条暂存文档的预期 doc_id 对线上 kb_<type>
│ 索引做 mget。命中 ⇒ 提交将覆盖一条已提交文档 → 附加
│ ExistingDocSnapshot,并令 collision_action = None(阻断提交)。
│ 同批内身份相同的暂存文档改为折叠成一个精确重复组。
│ find_related(): 附加相关已提交文档(同 error_code / 机台 /
│ 向量或 BM25 相似度)到 StagedDocument.related[]
│
▼
[4] 会话进入 READY 状态
│ ImportSession.documents 已填充;status = ready_for_review
│
▼ (客户端审阅 / 编辑 / 接受拒绝 / 解决冲突)
│
[5] POST /commit
│ 对每个 accepted=True 的 StagedDocument:
│ → 冲突拦截:未解决冲突 → 报告但不写入;"keep" → 跳过(保留现有);
│ overwrite/merge → 正常写入
│ → _staged_to_knowledge_doc(): 转为 Alarm/Setup/ExperienceDoc
│ → validate_against_taxonomy()
│ → 通过 DashScope embed [title_text, body_text](尽力而为)
│ → 以 refresh="wait_for" 写入 kb_<type>_v1 别名
│ → 按 file_hash 聚合,供 tracker 更新
│ record_committed(file_hash, [es_actions])
│ → CommitResponse {committed, skipped, errors}
步骤 1–4 在后台 asyncio.create_task 中执行;upload/scan 接口立即返回 202 Accepted 与 session_id。客户端轮询 GET /sessions/{id}(携带 files_processed、单文件 status/message 及会话级 message)直到 status == ready_for_review。
文本抽取(services/extraction.py)¶
每种文件类型都有专门抽取函数,返回 list[PageText] = list[(int, str)]。页码全程保留,切分得到的文档可通过 source_pages 回溯到源文件页码。
| 类型 | 后端 | 说明 |
|---|---|---|
pymupdf (fitz) |
page.get_text 抽取正文 + page.find_tables() 将表格渲染为竖线网格;图片型页面回退 PaddleOCR |
|
| XLSX/XLS | openpyxl |
一个 sheet 一页;行渲染为 \| col \| col \| 竖线网格;顶部标注 sheet 名称 |
| CSV | 标准库 csv |
自动检测编码(utf-8-sig / utf-8 / gb18030 / latin-1);行以 tab 拼接 |
| PPTX | python-pptx |
每张幻灯片一页;表格渲染为竖线网格;附演讲者备注 |
| DOCX | python-docx |
按文档顺序遍历正文(body.iterchildren()),段落与表格保持原文交织;表格渲染为竖线网格 |
表格感知:PDF、DOCX、PPTX、XLSX 的表格统一以 | 单元格 | 单元格 | 单元格 | 形式渲染,使列/行关系在送入 LLM 时得以保留——无论横表(表头在上)还是竖表(表头在左),都按底层库返回的布局原样呈现。对 PDF 来说,get_text 的扁平 token 视图与表格网格视图同时送给 LLM。单元格中嵌入的 | 会替换为 / 以避免破坏网格结构。
DOCX 文档顺序:DOCX 抽取按真实阅读顺序遍历文档正文(doc.element.body.iterchildren(),根据 w:p / w:tbl 标签分发),不再采用旧的"先所有段落、再所有表格"两遍式。两遍式会破坏散文条目与紧随其后、标注了项目/机台的单行表格之间的位置关系——交织遍历保留这种关联,既利于切分,也利于下文的项目/机台原文抽取。
PDF 文本清洗:_clean_extracted_text 会剥离 NUL、软连字符(\xad)、BOM、换页符及其它常见的 C0 控制字符;统一 Windows/Mac 换行符,将 3 行以上的空行折叠为 2 行。这样下游切分与 ES 索引无需再防御这些可能破坏检索或 JSON 解析的"隐形字符"。
OCR 回退仅在 ingest.ocr_enabled = true 且页面直接文本过短(或可打印字符比例偏低)且包含图片时触发。PaddleOCR(ocr_lang 默认 ch)首次使用时懒加载,存在明显冷启动延迟。OCR 结果仅在显著更长(>20%)且通过可打印字符健全性检测后,才会替换直接文本——避免 OCR 噪声覆盖原本质量良好的抽取文本。OCR 异常会被捕获并记录日志,默认保留直接文本。
可选依赖通过 _try_import 加载——缺失某个后端(如 PaddleOCR)不会导致服务崩溃,但相关文件会失败并给出清晰的 ImportError。安装额外依赖:pip install -e ".[ingest]"。
不阻塞事件循环:extract_file 是同步且 CPU 密集的(pymupdf / python-docx / openpyxl / PaddleOCR),因此管道通过 asyncio.to_thread 调用它。这样一个大文件或 OCR 繁重的文件就不会冻结单一的异步事件循环、拖垮其它进行中的请求(上传、状态轮询、健康检查)——否则前置代理会把这种卡顿报成 408/504。
知识类型规范(config/knowledge_types/*.yaml)¶
每种知识类型都有一个 YAML 规范文件,同时驱动 LLM 提示词和存储契约。修改 YAML 会同步改变 LLM 被告知要提取的内容以及与 pydantic 模型的对齐校验——两者不会再出现漂移。
config/knowledge_types/
├── alarm.yaml ← 对应 config/机台报警_header.csv
├── setup.yaml ← 对应 config/机台setup_header.csv
└── experience.yaml ← 对应 config/设备经验_header.csv
每个规范文件包含:
| 块 | 用途 |
|---|---|
summary_zh / summary_en |
一句话描述,写入路由提示词供 LLM 判断是否选用此类型 |
fields[] |
输出 JSON 结构——每个字段含 name、desc,可选 label_zh、csv_column 及 required 标记。三个规范均含 project、equipment 字段,由 LLM 从源文原文填写("Project: X" / "项目: X" 单元格),源文未提及时填 ""——禁止从文件名或上下文猜测 |
boundary_hints[] |
切分条目时的判定线索 |
skip_if[] |
"非正文"判定规则(封面、目录、前言…) |
confidence_guide |
单条 confidence 评分准则 |
example_input / example_output |
取自 CSV 第一行的 few-shot 范例 |
services/spec.py 负责加载并缓存这些 YAML,然后渲染两类提示词:
render_segmentation_prompt(spec)— 渲染按类型的抽取提示词。包含字段列表(向 LLM 展示中文标签与 CSV 列名)、范例,以及多条显式规则:(a) "仅提取<type>类型条目;同块中其他类型的内容请直接忽略";(b) "若该块不含<type>条目,返回空数组[];不要为凑数而输出必填字段为空的占位条目";(c) "每条输出条目的必填字段(由规范计算得出)必须原文填写——找不到必填字段值时丢弃该条目。" (b)/(c) 中的必填字段清单由规范里required: true的字段计算得出,因此提示词始终与条目校验所强制的内容一致。render_router_prompt(specs)— 渲染路由提示词。返回{"types": [...]}(列表),但规则偏向单一主导类型:仅当各类型条目各自独立成块、结构清晰时(如一张独立的报警代码表 与 一段独立的编号调试流程)才返回多个类型。提示词附带反例——报警的"解除流程"编号步骤仍属["alarm"]、内嵌引用报警代码的调试流程仍属["setup"]。
对齐校验测试(tests/unit/test_spec.py)确保 pydantic 模型的每个必需字段都被规范覆盖,且规范中的 example_output 能完整通过 _parsed_to_staged() 流程——提示词与模型的漂移在测试阶段就会暴露,而不是等到提交时才报错。
切分(services/segmentation.py)¶
LLM 在此扮演结构化解析器,不是写作者。按类型的系统提示词由上述 YAML 规范渲染得到,要求模型:
- 完全原文复制——禁止改写、捏造或概括。
- 源文中缺失的可选字段填
""。必填内容字段必须原文填写,否则丢弃该条目——不得编造占位符。 - 将
| col | col | col |形式的行识别为表格行,保留单元格顺序。 - 输出 JSON 数组,每条记录附带
confidence评分(0.0–1.0)。当该块不含目标类型条目时,返回空数组[]——绝不输出字段为空的占位条目。 - 只提取当前提示词指定的类型;同块中其他类型的内容必须忽略。
健壮性管道按外→内分层:结构化切块 → JSON 抢救(最长前缀 + 对象扫描)→ 修复重试 → 二分恢复 → 条目校验 → 跨块去重。 各层详见下文。
切块¶
chunk_pages() 将页打包为受 segmentation_chunk_chars 约束的块,相邻块保留 _OVERLAP_PAGES = 1 页重叠,确保跨块边界的条目仍能被完整看到。该重叠是预算感知的:只有当上一页与下一页合计仍不超过预算(len(overlap) + len(page) <= max_chars)时,才把上一页带入下一块;否则下一块从空开始。这样可保证每个块都不超过 max_chars——此前无条件把接近占满的整页作为重叠再加进去,会与下一页合成超预算的块(例如 12k 预算下出现 16k 的块),LLM 可能在 JSON 中途被截断,从而悄悄丢掉该页全部条目("未提取到文档 / No documents extracted")。
打包前,_split_oversized_page() 会对任何单页超过 max_chars 的页进行结构化细分,优先级如下:
- 类标题边界 — markdown 标题、中文
第N章/节、英文Chapter N、编号小节(1.2.3 …)、纯大写行。 - 段落分隔(
\n\n)。 - 行分隔(
\n)。 - 硬字符截断(最后手段,仅在单行长度超过
max_chars时使用)。
按标题切分时,第一个标题之前的前导文本会作为独立片段保留,而非被丢弃——否则位于首个标题行之上的条目(如出现在首个纯文本标题之前、以表格渲染的告警行)会丢失,可能导致整个文件零文档提取。
细分得到的子页保留原页码,因此 source_pages 的可追溯性不受影响。这堵上了之前一个隐性漏洞——单个超大页(如 1 页的 DOCX、巨大的 sheet、长版 PDF 页)会越过 LLM 输入预算被悄悄送出。
JSON 健壮性¶
LLM 的失败形式多样:输出被截断(中途撞上 max_tokens)、从噪声 PDF 复制了非法控制字符、加上 "Here is the JSON:" 之类的前言,或包了 markdown 围栏。_parse_json_array() 全部处理:
- 剥离 markdown 围栏,清洗控制字符(见下)。
- 直接尝试
json.loads。 - 失败后从
[开始扫描,按括号/引号深度寻找最长合法前缀——即便响应在某条记录中途被截断,也能恢复已完成的条目。 - 将单个对象提升为只含一个元素的数组。
- 对象扫描(最后手段):
_sweep_json_objects()遍历整段文本,收集所有能独立解析为 dict 的平衡{…}子串——完全无视逗号与数组语法。这能处理形如[ 散文… {…}{…} ]的响应。
控制字符清洗(_sanitize_json)感知字符串上下文,而非一刀切剥离。按 RFC 8259,JSON 字符串字面量内部的裸 TAB/LF/CR 是非法的,而 Qwen-turbo 等模型经常在 content / resolution 字符串里直接输出这些字符——这正是旧版 Unrecoverable JSON: char 0 失败的主因。清洗器跟踪字符串/非字符串上下文:字符串内部将 TAB/LF/CR 转义为 \t/\n/\r 并丢弃其它 C0 控制字符;字符串外部保留空白原样。
如果某块仍解析失败,_segment_chunk_with_fallback() 启用两层恢复机制:
- 修复重试(每块至多一次):将 LLM 自己的坏输出回传给它,要求重新输出合法 JSON(修复提示也加上"若无条目则返回
[]")。_try_repair_json()在修复调用或其解析也失败时返回None——与空列表区分开——提示调用方继续走二分恢复,而非把失败误当成"无条目"。 - 按页二分恢复:将失败块在页边界一分为二,每半重新切分(递归下限:单页)。这就是应对"单条记录超过
max_tokens"的方案——块会持续缩小,直到该条目能装下。
LLM 的网络/HTTP 异常同样会触发二分恢复。注意:能解析成功但只产出空字段/低置信度条目的块不算解析失败——它会被条目校验过滤,返回空且不触发重试或二分。
条目校验(路由误判防护)¶
_filter_valid_entries() 在条目被接受前,对每个解析(及修复)得到的条目列表执行。满足以下任一条件即丢弃该条目:
confidence低于_HARD_DROP_CONFIDENCE = 0.3,或- 规范中任一
required: true字段为空(值落在{"", "—", "-", "n/a", "na", "none", "null"},或为空列表/字典)。
这是对路由误判失败形态的首要防护:当分类器把某块错误展开到它实际不含的类型时,按类型抽取器被要求抽取并不存在的条目,LLM 往往会输出字段全空、confidence: 0.0 的占位 dict。在上游丢弃它们,意味着某个被路由的类型若无产出,会被视作真正的无条目,而不会作为低置信度文档堆给审阅者。
跨块近重复分组¶
_group_duplicates() 处理由 1 页重叠产生的重复条目,按知识类型使用不同 key:
| 类型 | 分组 key | 主条目(保持勾选) |
|---|---|---|
| ALARM | 归一化的 error_code |
confidence 最高者 |
| SETUP | 归一化的 station + procedure 前 80 字符 |
confidence 最高者 |
| EXPERIENCE | 归一化的 problem + failure_desc 前 80 字符 |
confidence 最高者 |
与硬去重不同,所有变体都保留,以便审阅者比较各版本并选出或编辑最佳者。多变体组的
成员共享 dup_group_id,置信度最高者为 dup_primary(保持 accepted),其余默认不勾选。
key 为空的条目独立存在(dup_group_id = None)。这取代了旧的 _deduplicate_entries()——
后者会静默丢弃除最高置信度副本以外的全部条目。
按块多类型路由¶
classify_chunk_types() 对每一块独立分类,返回所含类型的列表:
[](路由器返回skip)→ 丢弃该块;生成SkippedChunk(reason="non_content")带友好提示。[KnowledgeType.ALARM]→ 一次抽取调用,使用 alarm 提示词。这是默认情况。[KnowledgeType.ALARM, KnowledgeType.SETUP]→ 对同一块文本调用两次抽取器;由于提示词显式要求忽略其他类型内容,两边各自只产出自己类型的条目。路由器仅在各类型条目各自独立成块、结构清晰时才如此展开。
当客户端在上传时传入 knowledge_type_hint,该 hint 会锁定所有块为该类型,路由器被完全跳过。detect_knowledge_type() 作为对外的"主导类型"便捷接口保留,其实现就是取 classify_chunk_types 的第一项。
非正文处理(封面、目录、前言)¶
非正文页面(封面、目录、前言、修订记录、术语表、索引、版权页,或纯散文)会被路由器按各 spec 的 skip_if[] 规则识别并在切分前丢弃。它们以 FileInfo.skipped_chunks: list[SkippedChunk] 形式返回给 UI,每条包含:
page_range— 被跳过的页码范围reason—non_content|no_entrieshint— 一句话说明,便于审阅者直接处理
整块 no_entries 提示仅在所有被路由类型都返回空时才生成。文件卡片的 message 也会做汇总,例如:"Extracted 14 documents. 2 non-content page(s) skipped (covers/TOC/preface)."
保真校验(反捏造)¶
切分完成后,每个需要原文复制的字段(content、resolution、procedure、failure_desc)都会调用 verify_extraction_fidelity() 与源文本比对:先严格匹配当前块文本,再回退匹配整篇文件文本(处理跨块边界的合法内容)。校验失败时字段仍保留,但暂存文档会带上 fabrication_warning: <field> 标记供审阅。
原文摘录(按条目溯源)¶
每个暂存文档都带一个 raw_text_excerpt——支撑该条目的原始源文片段,展示给审阅者(UI 用 <pre> 渲染,保留原始表格/换行布局)。单个块通常产出多个条目(告警表的每一行、一页里的多条经验),而整篇 DOCX 又作为一个块到达,因此"取块头部"的摘录会让每张卡片都显示相同的开头几行。为此 _build_raw_excerpt() 以条目最具辨识度的 token 作为锚点——告警用 error_code/title,setup 用 station,经验用 problem——返回从含该 token 的行开始的窗口;无锚点匹配时回退到块头部。上文的保真校验仍针对空白折叠后的文本进行;只有展示用的摘录保留原始布局。
项目与机台解析¶
project / equipment 通过三级优先链解析,确保每个暂存文档都带上可用、且符合 taxonomy 的项目,同时绝不阻塞审阅者:
- LLM 原文抽取——各规范声明
project/equipment字段。切分器仅在源文显式提及时填写,否则填""。_parsed_to_staged()读取并优先于上传 hint。 - 文件名 / 上传 hint——条目本身无值时,使用上传时的
project_hint/equipment_hint。若上传也未提供,_detect_taxonomy_from_filename()对文件名主干分词,仅以完整小写 token匹配 taxonomy 值——因此PDX-aligner-faults.pdf→project=PDX, equipment=Aligner,而stages.pdf不会匹配到Stage机台。 - Taxonomy 解析——切分后,
_resolve_taxonomy_fields()对照taxonomy.yaml校验(不区分大小写,以规范大小写存储)。未知值被清空并追加审阅者可见的警告(unknown_project:/unknown_equipment:)。随后项目回退到跨项目桶所有项目;机台未知时留空(可选)。
审阅者仍可在预览 UI 中逐条覆盖以上任一值。"必填字段缺失"警告仅标记项目为空的文档。
超时¶
_estimate_timeout() 根据实际 payload 大小估算 HTTP 读超时,使用 CJK 感知的 token 估算(中文 ≈ 1.5 tok/字符,拉丁 ≈ 0.25 tok/字符)。超长块不会触发超时——它们会先撞上 max_tokens 上限,再走二分恢复路径。
冲突检测与交叉引用(services/cross_reference.py)¶
文件字节去重(见下文)只能拦截同一文件被重复上传。更危险的是文档级冲突:某条暂存
文档的内容寻址 doc_id——hash(knowledge_type | project | equipment | title | error_codes),
与 commit 所用的 id 完全一致——在知识库中已存在。直接提交会静默覆盖它。分段之后
(步骤 [3.5],会话进入 READY 之前)对 session.documents 运行两项尽力而为的富化。
detect_collisions¶
compute_staged_doc_id() 复用提交时的同一转换(_staged_to_knowledge_doc → doc_id),
因此"将覆盖"的判定与真正会发生的覆盖一致。预期 id 按 kb_<type> 别名分组,每类一次
mget。命中时,暂存文档获得 collision: ExistingDocSnapshot(现有文档的身份及其存储的
sections 作为 fields,供字段级 diff),collision_action 保持 None——该文档的提交被
阻断直到解决。同批内彼此身份相同的暂存文档是与同伴而非知识库冲突,因此被折叠成一个
精确重复组(最高置信度为主,其余不勾选),而不标记为知识库冲突。
通过 PATCH .../documents/{idx}/resolve(pipeline.resolve_collision)记录决定:
action |
提交行为 |
|---|---|
keep |
跳过该暂存文档——保留现有知识库文档(计入 skipped)。 |
overwrite |
原样写入暂存文档,替换现有文档。 |
merge |
应用 merged_fields(一个 DocumentUpdate——仅内容字段;身份字段被排除,使 doc_id 保持稳定),再写入。 |
commit_session 中的冲突拦截会把仍未解决的冲突作为错误("Unresolved conflict …")报告
而非写入,从而杜绝静默覆盖。
find_related¶
对每条暂存文档,find_related 附加至多 cross_reference_max 条相关已提交文档到
StagedDocument.related[],按共享 error_codes、共享 equipment,以及——当
cross_reference_semantic 开启且配置了嵌入 key 时——title+body 的向量相似度匹配(整个会话
一次批量嵌入;否则回退 BM25)。会排除暂存文档自身的预期 id 及来自同一源文件的文档。每条
RelatedDoc 带 match_reason(error_code | equipment | similar)与简短 snippet,让
审阅者在再导入一份副本前看到已有覆盖。
提交前汇总¶
GET .../sessions/{id}/summary(pipeline.session_summary)返回 CommitSummary 后果计数
——new、overwrite、keep、unresolved_conflicts、dup_groups、missing_required、
skipped_duplicate_files、rejected——审阅 UI 据此渲染横幅并控制提交按钮(当任一已接受文档
存在未解决冲突时禁用)。全部由线上会话计算,不写 ES。
以上均为尽力而为:任何 ES/嵌入失败都会被吞掉,审阅者只是看不到徽章。可通过
ingest.collision_detection_enabled 与 ingest.cross_reference_enabled /
cross_reference_max / cross_reference_semantic 开关。
会话状态与审核¶
class ImportSession:
session_id: str # uuid4
status: ImportStatus # extracting | ready_for_review | committed | failed
files: list[FileInfo] # 单文件抽取状态
documents: list[StagedDocument]
...hints, created_at
会话仅存储在内存中(ImportPipeline._sessions: dict[str, ImportSession])。服务重启会丢失所有进行中的会话,用户需重新上传。已 commit 的文件不受影响——它们保存在 ES 中,下次启动会自动恢复(见文件追踪器)。
StagedDocument 以并集方式承载所有类型字段(alarm 的 content/resolution,setup 的 procedure/prerequisites,experience 的 body_text)。accepted 默认 True,客户端通过 PATCH 接口切换。字段编辑走 PUT,直接修改对象——不保留修改历史。
空闲会话由后台清扫器回收:软 TTL(session_ttl_minutes,默认 120)回收 COMMITTED/FAILED 会话;硬 TTL(session_hard_ttl_minutes,默认 480)回收任意会话(含审核中),以约束被遗弃预览占用的内存。
Commit 流程(commit_session)¶
对每个 accepted=True 的 StagedDocument:
- 冲突拦截(见冲突检测):存在未解决
collision的文档会被报告且不写入;keep决定被跳过(保留现有文档);overwrite/merge继续走下面的步骤。 _staged_to_knowledge_doc根据类型构建子类(AlarmDoc/SetupDoc/ExperienceDoc)。缺失的必填字符串回退为"—";setup 缺少标题时回退为f"{equipment} 调试"。validate_against_taxonomy拒绝未知的project/equipment——这些会被聚合到errors列表。EmbeddingClient.embed([title_text, body_text])采用尽力而为策略:任何失败都仅记 warning,文档以null向量入库(BM25 仍可用,向量重排会静默忽略)。- 以
refresh="wait_for"调用es.index(...)写入对应别名,_id = doc_id(doc)为稳定哈希,重复 commit 幂等。 - ES 写入动作按源文件
file_hash分组收集。
循环结束后,record_committed(file_hash, actions) 更新 tracker。校验/写入失败会中断循环(break),避免留下部分提交的不一致状态,用户修复后可重新 commit。
友好的 commit 错误:_friendly_validation_message() 将原始 pydantic 错误转成一句指向具体字段的可操作提示,例如:"'resolution' is empty. Required for alarms — paste the Remedy / 解除流程 section." CommitResponse.errors[] 中每一项同时携带 error 与 hint。
文件追踪器(kb_import_files 索引)¶
Tracker 承担两项职责:去重与自动恢复。
去重:以文件字节的 SHA-256 为 key。start_upload 在持久化前调用 tracker.exists(hash),若已有 committed 记录且未传 force=true,文件被标记为 SKIPPED_DUPLICATE。该跳过的 FileInfo 会带上由现有 tracker 记录构建的 duplicate_info(DuplicateInfo)——原始文件名、imported_at、总 doc_count,以及条目摘要的截断预览(_DUPLICATE_DOC_PREVIEW_CAP = 50)——使 UI 能解释为何跳过、以及该文件曾贡献了什么,而非仅显示一个"已跳过"徽章。无需额外 ES 往返:摘要直接取自 exists() 已返回的 committed_docs。失败文件可通过重试接口重新处理(可选强制 OCR)。
committed 记录受写保护:record_pending 是原子 upsert——全新哈希插入一条新的 pending 记录,而对已存在的记录会运行受 _PRESERVE_COMMITTED 守卫的 painless 脚本:若该记录已是 committed 则写入为 no-op,否则重置为一次新的 pending 尝试。record_failed 带同样的守卫。这保护了持久的 committed_docs 负载(restore_imports() 会在重 seed 后回放它):用户随后放弃的 force 重导入、或抽取失败的重处理,都不再能把先前正常的导入从 tracker 中抹掉、并在下次重 seed 时丢失。record_pending 还以 refresh=False 写入(该标记只需持久、无需即时可检索——exists() 通过实时 GET-by-id 读取),使同步上传路径不必等待 ES 的 refresh 周期。
自动恢复:每个已 commit 文档的完整 ES source 被存入 tracker 记录的 committed_docs[]。启动时 seed 会先用 CSV 清空并重建主索引;随后 restore_imports()(位于 services/seed.py)调用 tracker.get_all_committed() 并批量重新写回对应别名。这正是导入文档能在"启动即重新 seed"机制下幸存的原因——tracker(而非源文件)是导入数据的事实来源。
Tracker 记录的生命周期状态:
import_status |
设置位置 | 含义 |
|---|---|---|
pending |
上传时由 record_pending() 写入 |
文件已落盘,等待抽取 |
committed |
commit 后由 record_committed() 写入 |
已接受文档全部入库,payload 已缓存用于恢复 |
failed |
抽取失败时由 record_failed() 写入 |
错误信息已记录,不会自动恢复 |
配置项¶
全部位于 config/settings.yaml 的 ingest: 段,或对应 KB_INGEST__* 环境变量——见配置 → Ingest。
关键设计约束¶
- 强制人工审核:未经显式 commit,文档绝不进入检索索引。即便是"快速通道"(扫描整个文件夹)也止步于
ready_for_review。 - 规范驱动:每种知识类型在
config/knowledge_types/<type>.yaml中只定义一次。LLM 提示词、范例、跳过规则与对齐校验都从该文件读取。 - 支持混合类型文件:路由按块进行,不按文件。路由器偏向单一主导类型,仅当各类型在结构上各自独立成块时才展开。
- 仅原文复制:切分提示明确禁止改写。低于 0.3 置信度阈值或必填字段为空的条目会被直接丢弃。
- 项目/机台绝不阻塞提交:值在源文提及时原文取用,否则从文件名推断,再否则默认为
所有项目。机台为可选。 - 友好反馈:被跳过的块与提交错误均附带可操作的
hint。 - Embedding 尽力而为:commit 时 embedding 失败不会中断写入。
- 绝不静默覆盖:
doc_id已存在于知识库的暂存文档,在审阅者解决(保留 / 覆盖 / 合并)前被阻断提交。 - 保留变体而非丢弃:近重复片段被分组以供并排比较,而不是塌缩为最高置信度的一条。
- 交叉引用:每条暂存文档会呈现相关已提交文档(同代码 / 机台 / 相似),让审阅者在导入前看到已有覆盖。
- 按内容哈希去重:相同字节二次上传直接短路,除非显式
force=true。 - 导入数据可在 CSV 重 seed 后存活:tracker 的
committed_docs缓存在启动重 seed 后被回放。 - 会话仅在内存:服务重启会丢失尚未 commit 的会话。