Files

311 lines
12 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""ingest_flow_docs.py —— 生信流程语料切块入库 flow_docs(幂等可重跑)。
语料源(按任务书优先级):
1) 仓库 docs/bioflow_corpus/PlantAI_现有分析全流程_基因家族_RNAseq_WGS_GWAS.md(79 章)
--corpus-file 覆盖路径;切块策略:按文件实际 #(一级/组)与 ##(二级/章)标题切块
2) dev gfa_core/schemas/stages/*_contract_v1.md(阶段契约,按 ## 节切块)
--stages-dir 覆盖路径
3) dev skills/*/SKILL.md(技能说明,按 ## 节切块)
--skills-dir 覆盖路径
输出:flow_dir/{index.json, chunks/*.json}(独立目录,与主库 vault/index 隔离)
幂等:块键 = source_id + '__' + md5(标题路径 + 源文件相对路径)[:12],
同键重跑覆盖,源文件删除后 --prune 清理孤儿块。
依赖:vstore.embed(ollama bge-m3);单元测试经 vstore.vault.set_embed_fn 注入假嵌入,
不依赖真服务。运行:/home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
from typing import Any
# 脚本放 vstore/scripts/ 下;允许包内直跑(python -m)与路径执行
if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from vstore.flow import (
CITE_ANCHOR,
CITE_LINE,
DOC_KIND_CONTRACT,
DOC_KIND_CORPUS,
DOC_KIND_LABELS,
DOC_KIND_SKILL,
SOURCE_CORPUS,
SOURCE_SKILL,
SOURCE_STAGE_CONTRACT,
FlowStore,
)
from vstore.vault import get_embed_fn
REPO = Path(__file__).resolve().parents[2] # PlantAI-rag-new 仓库根
DEFAULT_FLOW_DIR = REPO / "vstore" / "data" / "flow_docs"
DEFAULT_CORPUS_FILE = REPO / "docs" / "bioflow_corpus" / "PlantAI_现有分析全流程_基因家族_RNAseq_WGS_GWAS.md"
# 只读语料源(dev 仓库)
DEFAULT_STAGES_DIR = Path("/home/xzq/5090/teamWorkspace/zh/PlantAI-dev/gfa_core/schemas/stages")
DEFAULT_SKILLS_DIR = Path("/home/xzq/5090/teamWorkspace/zh/PlantAI-dev/skills")
CHUNK_TEXT_CAP = 6000 # 单块嵌入/展示上限(字符,超长块只保留前部并在 meta 注明截断)
# ---- 文本工具 ----
def _split_headings(lines: list[str]) -> list[tuple[int, str, int]]:
"""提取标题切点:[(行号0基, 标题原文, 级别1/2), ...],按出现顺序。"""
out: list[tuple[int, str, int]] = []
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("# ") and not stripped.startswith("## "):
out.append((i, stripped, 1))
elif stripped.startswith("## "):
out.append((i, stripped, 2))
return out
def _chunk_key(source_id: str, title_path: str, rel_path: str) -> str:
digest = hashlib.md5((title_path + "|" + rel_path).encode("utf-8")).hexdigest()[:12]
return f"{source_id}__{digest}"
def _store_text(store: FlowStore, meta: dict[str, Any], key: str,
text: str, line_start: int, line_end: int) -> None:
meta["line_start"] = int(line_start)
meta["line_end"] = int(line_end)
cite = meta.get("cite")
if isinstance(cite, dict) and cite.get("line") == 1:
cite["line"] = int(line_start)
truncated = len(text) > CHUNK_TEXT_CAP
meta["truncated"] = truncated
store.add_chunk(
{**meta, "chunk_key": key, "created_at": _now()},
text[:CHUNK_TEXT_CAP],
)
def _now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _embed(texts: list[str]) -> list[list[float]]:
return get_embed_fn()(texts)
# ---- 语料 1:全流程文档 ----
def ingest_corpus(store: FlowStore, corpus_file: Path, corpus_rel: str) -> int:
"""按文件实际 #(一级/组)与 ##(二级/章)标题切块。
分组规则:一级标题定义组;组内二级标题各成一块;组标题到首个二级标题
之间的内容(含无二级标题的章正文,如第 79 章)并入组块;首个一级标题
之前的前导区(文档头 + 前导二级章)分别成块。三级标题不切分,留在父块内。
"""
if not corpus_file.exists():
raise FileNotFoundError(f"corpus 文件不存在:{corpus_file}")
lines = corpus_file.read_text(encoding="utf-8").splitlines()
cuts = _split_headings(lines)
if not cuts:
raise ValueError(f"corpus 无标题切点:{corpus_file}")
doc_title = lines[0].replace("# ", "").strip()
added = 0
def _add(path: list[str], text: str, start: int, end: int, note: str) -> None:
nonlocal added
if not text.strip():
return
key = _chunk_key(SOURCE_CORPUS, " | ".join(path), corpus_rel)
meta = _corpus_meta(doc_title, path, corpus_rel, text, note)
_store_text(store, meta, key, text, start + 1, end)
added += 1
l1_idx = [k for k, c in enumerate(cuts) if c[2] == 1]
# 前导区:文档头(标题 + 引言 + 结构图),以及首个 L1 之前的 ## 章(如 ## 1 文档阅读说明)
first_l1_pos = cuts[l1_idx[0]][0] if l1_idx else len(lines)
pre_children = [c for c in cuts if c[2] == 2 and c[0] < first_l1_pos]
header_end = pre_children[0][0] if pre_children else first_l1_pos
_add([doc_title], "\n".join(lines[0:header_end]).strip(), 0, header_end,
"文档头(引言/结构图)")
for ci, (cstart, ctitle, _) in enumerate(pre_children):
cend = pre_children[ci + 1][0] if ci + 1 < len(pre_children) else first_l1_pos
_add([doc_title, ctitle], "\n".join(lines[cstart:cend]).strip(),
cstart, cend, "前导章(二级标题)")
# 组内:L1 组块(标题 + 组首内容)+ 各 ## 块
for gi, k in enumerate(l1_idx):
start, title, _ = cuts[k]
end = cuts[l1_idx[gi + 1]][0] if gi + 1 < len(l1_idx) else len(lines)
children = [c for c in cuts if c[2] == 2 and start < c[0] < end]
first_child = children[0][0] if children else end
_add([title], "\n".join(lines[start:first_child]).strip(),
start, first_child, "一级标题组块(组首内容,无二级标题章正文并入)")
for ci, (cstart, ctitle, _) in enumerate(children):
cend = children[ci + 1][0] if ci + 1 < len(children) else end
_add([title, ctitle], "\n".join(lines[cstart:cend]).strip(),
cstart, cend, "二级标题(章节)切块")
return added
def _corpus_meta(doc_title: str, full_path: list[str], corpus_rel: str,
text: str, chunking_note: str) -> dict[str, Any]:
return {
"source": SOURCE_CORPUS,
"source_id": SOURCE_CORPUS,
"doc_kind": DOC_KIND_CORPUS,
"doc_kind_label": DOC_KIND_LABELS[DOC_KIND_CORPUS],
"title": doc_title,
"title_path": " | ".join(full_path),
"heading": full_path[-1],
"path": corpus_rel,
"cite": {"anchor": full_path[-1]},
"line_start": 1, # _store_text 会覆盖
"line_end": 1,
"chunking": chunking_note,
"truncated": False,
}
# ---- 语料 2/3:阶段契约与技能说明(按 ## 节切块) ----
def ingest_contracts(store: FlowStore, stages_dir: Path, stages_rel: str) -> int:
added = 0
files = sorted(stages_dir.glob("*_contract_v1.md"))
if not files:
raise FileNotFoundError(f"契约目录无 *_contract_v1.md:{stages_dir}")
for f in files:
added += _ingest_md_sections(
store, f, f"{stages_rel}/{f.name}",
kind=DOC_KIND_CONTRACT, source=SOURCE_STAGE_CONTRACT,
)
return added
def ingest_skills(store: FlowStore, skills_dir: Path, skills_rel: str) -> int:
added = 0
files = sorted(skills_dir.glob("*/SKILL.md"))
if not files:
raise FileNotFoundError(f"技能目录无 */SKILL.md:{skills_dir}")
for f in files:
added += _ingest_md_sections(
store, f, f"{skills_rel}/{f.name}",
kind=DOC_KIND_SKILL, source=SOURCE_SKILL,
)
return added
def _ingest_md_sections(store: FlowStore, file: Path, rel_path: str,
kind: str, source: str) -> int:
"""按 ## 节切块(含标题路径);无 ## 时整文件一块。"""
lines = file.read_text(encoding="utf-8").splitlines()
h1 = next((l.strip() for l in lines if l.strip().startswith("# ")), "")
h1 = h1.replace("# ", "").strip() or file.stem
cuts = [(i, t) for i, t, lv in _split_headings(lines) if lv == 2]
stage_name = file.stem.removesuffix("_contract_v1")
if kind == DOC_KIND_SKILL:
stage_name = file.parent.name
def _meta(path: list[str], text: str) -> dict[str, Any]:
heading = path[-1]
return {
"source": source,
"source_id": source,
"doc_kind": kind,
"doc_kind_label": DOC_KIND_LABELS[kind],
"title": h1,
"title_path": " | ".join(path),
"heading": heading,
"stage_name": stage_name,
"path": rel_path,
"cite": {"anchor": heading, "line": 1},
"line_start": 1,
"line_end": 1,
"chunking": "二级标题(节)切块" if len(path) > 1 else "整文件(无二级标题)",
"truncated": False,
}
added = 0
if not cuts:
text = "\n".join(lines).strip()
if not text:
return 0
key = _chunk_key(source, h1, rel_path)
_store_text(store, _meta([h1], text), key, text, 1, len(lines))
return 1
for i, (start, heading) in enumerate(cuts):
end = cuts[i + 1][0] if i + 1 < len(cuts) else len(lines)
text = "\n".join(lines[start:end]).strip()
if not text:
continue
key = _chunk_key(source, f"{h1} | {heading}", rel_path)
_store_text(store, _meta([h1, heading], text), key, text, start + 1, end)
added += 1
# 文件级全文块:单节向量分散时由全文兜底(标题路径含文件级主题词)
full_text = "\n".join(lines).strip()
if full_text and len(full_text) > 200:
key = _chunk_key(source, f"{h1} | 全文", rel_path)
_store_text(store, _meta([h1, "全文"], full_text), key, full_text, 1, len(lines))
added += 1
return added
# ---- 主流程 ----
def main() -> int:
ap = argparse.ArgumentParser(description="生信流程语料切块入库 flow_docs(幂等)")
ap.add_argument("--flow-dir", default=str(DEFAULT_FLOW_DIR), help="flow_docs 数据目录")
ap.add_argument("--corpus-file", default=str(DEFAULT_CORPUS_FILE), help="全流程文档路径")
ap.add_argument("--stages-dir", default=str(DEFAULT_STAGES_DIR), help="阶段契约目录(dev 只读)")
ap.add_argument("--skills-dir", default=str(DEFAULT_SKILLS_DIR), help="技能说明目录(dev 只读)")
ap.add_argument("--skip-corpus", action="store_true", help="跳过全流程文档")
ap.add_argument("--skip-stages", action="store_true", help="跳过阶段契约")
ap.add_argument("--skip-skills", action="store_true", help="跳过技能说明")
ap.add_argument("--prune", action="store_true", help="清理索引中源文件已不存在的孤儿块")
args = ap.parse_args()
corpus_file = Path(args.corpus_file)
stages_dir = Path(args.stages_dir)
skills_dir = Path(args.skills_dir)
flow_dir = Path(args.flow_dir)
corpus_rel = f"docs/bioflow_corpus/{corpus_file.name}"
stages_rel = "gfa_core/schemas/stages"
skills_rel = "skills"
store = FlowStore(flow_dir)
counts: dict[str, int] = {}
if not args.skip_corpus:
counts["corpus"] = ingest_corpus(store, corpus_file, corpus_rel)
if not args.skip_stages:
counts["stages"] = ingest_contracts(store, stages_dir, stages_rel)
if not args.skip_skills:
counts["skills"] = ingest_skills(store, skills_dir, skills_rel)
pruned = 0
if args.prune:
# 保留:当前仍存在于三个源目录/文件中的所有块
known_files = {corpus_file.name}
known_files |= {f.name for f in stages_dir.glob("*_contract_v1.md")}
known_files |= {f.name for f in skills_dir.glob("*/SKILL.md")}
for row in store.index.rows():
fname = str(row.get("path") or "").rsplit("/", 1)[-1]
if fname and fname not in known_files:
store.index.remove_item(str(row.get("id")))
pruned += 1
stats = store.stats()
print(json.dumps({
"added_by_source": counts,
"pruned": pruned,
"stats": stats,
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())