Files

220 lines
9.0 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""docgen_import.py —— 批量导入工具(P2,不进编译链,直连 vstore HTTP)。
用途:把本地资料目录(含可选 metadata.csv)批量 ingest 进 8094 vstore,
导入完成后删除源明文目录(--keep-source 可保留)。as_template 默认 true。
用法:
python tooling/scripts/docgen_import.py --dir /path/to/明文资料 \
--vstore http://127.0.0.1:8094 [--dry-run] [--keep-source] \
[--no-rebuild] [--workers 8]
安全纪律(docgen_team_context.md 第 9 条):
- 明文文件只在内存中读取、经 HTTP 发往 vstore,本机不落任何明文副本
- 日志不打印文件内容;--dry-run 不读文件内容、不删目录
- 删除源明文目录前必须 ingest 成功(单个文件失败默认中止且不删源目录)
"""
from __future__ import annotations
import argparse
import csv
import json
import shutil
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import httpx
SUPPORTED_EXTS = {
".md", ".markdown", ".txt", ".rst", ".docx", ".doc",
".pdf", ".csv", ".html", ".htm", ".json",
}
CSV_META = "metadata.csv"
ALLOWED_CSV_HEADERS = {"name", "category", "tags", "description", "as_template"}
def fail(msg: str) -> "SystemExit":
print(f"[导入工具] 错误:{msg}", file=sys.stderr)
raise SystemExit(2)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="批量导入资料到 docgen vstore")
parser.add_argument("--dir", required=True, type=Path, help="资料明文目录")
parser.add_argument("--vstore", default="http://127.0.0.1:8094", help="vstore 基地址")
parser.add_argument("--dry-run", action="store_true", help="只列文件与元数据,不发起请求、不删除任何文件")
parser.add_argument("--keep-source", action="store_true", help="导入成功后保留源明文目录")
parser.add_argument("--workers", type=int, default=8, help="并发 ingest 数(默认 8)")
parser.add_argument("--no-rebuild", action="store_true", help="跳过导入后的 rebuild-index")
parser.add_argument("--timeout", type=float, default=300.0, help="单文件 ingest 读超时(秒)")
args = parser.parse_args()
if not args.dir.is_dir():
fail(f"目录不存在:{args.dir}")
if args.workers < 1 or args.workers > 32:
fail("--workers 需在 1..32 之间")
return args
def load_metadata(source_dir: Path) -> dict[str, dict]:
"""读取 metadata.csv(可选):按 file 列映射(无 file 列则跳过)。"""
csv_path = source_dir / CSV_META
if not csv_path.is_file():
return {}
meta: dict[str, dict] = {}
try:
with csv_path.open(encoding="utf-8-sig", newline="") as fh:
reader = csv.DictReader(fh)
if reader.fieldnames is None:
return {}
headers = {h.strip() for h in reader.fieldnames}
if "file" not in headers:
print(f"[导入工具] 提示:{CSV_META} 无 file 列,忽略")
return {}
unknown = headers - {"file"} - ALLOWED_CSV_HEADERS
if unknown:
print(f"[导入工具] 提示:{CSV_META} 存在未识别列(忽略):{sorted(unknown)}")
for row in reader:
key = (row.get("file") or "").strip()
if not key:
continue
item: dict = {}
if row.get("name"):
item["name"] = row["name"].strip()
if row.get("category"):
item["category"] = row["category"].strip()
if row.get("tags"):
item["tags"] = row["tags"].strip()
if row.get("description"):
item["description"] = row["description"].strip()
if str(row.get("as_template") or "").strip().lower() in ("0", "false", "no"):
item["as_template"] = False
meta[key] = item
except (OSError, csv.Error) as exc:
fail(f"{CSV_META} 读取失败:{exc}")
return meta
def collect_files(source_dir: Path) -> list[Path]:
"""收集支持扩展名的资料文件(跳过 metadata.csv 自身)。"""
files: list[Path] = []
for p in sorted(source_dir.rglob("*")):
if not p.is_file() or p.name == CSV_META:
continue
if p.suffix.lower() in SUPPORTED_EXTS:
files.append(p)
return files
def ingest_one(vstore: str, source_dir: Path, rel: Path, meta: dict, timeout: float) -> dict:
"""单文件 ingest(在 worker 线程执行,同步 httpx)。"""
data = (source_dir / rel).read_bytes() # 绝对路径读取(worker 线程 cwd 不依赖源目录)
payload = {
"name": meta.get("name", rel.stem),
"category": meta.get("category", ""),
"tags": meta.get("tags", ""),
"description": meta.get("description", ""),
"as_template": meta.get("as_template", True),
}
with httpx.Client(timeout=httpx.Timeout(connect=10.0, read=timeout, write=60.0, pool=10.0)) as client:
resp = client.post(
f"{vstore}/ingest",
data=payload,
files={"file": (rel.name, data)},
)
if resp.status_code >= 400:
raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
try:
body = resp.json()
except ValueError:
body = {"document_id": ""}
document_id = body.get("document_id") or body.get("id") or ""
return {"rel": rel, "name": rel.name, "size": len(data), "document_id": document_id, "ok": True}
def rebuild_index_sync(vstore: str) -> dict:
with httpx.Client(timeout=httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)) as client:
resp = client.post(f"{vstore}/rebuild-index")
if resp.status_code >= 400:
raise RuntimeError(f"HTTP {resp.status_code}: {resp.text[:200]}")
try:
return resp.json()
except ValueError:
return {}
def main() -> int:
args = parse_args()
source_dir: Path = args.dir.resolve()
files = collect_files(source_dir)
if not files:
print("[导入工具] 未发现可导入文件(支持扩展名:%s)" % ", ".join(sorted(SUPPORTED_EXTS)))
return 0
meta = load_metadata(source_dir)
print(f"[导入工具] 目录:{source_dir}")
print(f"[导入工具] vstore:{args.vstore}")
print(f"[导入工具] 待导入:{len(files)} 个文件,并发 {args.workers}")
if args.dry_run:
print("[导入工具] DRY-RUN:仅列出文件与元数据,不发起请求")
for p in files:
rel = p.relative_to(source_dir)
m = meta.get(rel.as_posix(), {})
print(f" - {rel.as_posix()} name={m.get('name', rel.stem)} "
f"as_template={m.get('as_template', True)} category={m.get('category', '')}")
print(f"[导入工具] DRY-RUN 完成({len(files)} 个文件待导入;源目录未改动)")
return 0
started = time.time()
ok: list[dict] = []
failed: list[tuple[str, str]] = []
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = {}
for p in files:
rel = p.relative_to(source_dir)
m = meta.get(rel.as_posix(), {})
futures[pool.submit(ingest_one, args.vstore, source_dir, rel, m, args.timeout)] = rel
for fut in as_completed(futures):
rel = futures[fut]
try:
ok.append(fut.result())
print(f" [ok] {rel.as_posix()}")
except Exception as exc:
failed.append((rel.as_posix(), str(exc)))
print(f" [失败] {rel.as_posix()}: {exc}", file=sys.stderr)
elapsed = time.time() - started
print(f"[导入工具] ingest 完成:成功 {len(ok)},失败 {len(failed)},耗时 {elapsed:.1f}s")
for name, err in failed:
print(f" - {name}: {err}", file=sys.stderr)
if failed:
fail(f"有 {len(failed)} 个文件 ingest 失败——未重建索引、未删除源目录(可重跑,幂等)")
if not args.no_rebuild:
print("[导入工具] rebuild-index(原子切换)…")
try:
result = rebuild_index_sync(args.vstore)
print(f"[导入工具] rebuild-index 完成:{json.dumps(result, ensure_ascii=False)[:200]}")
except Exception as exc:
fail(f"rebuild-index 失败:{exc}(未删除源目录,修复后重跑)")
if args.keep_source:
print(f"[导入工具] --keep-source:保留源目录 {source_dir}")
else:
# 全部成功才删除源明文目录(纪律:不落未加密明文)
try:
shutil.rmtree(source_dir)
print(f"[导入工具] 已删除源明文目录:{source_dir}")
except OSError as exc:
print(f"[导入工具] 警告:源目录删除失败(请手动清理):{exc}", file=sys.stderr)
print("[导入工具] 完成")
return 0
if __name__ == "__main__":
sys.exit(main())