Files
jerryandClaude Fable 5 5db3ead73e feat(docgen): doc_writer 能力全链——vstore 独立服务 + BFF docgen 路由 + 前端文档工作台
- 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>
2026-09-03 21:13:37 +08:00

220 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())