Files
PlantAI-rag-new/vstore/scripts/rebuild_chunks.py
adminandClaude Fable 5 e783a26be6 feat(priv): A1 实现私有资料问答 P1-P3——vstore chunk 索引 + BFF 私有问答端点 + 前端并行块与管理界面
- P1 vstore: chunks.py 切块(600字/8段/重叠1/上限200,表格原位聚合,outline
  不参与)+ChunkIndex 三文件原子写+无降级档;embed_batch_chunked 批量16x并发4;
  vault add/delete/rebuild_chunks 钩子(chunk 失败不阻断 doc 级);search-chunks/
  rebuild-chunks 端点+health chunk_count;rebuild CLI
- P2 BFF: privateqa.ask_private(同doc≤3去重/12k截断/40s超时/带引用短答);
  POST /api/qa/private 三态;schemas PrivateDocBlock 入 union;client.search_chunks;
  llm timeout 参数;main.py 注册
- P3 前端: _firePrivate 并行检索+竞态防护(pending/flush/防重入/跨轮归属);
  AssistantMessage private_doc 块;i18n/private.js;PrivateLibraryPane 挂 DocPane
  空态;removeLibrary(204 兜底)

自测: vstore pytest 26/27(1 失败为 T1 骨架笔误待修)+74 回归;前端 vitest
373/373;npm run build 0 error;sdk 222 passed(3 failed 为基线预存);红线 diff 空。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 22:22:22 +08:00

143 lines
5.6 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.
"""rebuild_chunks.py —— 存量资料 chunk 索引重建 CLI(FR-PRIV-007)。
不进 HTTP(避免超时),由 TL 在部署根后台执行:
cd {部署根}/vstore && ../venv/bin/python scripts/rebuild_chunks.py
路径推导(零硬编码,优先级从高到低):
- CLI 参数 --root / --vault / --index / --chunks(供测试/特殊部署)
- 环境变量 VSTORE_ROOT / VSTORE_VAULT_PATH / VSTORE_INDEX_PATH / VSTORE_CHUNK_PATH
- 默认:root = 脚本所在目录的父目录(部署根下的 vstore/)
密钥加载:cwd=部署根时加载 vstore/.env(VSTORE_KEY 必须可得),否则用环境变量。
进度打印 + 完成汇总;重建期间 vstore HTTP 服务(若运行中)不阻塞——
本脚本是独立子进程,只经文件锁写 chunks 目录。
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
def _load_env_file(path: Path) -> None:
"""vstore/.env → os.environ(已存在的键不覆盖)。"""
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
def main() -> int:
parser = argparse.ArgumentParser(description="重建私有资料 chunk 索引(FR-PRIV-007)")
parser.add_argument("--root", help="vstore 数据根(默认 VSTORE_ROOT 或脚本父目录)")
parser.add_argument("--vault", help="密文目录(默认 {root}/vault)")
parser.add_argument("--index", help="doc 级索引目录(默认 {root}/index)")
parser.add_argument("--chunks", help="chunk 索引目录(默认 {root}/chunks)")
parser.add_argument("--batch-size", type=int, default=16, help="批量嵌入每批条数")
parser.add_argument("--workers", type=int, default=4, help="批量嵌入并发线程数")
args = parser.parse_args()
script_dir = Path(__file__).resolve().parent
vstore_dir = script_dir.parent # 部署根下的 vstore/
deploy_root = vstore_dir.parent # 部署根
_load_env_file(vstore_dir / ".env") # cwd=部署根时密钥在此
root = args.root or os.environ.get("VSTORE_ROOT") or str(vstore_dir)
vault_path = args.vault or os.environ.get("VSTORE_VAULT_PATH") or str(Path(root) / "vault")
index_path = args.index or os.environ.get("VSTORE_INDEX_PATH") or str(Path(root) / "index")
chunk_path = args.chunks or os.environ.get("VSTORE_CHUNK_PATH") or str(Path(root) / "chunks")
# 密钥:cwd=部署根时先找 {deploy_root}/vstore/.env,再环境变量
_load_env_file(deploy_root / "vstore" / ".env")
sys.path.insert(0, str(vstore_dir.parent)) # 保证 `import vstore` 可用
from vstore import settings as _settings
_settings.set_paths(
_settings.VstorePaths(
root=root,
vault_path=vault_path,
index_path=index_path,
chunk_path=chunk_path,
)
)
try:
_settings.get_fernet_key()
except RuntimeError as exc:
print(f"[rebuild-chunks] 密钥缺失:{exc}", file=sys.stderr)
return 2
from vstore import vault as _vault
from vstore import embed as _embed
vault = _vault.Vault(vault_path, index_path)
vault.index.load()
vault.chunks.load()
enc_files = sorted(Path(vault_path).glob("*.enc"))
print(f"[rebuild-chunks] 密文目录:{vault_path}")
print(f"[rebuild-chunks] 待处理密文:{len(enc_files)} 条")
started = time.perf_counter()
docs: dict[str, list[dict[str, str | int | bool | list[float]]]] = {}
total_texts = 0
for n, enc in enumerate(enc_files, 1):
doc_id = enc.stem
try:
doc = _vault._deserialize_doc(_vault.load_encrypted(enc))
except Exception as exc:
print(f" [{n}/{len(enc_files)}] {doc_id} 解密失败,跳过:{exc}")
continue
meta = doc.get("meta") or {}
extracted = doc.get("extracted") or {}
name = str(meta.get("name") or doc_id)
built = _vault.build_chunks(
extracted.get("title") or name,
extracted.get("outline") or [],
[str(p or "") for p in extracted.get("paragraphs") or []],
extracted.get("tables") or [],
)
if not built:
print(f" [{n}/{len(enc_files)}] {doc_id} 无正文,0 块")
continue
vecs = _embed.embed_batch_chunked(
[c.text for c in built],
batch_size=args.batch_size,
workers=args.workers,
)
docs[doc_id] = [
{
"seq": c.seq,
"text": c.text,
"vector": list(vec),
"doc_name": name,
"doc_category": str(meta.get("category") or ""),
"doc_tags": str(meta.get("tags") or ""),
"as_template": bool(meta.get("as_template")),
"created_at": str(meta.get("created_at") or ""),
}
for c, vec in zip(built, vecs)
]
total_texts += len(built)
print(f" [{n}/{len(enc_files)}] {doc_id}({name[:30]})→ {len(built)} 块")
count = vault.chunks.rebuild_all(docs)
duration_ms = int((time.perf_counter() - started) * 1000)
print(f"[rebuild-chunks] 完成:docs={len(docs)}, chunks={count}, "
f"嵌入文本={total_texts}, duration_ms={duration_ms}")
return 0
if __name__ == "__main__":
sys.exit(main())