- 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>
124 lines
4.6 KiB
Python
124 lines
4.6 KiB
Python
"""backend.py —— 存储后端解析与握手校验(本地磁盘 / 远程 HTTP)。
|
||
|
||
- parse_backend(spec) → LocalBackend | RemoteBackend
|
||
含 :// 且 scheme http/https → RemoteBackend(vault/index 操作转 HTTP 指向远程根);
|
||
其余(含 file://)→ LocalBackend(本机磁盘)
|
||
- 启动握手校验:/health + 空检索;失败日志告警并抛异常(拒绝启动)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import urlparse
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BackendError(Exception):
|
||
"""后端不可用/握手失败(启动拒绝)。"""
|
||
|
||
|
||
def _parse_scheme(spec: str) -> str:
|
||
"""提取 scheme(http/https 才视为远程)。"""
|
||
return urlparse(spec).scheme.lower()
|
||
|
||
|
||
def is_remote_spec(spec: str) -> bool:
|
||
"""含 :// 且 scheme http/https → 远程。"""
|
||
return "://" in spec and _parse_scheme(spec) in ("http", "https")
|
||
|
||
|
||
class LocalBackend:
|
||
"""本机磁盘后端:vault/index 均为本地目录。"""
|
||
|
||
kind = "local"
|
||
|
||
def __init__(self, vault_path: str, index_path: str) -> None:
|
||
self.vault_path = Path(vault_path)
|
||
self.index_path = Path(index_path)
|
||
|
||
def health(self) -> dict[str, Any]:
|
||
"""本地健康:目录可写即 ok。"""
|
||
try:
|
||
self.vault_path.mkdir(parents=True, exist_ok=True)
|
||
self.index_path.mkdir(parents=True, exist_ok=True)
|
||
return {"ok": True, "backend": self.kind}
|
||
except OSError as exc:
|
||
raise BackendError(f"本地目录不可写:{exc}") from exc
|
||
|
||
def search_empty(self) -> dict[str, Any]:
|
||
"""空检索握手:本地恒可用。"""
|
||
return {"ok": True, "mode": "local", "items": []}
|
||
|
||
def close(self) -> None: # pragma: no cover - 无资源可释放
|
||
pass
|
||
|
||
|
||
class RemoteBackend:
|
||
"""远程 HTTP 后端:vault/index 操作指向远程 vstore 根。"""
|
||
|
||
kind = "remote"
|
||
|
||
def __init__(self, vault_url: str, index_url: str) -> None:
|
||
self.vault_url = vault_url.rstrip("/")
|
||
self.index_url = index_url.rstrip("/")
|
||
|
||
def health(self) -> dict[str, Any]:
|
||
"""远程健康:GET /health(超时 5s)。"""
|
||
try:
|
||
resp = httpx.get(f"{self.index_url}/health", timeout=5.0)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
except (httpx.HTTPError, ValueError) as exc:
|
||
raise BackendError(f"远程 vstore 健康检查失败:{exc}") from exc
|
||
|
||
def search_empty(self) -> dict[str, Any]:
|
||
"""空检索握手:GET /api/vstore/search?q=。"""
|
||
try:
|
||
resp = httpx.get(
|
||
f"{self.index_url}/api/vstore/search", params={"q": "", "top_k": 1}, timeout=5.0
|
||
)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
except (httpx.HTTPError, ValueError) as exc:
|
||
raise BackendError(f"远程 vstore 空检索握手失败:{exc}") from exc
|
||
|
||
def close(self) -> None: # pragma: no cover - 无连接可释放
|
||
pass
|
||
|
||
|
||
def parse_backend(vault_spec: str, index_spec: str) -> LocalBackend | RemoteBackend:
|
||
"""解析存储后端。
|
||
|
||
- 两个 spec 都是本地(含 file://)→ LocalBackend(file:// 剥掉前缀)
|
||
- 两个 spec 都是 http(s):// → RemoteBackend
|
||
- 混合形态不支持,抛 BackendError
|
||
"""
|
||
vault_remote = is_remote_spec(vault_spec)
|
||
index_remote = is_remote_spec(index_spec)
|
||
if vault_remote != index_remote:
|
||
raise BackendError(
|
||
f"vault/index 后端形态不一致(vault={'远程' if vault_remote else '本地'},"
|
||
f"index={'远程' if index_remote else '本地'}),不支持混合部署"
|
||
)
|
||
if vault_remote:
|
||
return RemoteBackend(vault_url=vault_spec, index_url=index_spec)
|
||
# 本地路径(file:// 前缀剥掉)
|
||
vault_path = vault_spec[len("file://"):] if vault_spec.startswith("file://") else vault_spec
|
||
index_path = index_spec[len("file://"):] if index_spec.startswith("file://") else index_spec
|
||
return LocalBackend(vault_path=vault_path, index_path=index_path)
|
||
|
||
|
||
def handshake(backend: LocalBackend | RemoteBackend) -> None:
|
||
"""启动握手:health + 空检索。失败日志告警并抛 BackendError(拒绝启动)。"""
|
||
health = backend.health()
|
||
if not health.get("ok"):
|
||
raise BackendError(f"后端健康检查未通过:{health}")
|
||
search = backend.search_empty()
|
||
if not search.get("ok"):
|
||
raise BackendError(f"后端空检索握手未通过:{search}")
|
||
logger.info("backend: %s 握手通过(health+空检索)", backend.kind)
|