Files
Cyrene_v2/internal/services/gateway/history_document_test.go
T
AskaEth 5b2b385524
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway): 文档多值化 + 抽取结论回写(design/24 S3.5)
- 缺口①:「一回合最多 5 个附件、落库只留 1 个」——document 列语义升级为 ≤5 个引用的**数组**(列名/字段名不变、无需迁移,旧单对象行可读回);落库收集全部引用、转录还原全部部件。

- 缺口②:抽取结论 status/chars/truncated 经 completeTurn 回合后**回写**该回合用户消息的同一列(id 按代次配对,忙时消息挂 pendingMsg.persisted 并在 drain 时以新 gen 重新配对);历史 DTO 改为 **documents: []**(omitempty ⇒ 0 个附件时省略)。

- 防御:编/解码超 5 都报 ErrTooManyDocuments(报错、绝不裁剪);坏列走既有不静默路径(转录 WARN + DTO unreadable 最小引用);>5 上限值被独立用具例钉死。

- 顺手修 S3-B 遗留的函数越界:submitInternal 81 → 79 行(播种块抽成 seedTurnLocked);session_orchestrator.go 484 行。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 12 文件)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿、gateway+history 437 PASS;变异独立复现(只留第一个附件 ⇒ 2 条红,含 HTTP 端到端 want 3 条)。

- 遗留(已登记):chat_documents_test.go 510 行(S3-B 遗留越界,本次未拆);中断/被取代的回合不回写结论;回写后不补发实时帧(status 需重新拉取/回放可见)。
2026-10-04 17:42:16 +08:00

154 lines
5.9 KiB
Go

