# 私有资料问答(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**,追溯矩阵已同步。
> 修订记录:v1.2(R2 回写)——TC-PRIV-L2-10/L2-11 信封含 ok:true;TC-PRIV-L2-12 确认空 question 与缺 question 均 422;TC-PRIV-L3-01/L4-03 注明无关查询 → hit:false(自谦答语确定性改判);TC-PRIV-L2-10 注明 BUG-PRIV-R2-01(LLM 上下文不含 question)现状。
> 修订记录:v1.3(R3 回写)——TC-PRIV-L2-10 判据去掉 R2 注,改为「LLM 收到用户问题与参考资料」(BUG-PRIV-R2-01 已关闭,92e2754)。
## 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}(R2 起信封含 ok:true) | answer 中每个 [资料n] 编号与 items[].ref 一一对应;answer ≤300 字;R3 起:LLM 收到用户问题与参考资料(user_message = 用户问题 + [资料n] 参考资料) |
| TC-PRIV-L2-11 | FR-PRIV-004 | /api/qa/private 未命中 | 库中无相关内容 | curl POST | {"question":"无关问题"} | HTTP 200;{ok:true,hit:false,mode:"empty",answer:"",items:[]}(R2 起信封含 ok:true;自谦答语确定性改判 hit:false) | 三态信封完整;无 error 字段 |
| TC-PRIV-L2-12 | FR-PRIV-004 | /api/qa/private 参数校验 | 无 | curl POST | {"question":""} / 缺 question | HTTP 422 {ok:false,error:{code:"VALIDATION"}}(R2 确认:空 question 与缺 question 均 422,question min_length=1) | 统一 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 断言各项存在(R2 注:hit:false 改判后前端不产生 private_doc 块,本用例针对命中路径的块渲染) |
| 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 答案正常 | 零痕迹(R2 起:无关查询 → hit:false,自谦答语改判) |
| 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 先例),依赖不改 |