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

124 lines
4.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.
"""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)