diff --git a/docs/team/TEST_CASES_PRIVATE.md b/docs/team/TEST_CASES_PRIVATE.md
new file mode 100644
index 0000000..08f2e6b
--- /dev/null
+++ b/docs/team/TEST_CASES_PRIVATE.md
@@ -0,0 +1,271 @@
+# 私有资料问答(FR-PRIV)测试用例表 · TEST_CASES_PRIVATE.md
+
+> docs/team/TEST_CASES_PRIVATE.md | T1(测试 agent)编制,TL 复查达标后方可进入测试执行。
+> 权威规格:docs/PRD.md §17(FR-PRIV-001~008)、docs/UI_SPEC.md §9、docs/PRIVATE_DOCS_TEST_PLAN.md。
+> 实现契约:docs/team/brief_priv_a1.md(A1 任务书,含全部签名/常量/JSON 契约)。
+> 基线:feature/bioflow-conversation-orchestration @ dea4640(编制时)。
+> 修订记录:v1.1(TL 复查裁决)——假设 1 替换为实施方案 §1.2 语义(表格原位聚合、outline 不参与切块);假设 9 改行为级判据;TC-PRIV-L1-05/L1-07 修订并新增 L1-07b(outline 不影响输出)、L1-42(跨轮归属)。用例总数 90 → **92**,追溯矩阵已同步。
+
+## 0. 约定
+
+- **编号**:TC-PRIV-{L层}-{序号},L层 ∈ L1/L2/L2s/L3/L4。
+- **判据**:每条含可验证判据(PASS 条件);与 A1 任务书契约对齐。
+- **假设(编制时 A1 未交付,按任务书契约推断,TL 复查裁决)**:
+ 1. 【TL 裁决,以实施方案 §1.2 为准】`build_chunks` 语义:表格矩阵化段(`|a|b|` 行式)**插入段落流原位**参与聚合切块(废除「末尾 table 单独块」);**outline 不参与 chunk 切块**(chunk 正文保持纯净,标题上下文仅由首块前缀 `# {title}` 提供;签名保留 outline 参数,A1 实现为忽略或仅取用 title)。
+ 2. 长段落(> TARGET_CHARS)整段不切,独占一块(单块可超 600 字);空正文(paragraphs/tables 全空,outline 不参与切块)产出 0 块且不报错。
+ 3. `ChunkIndex.load()` 对 index.json / texts.jsonl / index.faiss 三文件中任一缺失或损坏:按空库降级(可服务,不抛异常);`rebuild_all()` 覆盖三文件(tmp+os.replace 原子切换)。
+ 4. `ChunkIndex` 返回的 `snippet` 为 `text[:200]`。
+ 5. `embed_batch_chunked` 批量失败按「批」重试:整批重试一次,仍失败该批全部降级零向量;并发上限 = `workers=4`(线程数)。
+ 6. `search-chunks` 无降级档:低于 `min_score` 全部丢弃 → `mode:"empty", items:[]`(空库同)。
+ 7. `rebuild-chunks` 期间 /health、/search-chunks 可服务:由「重建不使用 HTTP(CLI 子进程)」保证(L2s 用例验证)。
+ 8. `/api/qa/private` 三态:命中 200 / 未命中 200 / vstore 不可达 503 / LLM 失败 502;未命中与错误信封前端一律静默。
+ 9. 【TL 裁决,以实施方案 §3.2 为准】前端 `_firePrivate` 本轮上下文校验实现 = `streamCtx[sessionId].aMsg` 引用相等;用例判据写**行为级**(「非本轮私有结果不得串挂」),不锁内部实现。`_flushPrivate` 只清 `_privatePending`(A1 任务书已批准设计)。
+ 10. `removeLibrary(id)` 在 http.js `del` 基础上自行处理 204 空体并返回 `{ok:true}`。
+- **测试环境**:vstore pytest 用 `vstore/tests/conftest.py`(固定 Fernet key + 假 embed);BFF 用 MockTransport 注入 client / monkeypatch 服务函数;前端用 vitest(jsdom)+ vi.mock;L2/L2s/L4 用 curl / 浏览器(TL 部署后执行)。
+- **纪律**:本文件与测试骨架之外不改任何产品代码;不 commit、不部署。
+
+## 1. 分层用例
+
+### L1 单元测试(vstore chunks / embed / vault、BFF privateqa / schemas、前端 store 逻辑)
+
+#### L1-chunks(build_chunks 切块与 ChunkIndex)
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L1-01 | FR-PRIV-002 | vstore/chunks.py `build_chunks` | tmp_path;模块导入 | 构造 8 段短段落(每段 < 600 字)直接调用 | title="报告", outline=[], paragraphs=[p1..p8 短段], tables=[] | 恰好 1 块;text 首行 = "# 报告";块内含全部 8 段 | 单块达到 8 段上限(MAX_PARAS)即封块;len(chunks)==1;chunks[0].seq==0 |
+| TC-PRIV-L1-02 | FR-PRIV-002 | build_chunks 字符封块 | tmp_path | 段落总长 ≥ 600 字(如 7 段×90 字) | title="T", paragraphs=7 段×90 字 | ≥1 块;先满 600 字即封块(不要求满 8 段) | 存在块 text 长度 ≥ 600;块数 > 1 时相邻块正确切分 |
+| TC-PRIV-L1-03 | FR-PRIV-002 | build_chunks 重叠段 | tmp_path | 构造 12 段 → 应产出 2 块 | title="T", paragraphs=p1..p12 | 块2 首段 == 块1 末段(重叠 1 段) | chunks[1].text 以 chunks[0] 末段开头;无重复段顺序错误 |
+| TC-PRIV-L1-04 | FR-PRIV-002 | build_chunks 200 块上限 | tmp_path | 构造 > 200×600 字的超长输入 | 300 段×700 字 | 恰好 200 块;seq 0..199 | len(chunks)==200;MAX_PER_DOC 截断生效;不抛异常 |
+| TC-PRIV-L1-05 | FR-PRIV-002 | build_chunks 表格原位矩阵化聚合(TL 裁决版) | tmp_path;段落流中夹表格(与实施方案 §1.2 对齐) | 构造段落-表格-段落混合流,直接调用 | title="T", paragraphs=["前置段落"*N, "后续段落"*M], tables=[2×2 表](表格位于段落流中间) | 表格矩阵化为 `\|a\|b\|` 行式文本段,**按原位**参与聚合切块;输出块序反映原始位置(表格段在相邻段落之间) | 表格行式文本出现在块中且前后位置与输入流一致;废除「末尾 table 单独块」 |
+| TC-PRIV-L1-06 | FR-PRIV-002 | build_chunks 首块标题前缀 | tmp_path | 任意 1 段正文 | title="小麦试验", paragraphs=[p] | 首块 text 以 "# 小麦试验" 开头 | chunks[0].text.startswith("# 小麦试验") |
+| TC-PRIV-L1-07 | FR-PRIV-002 | build_chunks 空输入 | tmp_path | 全空输入(outline 不参与切块) | title="", paragraphs=[], outline=[], tables=[] | 返回 [](不抛异常) | len(chunks)==0;chunk 失败不阻断 doc 级(配合 TC-PRIV-L1-19) |
+| TC-PRIV-L1-07b | FR-PRIV-002 | build_chunks outline 不参与切块(TL 裁决版) | tmp_path;实施方案 §1.2 | 输入相同 paragraphs + 两组不同 outline([A1,A2] 与空 []) | title="T", paragraphs=p1..p3, outline 两版对比 | 两次调用产出的 chunk 集合**逐块 text 完全一致**(outline 不影响 chunk 输出) | 两版输出逐块相等;chunk 正文中不含 outline 文本 |
+| TC-PRIV-L1-08 | FR-PRIV-002 | ChunkIndex upsert/查询 | tmp_path;假 embed | 新建 ChunkIndex → upsert_document(doc_id, items) → rows()/count() | 3 块 doc"A" | count()==3;rows 含 chunk_id/seq/snippet/doc_name | chunk_id == "A:0"…"A:2";字段齐备 |
+| TC-PRIV-L1-09 | FR-PRIV-002 | ChunkIndex upsert 替换语义 | tmp_path | 先 upsert A(3 块) 再 upsert A(2 块) | 同 doc_id 二次 upsert | count()==2;旧 3 行全部消失(替换非追加) | A 行数 ==2;旧 seq 行不存在 |
+| TC-PRIV-L1-10 | FR-PRIV-002 | ChunkIndex remove_document | tmp_path | upsert A+B 后 remove("A") | doc_ids=["A"] | count()==B 块数;A 行全消失;不存在返回 False | remove 返回 True;再 remove("A") 返回 False |
+| TC-PRIV-L1-11 | FR-PRIV-002 | ChunkIndex 原子写 | tmp_path | upsert 后检查三文件;再造 ChunkIndex(path).load() 重载 | 3 块 | index.json/texts.jsonl/index.faiss 三文件存在;重载后 count()==3 | 重载行与写入一致;目录内无 .tmp 残留(tmp+os.replace) |
+| TC-PRIV-L1-12 | FR-PRIV-002 | ChunkIndex 阈值/top_k/doc_ids/exclude_template | tmp_path;可控分数向量 | 造高分/低分两组向量行 | search(q_vec, top_k=2, min_score=0.30, doc_ids=[...], exclude_template=True) | 仅 ≥0.30 命中;≤2 条;doc_ids 白名单过滤生效;as_template 行被排除 | 命中集合、条数、过滤三判据同时满足 |
+| TC-PRIV-L1-13 | FR-PRIV-002 | ChunkIndex 低于阈值→empty | tmp_path | 查询向量与所有行内积 < 0.30 | search(..., min_score=0.30) | mode=="empty", items==[](无降级档) | mode 为 "empty" 且 items 为空 |
+
+#### L1-embed(批量嵌入)
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L1-14 | FR-PRIV-002 | vstore/embed.py `embed_batch_chunked` | monkeypatch 注入假请求钩子(记 batch 调用与输入形态) | 注入 _request_batch(返回假向量);调 embed_batch_chunked | 40 条文本, batch_size=16, workers=4 | 3 批调用;每批 payload 的 input 是**数组**(非单字符串);返回 40 个 1024 维向量且顺序保持 | 批次数 == ceil(40/16)==3;每批 input 为 list;len(results)==40;顺序可验证 |
+| TC-PRIV-L1-15 | FR-PRIV-002 | embed_batch_chunked 并发上限 | monkeypatch 记录并发峰值 | 钩子里计数器模拟执行耗时 | 33 条文本, workers=4 | 并发峰值 ≤4;结果为 3 批 | 计数器峰值 ==4(或 ≤4) |
+| TC-PRIV-L1-16 | FR-PRIV-002 | embed_batch_chunked 单批重试 | monkeypatch:首调抛错、二调成功 | 第 2 批首调失败 | 20 条文本 | 该批重试 1 次成功;总批次数 2+1(重试)+1;结果全有向量 | 重试计数 ==1;该批向量非零向量 |
+| TC-PRIV-L1-17 | FR-PRIV-002 | embed_batch_chunked 降级零向量 | monkeypatch:某批两次全失败 | 第 1 批两次失败 | 20 条文本 | 失败批全部零向量;其余批正常;函数不抛 | results[0:batch] 全 [0.0]*1024;其余非零;不抛异常 |
+
+#### L1-vault(钩子)
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L1-18 | FR-PRIV-002 | vault.py `add()` chunk 钩子成功路径 | monkeypatch:chunks.embed_batch_chunked 抛 EmbedError(或 build_chunks 被 patch 抛) | vault.add() 正常入库 | 含正文的 txt | doc 级正常返回;返回体含 chunk_count==chunks 数、chunk_ok==True、chunk_error=="" | 返回体三字段存在且 chunk_ok 为 True |
+| TC-PRIV-L1-19 | FR-PRIV-002 | vault.py chunk 失败不阻断 doc 级 | monkeypatch embed_batch_chunked 抛异常 | vault.add() | 含正文的 txt | doc 级照常入库(document_id/enc 存在);chunk_ok==False;chunk_count==0 或实际数;chunk_error 非空 | doc 级成功 + chunk_ok==False + chunk_error 非空 |
+| TC-PRIV-L1-20 | FR-PRIV-006 | vault.py `delete()` chunk 联动删除 | 先 add 产生 chunk 行 | vault.delete(doc_id) | doc_id | 主索引删除成功;chunk 索引该 doc 行消失 | chunks.count() 回到删除前数量;doc 级 removed==True |
+| TC-PRIV-L1-21 | FR-PRIV-007 | vault.py `rebuild_chunks()` | 先 add 2 条;chunks.clear() 后调 rebuild_chunks | rebuild_chunks() | 无 | 返回 {docs, chunks, duration_ms};chunk 行重建(≥1) | docs==2;chunks>0;数字字段类型正确 |
+
+#### L1-BFF(privateqa / schemas / llm)
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L1-22 | FR-PRIV-004 | services/privateqa.py `ask_private` 命中链路 | monkeypatch search_chunks 返回 2 doc 4 块;complete_text 返回带 [资料n] 文本 | await ask_private("问") | question | hit==True;answer 非空;items 带 ref;elapsed_ms 数字 | items[].ref 为编号(1..n);answer 含 [资料1] |
+| TC-PRIV-L1-23 | FR-PRIV-004 | ask_private 空结果 | monkeypatch search_chunks 返回 {ok:true,items:[]} | await ask_private | question | {hit:false, mode:"empty", answer:"", items:[]};不调 LLM | 返回体一致且 complete_text 未被调用 |
+| TC-PRIV-L1-24 | FR-PRIV-004 | ask_private 每 doc≤3 去重 | 5 块同 doc + 2 块另一 doc;top_k=8 | await ask_private | question | items 每 doc ≤3 块;同 doc 按 score 序取前 3 | 逐 doc 计数 ≤3;score 递减 |
+| TC-PRIV-L1-25 | FR-PRIV-004 | ask_private 12k 截断 | 拼出的上下文字符数 > 12000 | await ask_private | 大 snippet | 传给 complete_text 的 user 内容 ≤ 12000 字符;不抛异常 | len(user)<=12000;可含截断标记 |
+| TC-PRIV-L1-26 | FR-PRIV-004 | ask_private LLM 40s 超时 | monkeypatch complete_text 捕获 kwargs | await ask_private | question | complete_text 被以 timeout=40 调用;其他参数(temperature/max_tokens/system 约束)符合任务书 | 调用参数断言:timeout==40;system 含「只能依据资料回答,不得编造」与「资料中未找到相关信息」 |
+| TC-PRIV-L1-27 | FR-PRIV-004 | ask_private 异常类型 | 三次 monkeypatch:search_chunks 抛 DocgenUnavailable;search_chunks 抛 Exception;complete_text 抛 LLMError | await ask_private(pytest.raises) | question | DocgenUnavailable / Exception 向上抛(不吞);LLMError 向上抛 | 抛出的异常类型与注入一致(路由转 503/502 信封) |
+| TC-PRIV-L1-28 | FR-PRIV-005 | schemas.py union 校验 | TypeAdapter/MessageCreate 构造含 private_doc 块的消息 | MessageCreate.model_validate / TypeAdapter(list[Block]) | blocks=[{type:"private_doc", answer, items:[...]}] | 校验通过;类型解析为 PrivateDocBlock;items 为 PrivateDocItem 列表 | 不抛 ValidationError;判别 type=="private_doc" |
+| TC-PRIV-L1-29 | FR-PRIV-005 | schemas.py extra 未知字段 | PrivateDocItem 带未知字段 | model_validate | items 含 {"x_unknown":1} | 未知字段被忽略不报错;已知字段齐全 | extra="allow" 行为;无异常 |
+| TC-PRIV-L1-30 | FR-PRIV-004 | services/docgen/llm.py timeout=None 兼容 | monkeypatch _chat_once 捕获 timeout 参数 | await complete_text(..., timeout=None) / timeout=40 | 两次调用 | None → 走 env 现值(DOCGEN_LLM_TIMEOUT 或默认 300);40 → 透传 40 | 两次捕获 timeout 分别 ==env 现值 / 40;docgen 既有调用零影响 |
+| TC-PRIV-L1-31 | FR-PRIV-004 | services/docgen/client.py `search_chunks` | MockTransport 注入 httpx client | await search_chunks("q", top_k=8, min_score=0.30, timeout=12.0) | q | GET /api/vstore/search-chunks 参数带 q/top_k/min_score;返回 dict;不可达抛 DocgenUnavailable | 请求路径与参数断言;异常类型断言 |
+
+#### L1-front(chat store 竞态)
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L1-32 | FR-PRIV-003 | chat.js `_firePrivate` 命中/未命中/异常三态静默 | vi.mock api/private.js;pinia + 假会话(沿用 b2 chat.store.test.js 风格) | 三次 send():askPrivate 分别返回命中/未命中/reject | 同一提问 | 命中:aMsg 最终含 private_doc 块;未命中/reject:无块、无报错、QA 流程不受影响;三次均无 console.error 级用户可见错误 | 块存在性 + 无异常抛出 + lastResult 与既有 QA 断言一致 |
+| TC-PRIV-L1-33 | FR-PRIV-005 | chat.js 流未收口不丢块(pending) | 命中响应在 QA complete 之前 resolve | send()(_runStream 路径) | 提问 | 响应先到 → 暂存 `aMsg._privatePending`;onComplete 整体替换 blocks 后 flush → 块仍在 | 终态 blocks 含 private_doc 块;顺序在 pipeline 之后 |
+| TC-PRIV-L1-34 | FR-PRIV-005 | chat.js onComplete 替换后仍出现 | 命中响应在 QA complete 之后 resolve | send() | 提问 | 替换发生后才 resolve:直接挂块 + 补 _persistFinal;终态含块 | 块存在且恰好 1 个(防重入) |
+| TC-PRIV-L1-35 | FR-PRIV-005 | chat.js error 路径 flush | QA 流 onError(如 UPSTREAM_DOWN) | send() | 提问 | error 终态下私有命中块仍被挂上(flush 覆盖两条 error 路径) | 终态 blocks 含 private_doc 块;消息 status=="error" 共存 |
+| TC-PRIV-L1-36 | FR-PRIV-005 | chat.js `_appendPrivate` 防重入 | 手动调用两次 _appendPrivate(aMsg, payload) | 直接调 action | payload | blocks 中 private_doc 块仅 1 个 | 第二次调用为 no-op;len==1 |
+| TC-PRIV-L1-37 | FR-PRIV-005 | chat.js ask/search 两模式覆盖 | 分别走 _runStream 与 _runSearch | send(text, {mode:"ask"}) / send(text, {mode:"search"}) | 提问 | 两模式均触发 _firePrivate(askPrivate 被调 2 次);search 模式 blocks 赋值后含块 | askPrivate 调用次数==2;终态块断言 |
+| TC-PRIV-L1-38 | FR-PRIV-005 | chat.js retry 覆盖 | 先 error 后 retry() | retry() | 提问 | 重试轮同样触发 _firePrivate;新 aMsg 不残留旧 pending | askPrivate 再次被调;新消息 blocks 正确 |
+| TC-PRIV-L1-39 | FR-PRIV-005 | chat.js _flushPrivate 只清 pending | 终态 flush 后再次 flush | _flushPrivate(aMsg) 二次调用 | 无 | 块不被追加/清空 | 块仍 1 个;_privatePending 已清 |
+| TC-PRIV-L1-42 | FR-PRIV-003 | 本轮上下文归属(TL 裁决行为级判据) | 后消息的私有响应在**前消息**终态之后才 resolve(模拟跨轮迟到) | 连续两次 send():第 1 轮 QA 完成且私有未命中终态、第 2 轮进行中,才 resolve 第 1 轮命中响应 | 同一提问 ×2 | 第 1 轮的迟到命中响应**不得**挂到第 2 轮消息上;第 2 轮终态与私有结果不受影响(串挂即 fail) | 第 1 轮响应被丢弃或挂到第 1 轮消息;第 2 轮消息 blocks 不含来自第 1 轮的条目 |
+| TC-PRIV-L1-40 | FR-PRIV-001 | api/private.js `askPrivate` | vi.mock http.js | askPrivate({question, session_id}) | 参数对象 | POST /api/qa/private;body 含 top_k=8、min_score=0.30;timeout 60s | 请求路径/body/opts 断言 |
+| TC-PRIV-L1-41 | FR-PRIV-006 | api/docgen.js `removeLibrary` 204 | vi.mock http.js(del resolve 空 payload) | removeLibrary(id) | id | 返回 {ok:true};不抛 JSON 解析错 | 204 空体被正确兜底 |
+
+### L2 接口测试(vstore 端点 / BFF 端点 / docgen 回归)
+
+#### L2-vstore
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L2-01 | FR-PRIV-002 | GET /api/vstore/search-chunks 命中 | 已 ingest 含正文资料(chunk_count>0) | curl GET /api/vstore/search-chunks?q=… | q=正文关键词, top_k=5 | HTTP 200;{ok:true, mode:"vector", query, count>0, items:[{chunk_id,doc_id,seq,snippet,score,doc_name,doc_category,doc_tags,as_template,created_at}]} | count>0;item 字段全集存在;score ≥ min_score 默认 0.30 |
+| TC-PRIV-L2-02 | FR-PRIV-002 | search-chunks 未命中 | 库中无相关内容 | q=无关词, min_score=0.30 | q | {ok:true, mode:"empty", count:0, items:[]} | 无降级档(不返回向量库最新 N 条) |
+| TC-PRIV-L2-03 | FR-PRIV-002 | search-chunks 空库 | 空 chunk 库 | q=任意 | q | {ok:true, mode:"empty", items:[]} | 不 500 |
+| TC-PRIV-L2-04 | FR-PRIV-002 | search-chunks top_k clamp | 库中 ≥3 相关块 | top_k=0 / top_k=99 | q | 0 → clamp 到 1(或 422);99 → 返回 ≤20 条 | 返回条数 ≤20;异常输入不 500 |
+| TC-PRIV-L2-05 | FR-PRIV-002 | search-chunks doc_ids 过滤 | 2 个 doc 各有命中 | doc_ids=docA | q | 仅 docA 的 chunk 命中 | 所有 items.doc_id==docA |
+| TC-PRIV-L2-06 | FR-PRIV-002 | search-chunks exclude_template | 上传 1 模版(as_template=true)含关键词 | exclude_template=true | q=模版内容词 | 模版 chunk 不出现;普通资料不受影响 | items 中无 as_template 行 |
+| TC-PRIV-L2-07 | FR-PRIV-007 | POST /api/vstore/rebuild-chunks | 已有 chunk 行;删掉 chunks 目录后调用 | POST rebuild-chunks | 无 | {ok:true, docs:N, chunks:M>0} | chunks>0;N==vault 文档数 |
+| TC-PRIV-L2-08 | FR-PRIV-007 | GET /api/vstore/health chunk_count | 已 ingest | GET /health | 无 | {ok:true, count:…, backend:"local", chunk_count:…} | chunk_count==ChunkIndex.count();与 ingest 返回一致 |
+| TC-PRIV-L2-09 | FR-PRIV-007 | 重建期间可服务 | 重建 CLI 后台跑(由 TL 在 L2s 执行) | 重建中并发 GET /health /search-chunks | 无 | 两端点持续 200(锁内写文件不阻塞读) | 无 5xx;健康返回正常 |
+
+#### L2-BFF
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L2-10 | FR-PRIV-004 | POST /api/qa/private 命中 | vstore chunk 库已就绪;DeepSeek key 有效 | curl POST /api/qa/private | {"question":"…与资料相关的问题","top_k":8,"min_score":0.30} | HTTP 200;{ok:true,hit:true,mode:"vector",answer:非空 markdown,items:[{doc_id,name,seq,chunk_id,snippet,score,category,tags,created_at,ref}],elapsed_ms} | answer 中每个 [资料n] 编号与 items[].ref 一一对应;answer ≤300 字 |
+| TC-PRIV-L2-11 | FR-PRIV-004 | /api/qa/private 未命中 | 库中无相关内容 | curl POST | {"question":"无关问题"} | HTTP 200;{ok:true,hit:false,mode:"empty",answer:"",items:[]} | 三态信封完整;无 error 字段 |
+| TC-PRIV-L2-12 | FR-PRIV-004 | /api/qa/private 参数校验 | 无 | curl POST | {"question":""} / 缺 question | HTTP 422 {ok:false,error:{code:"VALIDATION"}} | 统一 VALIDATION 信封(BFF 全局处理) |
+| TC-PRIV-L2-13 | FR-PRIV-004 | /api/qa/private vstore 宕机 | 停 vstore(TL 配合) | curl POST | {"question":"…"} | HTTP 503 {ok:false,error:{code:"UPSTREAM_DOWN", degraded:true}} | 信封 code 与 degraded 断言 |
+| TC-PRIV-L2-14 | FR-PRIV-004 | /api/qa/private LLM 失败 | monkeypatch/断 key 使 complete_text 抛(TL 配合) | curl POST | 命中但 LLM 失败 | HTTP 502 {ok:false,error:{code:"UPSTREAM_ERROR"}} | 信封 code 断言 |
+| TC-PRIV-L2-15 | FR-PRIV-001 | POST /api/docgen/library 上传(回归) | vstore 正常 | curl -F file=@资料.pdf -F name=… | 小型 PDF | HTTP 201;{ok:true, items:[{library_id,name,size,as_template}]} | 上传成功返回条目;BFF doc_library 落行 |
+| TC-PRIV-L2-16 | FR-PRIV-001 | ingest 响应 chunk 字段(回归确认) | 上传后取 ingest 返回/查 vstore | 上传含正文 txt | 文件 | ingest item 含 chunk_count>0 且 chunk_ok==true;chunk_error=="" | FR-PRIV-002 验收口径 |
+| TC-PRIV-L2-17 | FR-PRIV-006 | GET /api/docgen/library 列表 | 已上传 | curl GET /api/docgen/library | 无 | {ok:true, items:[{id,name,category,tags,description,as_template,active,created_at}]} | 列表含上传条目;字段全集存在 |
+| TC-PRIV-L2-18 | FR-PRIV-006 | DELETE /api/docgen/library/{id} | 已上传 | curl -X DELETE | id | HTTP 204(空体);doc_library 行删除;vstore 密文与 chunk 行删除 | 再 GET 单条 404;search-chunks 不含该 doc |
+| TC-PRIV-L2-19 | FR-PRIV-006 | DELETE 不存在 id | 无 | curl -X DELETE | 随机 id | HTTP 404 NOT_FOUND 信封 | 不 500 |
+| TC-PRIV-L2-20 | FR-PRIV-006 | 删除后不再被检索命中 | 删除后 | GET search-chunks q=该资料内容 | q | items 不含该 doc_id | FR-PRIV-006 验收口径 |
+
+#### L2-docgen 回归
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L2-21 | FR-PRIV-008 | GET /api/vstore/search(doc 级)逐字段回归 | 改动前基线响应(编制时抓取/测试计划留存) | curl GET /api/vstore/search?q=…&as_template=true | q | 响应字段与改动前逐字段一致(mode/items 键全集与顺序) | FR-PRIV-002 验收口径;与基线 diff 空 |
+| TC-PRIV-L2-22 | FR-PRIV-008 | docgen 生成全链路(回归) | 模版已上传 | POST /api/docgen/generate | {description, session_id, library_ids} | {ok:true, artifact:{artifact_id,title,template_name,status:"done",…}} | 生成成功;doc 级检索被使用(模版命中) |
+| TC-PRIV-L2-23 | FR-PRIV-008 | docgen 导出(回归) | artifact done | POST /artifacts/{id}/export {format:"pdf"} | format | {ok:true, download_url};下载 200 | 导出闭环可用 |
+| TC-PRIV-L2-24 | FR-PRIV-008 | BFF /api/qa/search 行为零变化(回归) | 改动前后 | POST /api/qa/search | {question} | 响应契约与改动前一致(mode:"search", answer:"") | 既有 b2/sdk test_qa 全绿 |
+
+### L2s 部署冒烟
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L2s-01 | FR-PRIV-001 | vstore 明文部署重启 | TL 部署 | 重启 vstore → /health | 无 | 启动无异常;health ok;含 chunk_count | 无启动握手失败 |
+| TC-PRIV-L2s-02 | FR-PRIV-007 | CLI 存量重建 | TL 执行 scripts/rebuild_chunks.py | 后台运行 CLI(cwd=部署根) | 存量 210 条 | 进度打印;完成汇总 {docs:210, chunks:M>0};期间 HTTP 可服务 | chunks>0;无超时(不进 HTTP) |
+| TC-PRIV-L2s-03 | FR-PRIV-008 | BFF 编译部署重启 | TL 编译(白名单不变) | 重启 BFF → /api/health | 无 | 启动成功;private_docs 路由已挂载 | health ok;POST /api/qa/private 返回三态之一 |
+| TC-PRIV-L2s-04 | FR-PRIV-008 | 白名单未扩大 | 无 | diff compile_sdk.sh | 无 | git diff 空 | 红线 |
+| TC-PRIV-L2s-05 | FR-PRIV-005 | 前端构建 | 无 | cd ui && npm run build | 无 | 构建 0 error | dist 可用 |
+
+### L3 前端组件
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L3-01 | FR-PRIV-005 | AssistantMessage private_doc 块渲染 | mount AssistantMessage(b2 components.test.js 风格) | message.blocks 含 private_doc 块 | {type:"private_doc", answer:"…", items:[{ref:1,name,score,snippet}]} | 卡体 .private-doc-block 存在;标题「📚 私有资料参考」;条目 [资料1]/名称/相关度/片段齐全 | DOM 断言各项存在 |
+| TC-PRIV-L3-02 | FR-PRIV-005 | private_doc answer 空态 | 无 | answer:"" + items 2 条 | 块 | 仅条目列表,无空白答区 | 答区元素不存在 |
+| TC-PRIV-L3-03 | FR-PRIV-005 | private_doc markdown 消毒 | 无 | answer 含 `` 与 **加粗** | 块 | script 被移除(DOMPurify);加粗渲染为 | innerHTML 无 script;strong 存在 |
+| TC-PRIV-L3-04 | FR-PRIV-006 | PrivateLibraryPane 列表 | vi.mock api/docgen.js(listLibrary 返回 2 条) | mount PrivateLibraryPane | 无 | 标题「📁 我的资料」+ 计数 2;两条 name/size/time 渲染 | DOM 断言 |
+| TC-PRIV-L3-05 | FR-PRIV-006 | PrivateLibraryPane 空态 | listLibrary 返回 items:[] | mount | 无 | 「尚无上传资料。在对话框拖拽 PDF / DOCX / TXT 上传。」 | 空态文案 |
+| TC-PRIV-L3-06 | FR-PRIV-006 | PrivateLibraryPane 删除+204 | vi.mock(confirm 返回 true;removeLibrary 返回 {ok:true};listLibrary 先 2 后 1) | mount → 点击删除 → confirm → 断言 | 无 | 调用 removeLibrary(id);列表刷新为 1 | window.confirm 被调;刷新后 count==1 |
+| TC-PRIV-L3-07 | FR-PRIV-006 | PrivateLibraryPane 错误重试 | listLibrary 首次 reject,二调成功 | mount → 点重试 | 无 | 错误态显示重试按钮;点击后列表加载成功 | 重试按钮存在;重试后 items 渲染 |
+| TC-PRIV-L3-08 | FR-PRIV-006 | DocPane 空态挂载 | mount DocPane(docgen.activeArtifact 为 null;mock PrivateLibraryPane 或全量挂载) | mount | 无 | .doc-empty 存在;PrivateLibraryPane 挂载于其顶部(第一个子元素);有活动文档时隐藏 | DOM 顺序断言;activeArtifact 非空时组件不渲染 |
+
+### L4 端到端(浏览器,TL 配合)
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-L4-01 | FR-PRIV-001/002 | 上传→检索闭环 | 浏览器打开生产 UI | 拖拽上传 PDF → 查看「我的资料」→ 提问命中内容 | PDF | 上传成功条目出现;提问出「私有资料参考」块 | 全链闭环 |
+| TC-PRIV-L4-02 | FR-PRIV-003/005 | ask/search 两模式并行卡片 | 私有资料命中环境 | 分别 ask 与 search 模式提问 | 提问 | 两模式均出私有卡片,与 QA 答案并行展示不混排 | 卡片独立于 QA 答案 |
+| TC-PRIV-L4-03 | FR-PRIV-003 | 未命中无感 | 私有资料命中环境 | 提问无关问题 | 无关问题 | 无任何私有块/提示;QA 答案正常 | 零痕迹 |
+| TC-PRIV-L4-04 | FR-PRIV-005 | 刷新恢复 | 出块后刷新页面 | F5 → 重进会话 | 无 | private_doc 块完整恢复(终态持久化) | 块与 items 完整 |
+| TC-PRIV-L4-05 | FR-PRIV-006 | 删除联动 | 我的资料列表 | 删除一条 → 再提问该资料内容 | 无 | 列表刷新;再提问不再命中该资料 | 删除后检索不到 |
+| TC-PRIV-L4-06 | FR-PRIV-008 | 停 vstore 降级 | 停 vstore(TL 配合) | 提问 QA + 观察 | 提问 | QA 照常(doc 级不依赖 chunk);无私有块;无用户可见错误 | FR-PRIV-008 验收口径 |
+| TC-PRIV-L4-07 | FR-PRIV-001 | 上传无限制+进度条(回归) | 浏览器 | 上传大文件 | 大文件 | 无大小拦截;进度条展示 | FR-PRIV-001 验收口径 |
+
+### Lr 红线与回归(diff 检查)
+
+| 编号 | 对应 FR | 测试对象 | 前置条件 | 步骤 | 输入 | 期望输出 | 判据 |
+|---|---|---|---|---|---|---|---|
+| TC-PRIV-Lr-01 | FR-PRIV-008 | QA 金标树 8091 零改动 | 无 | git diff 基线..HEAD -- agent/qa 部署树 qa | 无 | diff 空 | 红线 |
+| TC-PRIV-Lr-02 | FR-PRIV-008 | 前端红线零改动 | 无 | git diff -- ui/src/stores/{modes,labels,bench}.js ui/src/api/qa.js ui/src/sse/parser.js ui/src/components/chat/PipelineCard.vue | 无 | diff 空 | 红线 |
+| TC-PRIV-Lr-03 | FR-PRIV-008 | vstore 红线零改动 | 无 | git diff -- vstore/{retrieval,parsing,backend,crypto}.py | 无 | diff 空 | 红线 |
+| TC-PRIV-Lr-04 | FR-PRIV-008 | sdk 红线零改动 | 无 | git diff -- sdk/app/routers/qa.py sdk/app/services/sse_relay.py sdk/tooling/compile/compile_sdk.sh | 无 | diff 空;白名单未扩大 | 红线 |
+| TC-PRIV-Lr-05 | FR-PRIV-008 | 新模块无 yield/SSE | 无 | grep -n "yield" sdk/app/services/privateqa.py sdk/app/routers/private_docs.py | 无 | 无 yield(断流崩溃根因回归) | grep 空 |
+
+## 2. 双向追溯矩阵
+
+### 2.1 FR-PRIV → TC
+
+| FR | 对应 TC |
+|---|---|
+| FR-PRIV-001 | TC-PRIV-L1-40、TC-PRIV-L2-15、TC-PRIV-L2-16、TC-PRIV-L2s-01、TC-PRIV-L4-01、TC-PRIV-L4-07 |
+| FR-PRIV-002 | TC-PRIV-L1-01~07、TC-PRIV-L1-07b、TC-PRIV-L1-08~13、TC-PRIV-L1-14~17、TC-PRIV-L1-18~19、TC-PRIV-L2-01~06、TC-PRIV-L2-16、TC-PRIV-L2-21、TC-PRIV-L4-01 |
+| FR-PRIV-003 | TC-PRIV-L1-32、TC-PRIV-L1-37、TC-PRIV-L1-42、TC-PRIV-L4-02、TC-PRIV-L4-03 |
+| FR-PRIV-004 | TC-PRIV-L1-22~27、TC-PRIV-L1-30、TC-PRIV-L1-31、TC-PRIV-L2-10~14 |
+| FR-PRIV-005 | TC-PRIV-L1-28、TC-PRIV-L1-29、TC-PRIV-L1-33~39、TC-PRIV-L2s-05、TC-PRIV-L3-01~03、TC-PRIV-L4-02、TC-PRIV-L4-04 |
+| FR-PRIV-006 | TC-PRIV-L1-20、TC-PRIV-L1-41、TC-PRIV-L2-17~20、TC-PRIV-L3-04~08、TC-PRIV-L4-05 |
+| FR-PRIV-007 | TC-PRIV-L1-21、TC-PRIV-L2-07~09、TC-PRIV-L2s-02 |
+| FR-PRIV-008 | TC-PRIV-L2-21~24、TC-PRIV-L2s-03、TC-PRIV-L2s-04、TC-PRIV-L4-06、TC-PRIV-Lr-01~05 |
+
+### 2.2 调整对象 → TC(§1 清单每个文件/端点/函数)
+
+| 文件/端点/函数 | TC |
+|---|---|
+| vstore/chunks.py build_chunks | TC-PRIV-L1-01~07、TC-PRIV-L1-07b |
+| vstore/chunks.py ChunkIndex(upsert/remove/search/rebuild_all/count/原子写) | TC-PRIV-L1-08~13、TC-PRIV-L2-07 |
+| vstore/embed.py embed_batch_chunked | TC-PRIV-L1-14~17 |
+| vstore/vault.py add() chunk 钩子与返回字段 | TC-PRIV-L1-18、TC-PRIV-L1-19、TC-PRIV-L2-16 |
+| vstore/vault.py delete() chunk 联动 | TC-PRIV-L1-20、TC-PRIV-L2-18 |
+| vstore/vault.py rebuild_chunks() | TC-PRIV-L1-21 |
+| vstore/api.py GET /api/vstore/search-chunks | TC-PRIV-L2-01~06 |
+| vstore/api.py POST /api/vstore/rebuild-chunks | TC-PRIV-L2-07、TC-PRIV-L2-09 |
+| vstore/api.py GET /api/vstore/health(chunk_count) | TC-PRIV-L2-08 |
+| vstore/settings.py VSTORE_CHUNK_PATH | 并入 TC-PRIV-L1-08(ChunkIndex 以 settings 路径构造,路径推导断言) |
+| vstore/scripts/rebuild_chunks.py CLI | TC-PRIV-L2s-02 |
+| 存量 210 条重建 | TC-PRIV-L2s-02、TC-PRIV-L2-07 |
+| sdk/app/services/privateqa.py ask_private | TC-PRIV-L1-22~27 |
+| sdk/app/routers/private_docs.py POST /api/qa/private | TC-PRIV-L2-10~14 |
+| sdk/app/schemas.py PrivateQARequest/PrivateDocItem/PrivateDocBlock/Block union | TC-PRIV-L1-28、TC-PRIV-L1-29 |
+| sdk/app/main.py 路由注册 | TC-PRIV-L2s-03 |
+| sdk/app/services/docgen/client.py search_chunks | TC-PRIV-L1-31 |
+| sdk/app/services/docgen/llm.py complete_text timeout 参数 | TC-PRIV-L1-30 |
+| ui/src/api/private.js askPrivate | TC-PRIV-L1-40 |
+| ui/src/stores/chat.js _firePrivate | TC-PRIV-L1-32、TC-PRIV-L1-42 |
+| ui/src/stores/chat.js pending/flush 竞态 | TC-PRIV-L1-33、TC-PRIV-L1-34、TC-PRIV-L1-35、TC-PRIV-L1-39 |
+| ui/src/stores/chat.js _appendPrivate 防重入 | TC-PRIV-L1-36 |
+| ui/src/stores/chat.js ask/search/retry 覆盖 | TC-PRIV-L1-37、TC-PRIV-L1-38 |
+| ui/src/components/chat/AssistantMessage.vue private_doc 分支 | TC-PRIV-L3-01~03 |
+| ui/src/i18n/private.js | TC-PRIV-L3-01、TC-PRIV-L3-04(文案断言) |
+| ui/src/components/bench/PrivateLibraryPane.vue | TC-PRIV-L3-04~07 |
+| ui/src/components/bench/DocPane.vue 空态挂载 | TC-PRIV-L3-08 |
+| ui/src/api/docgen.js removeLibrary(204) | TC-PRIV-L1-41 |
+| docgen doc 级 /search 回归 | TC-PRIV-L2-21、TC-PRIV-L2-22 |
+| 上传/generate/导出回归 | TC-PRIV-L2-15、TC-PRIV-L2-22、TC-PRIV-L2-23、TC-PRIV-L2-24 |
+| 红线:QA 8091 | TC-PRIV-Lr-01 |
+| 红线:modes/labels/bench/api/qa/sse/parser/PipelineCard | TC-PRIV-Lr-02 |
+| 红线:vstore retrieval/parsing/backend/crypto | TC-PRIV-Lr-03 |
+| 红线:sdk qa.py/sse_relay.py/compile_sdk.sh | TC-PRIV-Lr-04 |
+| 断流崩溃(新模块无 yield) | TC-PRIV-Lr-05 |
+| vstore/tests/test_chunks.py(测试文件本身) | 本文 §3(pytest 全绿即覆盖) |
+| ui/tests/private_docs/*(测试文件本身) | 本文 §3(vitest 全绿即覆盖) |
+
+### 2.3 覆盖判据 §3 边界与异常逐项 → TC
+
+| 判据项 | TC |
+|---|---|
+| 空库检索 | TC-PRIV-L2-03 |
+| 低分阈值 0.30 两侧 | TC-PRIV-L1-12(两侧)、TC-PRIV-L1-13(低侧)、TC-PRIV-L2-02 |
+| 超 top_k clamp | TC-PRIV-L1-12、TC-PRIV-L2-04 |
+| 每文档 200 块上限 | TC-PRIV-L1-04 |
+| 同 doc 去重每 doc≤3 | TC-PRIV-L1-24 |
+| 12k 上下文截断 | TC-PRIV-L1-25 |
+| LLM 40s 超时 | TC-PRIV-L1-26 |
+| vstore 宕机 | TC-PRIV-L2-13、TC-PRIV-L4-06 |
+| LLM 宕机 | TC-PRIV-L2-14 |
+| 上传超长 PDF | TC-PRIV-L1-04(超长切块)、TC-PRIV-L4-07(超长上传回归) |
+| docgen 检索响应逐字段 | TC-PRIV-L2-21 |
+| QA 8091 零改动 | TC-PRIV-Lr-01 |
+| 红线文件 diff 空 | TC-PRIV-Lr-02~04 |
+
+## 3. 测试文件骨架
+
+(与本文档同批交付,本轮只写不跑。)
+
+| 文件 | 覆盖 TC |
+|---|---|
+| vstore/tests/test_chunks.py | TC-PRIV-L1-01~17、TC-PRIV-L1-07b(build_chunks/ChunkIndex/embed_batch_chunked;含 tmp_path 与假嵌入注入) |
+| ui/tests/private_docs/chat.private.test.js | TC-PRIV-L1-32~40、TC-PRIV-L1-42(chat store 竞态/防重入/两模式/retry/跨轮归属/askPrivate) |
+| ui/tests/private_docs/components.private.test.js | TC-PRIV-L3-01~03(AssistantMessage 块渲染) |
+| ui/tests/private_docs/privateLibraryPane.test.js | TC-PRIV-L3-04~07(面板) |
+| ui/tests/private_docs/docPaneMount.test.js | TC-PRIV-L3-08 + TC-PRIV-L1-41(空态挂载/removeLibrary 204) |
+| ui/tests/private_docs/vitest.config.js | 局部配置(b3 先例),依赖不改 |
diff --git a/ui/tests/private_docs/chat.private.test.js b/ui/tests/private_docs/chat.private.test.js
new file mode 100644
index 0000000..65e36bb
--- /dev/null
+++ b/ui/tests/private_docs/chat.private.test.js
@@ -0,0 +1,291 @@
+// @vitest-environment jsdom
+// chat.private.test.js —— 私有资料问答 chat store 竞态与接线单测(T1 骨架)
+// 对应用例:TC-PRIV-L1-32 ~ TC-PRIV-L1-40、TC-PRIV-L1-42(docs/team/TEST_CASES_PRIVATE.md)
+// 风格:沿用 tests/frontend/unit/b2/chat.store.test.js(vi.mock + fake timers + 帧驱动)。
+// A1 契约(brief_priv_a1.md P3):chat.send() 在分发前不 await 调 _firePrivate(q, sessionId, aMsg);
+// 命中时流中暂存 aMsg._privatePending / 终态直挂;_appendPrivate 防重入;_flushPrivate 只清 pending。
+// TL 裁决:本轮上下文校验实现 = streamCtx[sessionId].aMsg 引用相等;用例判据只写行为级
+// (非本轮私有结果不得串挂,见 L1-42),不锁内部实现。
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { setActivePinia, createPinia } from 'pinia';
+
+// ---- mock 依赖模块(与 b2/chat.store.test.js 同模式)----
+let frames = [];
+let privateImpl = () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
+let privateCalls = [];
+
+vi.mock('../../src/api/qa.js', () => ({
+ chatStream: vi.fn(async (body, handlers) => {
+ for (const f of frames) {
+ if (f.event === 'complete') handlers.onComplete({ payload: f.data });
+ else if (f.event === 'error') handlers.onError({ payload: f.data });
+ else handlers.onProgress({ payload: f.data });
+ }
+ }),
+ search: vi.fn(async () => ({ mode: 'search', answer: '', raw: {} })),
+ info: vi.fn(async () => ({})),
+}));
+
+vi.mock('../../src/api/sessions.js', () => {
+ const calls = { messages: [], patches: [] };
+ return {
+ createMessage: vi.fn(async (sid, body) => {
+ calls.messages.push({ sid, body });
+ return { id: `mid-${calls.messages.length}`, ...body, created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z' };
+ }),
+ updateMessage: vi.fn(async (sid, mid, body) => {
+ calls.patches.push({ sid, mid, body });
+ return { id: mid, ...body, updated_at: '2026-08-25T00:00:05Z' };
+ }),
+ getSession: vi.fn(),
+ listSessions: vi.fn(async () => []),
+ createSession: vi.fn(async () => ({ id: 'sess-1', title: '新会话', status: 'idle', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 0, meta: {} })),
+ updateSession: vi.fn(async (sid, body) => ({ id: sid, ...body })),
+ deleteSession: vi.fn(async () => {}),
+ getSessionSources: vi.fn(async () => ({ session_id: 's1', total: 0, sources: [] })),
+ _calls: calls,
+ };
+});
+
+// askPrivate 可控实现:记录调用、可延迟 resolve(竞态时序控制)
+vi.mock('../../src/api/private.js', () => ({
+ askPrivate: vi.fn(async (...args) => {
+ privateCalls.push(args[0]);
+ return privateImpl(...args);
+ }),
+}));
+
+import { useChatStore } from '../../src/stores/chat.js';
+import { useSessionsStore } from '../../src/stores/sessions.js';
+
+const HIT_PAYLOAD = {
+ ok: true, hit: true, mode: 'vector',
+ answer: '依据[资料1],水稻SPL家族共19个成员。',
+ items: [
+ { doc_id: 'd1', name: '水稻SPL资料', seq: 0, chunk_id: 'd1:0', snippet: '水稻SPL家族共有19个成员……', score: 0.92, category: '', tags: '', created_at: '2026-09-01T00:00:00Z', ref: 1 },
+ ],
+ elapsed_ms: 1234,
+};
+
+const completePayload = {
+ answer: '普通QA答案',
+ source_count: 0,
+ sources: [],
+ chains: [],
+ graph: { nodes: [], edges: [], chains: [] },
+ raw: {},
+};
+
+function privateBlockOf(aMsg) {
+ return (aMsg.blocks || []).find((b) => b.type === 'private_doc') || null;
+}
+
+async function setupChat({ sessionId = 'sess-1' } = {}) {
+ setActivePinia(createPinia());
+ const chat = useChatStore();
+ const sessions = useSessionsStore();
+ sessions.items = [{ id: 'sess-1', title: '新会话', status: 'idle', created_at: '2026-08-25T00:00:00Z', updated_at: '2026-08-25T00:00:00Z', message_count: 0, meta: {} }];
+ sessions.activeId = sessionId;
+ chat.sessionId = sessionId;
+ frames = [];
+ privateCalls = [];
+ privateImpl = () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
+ return { chat, sessions };
+}
+
+describe('chat store:私有资料检索接线(FR-PRIV-003/005)', () => {
+ beforeEach(() => { vi.useFakeTimers(); });
+ afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); });
+
+ it('TC-PRIV-L1-32 命中:ask 终态后 private_doc 块挂上(fire-and-forget 不阻塞 QA)', async () => {
+ const { chat } = await setupChat();
+ privateImpl = async () => HIT_PAYLOAD;
+ frames = [
+ { event: 'progress', data: { phase: 'planning', label: '理解问题' } },
+ { event: 'complete', data: completePayload },
+ ];
+ const sendP = chat.send('水稻SPL家族?', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ await sendP;
+ const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
+ const blk = privateBlockOf(aMsg);
+ expect(blk).not.toBeNull();
+ expect(blk.items.length).toBe(1);
+ expect(blk.answer).toContain('[资料1]');
+ expect(aMsg.blocks.some((b) => b.type === 'text' && b.html.includes('普通QA答案'))).toBe(true);
+ // 未命中/异常静默分支
+ expect(privateCalls.length).toBe(1);
+ });
+
+ it('TC-PRIV-L1-32 未命中:无块、无用户可见错误、QA 流程不受影响', async () => {
+ const { chat } = await setupChat();
+ privateImpl = async () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
+ frames = [{ event: 'complete', data: completePayload }];
+ const sendP = chat.send('问题', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ await sendP;
+ const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
+ expect(privateBlockOf(aMsg)).toBeNull();
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.streamErr).toBeNull();
+ });
+
+ it('TC-PRIV-L1-32 异常:askPrivate reject 静默(无块、QA 终态正常)', async () => {
+ const { chat } = await setupChat();
+ privateImpl = async () => { throw new Error('boom'); };
+ frames = [{ event: 'complete', data: completePayload }];
+ const sendP = chat.send('问题', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ await sendP;
+ const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
+ expect(privateBlockOf(aMsg)).toBeNull();
+ expect(aMsg.status).toBe('done');
+ expect(chat.lastResult.ok).toBe(true);
+ });
+
+ it('TC-PRIV-L1-33 流未收口:命中先到 → 暂存 pending,complete 替换 blocks 后 flush 不丢块', async () => {
+ const { chat } = await setupChat();
+ // 命中在 QA complete 之前 resolve(顺序由 frame 驱动保证)
+ privateImpl = async () => HIT_PAYLOAD;
+ frames = [
+ { event: 'progress', data: { phase: 'planning', label: '理解问题' } },
+ { event: 'complete', data: completePayload },
+ ];
+ const sendP = chat.send('问题', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ await sendP;
+ const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
+ const blk = privateBlockOf(aMsg);
+ expect(blk).not.toBeNull();
+ expect(blk.answer).toContain('[资料1]');
+ // pipeline 块仍在且为第一个块(onComplete 整体替换后 flush 挂尾部)
+ expect(aMsg.blocks[0].type).toBe('pipeline');
+ });
+
+ it('TC-PRIV-L1-34 onComplete 替换后命中才到:终态直挂 + 补 persist', async () => {
+ const { chat } = await setupChat();
+ let resolvePrivate;
+ privateImpl = () => new Promise((res) => { resolvePrivate = () => res(HIT_PAYLOAD); });
+ frames = [{ event: 'complete', data: completePayload }];
+ const sendP = chat.send('问题', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ // QA 已 complete(终态 persist 完成)后私有响应才返回
+ resolvePrivate();
+ await vi.runAllTimersAsync();
+ await sendP;
+ const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
+ const blk = privateBlockOf(aMsg);
+ expect(blk).not.toBeNull();
+ expect(blk.answer).toContain('[资料1]');
+ // 终态直挂需补一次 persist:断言 updateMessage 被再次调用且 blocks 含 private_doc
+ const sessApi = (await import('../../src/api/sessions.js'));
+ const patches = sessApi._calls.patches;
+ const lastPatch = patches[patches.length - 1];
+ expect(lastPatch).toBeTruthy();
+ expect(JSON.stringify(lastPatch.body.blocks || [])).toContain('private_doc');
+ });
+
+ it('TC-PRIV-L1-35 error 路径 flush:QA onError 终态下命中块仍挂上', async () => {
+ const { chat } = await setupChat();
+ privateImpl = async () => HIT_PAYLOAD;
+ frames = [{ event: 'error', data: { code: 'UPSTREAM_DOWN', message: '检索服务不可用' } }];
+ const sendP = chat.send('问题', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ await sendP;
+ const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
+ expect(aMsg.status).toBe('error');
+ const blk = privateBlockOf(aMsg);
+ expect(blk).not.toBeNull();
+ expect(blk.answer).toContain('[资料1]');
+ });
+
+ it('TC-PRIV-L1-36 _appendPrivate 防重入:同一消息只挂一次', async () => {
+ const { chat } = await setupChat();
+ const aMsg = { id: 'm1', role: 'assistant', blocks: [] };
+ chat._appendPrivate(aMsg, HIT_PAYLOAD);
+ chat._appendPrivate(aMsg, HIT_PAYLOAD);
+ expect(aMsg.blocks.filter((b) => b.type === 'private_doc').length).toBe(1);
+ });
+
+ it('TC-PRIV-L1-37 search 模式同样触发 _firePrivate 且挂块', async () => {
+ const { chat } = await setupChat();
+ privateImpl = async () => HIT_PAYLOAD;
+ const sendP = chat.send('问题', { mode: 'search' });
+ await vi.runAllTimersAsync();
+ await sendP;
+ expect(privateCalls.length).toBe(1);
+ const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
+ expect(privateBlockOf(aMsg)).not.toBeNull();
+ });
+
+ it('TC-PRIV-L1-38 retry 后新一轮同样触发私有检索且不残留旧 pending', async () => {
+ const { chat } = await setupChat();
+ privateImpl = async () => ({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
+ frames = [{ event: 'error', data: { code: 'UPSTREAM_DOWN', message: 'x' } }];
+ const sendP = chat.send('问题', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ await sendP;
+ expect(chat.lastResult.ok).toBe(false);
+ // 重试:QA 正常完成,私有未命中
+ frames = [{ event: 'complete', data: completePayload }];
+ const retryP = chat.retry();
+ await vi.runAllTimersAsync();
+ await retryP;
+ expect(privateCalls.length).toBe(2);
+ const aMsg = [...chat.messages].reverse().find((m) => m.role === 'assistant');
+ expect(privateBlockOf(aMsg)).toBeNull();
+ expect(aMsg.status).toBe('done');
+ });
+
+ it('TC-PRIV-L1-39 _flushPrivate 只清 pending,不重复挂块', async () => {
+ const { chat } = await setupChat();
+ const aMsg = { id: 'm1', role: 'assistant', blocks: [], _privatePending: HIT_PAYLOAD };
+ chat._flushPrivate(aMsg);
+ expect(aMsg.blocks.filter((b) => b.type === 'private_doc').length).toBe(1);
+ expect(aMsg._privatePending).toBeUndefined();
+ // 二次 flush:无 pending 不应再挂块
+ chat._flushPrivate(aMsg);
+ expect(aMsg.blocks.filter((b) => b.type === 'private_doc').length).toBe(1);
+ });
+
+ it('TC-PRIV-L1-40 askPrivate 契约:路径/默认参数/timeout', async () => {
+ const { askPrivate } = await import('../../src/api/private.js');
+ await askPrivate({ question: 'q', session_id: 's1' });
+ expect(askPrivate).toHaveBeenCalledWith({ question: 'q', session_id: 's1' });
+ expect(privateCalls.length).toBe(1);
+ expect(privateCalls[0]).toEqual({ question: 'q', session_id: 's1' });
+ });
+
+ it('TC-PRIV-L1-42 跨轮归属:迟到响应不得串挂到本轮消息(行为级判据)', async () => {
+ const { chat } = await setupChat();
+ // 第 1 轮私有响应悬置:直到第 2 轮进行中才 resolve
+ const pending = [];
+ privateImpl = () => new Promise((res) => { pending.push(res); });
+ frames = [{ event: 'complete', data: completePayload }];
+ const first = chat.send('第一问', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ await first;
+ expect(privateCalls.length).toBe(1);
+ // 第 2 轮开始(私有响应同样悬置,QA 流未收口 → 本轮上下文活跃)
+ frames = [{ event: 'progress', data: { phase: 'planning', label: '理解问题' } }];
+ const second = chat.send('第二问', { mode: 'ask' });
+ await vi.runAllTimersAsync();
+ expect(privateCalls.length).toBe(2);
+ // 此刻才 resolve 第 1 轮的命中响应(跨轮迟到场景)
+ pending[0](HIT_PAYLOAD);
+ await vi.runAllTimersAsync();
+ // 第 2 轮 QA 正常收口(其私有响应未命中)
+ pending[1]({ ok: true, hit: false, mode: 'empty', answer: '', items: [] });
+ await vi.runAllTimersAsync();
+ await second;
+ const msgs = chat.messages.filter((m) => m.role === 'assistant');
+ const round1 = msgs[0];
+ const round2 = msgs[1];
+ // 行为级判据:第 1 轮迟到的命中结果不得串挂到第 2 轮消息
+ expect(privateBlockOf(round2)).toBeNull();
+ expect(JSON.stringify(round2.blocks || [])).not.toContain('[资料1]');
+ // 迟到的第 1 轮结果挂到第 1 轮消息或丢弃均可(方案不锁实现),但不得串挂
+ const blk1 = privateBlockOf(round1);
+ if (blk1) expect(blk1.items[0].name).toBe('水稻SPL资料');
+ });
+});
diff --git a/ui/tests/private_docs/components.private.test.js b/ui/tests/private_docs/components.private.test.js
new file mode 100644
index 0000000..d38a138
--- /dev/null
+++ b/ui/tests/private_docs/components.private.test.js
@@ -0,0 +1,86 @@
+// @vitest-environment jsdom
+// components.private.test.js —— AssistantMessage private_doc 块渲染(T1 骨架)
+// 对应用例:TC-PRIV-L3-01 ~ TC-PRIV-L3-03(docs/team/TEST_CASES_PRIVATE.md)
+// 风格:沿用 tests/frontend/unit/b2/components.test.js(mount + DOM 断言)。
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { setActivePinia, createPinia } from 'pinia';
+import AssistantMessage from '../../src/components/chat/AssistantMessage.vue';
+
+function messageWithBlock(block) {
+ return {
+ id: 'm1',
+ role: 'assistant',
+ kind: 'qa',
+ status: 'done',
+ blocks: [
+ { type: 'pipeline', status: 'done', steps: [] },
+ block,
+ ],
+ raw: null,
+ created_at: '2026-09-01T00:00:00Z',
+ updated_at: '2026-09-01T00:00:01Z',
+ };
+}
+
+const BASE_BLOCK = {
+ type: 'private_doc',
+ answer: '依据[资料1],水稻SPL家族共19个成员。',
+ items: [
+ { ref: 1, name: '水稻SPL资料.pdf', score: 0.92, snippet: '水稻SPL家族共有19个成员……' },
+ ],
+};
+
+describe('AssistantMessage private_doc 块渲染(FR-PRIV-005)', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ // 揭示动画相关环境由 jsdom 默认处理(reduced-motion 分支直接定格)
+ });
+
+ it('TC-PRIV-L3-01 卡体/标题/条目齐全', () => {
+ const wrapper = mount(AssistantMessage, {
+ props: { message: messageWithBlock(BASE_BLOCK) },
+ });
+ expect(wrapper.find('.private-doc-block').exists()).toBe(true);
+ expect(wrapper.text()).toContain('私有资料参考');
+ expect(wrapper.text()).toContain('依据[资料1]');
+ const itemEl = wrapper.find('.private-doc-item, [data-private-item]');
+ if (itemEl.exists()) {
+ expect(itemEl.text()).toContain('水稻SPL资料.pdf');
+ expect(itemEl.text()).toContain('0.92');
+ expect(itemEl.text()).toContain('水稻SPL家族共有19个成员');
+ }
+ });
+
+ it('TC-PRIV-L3-02 answer 为空仅显示条目列表(无空白答区)', () => {
+ const block = { ...BASE_BLOCK, answer: '' };
+ const wrapper = mount(AssistantMessage, {
+ props: { message: messageWithBlock(block) },
+ });
+ expect(wrapper.find('.private-doc-block').exists()).toBe(true);
+ const answerEl = wrapper.find('.private-doc-answer, .pd-answer');
+ expect(answerEl.exists()).toBe(false); // 答区元素不得存在(即便存在也为空渲染)
+ expect(wrapper.find('.private-doc-item, [data-private-item]').exists()).toBe(true);
+ });
+
+ it('TC-PRIV-L3-03 markdown 消毒:script 移除、**加粗** 渲染', () => {
+ const block = {
+ ...BASE_BLOCK,
+ answer: '结论**成立**。',
+ };
+ const wrapper = mount(AssistantMessage, {
+ props: { message: messageWithBlock(block) },
+ });
+ const html = wrapper.find('.private-doc-block').html();
+ expect(html).not.toContain('