Files
Cyrene_v2/internal/runtime/loop/document_reason_test.go
T
AskaEth 902b6eecfd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。

- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。

- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。

- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。

- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。

- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
2026-10-04 21:50:08 +08:00

99 lines
4.1 KiB
Go

package loop
import (
"context"
"encoding/json"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住契约 v0.63 的 `documents[].reason`(客户端登记:`status:"unreadable"`
// 只有状态没有原因,客户端只能显示通用句、不愿自己编服务端的理由):
//
// - **只在读不到时**下发(`extracted` / `listed` 时该键从 DTO 里消失);
// - 文案**逐字**等于注入给模型的那份原因 —— 两处同源于一个字符串,服务端因此
// 不为客户端另编一句,两个出口也不可能分叉;
// - 解析器整条消息失败时也有诚实的兜底理由(绝不「读不到但没有理由」)。
//
// 真实的「扫描件 PDF ⇒ 含 OCR 的原因」链路在 `internal/runtime/documents` 的
// `TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim` 里跑(那里有 PDF 夹具)。
// docC 是第三个合法的 S1 存储名形态(32 位十六进制 + 扩展名),供本文件使用。
const docC = "00112233445566778899aabbccddeeff.csv"
// TestResolveDocuments_ReasonOnlyWhenUnreadable 钉住 reason 的三个出口:能读到与
// 只给清单时为空、读不到时逐字等于注入模型的那份原因。
func TestResolveDocuments_ReasonOnlyWhenUnreadable(t *testing.T) {
t.Parallel()
res := &fakeDocumentResolver{
refs: map[string]history.DocumentRef{
docA: {Status: history.DocumentStatusExtracted},
docB: {Status: history.DocumentStatusUnreadable},
docC: {Status: history.DocumentStatusListed},
},
bodies: map[string]string{
docA: "抽到的正文",
docB: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)",
docC: "不该被注入的正文",
},
}
l := New(Options{DocumentResolver: res})
in := []model.Message{
documentMessage(docA, "text/plain", 10),
documentMessage(docB, "application/pdf", 20),
documentMessage(docC, "text/plain", 30),
}
out, refs := l.resolveDocuments(context.Background(), in)
byName := map[string]history.DocumentRef{}
for _, ref := range refs {
byName[ref.Name] = ref
}
if got := byName[docA].Reason; got != "" {
t.Fatalf("extracted 的 reason = %q, want 空(该键必须缺席)", got)
}
if got := byName[docC].Reason; got != "" {
t.Fatalf("listed 的 reason = %q, want 空", got)
}
want := res.bodies[docB]
if got := byName[docB].Reason; got != want {
t.Fatalf("unreadable 的 reason = %q, want %q(必须与注入模型的原因逐字一致)", got, want)
}
// 同一份文案确实进了模型可见上下文 —— 两处同源因此不可能分叉。
if !strings.Contains(messageTextOf(out[1]), want) {
t.Fatalf("注入块 = %q, want 含同一份原因文案", messageTextOf(out[1]))
}
// DTO 形状:unreadable 带 reason 键,extracted 不带。
raw, err := json.Marshal(byName[docB])
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"reason":`) {
t.Fatalf("unreadable 的 DTO = %s, want 带 reason 键", raw)
}
extractedRaw, err := json.Marshal(byName[docA])
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(extractedRaw), `"reason"`) {
t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", extractedRaw)
}
}
// TestResolveDocuments_ResolverErrorCarriesHonestReason 钉住兜底路径也带原因:解析器
// 整条消息失败时,客户端拿到的是同一句诚实兜底(`documentUnreadableBody`)——
// 「读不到但没有理由」正是本字段要消灭的状态。
func TestResolveDocuments_ResolverErrorCarriesHonestReason(t *testing.T) {
t.Parallel()
l := New(Options{DocumentResolver: &fakeDocumentResolver{err: errDocResolve}})
_, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 10)})
if len(refs) != 1 {
t.Fatalf("结论 = %+v, want 一条", refs)
}
if refs[0].Reason != documentUnreadableBody {
t.Fatalf("reason = %q, want %q(诚实兜底,绝不空着)", refs[0].Reason, documentUnreadableBody)
}
}