2026-09-03 21:13:37 +08:00
|
|
|
|
"""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
|
2026-09-07 22:22:22 +08:00
|
|
|
|
FR-PRIV 新增(chunk 级索引,A1):
|
|
|
|
|
|
- GET /api/vstore/search-chunks ?q&top_k&min_score&doc_ids&exclude_template
|
|
|
|
|
|
- POST /api/vstore/rebuild-chunks
|
2026-09-03 21:13:37 +08:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-09-07 22:22:22 +08:00
|
|
|
|
import asyncio
|
2026-09-03 21:13:37 +08:00
|
|
|
|
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
|
2026-09-07 22:22:22 +08:00
|
|
|
|
from .chunks import CHUNK_MIN_SCORE, CHUNK_TOP_K_MAX
|
2026-09-03 21:13:37 +08:00
|
|
|
|
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]:
|
2026-09-07 22:22:22 +08:00
|
|
|
|
"""健康检查:后端 ok + 库存量 + chunk 库存量(FR-PRIV-007)。"""
|
2026-09-03 21:13:37 +08:00
|
|
|
|
vault = _vault.get_vault()
|
|
|
|
|
|
count = vault.index.count() if hasattr(vault, "index") else len(vault.list_rows())
|
2026-09-07 22:22:22 +08:00
|
|
|
|
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}
|
2026-09-03 21:13:37 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"]}
|
2026-09-07 22:22:22 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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}
|