Files
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

215 lines
7.3 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.
"""api.py —— vstore FastAPI 服务(8094)。
端点(docs/team/brief_dev1_p1.md):
- POST /api/vstore/ingest multipart: file + name/category/description/tags/as_template
- GET /api/vstore/search ?q&top_k&category&as_template
- GET /api/vstore/documents/{id} 明文全文 + outline + meta(仅生成时取)
- GET /api/vstore/documents/{id}/style
- DELETE /api/vstore/documents/{id}
- POST /api/vstore/rebuild-index
- GET /api/vstore/health
FR-PRIV 新增(chunk 级索引,A1):
- GET /api/vstore/search-chunks ?q&top_k&min_score&doc_ids&exclude_template
- POST /api/vstore/rebuild-chunks
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from . import vault as _vault
from .backend import BackendError, handshake, parse_backend
from .chunks import CHUNK_MIN_SCORE, CHUNK_TOP_K_MAX
from .settings import (
DEFAULT_TOP_K,
MAX_TOP_K,
get_paths,
)
from .vault import VaultError
logger = logging.getLogger(__name__)
app = FastAPI(title="PlantAI vstore", version="0.1.0")
def _error(code: str, message: str, status: int, degraded: bool = False) -> JSONResponse:
"""错误信封(与 BFF 现有格式一致:{ok, error:{code, message, degraded}})。"""
return JSONResponse(
status_code=status,
content={"ok": False, "error": {"code": code, "message": message, "degraded": degraded}},
)
# ---- 启动钩子:解析后端 + 握手校验(失败抛异常拒绝启动)----
def _init_service() -> None:
paths = get_paths()
backend = parse_backend(paths.vault_path, paths.index_path)
try:
handshake(backend)
except BackendError as exc:
logger.error("vstore: 启动握手失败,拒绝启动:%s", exc)
raise
_vault.set_backend(backend)
_vault.init_vault()
@app.on_event("startup")
def _startup() -> None:
_init_service()
# ---- 端点 ----
@app.get("/api/vstore/health")
async def health() -> dict[str, Any]:
"""健康检查:后端 ok + 库存量 + chunk 库存量(FR-PRIV-007)。"""
vault = _vault.get_vault()
count = vault.index.count() if hasattr(vault, "index") else len(vault.list_rows())
chunks = getattr(vault, "chunks", None)
chunk_count = chunks.count() if chunks is not None else 0
return {"ok": True, "count": count, "backend": "local", "chunk_count": chunk_count}
@app.post("/api/vstore/ingest")
async def ingest(
file: UploadFile = File(...),
name: str = Form(""),
category: str = Form(""),
description: str = Form(""),
tags: str = Form(""),
as_template: bool = Form(False),
) -> dict[str, Any]:
"""multipart 入库。文件解析/嵌入/加密落盘/索引由 vault.add 完成。"""
content = await file.read()
try:
item = _vault.get_vault().add(
content,
filename=file.filename or "document",
meta={"name": name, "category": category, "description": description, "tags": tags},
as_template=bool(as_template),
)
except VaultError as exc:
return _error("VALIDATION", str(exc), 422)
return {"ok": True, "item": item}
@app.get("/api/vstore/search")
async def search(
q: str = "",
top_k: int = DEFAULT_TOP_K,
category: str = "",
as_template: bool | None = None,
) -> dict[str, Any]:
"""三档降级检索。"""
try:
result = _vault.get_vault().search(
q,
top_k=max(1, min(top_k, MAX_TOP_K)),
category=category or None,
as_template=as_template,
)
return {"ok": True, **result}
except VaultError as exc:
return _error("INTERNAL", str(exc), 500, degraded=True)
@app.get("/api/vstore/documents/{doc_id}")
async def get_document(doc_id: str) -> dict[str, Any]:
"""明文全文 + outline + meta(仅生成时取)。"""
doc = _vault.get_vault().get_document_json(doc_id)
if doc is None:
raise HTTPException(status_code=404, detail="document not found")
return {"ok": True, "document": doc}
@app.get("/api/vstore/documents/{doc_id}/style")
async def get_style(doc_id: str) -> dict[str, Any]:
"""docx 样式提取(导出对齐用)。"""
style = _vault.get_vault().get_style(doc_id)
if style is None:
raise HTTPException(status_code=404, detail="document not found")
return {"ok": True, "style_hints": style}
@app.delete("/api/vstore/documents/{doc_id}")
async def delete_document(doc_id: str) -> dict[str, Any]:
"""删除:索引行 + 密文 secure_delete。"""
removed = _vault.get_vault().delete(doc_id)
return {"ok": True, "removed": removed}
@app.post("/api/vstore/rebuild-index")
async def rebuild_index() -> dict[str, Any]:
"""重建索引:全部密文重新解密 → 嵌入 → 原子切换。
明文只在内存流转;任何一条失败(密钥不符/嵌入失败)整库拒绝切换。
"""
vault = _vault.get_vault()
rebuilt = vault.rebuild_index()
if rebuilt["status"] == "error":
return _error("INTERNAL", rebuilt["message"], 500, degraded=True)
return {"ok": True, "count": rebuilt["count"]}
# ---------------------------------------------------------------------------
# FR-PRIV(A1):chunk 级索引端点
# ---------------------------------------------------------------------------
@app.get("/api/vstore/search-chunks")
async def search_chunks(
q: str = "",
top_k: int = 5,
min_score: float = CHUNK_MIN_SCORE,
doc_ids: str = "",
exclude_template: bool = False,
) -> dict[str, Any]:
"""chunk 级检索(FR-PRIV-002)。无降级档:低于 min_score 全部丢弃 → empty。
top_k clamp 1..20;doc_ids 为逗号分隔白名单(空 = 不过滤)。
"""
vault = _vault.get_vault()
chunks = getattr(vault, "chunks", None)
if chunks is None:
return {"ok": True, "mode": "empty", "query": q, "count": 0, "items": []}
top_k = max(1, min(int(top_k), CHUNK_TOP_K_MAX))
wanted = [d.strip() for d in doc_ids.split(",") if d.strip()] or None
try:
# 同步阻塞段(查询嵌入 + faiss 检索)一律 to_thread,不阻塞事件循环
vector = await asyncio.to_thread(_vault.get_embed_fn(), [q or ""])
result = await asyncio.to_thread(
chunks.search,
vector[0] if vector else [0.0] * _vault.EMBED_DIM,
top_k,
min_score,
wanted,
bool(exclude_template),
)
return {
"ok": True,
"mode": result.get("mode", "empty"),
"query": q,
"count": len(result.get("items") or []),
"items": result.get("items") or [],
}
except VaultError as exc:
return _error("INTERNAL", str(exc), 500, degraded=True)
@app.post("/api/vstore/rebuild-chunks")
async def rebuild_chunks() -> dict[str, Any]:
"""重建 chunk 索引(FR-PRIV-007):全部密文解密 → 切块 → 批量嵌入 →
原子切换。同步段 to_thread。
"""
vault = _vault.get_vault()
try:
result = await asyncio.to_thread(vault.rebuild_chunks)
except Exception as exc:
logger.exception("vstore: rebuild-chunks 失败")
return _error("INTERNAL", f"chunk 重建失败:{exc}", 500, degraded=True)
return {"ok": True, **result}