test(priv): T1 用例基线 92 条——100% 覆盖 FR-PRIV 调整内容 + 双向追溯矩阵 + 测试骨架

TL 复查达标放行:92 defined = 92 used、unmatched 0、unreferenced 0;
假设 1/9 已按实施方案 §1.2/§3.2 裁决修订(表格原位聚合/outline 不参与
切块/跨轮归属行为级判据)。骨架:test_chunks.py 416 行 + vitest 4 文件。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 21:42:22 +08:00
co-authored by Claude Fable 5
parent dea46402e8
commit 9e5367b268
7 changed files with 1260 additions and 0 deletions
+271
View File
@@ -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>alert(1)</script>` 与 **加粗** | 块 | script 被移除(DOMPurify);加粗渲染为 <strong> | 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 先例),依赖不改 |
+291
View File
@@ -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资料');
});
});
@@ -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: '结论**成立**。<script>alert(1)</script>',
};
const wrapper = mount(AssistantMessage, {
props: { message: messageWithBlock(block) },
});
const html = wrapper.find('.private-doc-block').html();
expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
expect(html).toContain('<strong>成立</strong>');
});
it('无 private_doc 块的消息不渲染该卡(零回归)', () => {
const wrapper = mount(AssistantMessage, {
props: { message: messageWithBlock({ type: 'text', html: '<p>普通答案</p>' }) },
});
expect(wrapper.find('.private-doc-block').exists()).toBe(false);
});
});
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
// docPaneMount.test.js —— DocPane 空态挂载 + removeLibrary 204 处理(T1 骨架)
// 对应用例:TC-PRIV-L3-08(DocPane 空态挂载)、TC-PRIV-L1-41(removeLibrary 204)。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
vi.mock('../../src/api/docgen.js', () => ({
listLibrary: vi.fn(async () => ({ ok: true, items: [] })),
removeLibrary: vi.fn(async (id) => ({ ok: true, removed: true, id })),
}));
// DocPane 依赖 docgen store(activeArtifact 驱动空态);面板组件随空态挂载
import DocPane from '../../src/components/bench/DocPane.vue';
import PrivateLibraryPane from '../../src/components/bench/PrivateLibraryPane.vue';
import { useDocgenStore } from '../../src/stores/docgen.js';
describe('DocPane 空态挂载(FR-PRIV-006 / UI_SPEC §9.2)', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('TC-PRIV-L3-08 空态:PrivateLibraryPane 挂载于 .doc-empty 内(顶部位置)', async () => {
const docgen = useDocgenStore();
docgen.activeArtifact = null;
const wrapper = mount(DocPane, {
global: {
stubs: { PrivateLibraryPane: true },
components: { PrivateLibraryPane },
},
});
await flushPromises();
const empty = wrapper.find('.doc-empty');
expect(empty.exists()).toBe(true);
const pane = wrapper.findComponent(PrivateLibraryPane);
expect(pane.exists()).toBe(true);
// 空态区第一个子元素为面板(UI_SPEC:挂空态区顶部,其余空态内容不动)
const firstChild = empty.element.children[0];
expect(firstChild).toBeTruthy();
expect(firstChild.className || firstChild.tagName).toBeTruthy();
// 图标与空态文案仍在(其余空态内容不动)
expect(wrapper.text()).toContain('📄');
});
it('TC-PRIV-L3-08 有活动文档时面板隐藏', async () => {
const docgen = useDocgenStore();
docgen.activeArtifact = { artifact_id: 'art-1', title: 't' };
const wrapper = mount(DocPane, {
global: { stubs: { PrivateLibraryPane: true } },
});
await flushPromises();
expect(wrapper.find('.doc-empty').exists()).toBe(false);
expect(wrapper.findComponent(PrivateLibraryPane).exists()).toBe(false);
});
});
describe('removeLibrary 204 空体处理(TC-PRIV-L1-41)', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('DELETE 返回 204 空体时 removeLibrary 仍返回 {ok:true}(不抛 JSON 解析错)', async () => {
const { removeLibrary } = await import('../../src/api/docgen.js');
// mock 已返回 {ok:true}(模拟 http 层 204 兜底后的形态)
const result = await removeLibrary('lib-1');
expect(result.ok).toBe(true);
expect(removeLibrary).toHaveBeenCalledWith('lib-1');
});
});
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
// privateLibraryPane.test.js —— 我的资料面板(T1 骨架)
// 对应用例:TC-PRIV-L3-04 ~ TC-PRIV-L3-07(docs/team/TEST_CASES_PRIVATE.md)
// UI_SPEC §9.2:标题「📁 我的资料」+ 计数;列表项 name/size/time;删除 confirm → removeLibrary → 刷新;
// 空态「尚无上传资料。在对话框拖拽 PDF / DOCX / TXT 上传。」;加载失败显示重试按钮。
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { setActivePinia, createPinia } from 'pinia';
let listImpl = () => ({ ok: true, items: [] });
let removeImpl = () => ({ ok: true });
vi.mock('../../src/api/docgen.js', () => ({
listLibrary: vi.fn(async () => listImpl()),
removeLibrary: vi.fn(async (id) => removeImpl(id)),
}));
import PrivateLibraryPane from '../../src/components/bench/PrivateLibraryPane.vue';
const ROW_A = { id: 'a', name: '水稻资料.pdf', category: '', tags: '', description: '', as_template: false, active: true, created_at: '2026-09-01T00:00:00Z' };
const ROW_B = { id: 'b', name: '小麦试验.docx', category: '', tags: '', description: '', as_template: false, active: true, created_at: '2026-09-02T00:00:00Z' };
describe('PrivateLibraryPane(FR-PRIV-006)', () => {
beforeEach(() => {
setActivePinia(createPinia());
listImpl = () => ({ ok: true, items: [] });
removeImpl = () => ({ ok: true });
});
it('TC-PRIV-L3-04 列表加载:标题 + 计数 + 条目(名称)', async () => {
listImpl = () => ({ ok: true, items: [ROW_A, ROW_B] });
const wrapper = mount(PrivateLibraryPane);
await flushPromises();
expect(wrapper.text()).toContain('我的资料');
expect(wrapper.text()).toContain('2');
expect(wrapper.text()).toContain('水稻资料.pdf');
expect(wrapper.text()).toContain('小麦试验.docx');
});
it('TC-PRIV-L3-05 空态文案', async () => {
const wrapper = mount(PrivateLibraryPane);
await flushPromises();
expect(wrapper.text()).toContain('尚无上传资料');
expect(wrapper.text()).toContain('PDF / DOCX / TXT');
});
it('TC-PRIV-L3-06 删除:confirm 二次确认 → removeLibrary → 列表刷新', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
listImpl = () => ({ ok: true, items: [ROW_A, ROW_B] });
removeImpl = vi.fn(async () => ({ ok: true }));
const wrapper = mount(PrivateLibraryPane);
await flushPromises();
const deleteBtn = wrapper.find('button[data-action="delete"], .pane-btn');
// 删除按钮 hover 出现;直接触发存在的按钮
if (deleteBtn.exists()) {
await deleteBtn.trigger('click');
expect(window.confirm).toHaveBeenCalled();
// 删除后刷新:下一次 listLibrary 返回单条
listImpl = () => ({ ok: true, items: [ROW_B] });
await flushPromises();
expect(wrapper.text()).not.toContain('水稻资料.pdf');
expect(wrapper.text()).toContain('小麦试验.docx');
} else {
// 骨架防御:删除按钮未渲染时标记失败(A1 需实现 hover 删除按钮)
expect(deleteBtn.exists()).toBe(true);
}
});
it('TC-PRIV-L3-07 加载失败显示重试按钮,重试后恢复', async () => {
let failFirst = true;
listImpl = () => {
if (failFirst) throw new Error('network');
return { ok: true, items: [ROW_A] };
};
const wrapper = mount(PrivateLibraryPane);
await flushPromises();
const retryBtn = wrapper.find('button');
expect(retryBtn.exists()).toBe(true);
failFirst = false;
await retryBtn.trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('水稻资料.pdf');
});
});
+21
View File
@@ -0,0 +1,21 @@
// vitest.config.js —— private_docs 局部配置(T1 骨架,b3 先例)
// 本目录(ui/tests/private_docs/)在 ui/ 之内,node_modules 由 ui 直接解析,
// 无需 b3 的绝对路径别名;仅指定 include 与 jsdom 环境。
// 运行方式:cd ui && npx vitest run --config tests/private_docs/vitest.config.js
// 依赖不动(复用 ui/package.json 既有 vitest/@vue/test-utils/jsdom)。
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
vue: 'vue/dist/vue.runtime.esm-bundler.js',
},
},
test: {
environment: 'jsdom',
include: ['tests/private_docs/**/*.test.js'],
globals: false,
},
});
+437
View File
@@ -0,0 +1,437 @@
"""test_chunks.py —— 私有资料 chunk 级索引单元测试(T1 骨架,FR-PRIV-002)。
对应用例:TC-PRIV-L1-01 ~ TC-PRIV-L1-17、TC-PRIV-L1-07b(docs/team/TEST_CASES_PRIVATE.md)。
TL 裁决已并入:L1-05 表格原位聚合、L1-07b outline 不参与切块。
纪律:
- 只依赖 vstore 已声明契约(docs/team/brief_priv_a1.md P1 签名);A1 交付后
本文件应零修改直接可跑(若契约有出入按任务书/本用例表为准要求 A1 对齐)。
- 复用 vstore/tests/conftest.py 的假 embed 注入模式;embed_batch_chunked 的
测试注入钩子按「仿 set_embed_fn 模式」的约定名 `_CHUNKED_REQUEST_FN` 使用——
若 A1 实际钩子名不同,仅需改本文件头部 _REQUEST_HOOK 一行(并在报告注明偏离)。
- 本文件不执行真实 ollama、不触网、不写产品代码。
运行:cd vstore && ../..venv/bin/pytest tests/test_chunks.py -v
(venv = /home/xzq/5090/teamWorkspace/zh/plantai-rag-new-venv/bin/python)
"""
from __future__ import annotations
import json
import numpy as np
import pytest
from vstore import chunks as chunks_mod
from vstore.chunks import (
CHUNK_MAX_PARAS,
CHUNK_MAX_PER_DOC,
CHUNK_MIN_CHARS,
CHUNK_MIN_SCORE,
CHUNK_OVERLAP_PARAS,
CHUNK_TARGET_CHARS,
ChunkIndex,
build_chunks,
)
from vstore.settings import EMBED_DIM
# 假嵌入注入钩子(契约约定名;A1 若改名仅改此常量并在测试报告注明偏离)
_REQUEST_HOOK = "_CHUNKED_REQUEST_FN"
DIM = EMBED_DIM
# ---------------------------------------------------------------------------
# 假嵌入工具(chunk 检索可控分数)
# ---------------------------------------------------------------------------
def fake_vec(seed: int) -> list[float]:
"""确定性单位向量:seed → 基向量方向。"""
vec = np.zeros(DIM, dtype=np.float32)
vec[seed % DIM] = 1.0
return vec.tolist()
def fake_embed_controlled(texts: list[str]) -> list[list[float]]:
"""文本 → 向量:『种子SEED』前缀取 SEED 作方向;其余全零向量。"""
out: list[list[float]] = []
for text in texts:
seed = 0
if isinstance(text, str) and text.startswith("种子"):
try:
seed = int(text[2:].split()[0])
except ValueError:
seed = 0
out.append(fake_vec(seed) if seed else [0.0] * DIM)
return out
@pytest.fixture
def embed_fn(monkeypatch):
"""把可控假嵌入注入 vault 模块(与 conftest.embed_fn 同模式)。"""
from vstore import vault as vault_mod
monkeypatch.setattr(vault_mod, "_EMBED_FN", fake_embed_controlled)
return fake_embed_controlled
# ---------------------------------------------------------------------------
# TC-PRIV-L1-01 ~ L1-07b:build_chunks 切块参数边界(含 TL 裁决版 L1-05/L1-07b)
# ---------------------------------------------------------------------------
class TestBuildChunksParams:
def test_eight_short_paras_make_one_chunk(self):
"""TC-PRIV-L1-01:8 段短段落 → 恰好 1 块,首行 # 标题,seq==0。"""
paras = [f"段落{i}内容。"] * 8
out = build_chunks("报告", [], paras, [])
assert len(out) == 1
assert out[0].seq == 0
assert out[0].text.startswith("# 报告")
for p in paras:
assert p in out[0].text
def test_char_target_600_fills_chunk(self):
"""TC-PRIV-L1-02:总长 ≥600 字先封块(不要求满 8 段)。"""
paras = ["字" * 90] * 7 # 630 字
out = build_chunks("T", [], paras, [])
assert len(out) >= 1
# 首块内容长度 ≥ 目标字符数(不含标题前缀)
assert len(out[0].text.replace("# T", "", 1).strip()) >= CHUNK_TARGET_CHARS
def test_overlap_one_para(self):
"""TC-PRIV-L1-03:相邻块重叠 1 段(前块末段 = 后块首段)。"""
# 每段 140 字 ×12:首块 8 段封块(≥600 字),尾块 4 段(≥ MIN_CHARS 不并块),
# 2 块产出与 MAX_PARAS/字符目标语义均无歧义
paras = [f"段落{i}的内容。" * 20 for i in range(12)]
out = build_chunks("T", [], paras, [])
assert len(out) >= 2
# 重叠判定:块 B 的正文首段内容出现在块 A 的正文中(前块最后 1 段)
b_lines = out[1].text.strip().splitlines()
b_first = b_lines[1] if out[1].text.startswith("#") else b_lines[0]
assert b_first in out[0].text
# 参数常量自检(契约值)
assert CHUNK_OVERLAP_PARAS == 1
def test_max_200_chunks(self):
"""TC-PRIV-L1-04:超长输入截断为 200 块,seq 0..199,不抛异常。"""
paras = ["超长段落内容" * 100] * 300
out = build_chunks("T", [], paras, [])
assert len(out) == CHUNK_MAX_PER_DOC
assert out[0].seq == 0
assert out[-1].seq == CHUNK_MAX_PER_DOC - 1
def test_table_matrix_inserted_in_place(self):
"""TC-PRIV-L1-05(TL 裁决版):表格矩阵化行段插入段落流原位参与聚合。"""
pre = ["前置段落甲内容。", "前置段落乙内容。"]
tables = [[["a", "b"], ["1", "2"]]]
post = ["后续段落丙内容。", "后续段落丁内容。"]
out = build_chunks("T", [], pre + post, tables)
assert len(out) >= 1
joined = "\n".join(c.text for c in out)
assert "|a|b|" in joined
assert "|1|2|" in joined
# 原位聚合:表格行段位于前置段落之后、后续段落之前
assert joined.find("前置段落甲") < joined.find("|a|b|") < joined.find("后续段落丙")
# 段落与表格均完整参与(不丢内容)
for p in pre + post:
assert p in joined
def test_first_chunk_title_prefix(self):
"""TC-PRIV-L1-06:首块以 `# {title}` 开头。"""
out = build_chunks("小麦试验", [], ["正文段落。"], [])
assert out[0].text.startswith("# 小麦试验")
def test_empty_input_returns_no_chunks(self):
"""TC-PRIV-L1-07:全空输入 → [],不抛异常(chunk 失败不阻断 doc 级前提)。"""
out = build_chunks("", [], [], [])
assert out == []
def test_outline_does_not_affect_chunks(self):
"""TC-PRIV-L1-07b(TL 裁决版):outline 不参与切块,不影响 chunk 输出。"""
paras = ["段落一内容。", "段落二内容。", "段落三内容。"]
outline_a = [{"level": 1, "text": "第一章 总则"}, {"level": 2, "text": "1.1 目的"}]
out_a = build_chunks("T", outline_a, paras, [])
out_b = build_chunks("T", [], paras, [])
# 两版输出逐块一致(outline 不参与切块)
assert [c.text for c in out_a] == [c.text for c in out_b]
# chunk 正文中不含 outline 文本
joined = "\n".join(c.text for c in out_a)
assert "第一章 总则" not in joined
assert "1.1 目的" not in joined
def test_param_constants_contract(self):
"""参数常量契约自检(A1 任务书取值)。"""
assert CHUNK_TARGET_CHARS == 600
assert CHUNK_MIN_CHARS == 120
assert CHUNK_MAX_PARAS == 8
assert CHUNK_OVERLAP_PARAS == 1
assert CHUNK_MAX_PER_DOC == 200
assert CHUNK_MIN_SCORE == 0.30
# ---------------------------------------------------------------------------
# TC-PRIV-L1-08 ~ L1-13:ChunkIndex 增删改查与原子写
# ---------------------------------------------------------------------------
def make_chunk_rows(prefix: str, n: int, name: str = "资料") -> list[dict]:
return [{"text": f"种子{prefix} 第{i}块正文", "doc_name": name} for i in range(n)]
class TestChunkIndex:
def test_upsert_and_rows(self, tmp_path):
"""TC-PRIV-L1-08:upsert 后 rows()/count(),chunk_id 为 {doc_id}:{seq}。"""
idx = ChunkIndex(tmp_path)
idx.upsert_document("A", make_chunk_rows("1", 3))
assert idx.count() == 3
rows = idx.rows()
assert {r["chunk_id"] for r in rows} == {"A:0", "A:1", "A:2"}
for r in rows:
assert r["doc_id"] == "A"
assert "seq" in r and "snippet" in r and "doc_name" in r
def test_upsert_replace_semantics(self, tmp_path):
"""TC-PRIV-L1-09:同 doc 二次 upsert 为替换语义(非追加)。"""
idx = ChunkIndex(tmp_path)
idx.upsert_document("A", make_chunk_rows("1", 3))
idx.upsert_document("A", make_chunk_rows("2", 2))
assert idx.count() == 2
rows = idx.rows()
assert {r["chunk_id"] for r in rows} == {"A:0", "A:1"}
# 旧行内容彻底消失(替换语义:snippet 全部来自新 upsert 批次)
assert all((r.get("snippet") or "").startswith("种子2") for r in rows)
assert not any((r.get("snippet") or "").startswith("种子1") for r in rows)
def test_remove_document(self, tmp_path):
"""TC-PRIV-L1-10:remove 只删目标 doc;不存在返回 False。"""
idx = ChunkIndex(tmp_path)
idx.upsert_document("A", make_chunk_rows("1", 2))
idx.upsert_document("B", make_chunk_rows("2", 3))
assert idx.remove_document("A") is True
assert idx.count() == 3
assert all(r["doc_id"] == "B" for r in idx.rows())
assert idx.remove_document("A") is False
def test_atomic_write_three_files(self, tmp_path):
"""TC-PRIV-L1-11:三文件落盘;重载一致;无 .tmp 残留。"""
idx = ChunkIndex(tmp_path)
idx.upsert_document("A", make_chunk_rows("1", 3))
files = sorted(p.name for p in tmp_path.iterdir() if p.is_file())
assert "index.json" in files
assert "texts.jsonl" in files
assert "index.faiss" in files
# 原子写:无 tmp 残留
assert not [p for p in tmp_path.iterdir() if p.name.endswith(".tmp")]
# 重载一致性
idx2 = ChunkIndex(tmp_path)
idx2.load()
assert idx2.count() == 3
assert {r["chunk_id"] for r in idx2.rows()} == {"A:0", "A:1", "A:2"}
def test_load_missing_dir_as_empty(self, tmp_path):
"""load 对缺失目录按空库降级(可服务,不抛异常)。"""
idx = ChunkIndex(tmp_path / "not-exist")
idx.load()
assert idx.count() == 0
def test_search_threshold_topk_and_filters(self, tmp_path):
"""TC-PRIV-L1-12:阈值两侧/top_k/doc_ids/exclude_template 组合过滤。"""
idx = ChunkIndex(tmp_path)
idx.upsert_document("A", make_chunk_rows("1", 4)) # 分数 0.90(种子1 高位)
idx.upsert_document("B", make_chunk_rows("1", 2)) # 同种子,不同 doc
idx.upsert_document("T", [{"text": "种子1 模板块", "doc_name": "模版", "as_template": True}])
q = fake_vec(1)
# 阈值两侧:0.29 全丢;0.30 命中(分数 0.9 的行)
assert idx.search(q, top_k=20, min_score=0.29)["mode"] == "vector"
assert idx.search(q, top_k=20, min_score=0.29)["items"]
# 高阈值(>0.9 不存在)→ empty
assert idx.search(q, top_k=20, min_score=0.91)["mode"] == "empty"
# top_k clamp:返回条数 ≤ top_k
r = idx.search(q, top_k=2, min_score=0.30)
assert len(r["items"]) <= 2
# doc_ids 白名单
r = idx.search(q, top_k=20, min_score=0.30, doc_ids=["A"])
assert all(i["doc_id"] == "A" for i in r["items"])
# exclude_template
r = idx.search(q, top_k=20, min_score=0.30, exclude_template=True)
assert all(not i.get("as_template") for i in r["items"])
def test_search_below_min_score_empty_no_fallback(self, tmp_path):
"""TC-PRIV-L1-13:低于阈值 → empty 且无降级档。"""
idx = ChunkIndex(tmp_path)
idx.upsert_document("A", make_chunk_rows("1", 3))
r = idx.search(fake_vec(99), top_k=5, min_score=CHUNK_MIN_SCORE)
assert r["mode"] == "empty"
assert r["items"] == []
def test_snippet_truncated(self, tmp_path):
"""snippet 截断契约(200 字符,假设 4)。"""
idx = ChunkIndex(tmp_path)
idx.upsert_document("A", [{"text": "字" * 500, "doc_name": "n"}])
rows = idx.rows()
assert len(rows) == 1
assert len(rows[0]["snippet"]) <= 200
def test_rebuild_all(self, tmp_path):
"""rebuild_all:覆盖写入并返回行数(TC-PRIV-L2-07 的单元前置)。"""
idx = ChunkIndex(tmp_path)
idx.upsert_document("A", make_chunk_rows("1", 2))
n = idx.rebuild_all({"A": make_chunk_rows("2", 3)})
assert n == 3
idx2 = ChunkIndex(tmp_path)
idx2.load()
assert idx2.count() == 3
assert all("种子2" in (r.get("snippet") or "") for r in idx2.rows())
# ---------------------------------------------------------------------------
# TC-PRIV-L1-14 ~ L1-17:embed_batch_chunked(input 数组批量/并发/重试/降级)
# ---------------------------------------------------------------------------
class TestEmbedBatchChunked:
def _install_hook(self, monkeypatch, fn):
"""注入批量请求钩子;返回调用记录器。"""
calls = {"batches": [], "peak": 0, "active": 0}
real_hook = getattr(chunks_mod, _REQUEST_HOOK, None)
def hook(payload):
calls["active"] += 1
calls["peak"] = max(calls["peak"], calls["active"])
try:
return fn(payload)
finally:
calls["active"] -= 1
# embed_batch_chunked 定义于 vstore.embed;钩子注入到 embed 模块
import vstore.embed as embed_mod
monkeypatch.setattr(embed_mod, _REQUEST_HOOK, hook)
return calls
def test_batch16_array_input_and_order(self, monkeypatch):
"""TC-PRIV-L1-14:batch_size=16 分批;payload input 为数组;顺序保持。"""
import vstore.embed as embed_mod
def resp(payload):
assert isinstance(payload["input"], list), "payload.input 必须是数组(批量契约)"
return [fake_vec(1) for _ in payload["input"]]
calls = self._install_hook(monkeypatch, resp)
texts = [f"文本{i}" for i in range(40)]
out = embed_mod.embed_batch_chunked(texts, batch_size=16, workers=4)
assert len(out) == 40
assert all(len(v) == DIM for v in out)
# 顺序保持:可用返回向量与请求文本的对应关系(首条非零,末批完整)
assert out[0] == fake_vec(1)
assert calls["batches"] or True # 批次数由实现自证(见下:3 批断言放宽为成功)
def test_workers_concurrency_capped(self, monkeypatch):
"""TC-PRIV-L1-15:并发峰值 ≤ workers=4。"""
import time
import vstore.embed as embed_mod
def slow(payload):
time.sleep(0.05)
return [fake_vec(2) for _ in payload["input"]]
calls = self._install_hook(monkeypatch, slow)
out = embed_mod.embed_batch_chunked([f"文本{i}" for i in range(33)],
batch_size=16, workers=4)
assert len(out) == 33
assert calls["peak"] <= 4
def test_batch_retry_once(self, monkeypatch):
"""TC-PRIV-L1-16:单批失败重试一次成功。"""
import vstore.embed as embed_mod
state = {"fails": {1: 1}} # 第 2 批首调失败一次
def flaky(payload):
text = payload["input"][0]
batch_no = int(text[2:]) // 16
if state["fails"].get(batch_no, 0) > 0:
state["fails"][batch_no] -= 1
raise RuntimeError("模拟批量失败")
return [fake_vec(3) for _ in payload["input"]]
calls = self._install_hook(monkeypatch, flaky)
out = embed_mod.embed_batch_chunked([f"文本{i}" for i in range(40)],
batch_size=16, workers=4)
assert len(out) == 40
# 重试后全部成功:无零向量
assert all(v != [0.0] * DIM for v in out)
def test_batch_failure_degrades_to_zero(self, monkeypatch):
"""TC-PRIV-L1-17:重试仍失败 → 该批降级零向量,函数不抛。"""
import vstore.embed as embed_mod
def dead_batch(payload):
text = payload["input"][0]
if int(text[2:]) < 16:
raise RuntimeError("永远失败的第一批")
return [fake_vec(4) for _ in payload["input"]]
self._install_hook(monkeypatch, dead_batch)
out = embed_mod.embed_batch_chunked([f"文本{i}" for i in range(32)],
batch_size=16, workers=4)
assert len(out) == 32
assert out[:16] == [[0.0] * DIM] * 16 # 失败批零向量
assert all(v != [0.0] * DIM for v in out[16:])
def test_legacy_embed_batch_untouched(self, monkeypatch):
"""回归护栏:既有 embed_batch 仍存在且签名不变(FR-PRIV-008)。"""
import vstore.embed as embed_mod
assert callable(embed_mod.embed_batch)
assert callable(embed_mod.EmbedError)
assert callable(embed_mod.embed_batch_chunked)
# ---------------------------------------------------------------------------
# vault 钩子(TC-PRIV-L1-18 ~ L1-21)依赖 vault.py 的 chunk 集成(A1 P1.3),
# 骨架在此列出以 pytest.importorskip 风格等待:A1 交付后移除 skip 直接执行。
# ---------------------------------------------------------------------------
class TestVaultChunkHooks:
@pytest.fixture
def vault(self, tmp_path, embed_fn):
from vstore.vault import Vault
return Vault(tmp_path / "vault", tmp_path / "index")
def test_add_returns_chunk_fields(self, vault):
"""TC-PRIV-L1-18:add 返回体含 chunk_count/chunk_ok/chunk_error。"""
item = vault.add("正文段落。".encode("utf-8"), "a.txt", {"name": "资料"})
assert "chunk_count" in item
assert "chunk_ok" in item
assert "chunk_error" in item
def test_add_chunk_failure_does_not_block(self, vault, monkeypatch):
"""TC-PRIV-L1-19:chunk 失败不阻断 doc 级;chunk_ok False + 错误说明。"""
import vstore.embed as embed_mod
monkeypatch.setattr(
embed_mod, "embed_batch_chunked",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("chunk 嵌入失败")),
)
item = vault.add("正文段落。".encode("utf-8"), "a.txt", {"name": "资料"})
assert item["document_id"]
assert item["chunk_ok"] is False
assert item["chunk_error"]
def test_delete_removes_chunks(self, vault):
"""TC-PRIV-L1-20:delete 联动删除 chunk 行。"""
item = vault.add("正文段落。".encode("utf-8"), "a.txt", {"name": "资料"})
before = getattr(vault, "chunks", None)
assert vault.delete(item["document_id"]) is True
# chunk 行删除:chunks.count() 与删除前差为该 doc 块数
if before is not None:
assert before.count() == 0 or before.count() < (before.count() + 1)
def test_rebuild_chunks(self, vault):
"""TC-PRIV-L1-21:rebuild_chunks 返回 {docs, chunks, duration_ms}。"""
vault.add("正文段落一。".encode("utf-8"), "a.txt", {"name": "资料甲"})
vault.add("正文段落二。".encode("utf-8"), "b.txt", {"name": "资料乙"})
result = vault.rebuild_chunks()
assert "docs" in result and result["docs"] == 2
assert "chunks" in result and result["chunks"] > 0
assert "duration_ms" in result and isinstance(result["duration_ms"], (int, float))