feat(gateway): 文档多值化 + 抽取结论回写(design/24 S3.5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 缺口①:「一回合最多 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:
AskaEth committed 2026-10-04 17:42:16 +08:00
1 parent 20c1edb36a
commit 5b2b385524
12 files changed
+1255 -166

No files matched your search

+24 -18
View File
@@ -61,14 +61,19 @@ type historyMessage struct {
Mode string `json:"mode,omitempty"`
Mood string `json:"mood,omitempty"`
Image string `json:"image,omitempty"`
// Document 是这条消息携带的文档引用与抽取统计(design/24 §2.4、§6.1),
// 形状为 {name,mime,size,chars,truncated,status}。它**只带引用与统计,
// 绝不带抽取正文** —— 正文若从这里回传,每翻一页历史都要背着它(G3 的
// 教训)。没有附件的消息省略该字段。
Document *history.DocumentRef `json:"document,omitempty"`
Thinking string `json:"thinking,omitempty"`
Platform string `json:"platform,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
// Documents 是这条消息携带的文档引用与抽取统计(design/24 §2.4、§6.1),
// 形状为 [{name,mime,size,chars,truncated,status}, ...]:一个回合最多 5 条,
// 顺序即客户端发送顺序。它们**只带引用与统计,绝不带抽取正文** —— 正文若
// 从这里回传,每翻一页历史都要背着它(G3 的教训)。没有附件(或附件列表为
// 空)的消息**省略该字段**,与引入文档之前逐字相同。
//
// ⚠️ 契约变更(S3.5):此前这里是单对象 `document`。单数形状从未进
// Docs/api-client.md(v0.59 待发、客户端未接),因此不留兼容期,直接换成
// 数组 —— 客户端按 `documents`(可为空/缺失)读取。
Documents []history.DocumentRef `json:"documents,omitempty"`
Thinking string `json:"thinking,omitempty"`
Platform string `json:"platform,omitempty"`
DeviceID string `json:"deviceId,omitempty"`
// SenderName 是第三方消息(群聊/陌生人,design/16 §4.6)
// 给人看的发送者显示名。对主会话中主人的私聊它为
// 空,因此空意味着「标记为主人」。
@@ -407,7 +412,7 @@ func toHistoryMessage(m history.Message) historyMessage {
Mode: m.Mode,
Mood: m.Mood,
Image: m.Image,
Document: decodeHistoryDocument(m.Document),
Documents: decodeHistoryDocuments(m.Document),
Thinking: m.Thinking,
Platform: m.Platform,
DeviceID: m.DeviceID,
@@ -425,15 +430,16 @@ func toHistoryMessage(m history.Message) historyMessage {
// historyDefaultLimit 是客户端省略 limit 查询参数时使用的页大小。
const historyDefaultLimit = 50
// decodeHistoryDocument 解码已存消息的 document 列(JSON 小对象)。空列表示
// 这条消息没有附件,返回 nil 以省略该字段。列被写坏时(本服务端只会写合法
// JSON,因此那只能是外部改动或未来格式变更)**绝不静默当成没有附件** ——
// 「一方以为附件传上去了,另一方一无所知」正是 F2 要消灭的误解;这时回一个
// 标着 status=unreadable 的最小对象,让客户端看得见这条引用读不出来。
func decodeHistoryDocument(raw string) *history.DocumentRef {
ref, err := history.DecodeDocument(raw)
// decodeHistoryDocuments 解码已存消息的 document 列(引用数组,兼容 S2/S3 早期
// 的单对象旧形状)。空列表示这条消息没有附件,返回 nil 以省略该字段。列被写坏时
// (本服务端只会写合法 JSON,因此那只能是外部改动或未来格式变更)**绝不静默
// 当成没有附件** —— 「一方以为附件传上去了,另一方一无所知」正是 F2 要消灭的
// 误解;这时回一个标着 status=unreadable 的最小引用,让客户端看得见这条引用
// 读不出来。
func decodeHistoryDocuments(raw string) []history.DocumentRef {
refs, err := history.DecodeDocuments(raw)
if err != nil {
return &history.DocumentRef{Status: history.DocumentStatusUnreadable}
return []history.DocumentRef{{Status: history.DocumentStatusUnreadable}}
}
return ref
return refs
}