# 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 与降级响应):
```json
{ "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` 数组元素为判别式联合:
```jsonc
{ "type": "text", "html": "
…
" } // 已消毒的 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": [ ] } // 见 §8
{ "type": "graph", "nodes": [ ], "edges": [ ], "chains": [ ] } // 见 §9
```
---
## 3. 健康与元信息
### GET /api/health
并发探测 7920、8000、7900 与 Neo4j(1s 超时,5s 缓存)。
```json
{
"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` 并精简:
```json
{
"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` 拼出):
```json
{
"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
```json
[ { "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`:
```json
{ "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`(出现次数):
```json
{ "session_id": "…", "total": 12, "sources": [ { , "count": 3 } ] }
```
---
## 6. 知识图谱浏览(BFF 直连 Neo4j)
> 真实 schema 见 `DISCOVERY.md` §2。**当选实例 `neo4j-plant-srt-direct-v1-full`**(bolt 18788,db `neo4j`)。
> 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_norm` RANGE 索引(`=` 或 `STARTS WITH`,0.09–0.10s)。**不得创建索引修改共享实例**(团队决策前)。
> 回退顺序:18788 → 19788(normalized 实例,schema 不同需单独 Cypher 适配)→ 17687(空库)。
### GET /api/kg/status
```json
{ "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` 统计:
```json
{ "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 索引:
```json
{ "query": "OsSPL14", "results": [ { "id": "cent_xxx", "name": "OsSPL14", "type": "gene", "support_count": 12 } ] }
```
### GET /api/kg/entities/{id}
`id` 为 `canonical_id`(`cent_*`)或名字(先按 `name_norm` 定位):
```json
{ "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 吃满预算):
```json
{ "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 共用)
```json
{
"query_id": "…", "version": "1.1.73-assertion-anchored",
"mode": "chat|search",
"answer": "…markdown 文本…", ← search 模式为 ""
"source_count": 12,
"sources": [ ],
"used_sources": [ ],
"chains": [ ],
"graph": { "nodes": [], "edges": [], "chains": [] },
"retrieval_plan": {…} , "retrieval_notes": "…",
"timing": { "total_s": 41.2, "…": "…" },
"citation_map": {…},
"raw": { …上游原始完整响应… }
}
```
归一化别名规则(`pick(obj, *names)` 取第一个存在的键;**真实字段名以 `DISCOVERY.md` §4.1 为准**):
- `answer | response | message` → `answer`
- `sources | citations` → `sources`;`used_sources | references` → `used_sources`
- `retrieval_plan | agentic_plan | query_plan` → `retrieval_plan`(真实上游的键是 `query_plan`,其内嵌套 `agentic_plan`);`retrieval_notes | notes` → `retrieval_notes`
- `timing | 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(来源)结构
```json
{
"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)
```json
{ "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 数组的长度**,与上游「截断后条数」语义一致):
```json
{ "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 归一化为:
```json
{ "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` → 每 3s `PATCH` 检查点 → 终态 `PATCH`。刷新中断恢复依赖此节奏。
- 所有列表请求失败都渲染空态/离线态,**禁止**无限 loading 转圈。