forked from admin/PlantAI-rag-new
- vstore 独立安装包(8094):解析/嵌入/加密 vault/faiss 检索,存储路径支持本地 file 与 http(s):// URL,启动握手校验,集群化预留(embed 独立模块) - BFF docgen 路由:上传入库/generate/编辑/导出/下载/主库管理,意图护栏(与前端同词典),vstore 失败兜底,quota 507 与 TTL 惰性清理 - 前端:自然触发意图路由、拖拽上传 chips、能力徽标、右侧文档 TAB(WYSIWYG 编辑/prompt 修改/PDF+DOCX 导出下载)、右栏拖宽+手柄仿左栏 - 批量导入工具 docgen_import.py(并发 8、导入即加密+删源明文) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
220 lines
9.0 KiB
Python
220 lines
9.0 KiB
Python
#!/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())
|