- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化 - Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab - 四档响应式(桌面/笔记本/平板/手机)、深浅主题 - 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测 - 真实凭据已脱敏,运行时配置在 backend/.env(不入 git) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
20 KiB
API.md —— BFF 接口契约
版本 1.0 | 作者 PM | 本文件由 PM 独占写入,所有开发/测试 agent 只读
BFF 是前端唯一的数据来源。前端禁止直连任何上游。 上游真实响应结构以
DISCOVERY.md为准;本文件定义 BFF 暴露给前端的契约。 契约变更必须由 PM 在本文件修改并广播,任何人不得私自改接口形态。
1. 通用约定
- Base:
/api - 编码:UTF-8,JSON;时间一律 ISO 8601 UTC(
2026-08-25T12:34:56Z) - ID:UUID4 字符串
- 统一错误信封(所有非 2xx 与降级响应):
{ "ok": false, "error": { "code": "UPSTREAM_DOWN", "message": "知识问答服务当前不可用,请稍后重试", "degraded": true } }
| code | HTTP | 含义 | 前端行为 |
|---|---|---|---|
UPSTREAM_DOWN |
503 | 依赖的上游不可用 | 降级提示(禁用对应功能),不弹窗 |
UPSTREAM_ERROR |
502 | 上游返回了错误 | 显示 message + 重试按钮 |
TIMEOUT |
504 | 上游超时 | 显示「检索超时」+ 重试按钮 |
INVALID_CITATION |
502 | 引用校验失败(已自动重试一次仍失败) | 显示「引用校验失败」+ 重试按钮 |
NOT_FOUND |
404 | 会话/消息不存在 | 回列表 |
VALIDATION |
422 | 请求参数不合法 | 展示 message |
INTERNAL |
500 | BFF 自身错误 | 显示 message |
degraded 语义裁决(2026-08-25,PM):degraded:true 仅用于「上游服务整体不可用」(UPSTREAM_DOWN),对应前端的降级横幅/功能禁用;请求级失败(UPSTREAM_ERROR/TIMEOUT/INVALID_CITATION/NOT_FOUND/VALIDATION/INTERNAL)一律 degraded:false,对应前端的内联错误 + 重试。两者的 UI 处置不同,不得混用。
- 成功响应不包
{ok:true}信封,直接返回业务 JSON。 - 健康接口(
/api/health)例外:返回{status, ...}结构(见 §3)。 - 请求参数校验错误(422)例外:FastAPI 原生
{"detail":[...]}形态,不包统一信封(集成清单:M2 由全局异常处理器收敛为VALIDATION信封,前端 http.js 在收敛前需兼容两种形态)。 - 已裁决的偏差(2026-08-25,A3):抽取上传用
SpooledTemporaryFile中间落盘(≤5MB 驻内存),语义为「不整文件读进内存」;kg/extraction 信封用顶层 JSONResponse(与 mock 一致);/api/extraction/jobs/{id}的 plan 拉取失败(含双上游不可达)置plan:null不报错(job 详情可用性优先)。
2. 块联合(消息内容单元)
助手消息的 blocks 数组元素为判别式联合:
{ "type": "text", "html": "<p>…</p>" } // 已消毒的 HTML
{ "type": "table", "headers": ["…"], "rows": [["…"]] } // 二维数组
{ "type": "code", "text": "…" }
{ "type": "stream", "text": "…" } // 瞬态,绝不落库
{ "type": "pipeline", "status": "running|done|error",
"steps": [ { "name": "规划", "state": "waiting|running|done", "desc": "…", "phase": "planning" } ] }
{ "type": "tool", "icon": "🔬", "name": "…", "status": "running|done|error",
"dur": "42s", "open": true, "params": "…", "result": "…",
"logs": [ { "text": "…", "cls": "log-ok|log-run" } ], "phase": "retrieval" }
{ "type": "sources", "items": [ <source> ] } // 见 §8
{ "type": "graph", "nodes": [ <node> ], "edges": [ <edge> ], "chains": [ <chain> ] } // 见 §9
3. 健康与元信息
GET /api/health
并发探测 7920、8000、7900 与 Neo4j(1s 超时,5s 缓存)。
{
"status": "ok|degraded",
"version": "0.1.0",
"upstreams": {
"qa": { "ok": true, "version": "1.1.73-assertion-anchored" },
"rag": { "ok": true },
"ragAlt":{ "ok": true },
"neo4j": { "ok": true, "active": "neo4j-plant-srt-pc2025-v3-20260817-normalized" }
},
"db": { "ok": true, "sessions": 12 }
}
GET /api/qa/info
透传上游 /info 并精简:
{
"name": "QA V1.1.73 Assertion-Anchored Full Graph",
"version": "1.1.73-assertion-anchored",
"pipeline": [ "question", "provisional answer", "…共 12 段" ],
"config": { "max_evidence": 40, "bge_rerank": true, "knowledge_chain_defaults": { "…": "…" } }
}
4. QA 问答
POST /api/qa/chat
请求(mode 由前端从 config/modes.js 拼出):
{
"question": "水稻SPL基因家族的功能研究有哪些关键进展?",
"use_llm": true,
"debug": false,
"chain_top_k": 3, "chain_hops": 2, "chain_max_edges": 12, "chain_max_nodes": 10,
"native_chain_enabled": true, "native_chain_max_hops": 3, "native_chain_top_k": 5
}
响应:归一化 QA 响应(完整结构见 §7)。读超时 300s。invalid_llm_citation 自动重试一次。
POST /api/qa/chat/stream(SSE)
请求同 /chat。响应 Content-Type: text/event-stream,帧序列:
event: progress data: {"stage":"entities_identified","phase":"planning","label":"已识别实体","details":{"entities":["OsSPL14",…]},"at":"2026-08-25T…Z"}
event: progress data: {"stage":"evidence_review_completed","phase":"evidence","label":"证据审核完成","details":{"accepted_count":12,"publication_count":5},"at":"…"}
event: complete data: {<同 /chat 的归一化响应>}
event: error data: {"code":"TIMEOUT","message":"检索超时","retryable":true}
event: timeout data: {"message":"检索超时"}
event: raw data: {<无法分类的上游原始 JSON>}
: ping ← 15s 保活注释帧
规则:
phase由 BFF 计算,值域planning|retrieval|evidence|generation|done。前端绝不解析上游 stage 名。label为该事件的中文名(BFF 内置映射,与i18n/labels.js一致)。complete之后流关闭。上游 240s 无事件 →event: timeout并关闭。- 前端
AbortController断开 → BFF 取消上游请求。
阶段→相位映射(BFF 与前端共用,见 i18n/labels.js):
| phase | stage | label |
|---|---|---|
| planning | understanding_started |
开始理解问题 |
| planning | entities_identified |
已识别实体 |
| planning | related_search_started |
开始关联检索 |
| retrieval | concepts_found |
发现相关概念 |
| retrieval | subquestion_retrieved |
子问题召回完成 |
| retrieval | agent_review_started |
开始智能体审核 |
| retrieval | subquestion_retry_started |
子问题重试中 |
| evidence | evidence_accepted |
证据已采纳 |
| evidence | evidence_review_completed |
证据审核完成 |
| generation | answer_generation_started |
开始生成答案 |
| done | completed |
检索完成 |
未知 stage → phase:"retrieval",label = stage 原文。
POST /api/qa/search
请求同 /chat 但 BFF 强制 use_llm:false。响应:归一化响应但 answer 为空、mode:"search"。读超时 180s(实测单次检索约 92s,主要耗时在 native graph chain),不重试。
5. 会话与消息
GET /api/sessions
[ { "id": "uuid", "title": "水稻SPL…", "status": "done",
"created_at": "…", "updated_at": "…", "message_count": 2,
"meta": {"title_manual": false} } ]
按 updated_at 倒序。
POST /api/sessions
请求 { "title": "新会话" }(可空,默认「新会话」)。返回会话对象。
GET /api/sessions/{id}
返回会话对象 + messages:
{ "id": "…", "title": "…", "status": "done", "created_at": "…", "updated_at": "…",
"meta": {"title_manual": false},
"messages": [
{ "id": "…", "role": "user", "kind": "qa", "status": "done",
"blocks": [], "raw": null,
"text": "问题原文", ← 仅 role=user 有
"created_at": "…", "updated_at": "…" },
{ "id": "…", "role": "assistant", "kind": "qa", "status": "done",
"blocks": [ …§2 块联合… ], "raw": {…},
"created_at": "…", "updated_at": "…" }
] }
PATCH /api/sessions/{id}
{ "title": "…" } 或 { "status": "running|done|error" }。
DELETE /api/sessions/{id}
204。级联删除消息。
POST /api/sessions/{id}/messages
{ "role": "user", "text": "…" } → 返回消息对象。
{ "role": "assistant", "kind": "qa", "status": "streaming", "blocks": [<初始 blocks>] } → 返回消息对象(流式骨架)。
PATCH /api/sessions/{id}/messages/{mid}
{ "status": "…", "blocks": [<检查点 blocks>], "raw": {…} } → 返回更新后的消息。流式检查点每 ~3s 调用一次。
GET /api/sessions/{id}/sources
全会话来源聚合:按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数):
{ "session_id": "…", "total": 12, "sources": [ { <source 字段见 §8>, "count": 3 } ] }
6. 知识图谱浏览(BFF 直连 Neo4j)
真实 schema 见
DISCOVERY.md§2。当选实例neo4j-plant-srt-direct-v1-full(bolt 18788,dbneo4j)。 schema 为 canonical:节点SRT2CanonicalEntity(entity_type/name/name_norm/canonical_id/support_count)、SRT2CanonicalAssertion(证据断言,含source/relation_norm/target/best_evidence/title/doi/pmid/publication_year等)、SRT2Paper;关系SRT2_CANONICAL_ASSERTS/SRT2_SUPPORTED_BY。 没有全文索引;名字检索走name_normRANGE 索引(=或STARTS WITH,0.09–0.10s)。不得创建索引修改共享实例(团队决策前)。 回退顺序:18788 → 19788(normalized 实例,schema 不同需单独 Cypher 适配)→ 17687(空库)。
GET /api/kg/status
{ "ok": true, "active": "neo4j-plant-srt-direct-v1-full",
"schema": "canonical",
"labels": ["SRT2CanonicalEntity","SRT2CanonicalAssertion","SRT2Paper"],
"relationship_types": ["SRT2_CANONICAL_ASSERTS","SRT2_SUPPORTED_BY"],
"entity_count": 3820996, "assertion_count": 8246875,
"name_index": "srt2_canonical_entity_name_norm", "fulltext_index": null }
Neo4j 不可用 → 503 降级信封(UPSTREAM_DOWN)。
GET /api/kg/overview
按 entity_type 与 relation_category 统计:
{ "entities_by_type": [ {"type": "gene", "count": 43210}, … ],
"relations_by_category": [ {"type": "characterized_in", "count": 12033}, … ] }
GET /api/kg/entities?q=OsSPL14&limit=20
name_norm = toLower(q) 或 STARTS WITH toLower(q),走 name_norm 索引:
{ "query": "OsSPL14", "results": [ { "id": "cent_xxx", "name": "OsSPL14", "type": "gene", "support_count": 12 } ] }
GET /api/kg/entities/{id}
id 为 canonical_id(cent_*)或名字(先按 name_norm 定位):
{ "id": "cent_xxx", "name": "OsSPL14", "type": "gene", "properties": { …节点全量属性… } }
未命中 → 404 信封(NOT_FOUND)。
GET /api/kg/entities/{id}/neighborhood?depth=1&limit=50
depth 1–2(默认 1,两跳约 13s 不默认开启),limit ≤200。每跳按 paper_count/support_count 降序截断(防 hub 吃满预算):
{ "center": "cent_xxx", "center_name": "OsSPL14", "depth": 1,
"nodes": [ {"id":"cent_xxx","label":"OsSPL14","type":"gene"}, … ],
"edges": [ {"source":"cent_xxx","target":"cent_yyy","relation":"characterized_in","label":"characterized_in",
"assertion_id":"cass_…","paper_count":5,"support_count":9,"title":"…","doi":"…","pmid":"…"} ] }
/api/kg/* 与消息内 graph 块使用同一 {nodes,edges} 形态——前端只有一个图渲染器。字段差异由 BFF 在 kg 路由处补齐(relation/label 同值,chain_id/hop 为 null 的边照常渲染)。
7. 归一化 QA 响应(/chat、/chat/stream 的 complete、/search 共用)
{
"query_id": "…", "version": "1.1.73-assertion-anchored",
"mode": "chat|search",
"answer": "…markdown 文本…", ← search 模式为 ""
"source_count": 12,
"sources": [ <source,见 §8> ],
"used_sources": [ <source> ],
"chains": [ <chain,见 §9> ],
"graph": { "nodes": [<node>], "edges": [<edge>], "chains": [<chain>] },
"retrieval_plan": {…} , "retrieval_notes": "…",
"timing": { "total_s": 41.2, "…": "…" },
"citation_map": {…},
"raw": { …上游原始完整响应… }
}
归一化别名规则(pick(obj, *names) 取第一个存在的键;真实字段名以 DISCOVERY.md §4.1 为准):
answer | response | message→answersources | citations→sources;used_sources | references→used_sourcesretrieval_plan | agentic_plan | query_plan→retrieval_plan(真实上游的键是query_plan,其内嵌套agentic_plan);retrieval_notes | notes→retrieval_notestiming | timings→timing(真实键是timings)citation_map | citation_validation→citation_map(真实键是citation_validation)knowledge_graph/graph_edges/native_graph_chains/graph_knowledge_chains→graph(链拆分进chains)
链条容错:native_graph_chains 可能是列表(真实形态,305 条),graph_knowledge_chains 可能是 dict({mode, chains[], chain_count, audit, summary})——两种形态都要能提取出 hop 列表。真实 chain item:{chain_id, chain_source, hop_count, path_score, start, end, bridge_entities, hops[], score_breakdown};真实 hop:{hop_index, from_id, from_name, to_id, to_name, source, relation, target, source_canonical_id, target_canonical_id, source_type, target_type, relation_category, assertion_id, paper_id, title, pmid, pmcid, doi, species, evidence, support_count, paper_count, best_score, edge_score, …}。归一化时:from_name|source → source、to_name|target → target、from_id|source_canonical_id → source 节点 id、to_id|target_canonical_id → target 节点 id、from_name/to_name → 节点 label、source_type/target_type → 节点 type、paper_id→paper_id、assertion_id → assertion_id。
已裁决的补充(2026-08-25,A1 偏差裁决通过):
- hop 归一化中间产物额外携带
source_id/target_id(内部派生用,最终 graph/chains 输出仍与 §9 一致)。 - 来源聚合(
GET /sessions/{id}/sources)去重键为paper_id(无则id),聚合值取首次出现的完整 source item,count为出现次数。 search模式answer恒为空串(而非缺键)。- PATCH 消息/会话空 body(三个字段全缺)按幂等成功返回当前状态。
- PATCH 会话
title时置meta.title_manual=true(对应 PRD FR-SESSION-008 的手动改名保护)。
8. Source(来源)结构
{
"id": "…", "title": "Tartary buckwheat SPL…",
"paper_id": "PMID12345|paper_xxx", "chunk_id": "…",
"pmid": "31286919", "pmcid": "PMC…", "doi": "10.xxx/…", "url": "https://doi.org/…",
"snippet": "…证据片段…", "score": 0.87,
"locator": "…", "section": "Results", "support_count": 2,
"type": "…", "metadata": {…}
}
别名映射(真实 source item 为 26 键,见 DISCOVERY.md §4.1):
content|evidence|text|snippet → snippet;
doc_id|paper_id|pubmed_id|id|chunk_id → paper_id(chunk_id 单独保留;真实键是 paper_id);
final_score|score|confidence|rerank_score → score(真实键是 final_score,另有 vector_score/rrf_score/rerank_score 保留进 metadata);
title|paper_title|source → title(注意:真实 item 的 source 是断言主体实体名而非论文标题;title 存在时优先取 title,两者并存时 source 落入 metadata.source_entity);
url|doi_url → url;只有 doi 时派生 url = https://doi.org/{doi};
evidence_id|id → id;relation_id|assertion_id → metadata.assertion_id;
output_layer、claim_scope、cluster_id、support_count、section、species 原样透传。
缺失字段键必须存在(值为 null),前端不得渲染 undefined。
9. 图谱结构(Node / Edge / Chain)
{ "node": { "id": "OsSPL14", "label": "OsSPL14", "type": "Gene" } }
{ "edge": { "source": "OsSPL14", "target": "panicle", "relation": "expressed_in",
"label": "expressed_in", "chain_id": "c1", "hop": 1,
"paper_id": "…", "pmid": "…", "pmcid": "…", "doi": "…" } }
{ "chain": { "chain_id": "c1",
"hops": [ { "source": "OsSPL14", "target": "panicle", "relation": "expressed_in",
"source_type": "Gene", "target_type": "Tissue",
"paper_id": "…", "pmid": "…", "pmcid": "…", "doi": "…" } ] } }
10. 抽取任务
上游真实结构见
DISCOVERY.md§3。上游没有 progress 数值字段,BFF 必须按stage派生progress_pct。
GET /api/extraction/jobs?limit=20
上游响应 {"jobs":[...], "count":N},BFF 归一化为(count 为归一化后 jobs 数组的长度,与上游「截断后条数」语义一致):
{ "jobs": [
{ "job_id": "2943168e9c48", "title": "wang2019.pdf",
"status": "queued|running|completed|failed",
"stage": "queued|mineru|llm|completed|failed",
"substage": "done|null",
"progress_pct": 100, ← BFF 派生:queued=5, mineru=35, llm=75, completed=100, failed=null
"message": "MinerU+LLM 方法学提取完成", ← 上游中文进度描述
"paper_id": "demo_paper", "family": "",
"source_filename": "wang2019.pdf",
"stage_count": 6, "species_count": 1,
"figure_table_caption_count": 9, "figure_table_stage_count": 12,
"updated_at": "2026-08-07T09:42:17",
"config_url": "/api/v1/methodology/extract-jobs/{id}/bbav5-config",
"methodology_plan_url": "/api/v1/methodology/extract-jobs/{id}/plan"
} ],
"count": 49 }
title 派生规则:source_filename 优先,缺失时用 paper_id,再缺用 job_id。
GET /api/extraction/jobs/{job_id}
上游返回扁平 JSON(与列表 item 同字段 + content_list、figure_table_files{...}、figure_table_warning、figure_table_inventory_url、reproduction_plan_preview_url、log_path)。BFF 归一化为:
{ "job_id": "…", "title": "…", "status": "…", "stage": "…", "progress_pct": 100,
"message": "…", "paper_id": "…", "family": "…",
"stats": { "stage_count": 6, "species_count": 1, "figure_table_caption_count": 9, "figure_table_stage_count": 12 },
"detail": { …上游原始扁平 JSON 全量… },
"plan": {…} | null }
未完成时 plan 为 null(上游返回 409,BFF 吞掉并置 null,不报错)。
GET /api/extraction/processing-status · /progress · /summary · /run-log
透传上游(全部是 JSON:前三者 {"rows":[...]},run-log 为 {"text":"..."},不是纯文本)。rows 为空属正常现象(上游批次目录未配置),前端显示空态而非错误。
POST /api/extraction/upload
multipart:file(必填,PDF 或 *_content_list.json)+ 可选 paper_id(默认 uploaded_paper)、family、output_name、llm_model、sandbox。BFF 仅限 PDF(≤50MB)。
成功(上游形态):{ "success": true, "job_id": "…", "status_url": "…", "message": "已提交抽取任务" }。
MinerU 不可用:502 降级信封,message 说明原因。读超时 600s。
上游选择:默认 8000;8000 不可达时自动回落 7900(每次请求探测,不缓存失败状态超过 30s)。注意 7900 的数据目录独立(任务列表为空属正常)。
11. 前端消费要点(给 B2/B3)
/api/qa/*与/api/qa/search的请求 body 由config/modes.js拼装,question之外字段前端不可增删。- SSE 消费用
fetch+ReadableStream(禁止 EventSource),解析器处理event:/data:/:三种行。 - 流式持久化节奏:骨架
POST /messages→ 每 3sPATCH检查点 → 终态PATCH。刷新中断恢复依赖此节奏。 - 所有列表请求失败都渲染空态/离线态,禁止无限 loading 转圈。