Files
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

109 lines
4.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package documents
import (
"context"
"encoding/json"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是契约 v0.63 的 `documents[].reason` 在**摄取层侧**的端到端回归(客户端
// 登记:`status:"unreadable"` 只有状态没有原因,客户端只能显示通用句,不愿自己
// 编服务端的理由)。它与 sidecar_test.go 的 `TestResolver_EndToEndWithLoop` 同族:
// 真实文件 → 真实 sidecar → 真实 loop 装配期,只是这里断言的是**回给客户端的原因**
// 与**注入模型的原因**是同一个字符串,以及「读不到才给、能读到就没有」这条口径。
// TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim 钉住契约 v0.63 的 ①:
// **扫描件 PDF(无文本层)** 经真实链路(真文件 → 真 sidecar → 真 loop 装配期)
// 后,`Result.Documents` 上带回**逐字**等于注入模型那份的原因文案(含「OCR」),
// 而**文本层 PDF / 纯文本**(extracted)一个 reason 都没有(该键从 DTO 里省略)。
//
// 「逐字一致」是本用例的核心断言:客户端展示的理由与模型看到的理由必须是同一个
// 字符串,服务端不为客户端另编一句。
func TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim(t *testing.T) {
t.Parallel()
layout := newTestLayout(t)
body := "季度报告:营收增长 12%。\n"
textName := layout.writeUpload(t, txtName, []byte(body))
scanName := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{imageContent}}))
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
ad := &recordingAdapter{}
ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(ad, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{Agent: ag, DocumentResolver: r, MaxRounds: 1})
msg := model.Message{Role: model.RoleUser, Content: []model.Content{
{Type: model.ContentTypeText, Text: "这两份都看看"},
{Type: model.ContentTypeDocument, DocumentName: textName, DocumentMIME: "text/plain", DocumentSize: int64(len(body))},
{Type: model.ContentTypeDocument, DocumentName: scanName, DocumentMIME: PDFMIME, DocumentSize: 2048},
}}
res, err := l.Run(context.Background(), "sess-doc-reason", []model.Message{msg})
if err != nil {
t.Fatalf("Run: %v", err)
}
byName := map[string]history.DocumentRef{}
for _, ref := range res.Documents {
byName[ref.Name] = ref
}
if got := byName[textName]; got.Status != history.DocumentStatusExtracted || got.Reason != "" {
t.Fatalf("纯文本结论 = %+v, want extracted 且无 reason", got)
}
scan := byName[scanName]
if scan.Status != history.DocumentStatusUnreadable {
t.Fatalf("扫描件结论 = %+v, want unreadable(这份 PDF 没有文本层)", scan)
}
if scan.Reason != reasonPDFNoTextLayer {
t.Fatalf("扫描件 reason = %q, want %q(必须与注入模型的原因逐字一致)", scan.Reason, reasonPDFNoTextLayer)
}
if !strings.Contains(scan.Reason, "OCR") {
t.Fatalf("扫描件 reason = %q, want 含「OCR」", scan.Reason)
}
// 模型可见的那一份:同一句话(清单块之后的诚实原因)。
if len(ad.reqs) == 0 {
t.Fatal("没有记录到模型请求")
}
modelSees := false
for _, m := range ad.reqs[0].Messages {
for _, part := range m.Content {
if part.Type == model.ContentTypeText && strings.Contains(part.Text, reasonPDFNoTextLayer) {
modelSees = true
}
}
}
if !modelSees {
t.Fatal("注入模型的原因文案与回给客户端的 reason 不是同一句")
}
// 反证:原因里没有路径 —— 盘上路径只可能是 `<uploads>/<存储名>`,原始错误若被
// 透出必然带出它;`uploads` 是真实目录段名,存储名本身则是另一路(DTO 的 name)。
for _, leak := range []string{layout.uploads, layout.dataDir, "uploads", scanName} {
if strings.Contains(scan.Reason, leak) {
t.Fatalf("reason = %q 含路径/引用片段 %q,原因文案里不得有路径", scan.Reason, leak)
}
}
// DTO 形状:unreadable 带 reason 键、extracted 不带。
raw, err := json.Marshal(scan)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if !strings.Contains(string(raw), `"reason":`) {
t.Fatalf("扫描件 DTO = %s, want 带 reason 键", raw)
}
textRaw, err := json.Marshal(byName[textName])
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(textRaw), `"reason"`) {
t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", textRaw)
}
}