package gateway
import (
"encoding/json"
"sort"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是历史 DTO 的 document 对象与持久化取值的测试(design/24 §2.4、§6.1):
// 引用与统计往返、**不回传抽取正文**、坏列可见,以及 session_persist 的
// messageTextImageDocument 取值。
// TestHistoryMessageDocumentsCarryReferenceOnly 验证 documents 数组的元素只带
// 引用与统计:字段集被逐个钉住,将来谁往 DocumentRef 里加一个正文/摘要字段,
// 这里就会红。
func TestHistoryMessageDocumentsCarryReferenceOnly(t *testing.T) {
t.Parallel()
raw, err := history.EncodeDocuments([]history.DocumentRef{{
Name: "0123456789abcdef0123456789abcdef.txt",
MIME: "text/plain",
Size: 12345,
Chars: 8000,
Truncated: true,
Status: history.DocumentStatusExtracted,
}})
if err != nil {
t.Fatalf("encode: %v", err)
}
wire := toHistoryMessage(history.Message{ID: "m1", SessionID: "web", Role: "user", Content: "总结这份报告", Document: raw})
b, err := json.Marshal(wire)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
docs, ok := decoded["documents"].([]any)
if !ok || len(docs) != 1 {
t.Fatalf("documents array missing in %s", b)
}
doc, ok := docs[0].(map[string]any)
if !ok {
t.Fatalf("documents[0] is not an object in %s", b)
}
keys := make([]string, 0, len(doc))
for k := range doc {
keys = append(keys, k)
}
sort.Strings(keys)
if got := strings.Join(keys, ","); got != "chars,mime,name,size,status,truncated" {
t.Fatalf("documents[0] keys = %q, want chars,mime,name,size,status,truncated", got)
}
if doc["name"] != "0123456789abcdef0123456789abcdef.txt" || doc["status"] != history.DocumentStatusExtracted {
t.Fatalf("documents[0] = %v, want the persisted reference", doc)
}
}
// TestHistoryMessageDocumentsAbsentWhenNoColumn 验证没有附件的消息省略该字段:
// 空列与「带附件」必须可区分(省略 = 没有附件)。
func TestHistoryMessageDocumentsAbsentWhenNoColumn(t *testing.T) {
t.Parallel()
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "没有附件"})
b, err := json.Marshal(wire)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(b), `"documents"`) {
t.Fatalf("empty column should omit documents, got %s", b)
}
}
// TestHistoryMessageDocumentsMultipleKeepOrder 验证一回合多个附件原样回传
// (缺口①):数组长度与顺序都一致,客户端因此不会只看到第一个附件。
func TestHistoryMessageDocumentsMultipleKeepOrder(t *testing.T) {
t.Parallel()
raw, err := history.EncodeDocuments([]history.DocumentRef{
{Name: "aaa.txt", MIME: "text/plain", Size: 1},
{Name: "bbb.pdf", MIME: "application/pdf", Size: 2, Status: history.DocumentStatusUnreadable},
{Name: "ccc.csv", MIME: "text/csv", Size: 3, Chars: 9, Status: history.DocumentStatusExtracted},
})
if err != nil {
t.Fatalf("encode: %v", err)
}
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "三个附件", Document: raw})
if len(wire.Documents) != 3 {
t.Fatalf("Documents = %+v, want 3 条", wire.Documents)
}
for i, want := range []string{"aaa.txt", "bbb.pdf", "ccc.csv"} {
if wire.Documents[i].Name != want {
t.Fatalf("Documents[%d].Name = %q, want %q(顺序必须与发送一致)", i, wire.Documents[i].Name, want)
}
}
}
// TestHistoryMessageDocumentsMalformedColumnIsVisible 验证列里是坏内容时
// 不静默当成「没有附件」:回一条标着 status=unreadable 的最小引用,让客户端
// 看得见这条引用读不出来(design/24 §4.4 的「失败必须可见」)。
func TestHistoryMessageDocumentsMalformedColumnIsVisible(t *testing.T) {
t.Parallel()
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "带附件", Document: "{not json"})
if len(wire.Documents) != 1 {
t.Fatalf("Documents = %+v, want 一条 unreadable 的最小引用", wire.Documents)
}
if wire.Documents[0].Status != history.DocumentStatusUnreadable {
t.Fatalf("status = %q, want %q", wire.Documents[0].Status, history.DocumentStatusUnreadable)
}
}
// TestMessageTextImageDocument 验证持久化取值:文本、首个图片 URL 与首个文档
// 引用各归各列,且文档列是引用(JSON 小对象)而不是正文。
func TestMessageTextImageDocument(t *testing.T) {
t.Parallel()
doc := model.Document{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", URL: "/api/v1/documents/aaaabbbbccccdddd.txt", Size: 1234}
m := model.DocumentMessage(model.RoleUser, "读一下", doc)
m.Content = append(m.Content, model.Content{Type: model.ContentTypeImage, ImageURL: "data:image/png;base64,abc"})
text, image, raw, err := messageTextImageDocument(m)
if err != nil {
t.Fatalf("messageTextImageDocument: %v", err)
}
if text != "读一下" {
t.Fatalf("text = %q, want 读一下", text)
}
if image != "data:image/png;base64,abc" {
t.Fatalf("image = %q", image)
}
refs, err := history.DecodeDocuments(raw)
if err != nil {
t.Fatalf("decode documents: %v", err)
}
if len(refs) != 1 || refs[0].Name != doc.Name || refs[0].MIME != doc.MIME || refs[0].Size != doc.Size {
t.Fatalf("refs = %+v, want name/mime/size of %+v", refs, doc)
}
}
// TestMessageTextImageDocumentWithoutDocument 验证没有文档部件时文档列为空
// (而不是 "[]"/"{}"):拼一个空形状会让读取侧把它当成一条读不出来的附件。
func TestMessageTextImageDocumentWithoutDocument(t *testing.T) {
t.Parallel()
_, _, raw, err := messageTextImageDocument(model.TextMessage(model.RoleUser, "只有文本"))
if err != nil {
t.Fatalf("messageTextImageDocument: %v", err)
}
if raw != "" {
t.Fatalf("document = %q, want empty", raw)
}
}