Files
PlantAI-rag-new/sdk/app/services/docgen/client.py
T
jerryandClaude Fable 5 4749f77544 fix(docgen): 两批手工测试修复——上传/预览/检索 + 护栏/导出/命名/编辑还原/二次预览
第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
       二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 15:49:54 +08:00

220 lines
7.9 KiB
Python
Raw 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.
"""services/docgen/client.py —— vstore HTTP 客户端(BFF → vstore 全经此处)。
- httpx.AsyncClient(连接池复用)+ 超时/重试(指数退避 0.5/1.0/2.0s)
- 方法:ingest / search_documents / get_document / get_style / delete_document /
update_metadata / rebuild_index / health
- 所有失败路径不抛未捕获异常:网络/超时抛 DocgenUnavailable,业务错误
返回 {"ok": False, "error": {...}} 结构供上层兜底
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from .settings import get_paths
logger = logging.getLogger(__name__)
DEFAULT_VSTORE_BASE_URL = "http://127.0.0.1:8094"
RETRY_BACKOFF = (0.5, 1.0, 2.0) # 重试退避(秒)
MAX_RETRIES = 3
DEFAULT_TIMEOUT = 600.0 # 2026-09-04 上传无限制:大文件解析+嵌入可超 60s,与前端 600s 兜底对齐
class DocgenUnavailable(Exception):
"""vstore 不可达(dev2 路由层兜底捕获:UPSTREAM_DOWN)。"""
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
super().__init__(message)
self.cause = cause
def _vstore_base_url() -> str:
"""vstore 基地址:VSTORE_BASE_URL env(dev2 会在 config.py 提供),
兜底 http://127.0.0.1:8094。"""
import os
return (os.environ.get("VSTORE_BASE_URL") or DEFAULT_VSTORE_BASE_URL).rstrip("/")
_client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient:
"""全局 AsyncClient(app 关闭时 close_client)。"""
global _client
if _client is None:
_client = httpx.AsyncClient(
base_url=_vstore_base_url(),
timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=5.0),
)
return _client
def set_client(client: httpx.AsyncClient) -> None:
"""注入 client(测试用 MockTransport)。"""
global _client
_client = client
async def close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
async def _request(
method: str,
path: str,
*,
retries: int = MAX_RETRIES,
**kwargs: Any,
) -> httpx.Response:
"""带重试的请求。网络/超时/5xx 重试;重试尽 → DocgenUnavailable。"""
client = get_client()
last_exc: BaseException | None = None
for attempt in range(retries + 1):
try:
resp = await client.request(method, path, **kwargs)
if resp.status_code < 500:
return resp
last_exc = RuntimeError(f"vstore 返回 {resp.status_code}")
except (httpx.HTTPError, httpx.TimeoutException) as exc:
last_exc = exc
if attempt < retries:
import asyncio
await asyncio.sleep(RETRY_BACKOFF[min(attempt, len(RETRY_BACKOFF) - 1)])
raise DocgenUnavailable(f"vstore 不可达({path}):{last_exc}", cause=last_exc)
def _check_ok(resp: httpx.Response) -> dict[str, Any]:
"""响应 → dict;ok=False 返回错误结构(不抛),HTTP 错误抛 DocgenUnavailable。"""
if resp.status_code >= 400:
raise DocgenUnavailable(f"vstore 返回错误状态 {resp.status_code}")
try:
payload = resp.json()
except ValueError as exc:
raise DocgenUnavailable(f"vstore 响应非 JSON:{exc}") from exc
return payload if isinstance(payload, dict) else {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "响应形态异常"}}
# ---------------------------------------------------------------------------
# dev2 契约导出(签名与 sdk/app/routers/docgen.py 桩段对齐)
# ---------------------------------------------------------------------------
async def ingest_bytes(
filename: str,
content: bytes,
*,
name: str,
category: str = "",
tags: str = "",
description: str = "",
as_template: bool = True,
) -> dict[str, Any]:
"""POST /api/vstore/ingest(multipart)。返回 {document_id, name, enc_path, ...}。"""
resp = await _request(
"POST", "/api/vstore/ingest",
params={"as_template": bool(as_template)},
data={"name": name, "category": category, "tags": tags, "description": description},
files={"file": (filename, content)},
retries=MAX_RETRIES,
)
payload = _check_ok(resp)
if not payload.get("ok"):
return payload # 错误结构供上层兜底
item = payload.get("item") or {}
if not item.get("document_id"):
return {"ok": False, "error": {"code": "BAD_RESPONSE", "message": "vstore 未返回 document_id"}}
return {**item, "ok": True}
async def search_documents(
query: str,
*,
as_template: bool | None = None,
top_k: int = 20,
category: str = "",
) -> list[dict[str, Any]]:
"""GET /api/vstore/search。检索失败抛 DocgenUnavailable(router 兜底 → 空模版集)。"""
params: dict[str, Any] = {"q": query, "top_k": top_k}
if as_template is not None:
params["as_template"] = as_template
if category:
params["category"] = category
resp = await _request("GET", "/api/vstore/search", params=params)
payload = _check_ok(resp)
if not payload.get("ok"):
return [] # 检索失败按空结果(不阻断生成)
return [item for item in payload.get("items", []) if isinstance(item, dict)]
async def get_document(document_id: str) -> dict[str, Any] | None:
"""GET /api/vstore/documents/{id}(明文全文 + outline + meta)。
404 → None(资料缺失占位提示,不阻断);不可达 → DocgenUnavailable。
"""
client = get_client()
try:
resp = await client.get(f"/api/vstore/documents/{document_id}", timeout=DEFAULT_TIMEOUT)
except (httpx.HTTPError, httpx.TimeoutException) as exc:
raise DocgenUnavailable(f"vstore 不可达(documents/{document_id}):{exc}", cause=exc) from exc
if resp.status_code == 404:
return None
if resp.status_code >= 400:
raise DocgenUnavailable(f"vstore 返回错误状态 {resp.status_code}")
payload = _check_ok(resp)
if not payload.get("ok"):
return None
doc = payload.get("document")
return doc if isinstance(doc, dict) else None
async def get_style(document_id: str) -> dict[str, Any] | None:
"""GET /api/vstore/documents/{id}/style(docx 样式提取)。404/失败 → None。"""
client = get_client()
try:
resp = await client.get(f"/api/vstore/documents/{document_id}/style", timeout=DEFAULT_TIMEOUT)
except (httpx.HTTPError, httpx.TimeoutException):
return None
if resp.status_code >= 400:
return None
try:
payload = resp.json()
except ValueError:
return None
hints = payload.get("style_hints") if isinstance(payload, dict) else None
return hints if isinstance(hints, dict) else None
async def delete_document(document_id: str) -> dict[str, Any]:
"""DELETE /api/vstore/documents/{id}。"""
resp = await _request("DELETE", f"/api/vstore/documents/{document_id}")
return _check_ok(resp)
async def update_metadata(document_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
"""PATCH 元数据。vstore 未提供 PATCH 端点 → 转发后返回错误结构(容错)。"""
# vstore V1 未提供 PATCH;meta 以 BFF doc_library 落库为准,
# 透传失败返回错误结构,由 router 容错忽略
return {"ok": False, "error": {"code": "NOT_IMPLEMENTED", "message": "vstore 无 meta 更新端点(以 BFF 落库为准)"}}
async def rebuild_index() -> dict[str, Any]:
"""POST /api/vstore/rebuild-index。"""
resp = await _request("POST", "/api/vstore/rebuild-index")
return _check_ok(resp)
async def health() -> dict[str, Any]:
"""GET /api/vstore/health。失败返回 {ok: False} 结构(不抛)。"""
try:
resp = await _request("GET", "/api/vstore/health", retries=0)
return _check_ok(resp)
except DocgenUnavailable as exc:
return {"ok": False, "error": {"code": "UPSTREAM_DOWN", "message": str(exc)}}