diff --git a/sdk/app/main.py b/sdk/app/main.py index ca6134b..c494a7f 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -24,6 +24,7 @@ from .config import get_settings from .db import close_db, init_db from .routers import capabilities, docgen, extraction, figures, health, kg, legacy, qa, sessions from .routers import bioflow_proxy +from .routers import private_docs from .upstream import close_client logging.basicConfig(level=logging.INFO) @@ -55,6 +56,7 @@ def create_app() -> FastAPI: app.include_router(figures.router) app.include_router(legacy.router) app.include_router(docgen.router) # docgen(P2) + app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004) if get_settings().bioflow_enabled: app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028) diff --git a/sdk/app/routers/private_docs.py b/sdk/app/routers/private_docs.py new file mode 100644 index 0000000..a7b721f --- /dev/null +++ b/sdk/app/routers/private_docs.py @@ -0,0 +1,61 @@ +"""routers/private_docs.py —— 私有资料问答 BFF 端点(FR-PRIV-004,A1)。 + +- POST /api/qa/private body={question, session_id?, top_k?, min_score?} + +三态契约(FR-PRIV 假设 8): +- 命中:200 {ok:true, hit:true, mode:"vector", answer, items, elapsed_ms} +- 未命中:200 {ok:true, hit:false, mode:"empty", answer:"", items:[]} +- vstore 不可达:503 UPSTREAM_DOWN;LLM 失败:502 UPSTREAM_ERROR + (信封复用 app.upstream.envelope:{ok:false, error:{code, message, degraded}}) + +技术纪律:无生成器/无 SSE(Cython 安全);服务层只用 dict/stdlib。 +""" +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from ..schemas import PrivateQARequest +from ..services.docgen.client import DocgenUnavailable +from ..services.docgen.llm import LLMError +from ..services.privateqa import ask_private +from ..upstream import envelope + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/qa", tags=["private_docs"]) + + +def _envelope( + code: str, + message: str | None = None, + degraded: bool | None = None, + status: int | None = None, +) -> JSONResponse: + if degraded is None: + degraded = code == "UPSTREAM_DOWN" + body, http = envelope(code, message=message, degraded=degraded, status=status) + return JSONResponse(status_code=http, content=body) + + +@router.post("/private") +async def private_qa(req: PrivateQARequest) -> dict[str, Any]: + """私有资料问答:命中/未命中 200;上游故障转错误信封。""" + try: + return await ask_private( + req.question, + top_k=req.top_k, + min_score=req.min_score, + ) + except DocgenUnavailable as exc: + logger.warning("privateqa: vstore 不可达:%s", exc) + return _envelope("UPSTREAM_DOWN", message="私有资料检索服务不可用,请稍后重试") + except LLMError as exc: + logger.warning("privateqa: LLM 失败:%s", exc) + return _envelope("UPSTREAM_ERROR", message="私有资料回答生成失败,请稍后重试") + except Exception as exc: + logger.exception("privateqa: 未预期异常") + return _envelope("INTERNAL", message=f"私有资料问答失败:{exc}") diff --git a/sdk/app/schemas.py b/sdk/app/schemas.py index 5141ded..8094955 100644 --- a/sdk/app/schemas.py +++ b/sdk/app/schemas.py @@ -200,11 +200,54 @@ class BioflowReuseBlock(BaseModel): prefill: dict[str, Any] = Field(default_factory=dict) +# --------------------------------------------------------------------------- +# §2c 私有资料问答块(FR-PRIV-004/005):BFF 仅做持久化校验,不参与渲染—— +# 字段契约归前端 store 与 BFF private_docs 路由;union 判别靠 type 字面量, +# 其余字段全默认值 + extra="allow" 宽松接收(A1)。 +# --------------------------------------------------------------------------- + +class PrivateDocItem(BaseModel): + """私有资料命中条目(FR-PRIV-004 items 形态)。""" + + model_config = ConfigDict(extra="allow") + + doc_id: str = "" + name: str = "" + seq: int = 0 + chunk_id: str = "" + snippet: str = "" + score: float = 0.0 + category: str = "" + tags: str = "" + created_at: str = "" + ref: int = 0 + + +class PrivateDocBlock(BaseModel): + """私有资料参考块(FR-PRIV-005):标题「私有资料参考」+ 短答 + 条目列表。""" + + model_config = ConfigDict(extra="allow") + + type: Literal["private_doc"] = "private_doc" + answer: str = "" + items: list[PrivateDocItem] = Field(default_factory=list) + + +class PrivateQARequest(BaseModel): + """POST /api/qa/private 请求体(FR-PRIV-004)。""" + + question: str + session_id: str | None = None + top_k: int = 8 + min_score: float = 0.30 + + Block = Union[ TextBlock, TableBlock, CodeBlock, StreamBlock, PipelineBlock, ToolBlock, SourcesBlock, GraphBlock, ArtifactBlock, BioflowPathBlock, BioflowProgressBlock, BioflowFormBlock, BioflowFileBlock, BioflowArchiveBlock, BioflowQaBlock, BioflowReuseBlock, + PrivateDocBlock, ] diff --git a/sdk/app/services/docgen/client.py b/sdk/app/services/docgen/client.py index c6251ff..b168b8b 100644 --- a/sdk/app/services/docgen/client.py +++ b/sdk/app/services/docgen/client.py @@ -153,6 +153,26 @@ async def search_documents( return [item for item in payload.get("items", []) if isinstance(item, dict)] +async def search_chunks( + query: str, + *, + top_k: int = 8, + min_score: float = 0.30, + timeout: float = 12.0, +) -> dict[str, Any]: + """GET /api/vstore/search-chunks(FR-PRIV-004)。失败抛 DocgenUnavailable。""" + resp = await _request( + "GET", + "/api/vstore/search-chunks", + params={"q": query, "top_k": top_k, "min_score": min_score}, + timeout=timeout, + ) + payload = _check_ok(resp) + if not isinstance(payload, dict) or not payload.get("ok"): + raise DocgenUnavailable("vstore search-chunks 返回异常") + return payload + + async def get_document(document_id: str) -> dict[str, Any] | None: """GET /api/vstore/documents/{id}(明文全文 + outline + meta)。 diff --git a/sdk/app/services/docgen/llm.py b/sdk/app/services/docgen/llm.py index cf410af..e44badc 100644 --- a/sdk/app/services/docgen/llm.py +++ b/sdk/app/services/docgen/llm.py @@ -65,6 +65,7 @@ async def _chat_once( temperature: float, max_tokens: int, json_mode: bool, + timeout: float | None = None, ) -> str: headers = {"Content-Type": "application/json"} if cfg["api_key"]: @@ -84,7 +85,7 @@ async def _chat_once( f"{cfg['base_url']}/chat/completions", headers=headers, json=body, - timeout=cfg["timeout"], + timeout=timeout if timeout is not None else cfg["timeout"], ) resp.raise_for_status() payload = resp.json() @@ -103,8 +104,13 @@ async def _complete( temperature: float, max_tokens: int, json_mode: bool, + timeout: float | None = None, ) -> str: - """带指数退避的完成调用(429/5xx/超时重试)。""" + """带指数退避的完成调用(429/5xx/超时重试)。 + + timeout:None 走 env 现值(DOCGEN_LLM_TIMEOUT 或默认 300);显式值覆盖 + (FR-PRIV-004 私有问答 40s 用)。 + """ cfg = _llm_config() last_exc: BaseException | None = None async with httpx.AsyncClient() as client: @@ -113,6 +119,7 @@ async def _complete( return await _chat_once( client, cfg, system, user, temperature=temperature, max_tokens=max_tokens, json_mode=json_mode, + timeout=timeout, ) except LLMError: raise # 业务错误不重试 @@ -136,10 +143,15 @@ async def complete_text( *, temperature: float = 0.3, max_tokens: int = 8000, + timeout: float | None = None, ) -> str: - """文本补全。失败抛 LLMError(调用方决定占位策略)。""" + """文本补全。失败抛 LLMError(调用方决定占位策略)。 + + timeout=None 走 env 现行为(docgen 既有调用零影响);显式值透传 _chat_once。 + """ return await _complete( - system, user, temperature=temperature, max_tokens=max_tokens, json_mode=False + system, user, temperature=temperature, max_tokens=max_tokens, json_mode=False, + timeout=timeout, ) diff --git a/sdk/app/services/privateqa.py b/sdk/app/services/privateqa.py new file mode 100644 index 0000000..54adf2e --- /dev/null +++ b/sdk/app/services/privateqa.py @@ -0,0 +1,126 @@ +"""services/privateqa.py —— 私有资料问答服务(FR-PRIV-004)。 + +BFF 直连 DeepSeek 生成 300 字内带 [资料n] 引用的 markdown 短答: +- ask_private(question, *, top_k=8, min_score=0.30) -> dict + 检索 chunk 索引 → 空则 {hit:false, mode:"empty", answer:"", items:[]}(不调 LLM) + → 同 doc 按 score 序去重(每 doc ≤3 块)再取 top_k + → 上下文拼 `[资料n]《name》(相关度 x.xx)\nsnippet`(12k 预算截断) + → complete_text(system 约束, temperature=0.2, max_tokens=800, timeout=40) + → {hit:true, mode:"vector", answer, items:[...带 ref], elapsed_ms} + +技术纪律:本模块只用 dict/stdlib;无生成器/无 SSE +(Cython 安全,断流崩溃回归)。 +异常不吞:DocgenUnavailable / Exception / LLMError 原样上抛,由路由转 503/502 信封。 +""" +from __future__ import annotations + +import logging +import time +from typing import Any + +from .docgen.client import search_chunks +from .docgen.llm import complete_text + +logger = logging.getLogger(__name__) + +# ---- 常量(任务书契约)---- +PRIVATE_QA_LLM_TIMEOUT = 40.0 +PRIVATE_QA_MAX_CTX_CHARS = 12000 +PRIVATE_QA_PER_DOC_MAX = 3 +PRIVATE_QA_MAX_TOKENS = 800 + +SYSTEM_PROMPT = ( + "你是一个只依赖客户上传资料回答的助手。只能依据资料回答,不得编造;" + "每个关键论断标注 [资料n];资料不足则答『资料中未找到相关信息』;" + "300 字内简体中文 markdown。" +) + + +def _safe_float(value: Any) -> bool: + try: + float(value) + return True + except (TypeError, ValueError): + return False + + +def _pick_items(items: list[dict[str, Any]], top_k: int) -> list[dict[str, Any]]: + """同 doc 按 score 序去重(每 doc ≤ PRIVATE_QA_PER_DOC_MAX 块),再取 top_k。 + + 输入已按 score 降序(vstore faiss 顺序);同 doc 靠前块优先。 + """ + picked: list[dict[str, Any]] = [] + per_doc: dict[str, int] = {} + for item in items: + if not isinstance(item, dict): + continue + doc_id = str(item.get("doc_id") or "") + used = per_doc.get(doc_id, 0) + if used >= PRIVATE_QA_PER_DOC_MAX: + continue + per_doc[doc_id] = used + 1 + picked.append(dict(item)) + if len(picked) >= top_k: + break + return picked + + +def _build_context(picked: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: + """拼 LLM 上下文(12k 字符预算截断)并给条目补 ref 编号。""" + parts: list[str] = [] + out_items: list[dict[str, Any]] = [] + budget = PRIVATE_QA_MAX_CTX_CHARS + for n, item in enumerate(picked, 1): + name = str(item.get("doc_name") or item.get("name") or "") + score_s = f"{float(item.get('score')):.2f}" if _safe_float(item.get("score")) else "0.00" + snippet = str(item.get("snippet") or "") + part = f"[资料{n}]《{name}》(相关度 {score_s})\n{snippet}" + if len(part) > budget: + part = part[:budget] if budget > 0 else "" + if not part: + continue + budget -= len(part) + parts.append(part) + entry = dict(item) + entry["ref"] = n + out_items.append(entry) + return "\n".join(parts), out_items + + +async def ask_private( + question: str, + *, + top_k: int = 8, + min_score: float = 0.30, +) -> dict[str, Any]: + """私有资料问答:检索 → 去重截断 → 拼上下文 → DeepSeek 短答。 + + 返回 {hit, mode, answer, items, elapsed_ms};未命中不调 LLM; + 异常(DocgenUnavailable / Exception / LLMError)原样上抛。 + """ + started = time.perf_counter() + payload = await search_chunks(question, top_k=top_k, min_score=min_score, timeout=12.0) + items = [i for i in payload.get("items") or [] if isinstance(i, dict)] + if not items: + return {"hit": False, "mode": "empty", "answer": "", "items": []} + + picked = _pick_items(items, top_k) + context, out_items = _build_context(picked) + if not context: + return {"hit": False, "mode": "empty", "answer": "", "items": []} + + answer = await complete_text( + SYSTEM_PROMPT, + context, + temperature=0.2, + max_tokens=PRIVATE_QA_MAX_TOKENS, + timeout=PRIVATE_QA_LLM_TIMEOUT, + ) + elapsed_ms = int((time.perf_counter() - started) * 1000) + return { + "hit": True, + "mode": "vector", + "answer": answer, + "items": out_items, + "elapsed_ms": elapsed_ms, + } diff --git a/ui/src/api/docgen.js b/ui/src/api/docgen.js index 3f39aba..c056e26 100644 --- a/ui/src/api/docgen.js +++ b/ui/src/api/docgen.js @@ -1,7 +1,7 @@ // api/docgen.js —— 文档生成端点(P3/dev3 归属) // 端点契约:P2 任务书 sdk/app/routers/docgen.py(dev2 开发中,联调后生效)。 // 全部经 http.js 封装;默认读超时 600s 兜底(生成/编辑最长路径不早于 BFF 超时自断)。 -import { get, post, patch } from './http.js'; +import { get, post, patch, del } from './http.js'; /** * 上传入库(用户拖拽即走此路):multipart → vstore /ingest,入库主向量库持久可用。 @@ -60,6 +60,13 @@ export function listLibrary({ q, category, as_template } = {}) { return get('/api/docgen/library', { q, category, as_template }); } +/** 删除资料(FR-PRIV-006):BFF DELETE 返回 204 空体——http.del 解析不出 JSON 时 + * 返回 null,此处兜底为 {ok:true}(假设 10:不抛 JSON 解析错);信封错误仍抛 ApiError。 */ +export async function removeLibrary(id) { + const res = await del(`/api/docgen/library/${encodeURIComponent(id)}`); + return res == null ? { ok: true } : res; +} + /** 下载地址(附件导出:后端带 RFC 5987 文件名) */ export function downloadUrl(artifactId, format) { return `/api/docgen/artifacts/${encodeURIComponent(artifactId)}/download?format=${encodeURIComponent(format)}`; diff --git a/ui/src/api/private.js b/ui/src/api/private.js new file mode 100644 index 0000000..552bc8b --- /dev/null +++ b/ui/src/api/private.js @@ -0,0 +1,17 @@ +// api/private.js —— 私有资料问答端点(FR-PRIV-004,A1) +// POST /api/qa/private;命中/未命中均 200(hit 字段判别);错误信封由 http.js 归一为 ApiError。 +// 前端 fire-and-forget 调用:未命中/异常静默(FR-PRIV-003)。 +import { post } from './http.js'; + +/** + * 私有资料问答(与 QA 主流程并行;调用方自行静默处理失败)。 + * @param {{question: string, session_id?: string|null, top_k?: number, min_score?: number}} payload + * @returns {Promise<{ok:boolean, hit:boolean, mode:string, answer:string, items:Array, elapsed_ms?:number}>} + */ +export function askPrivate({ question, session_id = null, top_k = 8, min_score = 0.30 }) { + return post( + '/api/qa/private', + { question, session_id, top_k, min_score }, + { timeout: 60000 }, // 私有检索+LLM 短答:60s 兜底(BFF 内部 40s LLM 超时自断) + ); +} diff --git a/ui/src/components/bench/DocPane.vue b/ui/src/components/bench/DocPane.vue index f8c0ac8..35de946 100644 --- a/ui/src/components/bench/DocPane.vue +++ b/ui/src/components/bench/DocPane.vue @@ -15,6 +15,8 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { useDocgenStore } from '../../stores/docgen.js'; import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js'; import { renderMarkdown, sanitizeHtml } from '../chat/markdown.js'; +// FR-PRIV-006:「我的资料」管理面板(挂空态区顶部,UI_SPEC §9.2) +import PrivateLibraryPane from './PrivateLibraryPane.vue'; const docgen = useDocgenStore(); @@ -325,8 +327,9 @@ onBeforeUnmount(() => {