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

+101 -16
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
)
@@ -15,6 +16,10 @@ import (
// 字符数、是否截断、抽取状态;**绝不存抽取正文**。G3(base64 原样落库 → 每页
// 历史背着重传)与 H1(每回合重算)两条教训说的都是同一件事:正文一旦进了
// 历史,每读一页都要为它付一次代价,而且它是提示词注入面(design/11 §10)。
//
// 列的内容是**引用数组**(≤maxDocumentsPerMessage 条),因为一个回合可以挂
// 多个附件(design/24 §6.1 的定稿上限 5);S2/S3 早期写下的**单对象**行仍能
// 读回(见 DecodeDocuments)。列名与 Message.Document 字段名保持不变。
// 文档在持久化与历史 DTO 里可能处于的抽取状态(design/24 §6.1),由装配期
// 与抽取器填入。
@@ -27,8 +32,27 @@ const (
DocumentStatusUnreadable = "unreadable"
)
// maxDocumentsPerMessage 是一条消息(一个回合)携带的文档引用数上限
// (design/24 §6.1/§9.4-3 的定稿值 5)。它与网关的 maxChatDocuments **同值
// 同义**:这里刻意不复用那个常量 —— 网关依赖本包,反向依赖会成环。两个包
// 各自具名并互相交叉引用,任一侧改动都必须同时改另一侧(网关侧另有
// TestMaxChatDocumentsMatchesDesign 把值钉在设计定稿上)。
const maxDocumentsPerMessage = 5
// 区分 document 列读写失败的哨兵错误。调用方用 errors.Is 判类,并按「不静默」
// 处理它:打一条带来源的 WARN 并省掉该条引用,**绝不**把它当成「这条消息没有
// 附件」—— 一方以为附件传上去了、另一方一无所知,正是 F2 要消灭的误解。
var (
// ErrBadDocuments 报告 document 列的内容不是合法的引用数据(乱码、非法
// JSON、无名引用等)。
ErrBadDocuments = errors.New("history: malformed document column")
// ErrTooManyDocuments 报告一次读写里的引用数超过 maxDocumentsPerMessage。
ErrTooManyDocuments = errors.New("history: too many document references")
)
// DocumentRef 是一条消息携带的文档引用与抽取统计。它就是 chat_messages.document
// 列里的 JSON 小对象,**同时**也是历史 DTO 里 document 对象的形状:两边共用
// 列里的 JSON 小对象,**同时**也是历史 DTO 里 documents 数组的元素形状:两边共用
// 同一个结构体,所以「不回传正文」是结构上保证的,而不是靠约定或代码审查。
type DocumentRef struct {
// Name 是服务端存储名(design/24 §3.2:32 位十六进制 + 白名单扩展名)。
@@ -46,31 +70,92 @@ type DocumentRef struct {
Status string `json:"status,omitempty"`
}
// EncodeDocument 把文档引用编码成落库用的 JSON 小对象。空引用返回 "",使
// 「这条消息没有附件」与「有附件」在库里、在 DTO 里都有确定区别。
func EncodeDocument(ref DocumentRef) (string, error) {
if ref == (DocumentRef{}) {
// EncodeDocuments 把一条消息携带的文档引用编码成落库用的 JSON 数组。空列表返回
// "",使「这条消息没有附件」与「有附件」在库里、在 DTO 里都有确定区别。
//
// 超限与无名引用都**报错**而不是就地裁剪:悄悄砍掉几条、或把一条没有名字的引用
// (它的 URL 只能从名字推出)塞进数组,都会让「客户端传上去的附件」与「库里记着
// 的附件」对不上 —— 那正是本列要消灭的静默。
func EncodeDocuments(refs []DocumentRef) (string, error) {
if len(refs) == 0 {
return "", nil
}
b, err := json.Marshal(ref)
if len(refs) > maxDocumentsPerMessage {
return "", fmt.Errorf("history: encode %d documents: %w", len(refs), ErrTooManyDocuments)
}
for _, ref := range refs {
if ref.Name == "" {
return "", fmt.Errorf("history: encode document ref without name: %w", ErrBadDocuments)
}
}
b, err := json.Marshal(refs)
if err != nil {
return "", fmt.Errorf("history: encode document ref: %w", err)
return "", fmt.Errorf("history: encode document refs: %w", err)
}
return string(b), nil
}
// DecodeDocument 解码落库的文档引用。空串(这条消息没有附件)返回 (nil, nil);
// 非法 JSON 返回错误,由调用方决定如何**可见地**报告 —— 解析失败绝不静默当成
// 「没有附件」,那正是 F2 要消灭的那种「一方以为传上去了,另一方一无所知」。
func DecodeDocument(raw string) (*DocumentRef, error) {
if raw == "" {
// DecodeDocuments 解码落库的文档引用。空串(这条消息没有附件)返回 (nil, nil);
// 坏内容返回包装了 ErrBadDocuments / ErrTooManyDocuments 的错误,由调用方决定
// 如何**可见地**报告 —— 解析失败绝不静默当成「没有附件」,那正是 F2 要消灭的
// 那种「一方以为传上去了,另一方一无所知」。
//
// 它兼容 S2/S3 早期写下的**单对象形状**:内容的首字符是 '{' 就按「单元素数组」
// 处理。那些行里带着一条真实引用,绝不能因为形状老就被当成坏数据丢掉。
func DecodeDocuments(raw string) ([]DocumentRef, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
var ref DocumentRef
if err := json.Unmarshal([]byte(raw), &ref); err != nil {
return nil, fmt.Errorf("history: decode document ref: %w", err)
refs, err := decodeDocumentData(trimmed)
if err != nil {
return nil, err
}
return &ref, nil
if len(refs) > maxDocumentsPerMessage {
return nil, fmt.Errorf("history: decode %d documents: %w", len(refs), ErrTooManyDocuments)
}
return refs, nil
}
// decodeDocumentData 按内容形状解码:'{' 开头是旧单对象形状(当成单元素数组),
// 其余按数组解析。空数组与字面量 null 都解出空结果,等价于「这条消息没有附件」。
func decodeDocumentData(raw string) ([]DocumentRef, error) {
if raw[0] == '{' {
var ref DocumentRef
if err := json.Unmarshal([]byte(raw), &ref); err != nil {
return nil, fmt.Errorf("history: decode document ref: %w", ErrBadDocuments)
}
return []DocumentRef{ref}, nil
}
var refs []DocumentRef
if err := json.Unmarshal([]byte(raw), &refs); err != nil {
return nil, fmt.Errorf("history: decode document refs: %w", ErrBadDocuments)
}
return refs, nil
}
// UpdateMessageDocument 只替换 id 所指消息的 document 列(引用数组),其余任何
// 列都保持已存值。它是「回合结束后补抽取结论」的写入端(design/24 §6.1)。
//
// 为什么需要它:用户消息在回合**开始前**就已落库,那时装配期的抽取还没发生,
// 因此列里只有引用、没有 status/chars/truncated。结论只能在回合结束后补进同一
// 列 —— 两段式是刻意的,也是客户端能看到「昔涟读不到这个文件」的唯一路径。
//
// 未知 id 返回 ErrMessageNotFound(与 UpdateMessage 同一口径),使调用方能区分
// 「那一行不见了」与「写失败」,并据此打带来源的 WARN,而不是静默当作写完了。
func (s *Store) UpdateMessageDocument(ctx context.Context, id, document string) error {
res, err := s.db.ExecContext(ctx, `UPDATE chat_messages SET document = ? WHERE id = ?`, document, id)
if err != nil {
return fmt.Errorf("history: update message document %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("history: update message document %s rows: %w", id, err)
}
if n == 0 {
return fmt.Errorf("history: update message document %s: %w", id, ErrMessageNotFound)
}
return nil
}
// ensureMessageColumns 把 chat_messages 上「建表之后才引入的列」前滚到既有表。
+156 -32
View File
@@ -2,32 +2,37 @@ package history
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件是文档引用列(chat_messages.document,design/24 §2.4)的存储测试:
// 引用与统计往返一致、库里**任何一列都不含抽取正文**、坏 JSON 不静默当成
// 「没有附件」,以及早于 0015 建出的旧表能被幂等前滚。
// 本文件是文档引用列(chat_messages.document,design/24 §2.4/§6.1)的存储测试:
// 引用与统计往返一致、旧单对象形状兼容、库里**任何一列都不含抽取正文**、坏内容
// 不静默当成「没有附件」、上限报错、按 id 回写抽取结论,以及早于 0015 建出的
// 旧表能被幂等前滚。
// TestDocumentRefRoundTrip 验证「引用 + 统计」落库再读出后逐字段一致。该列是
// 不透明 JSON:存储不解释、不裁剪它。
// TestDocumentRefRoundTrip 验证「引用 + 统计」落库再读出后逐字段一致、顺序不变。
// 该列是不透明 JSON:存储不解释、不裁剪它。
func TestDocumentRefRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
ref := DocumentRef{
Name: "0123456789abcdef0123456789abcdef.txt",
MIME: "text/plain",
Size: 12345,
Chars: 8000,
Truncated: true,
Status: DocumentStatusExtracted,
refs := []DocumentRef{
{
Name: "0123456789abcdef0123456789abcdef.txt",
MIME: "text/plain",
Size: 12345,
Chars: 8000,
Truncated: true,
Status: DocumentStatusExtracted,
},
{Name: "fedcba9876543210fedcba9876543210.md", MIME: "text/markdown", Size: 42, Status: DocumentStatusListed},
}
raw, err := EncodeDocument(ref)
raw, err := EncodeDocuments(refs)
if err != nil {
t.Fatalf("encode: %v", err)
}
@@ -42,44 +47,104 @@ func TestDocumentRefRoundTrip(t *testing.T) {
if len(msgs) != 1 {
t.Fatalf("len = %d, want 1", len(msgs))
}
got, err := DecodeDocument(msgs[0].Document)
got, err := DecodeDocuments(msgs[0].Document)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got == nil {
t.Fatal("document ref lost in round trip")
if len(got) != len(refs) {
t.Fatalf("refs = %+v, want %d 条(顺序与写入一致)", got, len(refs))
}
if *got != ref {
t.Fatalf("ref = %+v, want %+v", *got, ref)
for i := range refs {
if got[i] != refs[i] {
t.Fatalf("refs[%d] = %+v, want %+v", i, got[i], refs[i])
}
}
}
// TestDecodeDocumentsAcceptsLegacySingleObject 钉住旧行兼容:S2/S3 早期把
// document 列写成**单个**对象,那些行里带着一条真实引用,绝不能因为形状老就被
// 当成坏数据丢掉(这正是缺口①的另一半)。
func TestDecodeDocumentsAcceptsLegacySingleObject(t *testing.T) {
t.Parallel()
const legacy = `{"name":"aaaabbbbccccdddd.txt","mime":"text/plain","size":1234,"status":"extracted"}`
refs, err := DecodeDocuments(legacy)
if err != nil {
t.Fatalf("decode legacy: %v", err)
}
if len(refs) != 1 {
t.Fatalf("legacy refs = %+v, want 长度 1 的数组", refs)
}
if refs[0].Name != "aaaabbbbccccdddd.txt" || refs[0].Status != DocumentStatusExtracted || refs[0].Size != 1234 {
t.Fatalf("legacy refs[0] = %+v, want 旧行里的那条引用", refs[0])
}
}
// TestDocumentRefEmptyMeansNoAttachment 验证「没有附件」与「有附件」在库里、
// 在 DTO 里都有确定区别:空引用编码成空串,空串解码回 nil。
// 在 DTO 里都有确定区别:空列表编码成空串,空串解码回 nil。
func TestDocumentRefEmptyMeansNoAttachment(t *testing.T) {
t.Parallel()
raw, err := EncodeDocument(DocumentRef{})
raw, err := EncodeDocuments(nil)
if err != nil {
t.Fatalf("encode zero ref: %v", err)
t.Fatalf("encode zero refs: %v", err)
}
if raw != "" {
t.Fatalf("raw = %q, want empty column", raw)
}
ref, err := DecodeDocument("")
refs, err := DecodeDocuments("")
if err != nil {
t.Fatalf("decode empty: %v", err)
}
if ref != nil {
t.Fatalf("ref = %+v, want nil", ref)
if refs != nil {
t.Fatalf("refs = %+v, want nil", refs)
}
}
// TestDecodeDocumentRejectsMalformed 验证坏 JSON 返回错误而不是 (nil, nil):
// 解析失败绝不能静默变成「这条消息没有附件」,那正是 F2 要消灭的误解。
func TestDecodeDocumentRejectsMalformed(t *testing.T) {
// TestDecodeDocumentsRejectsMalformed 验证坏内容返回包装了 ErrBadDocuments 的错误
// 而不是 (nil, nil):解析失败绝不能静默变成「这条消息没有附件」,那正是 F2 要
// 消灭的误解(调用方按 errors.Is 判类后打带来源的 WARN)。
func TestDecodeDocumentsRejectsMalformed(t *testing.T) {
t.Parallel()
if _, err := DecodeDocument("{not json"); err == nil {
t.Fatal("DecodeDocument(malformed) = nil, want an error")
for _, raw := range []string{"{not json", "not json at all", `{"name":42}`} {
if _, err := DecodeDocuments(raw); !errors.Is(err, ErrBadDocuments) {
t.Fatalf("DecodeDocuments(%q) = %v, want ErrBadDocuments", raw, err)
}
}
}
// TestEncodeDocumentsRejectsUnnamedRef 验证无名引用报错而不是写进库:名字是 URL
// 与展示身份的唯一来源,存一条没有名字的引用等于存一条读不出来的附件。
func TestEncodeDocumentsRejectsUnnamedRef(t *testing.T) {
t.Parallel()
if _, err := EncodeDocuments([]DocumentRef{{MIME: "text/plain", Size: 12}}); !errors.Is(err, ErrBadDocuments) {
t.Fatalf("EncodeDocuments(unnamed) = %v, want ErrBadDocuments", err)
}
}
// TestDocumentCountCapIsFive 把上限的**值**单独钉在设计定稿上
// (design/24 §9.4-3、§6.1)。为什么必须单独钉一次:下面的超限用例只证明「逻辑
// 自洽」,上限被悄悄改宽时它们照样绿(S3-B 的测试质量教训)。
func TestDocumentCountCapIsFive(t *testing.T) {
t.Parallel()
if maxDocumentsPerMessage != 5 {
t.Fatalf("单条消息文档引用上限 = %d, want 5(design/24 §6.1 的定稿值)", maxDocumentsPerMessage)
}
}
// TestDocumentCountCapRejectsSix 覆盖防御性上限:超过 5 条引用时读写两端都**报错**
// 而不是悄悄裁掉几条 —— 悄悄裁剪就是静默丢失,而 6 条只可能来自坏数据或外部改动。
// 六条是字面量而不是由常量算出,常量本身另由 TestDocumentCountCapIsFive 钉住。
func TestDocumentCountCapRejectsSix(t *testing.T) {
t.Parallel()
six := []DocumentRef{
{Name: "a.txt"}, {Name: "b.txt"}, {Name: "c.txt"},
{Name: "d.txt"}, {Name: "e.txt"}, {Name: "f.txt"},
}
if _, err := EncodeDocuments(six); !errors.Is(err, ErrTooManyDocuments) {
t.Fatalf("EncodeDocuments(6) = %v, want ErrTooManyDocuments", err)
}
const sixJSON = `[{"name":"a.txt"},{"name":"b.txt"},{"name":"c.txt"},{"name":"d.txt"},{"name":"e.txt"},{"name":"f.txt"}]`
if _, err := DecodeDocuments(sixJSON); !errors.Is(err, ErrTooManyDocuments) {
t.Fatalf("DecodeDocuments(6) = %v, want ErrTooManyDocuments", err)
}
}
@@ -93,13 +158,13 @@ func TestPersistedDocumentHoldsNoBody(t *testing.T) {
ctx := context.Background()
const body = "抽取正文不可落库·SECRET-BODY-0123456789"
raw, err := EncodeDocument(DocumentRef{
raw, err := EncodeDocuments([]DocumentRef{{
Name: "aaaabbbbccccdddd.txt",
MIME: "text/plain",
Size: int64(len(body)),
Chars: len(body),
Status: DocumentStatusExtracted,
})
}})
if err != nil {
t.Fatalf("encode: %v", err)
}
@@ -125,6 +190,65 @@ func TestPersistedDocumentHoldsNoBody(t *testing.T) {
}
}
// TestUpdateMessageDocumentPersistsConclusion 覆盖状态回写的写入端
// (design/24 §6.1):回合结束后按消息 id 把抽取结论补进 document 列,且**只**
// 改这一列。
func TestUpdateMessageDocumentPersistsConclusion(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
before, err := EncodeDocuments([]DocumentRef{{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Size: 1234}})
if err != nil {
t.Fatalf("encode: %v", err)
}
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "读一下", Document: before})
if err != nil {
t.Fatalf("append: %v", err)
}
after, err := EncodeDocuments([]DocumentRef{{
Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Size: 1234,
Chars: 900, Truncated: true, Status: DocumentStatusExtracted,
}})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.UpdateMessageDocument(ctx, full.ID, after); err != nil {
t.Fatalf("UpdateMessageDocument: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("len = %d, want 1", len(msgs))
}
refs, err := DecodeDocuments(msgs[0].Document)
if err != nil {
t.Fatalf("decode: %v", err)
}
if len(refs) != 1 || refs[0].Status != DocumentStatusExtracted || refs[0].Chars != 900 || !refs[0].Truncated {
t.Fatalf("refs = %+v, want extracted/chars=900/truncated", refs)
}
// 其余列保持已存值:回写只碰 document 一列。
if msgs[0].Content != "读一下" || msgs[0].Role != RoleUser || msgs[0].ID != full.ID || msgs[0].CreatedAt != full.CreatedAt {
t.Fatalf("msg = %+v, want 除 document 外与落库时一致", msgs[0])
}
}
// TestUpdateMessageDocumentUnknownID 验证未知 id 返回 ErrMessageNotFound(与
// UpdateMessage 同一口径),使调用方能区分「那一行不见了」与「写失败」。
func TestUpdateMessageDocumentUnknownID(t *testing.T) {
t.Parallel()
st := newStore(t)
err := st.UpdateMessageDocument(context.Background(), "no-such-message", "[]")
if !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("UpdateMessageDocument(unknown) = %v, want ErrMessageNotFound", err)
}
}
// TestNewStoreRollsForwardDocumentColumn 验证早于 0015 建出的 chat_messages 表
// 会被 NewStore 幂等前滚出 document 列(与 client_msg_id 同一手法):全新库由
// 建表 DDL 一次建全,既有库靠补列,两条路都必须能写能读带引用的消息。
@@ -163,7 +287,7 @@ CREATE TABLE chat_messages (
if err != nil {
t.Fatalf("new store: %v", err)
}
raw, err := EncodeDocument(DocumentRef{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Status: DocumentStatusListed})
raw, err := EncodeDocuments([]DocumentRef{{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Status: DocumentStatusListed}})
if err != nil {
t.Fatalf("encode: %v", err)
}
+10 -5
View File
@@ -16,11 +16,16 @@ type Message struct {
Mode string
Mood string
Image string
// Document 是这条消息携带的文档引用与抽取统计(design/24 §2.4),即
// DocumentRef 序列化出的小对象 {name,mime,size,chars,truncated,status}。
// 它刻意只装引用与统计 —— **绝不装抽取正文**:正文一旦落库,每翻一页
// 历史都要背着它重传一次(G3 的教训),而且它是提示词注入面
// (design/11 §10)。空串表示这条消息没有附件。
// Document 是这条消息携带的文档引用与抽取统计(design/24 §2.4、§6.1),即
// DocumentRef 的 JSON **数组**(一个回合最多 5 个附件,见 document.go 的
// maxDocumentsPerMessage;S2/S3 早期的单对象行仍能读回)。它刻意只装引用与
// 统计 —— **绝不装抽取正文**:正文一旦落库,每翻一页历史都要背着它重传一次
// (G3 的教训),而且它是提示词注入面(design/11 §10)。空串表示这条消息没有
// 附件。
//
// 列内容有两个写入时点:回合开始前只写引用,回合结束后由
// (*Store).UpdateMessageDocument 把抽取结论(status/chars/truncated)补进同
// 一列 —— 抽取发生在回合的装配期,那时消息早已落库。
Document string
Thinking string
// Platform 标注一条消息的来源平台(design/16 §4.4)。Web 主会话是跨平台