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 需重新拉取/回放可见)。
This commit is contained in:
1 parent
20c1edb36a
commit
5b2b385524
12 files changed
+1255
-166
No files matched your search
@@ -14,19 +14,19 @@ import (
|
||||
// 引用与统计往返、**不回传抽取正文**、坏列可见,以及 session_persist 的
|
||||
// messageTextImageDocument 取值。
|
||||
|
||||
// TestHistoryMessageDocumentCarriesReferenceOnly 验证 document 对象只带
|
||||
// TestHistoryMessageDocumentsCarryReferenceOnly 验证 documents 数组的元素只带
|
||||
// 引用与统计:字段集被逐个钉住,将来谁往 DocumentRef 里加一个正文/摘要字段,
|
||||
// 这里就会红。
|
||||
func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) {
|
||||
func TestHistoryMessageDocumentsCarryReferenceOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := history.EncodeDocument(history.DocumentRef{
|
||||
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)
|
||||
}
|
||||
@@ -40,9 +40,13 @@ func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) {
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
doc, ok := decoded["document"].(map[string]any)
|
||||
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("document object missing in %s", b)
|
||||
t.Fatalf("documents[0] is not an object in %s", b)
|
||||
}
|
||||
keys := make([]string, 0, len(doc))
|
||||
for k := range doc {
|
||||
@@ -50,38 +54,61 @@ func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) {
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if got := strings.Join(keys, ","); got != "chars,mime,name,size,status,truncated" {
|
||||
t.Fatalf("document keys = %q, want chars,mime,name,size,status,truncated", got)
|
||||
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("document = %v, want the persisted reference", doc)
|
||||
t.Fatalf("documents[0] = %v, want the persisted reference", doc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryMessageDocumentAbsentWhenNoColumn 验证没有附件的消息省略该字段:
|
||||
// TestHistoryMessageDocumentsAbsentWhenNoColumn 验证没有附件的消息省略该字段:
|
||||
// 空列与「带附件」必须可区分(省略 = 没有附件)。
|
||||
func TestHistoryMessageDocumentAbsentWhenNoColumn(t *testing.T) {
|
||||
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), `"document"`) {
|
||||
t.Fatalf("empty column should omit document, got %s", b)
|
||||
if strings.Contains(string(b), `"documents"`) {
|
||||
t.Fatalf("empty column should omit documents, got %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryMessageDocumentMalformedColumnIsVisible 验证列里是坏 JSON 时
|
||||
// 不静默当成「没有附件」:回一个标着 status=unreadable 的最小对象,让客户端
|
||||
// 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 TestHistoryMessageDocumentMalformedColumnIsVisible(t *testing.T) {
|
||||
func TestHistoryMessageDocumentsMalformedColumnIsVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "带附件", Document: "{not json"})
|
||||
if wire.Document == nil {
|
||||
t.Fatal("malformed document column was silently dropped")
|
||||
if len(wire.Documents) != 1 {
|
||||
t.Fatalf("Documents = %+v, want 一条 unreadable 的最小引用", wire.Documents)
|
||||
}
|
||||
if wire.Document.Status != history.DocumentStatusUnreadable {
|
||||
t.Fatalf("status = %q, want %q", wire.Document.Status, history.DocumentStatusUnreadable)
|
||||
if wire.Documents[0].Status != history.DocumentStatusUnreadable {
|
||||
t.Fatalf("status = %q, want %q", wire.Documents[0].Status, history.DocumentStatusUnreadable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,17 +130,17 @@ func TestMessageTextImageDocument(t *testing.T) {
|
||||
if image != "data:image/png;base64,abc" {
|
||||
t.Fatalf("image = %q", image)
|
||||
}
|
||||
ref, err := history.DecodeDocument(raw)
|
||||
refs, err := history.DecodeDocuments(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decode document: %v", err)
|
||||
t.Fatalf("decode documents: %v", err)
|
||||
}
|
||||
if ref == nil || ref.Name != doc.Name || ref.MIME != doc.MIME || ref.Size != doc.Size {
|
||||
t.Fatalf("ref = %+v, want name/mime/size of %+v", ref, doc)
|
||||
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, "只有文本"))
|
||||
|
||||
Reference in new issue
Block a user