2026-09-12 22:19:40 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""tooling/aqua_kb/batch_runner.py —— 分批建库调度器
|
|
|
|
|
|
|
|
|
|
|
|
按 manifest 编排建库全链,全程断点续跑:
|
|
|
|
|
|
for each paper in manifest:
|
|
|
|
|
|
result.json 已存在 → 跳过抽取(外部/D2 负责抽取,本工具只调度后续步骤)
|
|
|
|
|
|
→ phase2b_converter.convert()(neo4j.cypher + qdrant_points.jsonl)
|
|
|
|
|
|
→ 攒批(--convert-batch 篇)后:
|
|
|
|
|
|
Neo4j:逐文件 cypher-shell 执行(--neo4j-shell 路径),成功记 checkpoint
|
|
|
|
|
|
Qdrant:qdrant_loader.load_files()(embed + upsert),成功记 checkpoint
|
|
|
|
|
|
→ 失败写 build/failed.jsonl(含错误),全部步骤可重跑
|
|
|
|
|
|
|
|
|
|
|
|
状态文件(<out>/state/,JSONL 每行一个 paper_id):
|
|
|
|
|
|
converted.jsonl / neo4j_loaded.jsonl / qdrant_loaded.jsonl / failed.jsonl
|
|
|
|
|
|
|
|
|
|
|
|
用法:
|
|
|
|
|
|
python batch_runner.py --manifest pilot_manifest.json --result-root <dir> \
|
|
|
|
|
|
--out <build dir> --qdrant http://127.0.0.1:27435 \
|
|
|
|
|
|
--collection aqua_srt_v1_20260930_normalized \
|
|
|
|
|
|
--neo4j-shell /opt/neo4j-community-5.15.0/bin/cypher-shell \
|
|
|
|
|
|
--neo4j-uri bolt://127.0.0.1:19889 --neo4j-user neo4j \
|
|
|
|
|
|
--neo4j-password-file kb-libraries/aqua/data/neo4j/.auth \
|
|
|
|
|
|
--embed-url http://127.0.0.1:11434/api/embed --embed-model bge-m3:567m \
|
|
|
|
|
|
--convert-batch 50 [--limit 20]
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import json
|
2026-09-13 01:06:38 +08:00
|
|
|
|
import re
|
2026-09-12 22:19:40 +08:00
|
|
|
|
import subprocess
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import time
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
import phase2b_converter
|
|
|
|
|
|
import qdrant_loader
|
|
|
|
|
|
|
2026-09-13 01:06:38 +08:00
|
|
|
|
# 与抽取链落盘一致的目录名净化:非 [0-9a-zA-Z._-] 一律替换为 `_`(保留 doi: 前缀)。
|
|
|
|
|
|
# 例:doi:10.1016/0044-8486(81)90060-0 → doi_10.1016_0044-8486_81_90060-0
|
|
|
|
|
|
_SANITIZE_RE = re.compile(r"[^0-9a-zA-Z._-]")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sanitize_paper_key(value: str) -> str:
|
|
|
|
|
|
"""把 paper_id 规范成与磁盘一致的目录名形态(md_batch/<sanitized>/result.json)。"""
|
|
|
|
|
|
return _SANITIZE_RE.sub("_", str(value))
|
|
|
|
|
|
|
2026-09-12 22:19:40 +08:00
|
|
|
|
|
|
|
|
|
|
def load_ids(path: Path) -> set:
|
|
|
|
|
|
ids: set = set()
|
|
|
|
|
|
if path.exists():
|
|
|
|
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
|
|
|
|
for line in handle:
|
|
|
|
|
|
line = line.strip()
|
|
|
|
|
|
if not line:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
ids.add(json.loads(line)) # 兼容 json.dumps 写入的带引号行
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
ids.add(line)
|
|
|
|
|
|
return ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def append_id(path: Path, value: str) -> None:
|
|
|
|
|
|
with open(path, "a", encoding="utf-8") as handle:
|
|
|
|
|
|
handle.write(json.dumps(value) + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Runner:
|
|
|
|
|
|
def __init__(self, args: argparse.Namespace) -> None:
|
|
|
|
|
|
self.args = args
|
|
|
|
|
|
self.out = Path(args.out)
|
|
|
|
|
|
self.state_dir = self.out / "state"
|
|
|
|
|
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
self.converted_path = self.state_dir / "converted.jsonl"
|
|
|
|
|
|
self.neo4j_loaded_path = self.state_dir / "neo4j_loaded.jsonl"
|
|
|
|
|
|
self.qdrant_loaded_path = self.state_dir / "qdrant_loaded.jsonl"
|
|
|
|
|
|
self.failed_path = self.out / "failed.jsonl"
|
|
|
|
|
|
self.converted = load_ids(self.converted_path)
|
|
|
|
|
|
self.neo4j_loaded = load_ids(self.neo4j_loaded_path)
|
|
|
|
|
|
self.qdrant_loaded = load_ids(self.qdrant_loaded_path)
|
|
|
|
|
|
self._embed: Optional[qdrant_loader.EmbedClient] = None
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def embed(self) -> qdrant_loader.EmbedClient:
|
|
|
|
|
|
if self._embed is None:
|
|
|
|
|
|
self._embed = qdrant_loader.EmbedClient(
|
|
|
|
|
|
self.args.embed_url, self.args.embed_model
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._embed
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- manifest ----------
|
|
|
|
|
|
|
|
|
|
|
|
def read_manifest(self) -> List[Dict[str, Any]]:
|
|
|
|
|
|
with open(self.args.manifest, "r", encoding="utf-8") as handle:
|
|
|
|
|
|
manifest = json.load(handle)
|
|
|
|
|
|
entries = manifest.get("papers") if isinstance(manifest, dict) else manifest
|
|
|
|
|
|
if not isinstance(entries, list):
|
|
|
|
|
|
raise RuntimeError("manifest must be a dict with 'papers' list or a list")
|
|
|
|
|
|
if self.args.limit:
|
|
|
|
|
|
entries = entries[: self.args.limit]
|
|
|
|
|
|
return entries
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def paper_key(entry: Dict[str, Any]) -> str:
|
|
|
|
|
|
return str(entry.get("paper_id") or entry.get("id") or entry.get("doi")
|
|
|
|
|
|
or entry.get("result") or json.dumps(entry, sort_keys=True))
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_result(self, entry: Dict[str, Any]) -> Path:
|
2026-09-13 01:06:38 +08:00
|
|
|
|
# 兼容 1:manifest 行显式给出 result 路径(相对 result-root 或绝对路径)
|
2026-09-12 22:19:40 +08:00
|
|
|
|
if entry.get("result"):
|
|
|
|
|
|
path = Path(entry["result"])
|
|
|
|
|
|
if not path.is_absolute():
|
|
|
|
|
|
path = Path(self.args.result_root) / path
|
|
|
|
|
|
return path
|
2026-09-13 01:06:38 +08:00
|
|
|
|
# 默认:result-root/<sanitized(paper_id)>/result.json
|
|
|
|
|
|
# sanitize 与抽取链落盘目录名一致(doi:/、/、() 等 → _),
|
|
|
|
|
|
# 直接用原始 paper_id 拼路径会因含 :/() 而找不到文件。
|
|
|
|
|
|
key = sanitize_paper_key(self.paper_key(entry))
|
2026-09-12 22:19:40 +08:00
|
|
|
|
return Path(self.args.result_root) / key / "result.json"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- steps ----------
|
|
|
|
|
|
|
|
|
|
|
|
def step_convert(self, key: str, result_path: Path, paper_json: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
|
|
|
"""转换幂等(产物确定性),checkpoint 只写一次。返回产物路径 stats。"""
|
|
|
|
|
|
stats = phase2b_converter.convert(
|
|
|
|
|
|
result_path, self.out, self.args.release_id, paper_json
|
|
|
|
|
|
)
|
|
|
|
|
|
if key not in self.converted:
|
|
|
|
|
|
append_id(self.converted_path, key)
|
|
|
|
|
|
print(f"[runner] converted {key}: {stats['assertion_count']} assertions")
|
|
|
|
|
|
return stats
|
|
|
|
|
|
|
|
|
|
|
|
def step_neo4j(self, key: str, cypher_path: Path) -> None:
|
|
|
|
|
|
if key in self.neo4j_loaded:
|
|
|
|
|
|
print(f"[runner] neo4j skip {key} (checkpoint)")
|
|
|
|
|
|
return
|
|
|
|
|
|
shell = self.args.neo4j_shell
|
|
|
|
|
|
auth = [f"--format=plain", f"--address={self.args.neo4j_uri}",
|
|
|
|
|
|
f"--username={self.args.neo4j_user}"]
|
|
|
|
|
|
with open(self.args.neo4j_password_file, "r", encoding="utf-8") as handle:
|
|
|
|
|
|
password = handle.read().strip().splitlines()[0]
|
|
|
|
|
|
command = [shell, *auth, f"--password={password}", "-f", str(cypher_path)]
|
|
|
|
|
|
proc = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
|
|
|
|
|
if proc.returncode != 0:
|
|
|
|
|
|
raise RuntimeError(f"cypher-shell failed for {key}: {proc.stderr[-2000:]}")
|
|
|
|
|
|
append_id(self.neo4j_loaded_path, key)
|
|
|
|
|
|
print(f"[runner] neo4j loaded {key}")
|
|
|
|
|
|
|
|
|
|
|
|
def step_qdrant(self, key: str, points_path: Path) -> None:
|
|
|
|
|
|
if key in self.qdrant_loaded:
|
|
|
|
|
|
print(f"[runner] qdrant skip {key} (checkpoint)")
|
|
|
|
|
|
return
|
|
|
|
|
|
embed = self.embed
|
|
|
|
|
|
probe = embed.embed("aquaculture warm-up probe")
|
|
|
|
|
|
qdrant_loader.ensure_collection(
|
|
|
|
|
|
self.args.qdrant, self.args.collection, len(probe)
|
|
|
|
|
|
)
|
|
|
|
|
|
qdrant_loader.upsert_file(
|
|
|
|
|
|
points_path,
|
|
|
|
|
|
self.args.qdrant,
|
|
|
|
|
|
self.args.collection,
|
|
|
|
|
|
self.args.batch,
|
|
|
|
|
|
checkpoint=self.state_dir / "qdrant_checkpoint.jsonl",
|
|
|
|
|
|
embed=embed,
|
|
|
|
|
|
)
|
|
|
|
|
|
append_id(self.qdrant_loaded_path, key)
|
|
|
|
|
|
print(f"[runner] qdrant loaded {key}")
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- main ----------
|
|
|
|
|
|
|
|
|
|
|
|
def run(self) -> None:
|
|
|
|
|
|
entries = self.read_manifest()
|
|
|
|
|
|
started = time.time()
|
|
|
|
|
|
stats = {"total": len(entries), "converted": 0, "neo4j": 0, "qdrant": 0,
|
|
|
|
|
|
"failed": 0}
|
|
|
|
|
|
for index, entry in enumerate(entries, 1):
|
|
|
|
|
|
key = self.paper_key(entry)
|
|
|
|
|
|
if key in self.failed_keys:
|
|
|
|
|
|
continue
|
|
|
|
|
|
paper_json = entry.get("paper") if isinstance(entry.get("paper"), dict) else None
|
|
|
|
|
|
try:
|
|
|
|
|
|
result_path = self.resolve_result(entry)
|
|
|
|
|
|
if not result_path.exists():
|
|
|
|
|
|
print(f"[runner] SKIP {key}: result.json missing "
|
|
|
|
|
|
f"({result_path}) — 抽取由外部完成")
|
|
|
|
|
|
continue
|
|
|
|
|
|
conv = self.step_convert(key, result_path, paper_json)
|
|
|
|
|
|
stats["converted"] += 1
|
|
|
|
|
|
cypher_path = Path(conv["cypher"])
|
|
|
|
|
|
points_path = Path(conv["points"])
|
|
|
|
|
|
self.step_neo4j(key, cypher_path)
|
|
|
|
|
|
stats["neo4j"] += 1
|
|
|
|
|
|
self.step_qdrant(key, points_path)
|
|
|
|
|
|
stats["qdrant"] += 1
|
|
|
|
|
|
if index % 10 == 0:
|
|
|
|
|
|
print(f"[runner] progress {index}/{stats['total']} "
|
|
|
|
|
|
f"({time.time() - started:.0f}s)")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
stats["failed"] += 1
|
|
|
|
|
|
self.record_failure(key, str(exc))
|
|
|
|
|
|
print(f"[runner] FAILED {key}: {exc}", file=sys.stderr)
|
|
|
|
|
|
print(f"[runner] DONE {json.dumps(stats, ensure_ascii=False)} in "
|
|
|
|
|
|
f"{time.time() - started:.1f}s")
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def failed_keys(self) -> set:
|
|
|
|
|
|
keys: set = set()
|
|
|
|
|
|
if self.failed_path.exists():
|
|
|
|
|
|
with open(self.failed_path, "r", encoding="utf-8") as handle:
|
|
|
|
|
|
for line in handle:
|
|
|
|
|
|
try:
|
|
|
|
|
|
keys.add(json.loads(line)["paper_key"])
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
return keys
|
|
|
|
|
|
|
|
|
|
|
|
def record_failure(self, key: str, error: str) -> None:
|
|
|
|
|
|
with open(self.failed_path, "a", encoding="utf-8") as handle:
|
|
|
|
|
|
handle.write(json.dumps(
|
|
|
|
|
|
{"paper_key": key, "error": error,
|
|
|
|
|
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")},
|
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
|
) + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
|
parser = argparse.ArgumentParser(description="aqua KB batch runner")
|
|
|
|
|
|
parser.add_argument("--manifest", required=True)
|
|
|
|
|
|
parser.add_argument("--result-root", required=True)
|
|
|
|
|
|
parser.add_argument("--out", required=True, help="build output root")
|
|
|
|
|
|
parser.add_argument("--release-id", default="aqua_srt_v1_20260930_normalized")
|
|
|
|
|
|
parser.add_argument("--qdrant", required=True)
|
|
|
|
|
|
parser.add_argument("--collection", default="aqua_srt_v1_20260930_normalized")
|
|
|
|
|
|
parser.add_argument("--batch", type=int, default=200)
|
|
|
|
|
|
parser.add_argument("--neo4j-shell", default="cypher-shell")
|
|
|
|
|
|
parser.add_argument("--neo4j-uri", default="bolt://127.0.0.1:19889")
|
|
|
|
|
|
parser.add_argument("--neo4j-user", default="neo4j")
|
|
|
|
|
|
parser.add_argument("--neo4j-password-file", required=True)
|
|
|
|
|
|
parser.add_argument("--embed-url", default="http://127.0.0.1:11434/api/embed")
|
|
|
|
|
|
parser.add_argument("--embed-model", default="bge-m3:567m")
|
|
|
|
|
|
parser.add_argument("--convert-batch", type=int, default=50,
|
|
|
|
|
|
help="batch size for Neo4j/Qdrant loading (checkpoint per paper)")
|
|
|
|
|
|
parser.add_argument("--limit", type=int, default=None,
|
|
|
|
|
|
help="only process first N manifest entries")
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
Runner(args).run()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|