# 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, "ablation": { "top_k_for_llm": 20, "graph_min_evidence": 10 }, "evidence_first": true } ``` 字段说明(2026-08-26 方案 2/3 契约新增,BFF 透传不增删): - `ablation`:**请求级证据量控制**。上游的 `QA_V1_MAX_EVIDENCE` 环境变量是死代码(无消费者),真正决定 LLM 证据量的是此 dict(任意键透传,上游 pipeline 读取)。`top_k_for_llm` 默认 30,本项目设 20;`graph_min_evidence` 默认 15,与 top_k 配套下调至 10 防图证据挤压非图证据。仅 ask/plan 模式携带。 - `evidence_first`:**两级体验开关**(见下节 `event: evidence` 帧)。BFF 在打开 chat 流时并发打上游 `/search`(无 LLM,约 92s),归一化后作为非终态帧先推给前端。仅 ask/plan 模式携带,`search` 模式天然无此需求。 响应:归一化 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: evidence data: {<归一化 search 响应:sources/graph/chains 齐全,answer 为空串,mode:"search">} event: complete data: {<同 /chat 的归一化响应>} event: error data: {"code":"TIMEOUT","message":"检索超时","retryable":true} event: timeout data: {"message":"检索超时"} event: raw data: {<无法分类的上游原始 JSON>} : ping ← 15s 保活注释帧 ``` `event: evidence` 帧规则(2026-08-26 方案 3 新增): - **非终态**,0 或 1 次,仅在请求带 `evidence_first:true` 且并发 search 成功时出现,顺序在 progress 与 complete 之间(通常比 complete 早约 1-2 分钟到达) - payload 为归一化 search 响应(与 §7 同构,`answer` 恒为空串) - search 失败 → 静默降级为纯 chat 流(不发 evidence,不影响主链) - 前端把它装配为 sources/graph 块先展示;complete 到达后**用 chat 的 sources 覆盖**(保证引用 chip 编号与答案内引用一致) 规则: - **`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 转圈。 ## §6 能力端点(Capability Gateway,2026-09-02 起) ### GET /api/capabilities 能力清单 + 默认值 + 当前会话开关 + QA 注册表合并。 - Query:`session_id`(可选;缺省返回默认开关) - 响应: ```json { "capabilities": [ {"name": "memory", "label": "多轮记忆", "description": "...", "provider": "sdk", "session_default": true, "enabled": true}, {"name": "web_search", "label": "联网搜索", "...": "...", "provider": "qa_gateway", "session_default": false, "gated_by": "QA_V1_ENABLE_WEB_SEARCH", "enabled": false} ], "defaults": {"memory": true, "web_search": false, "humanize": false}, "installed": [{"name": "web_search", "description": "..."}], "load_warnings": [] } ``` ### POST /api/capabilities/install 聊天内自助安装(BFF 透传 QA loopback)。热加载生效,不重启。 - 请求:`{"action": "register", "manifest": "<一段 YAML>", "apply": true}` 或 `{"action": "unregister", "name": "<能力名>"}` - 响应(register):`{"ok": true, "installed": [""], "warnings": [], "diff": {"added": [...], "removed": [], "changed": []}, "hot_reload": true}` - manifest 白名单:provider ∈ {http, mcp, python, prompt_pack}、 permission=read_only、单条目 ≤8KB、yaml 总大小 ≤1MB;坏条目跳过 - 错误:400 VALIDATION(manifest 校验失败)/ 503 UPSTREAM_DOWN / 502 UPSTREAM_ERROR ### PATCH /api/sessions/{id}(§5 扩展) `meta` 字段新增(2026-09-02):`{"meta": {"capabilities": {"web_search": true}}}`。 白名单键:memory / web_search / humanize;非白名单键丢弃、值强转 bool; 与既有 meta 深度合并(title_manual 等键不受影响)。 ### POST /api/qa/chat 与 /api/qa/chat/stream(§4 扩展) 请求体新增 BFF 契约字段 `session_id`(BFF 剥离后注入上游 history/capabilities, 不达 QA)。不传 session_id 时行为与既往一致。