From 6b2b07cbef5e60122ff755047f0c2d155e91bad3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 17:07:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20document=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E4=B8=8E=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=88?= =?UTF-8?q?design/24=20S2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model 新增 ContentTypeDocument + Content 的四字段 + DocumentMessage 构造器 + ValidateDocumentParts(文档部件只允许 RoleUser,非 user ⇒ ErrDocumentOnNonUser,fail-loud)。 - 两个编码器入口(buildMessages / buildAnthropicMessages)对文档部件报 ErrBadRequest: 本期 document 永不抵达编码器,且绝不静默丢块。 - history:拆出 types.go(Message 增 Document 列),新增 document.go(DocumentRef 同时是 落库 JSON 与 DTO 形状,6 字段含 status ⇒ §4.4 的「读不到要可见」;坏列回 unreadable 不静默)。 - chat_messages 加 document 列(迁移 0015 + NewStore 幂等前滚);historyMessage.Document DTO; session_persist 的 messageTextImage → messageTextImageDocument(保留窄视图以免牵连 steer 配对)。 - 只落「引用 + 统计」,绝不落抽取正文(TestPersistedDocumentHoldsNoBody 把全部列拼起来扫正文)。 验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、 全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、18 条 Document* 测试 PASS。 变异独立复现:ValidateDocumentParts 开头 return nil ⇒ 非 user 负例红。 --- internal/db/migrate_document_test.go | 71 +++++++ .../0015_chat_message_document.down.sql | 8 + .../0015_chat_message_document.up.sql | 19 ++ internal/runtime/history/document.go | 101 +++++++++ internal/runtime/history/document_test.go | 196 ++++++++++++++++++ internal/runtime/history/store.go | 101 ++------- internal/runtime/history/types.go | 88 ++++++++ internal/runtime/model/anthropic_encode.go | 12 +- internal/runtime/model/document.go | 73 +++++++ internal/runtime/model/document_test.go | 92 ++++++++ internal/runtime/model/model.go | 17 ++ internal/runtime/model/openai_encode.go | 10 + internal/services/gateway/history.go | 25 ++- .../services/gateway/history_document_test.go | 126 +++++++++++ internal/services/gateway/session_persist.go | 57 ++++- 15 files changed, 895 insertions(+), 101 deletions(-) create mode 100644 internal/db/migrate_document_test.go create mode 100644 internal/db/migrations/0015_chat_message_document.down.sql create mode 100644 internal/db/migrations/0015_chat_message_document.up.sql create mode 100644 internal/runtime/history/document.go create mode 100644 internal/runtime/history/document_test.go create mode 100644 internal/runtime/history/types.go create mode 100644 internal/runtime/model/document.go create mode 100644 internal/runtime/model/document_test.go create mode 100644 internal/services/gateway/history_document_test.go diff --git a/internal/db/migrate_document_test.go b/internal/db/migrate_document_test.go new file mode 100644 index 0000000..5d076f9 --- /dev/null +++ b/internal/db/migrate_document_test.go @@ -0,0 +1,71 @@ +package db + +import ( + "context" + "testing" +) + +// documentMigrationID 是文档引用列的迁移 id(design/24 §2.4)。 +const documentMigrationID = "0015_chat_message_document" + +// TestMigrateDocumentColumnReversible 验证 0015(chat_messages.document,文档 +// 引用列)可上下翻滚:up 建列、重复 up 是空操作、down 删列、再 up 恢复。 +// 它用自己的内存库,绝不碰 data/cyrene.db。 +func TestMigrateDocumentColumnReversible(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn := openMemory(t) + + if err := MigrateUp(ctx, conn, testLogger()); err != nil { + t.Fatalf("MigrateUp: %v", err) + } + if !columnExists(t, ctx, conn, "chat_messages", "document") { + t.Fatal("chat_messages.document should exist after MigrateUp") + } + + // 重复 up:已记录的迁移被跳过,列仍在且不报错(幂等)。 + if err := MigrateUp(ctx, conn, testLogger()); err != nil { + t.Fatalf("MigrateUp second call: %v", err) + } + if !columnExists(t, ctx, conn, "chat_messages", "document") { + t.Fatal("chat_messages.document should still exist after idempotent re-run") + } + + // 恰好回滚到 0015(含它之后新加的迁移),因此日后再加迁移也不会让本测试失效。 + ids := upMigrationIDs(t) + idx := -1 + for i, id := range ids { + if id == documentMigrationID { + idx = i + } + } + if idx < 0 { + t.Fatalf("%s not found in %v", documentMigrationID, ids) + } + if err := MigrateDown(ctx, conn, len(ids)-idx, testLogger()); err != nil { + t.Fatalf("MigrateDown(%d): %v", len(ids)-idx, err) + } + if columnExists(t, ctx, conn, "chat_messages", "document") { + t.Fatal("chat_messages.document should be dropped by the 0015 down") + } + + // 重新 up 恢复端到端可用:默认空串意味着「这条消息没有附件」。 + if err := MigrateUp(ctx, conn, testLogger()); err != nil { + t.Fatalf("re-MigrateUp: %v", err) + } + if !columnExists(t, ctx, conn, "chat_messages", "document") { + t.Fatal("chat_messages.document should exist again after re-MigrateUp") + } + if _, err := conn.ExecContext(ctx, + `INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`, + "m1", "web", "user", "hi", 1000); err != nil { + t.Fatalf("insert without document: %v", err) + } + var doc string + if err := conn.QueryRowContext(ctx, `SELECT document FROM chat_messages WHERE id = ?`, "m1").Scan(&doc); err != nil { + t.Fatalf("select document: %v", err) + } + if doc != "" { + t.Fatalf("document = %q, want the empty default", doc) + } +} diff --git a/internal/db/migrations/0015_chat_message_document.down.sql b/internal/db/migrations/0015_chat_message_document.down.sql new file mode 100644 index 0000000..6053778 --- /dev/null +++ b/internal/db/migrations/0015_chat_message_document.down.sql @@ -0,0 +1,8 @@ +-- 0015_chat_message_document down: 回滚文档引用列。 +-- +-- document 只被 chat_messages 自己引用(无索引、无视图、无约束牵涉),因此 +-- 直接删列即可。回滚后的状态是「服务端不再记得这条消息带过附件」:列里的引用 +-- 与统计随列一起丢弃,而文档本体(uploads/extracted)与元数据表不在本迁移的 +-- 范围内,由各自的切片/GC 处置。 + +ALTER TABLE chat_messages DROP COLUMN document; diff --git a/internal/db/migrations/0015_chat_message_document.up.sql b/internal/db/migrations/0015_chat_message_document.up.sql new file mode 100644 index 0000000..477cc47 --- /dev/null +++ b/internal/db/migrations/0015_chat_message_document.up.sql @@ -0,0 +1,19 @@ +-- 0015_chat_message_document: 给聊天消息挂上「文档引用」(design/24 §2.4)。 +-- +-- 「分享到 App · 超限走文件」把长文/文件做成服务端文档,再由 chat 以 +-- document 引用它。这条消息需要在历史里看得见「她读过这个文件」,因此 +-- chat_messages 增一列 document,存一个 JSON 小对象: +-- {name, mime, size, chars, truncated, status} +-- name 是服务端存储名(内容寻址、32 位十六进制)、mime 是服务端字节嗅探所得、 +-- chars/truncated 是抽取统计、status 是抽取状态(extracted|listed|unreadable)。 +-- +-- 它**刻意只存引用与统计,绝不存抽取正文**:正文一旦落库,每翻一页历史都要 +-- 背着它重传(G3 把 base64 原样落库的教训),而且附件正文是提示词注入面 +-- (design/11 §10)。正文留在 /extracted 的 sidecar 里,装配期按 +-- 引用读(design/24 §4.3:只抽一次,不每回合重算)。 +-- +-- 0003 的 chat_messages 表在此已存在,故 ALTER ADD COLUMN 是非破坏性的正确 +-- 一步;全新库由 internal/runtime/history.NewStore 幂等地前滚(含该列)。 +-- 表名沿用复数口径(援引 AGENTS §10 已登记的例外)。 + +ALTER TABLE chat_messages ADD COLUMN document TEXT NOT NULL DEFAULT ''; diff --git a/internal/runtime/history/document.go b/internal/runtime/history/document.go new file mode 100644 index 0000000..a05f6c4 --- /dev/null +++ b/internal/runtime/history/document.go @@ -0,0 +1,101 @@ +package history + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" +) + +// 本文件归属聊天消息携带的**文档引用**(design/24 §2.4),以及 chat_messages +// 上「建表之后才引入的列」的前滚聚合(client_msg_id 与 document)。 +// +// document 列只存「引用 + 抽取统计」——存储名、嗅探所得 MIME、字节数、抽取 +// 字符数、是否截断、抽取状态;**绝不存抽取正文**。G3(base64 原样落库 → 每页 +// 历史背着重传)与 H1(每回合重算)两条教训说的都是同一件事:正文一旦进了 +// 历史,每读一页都要为它付一次代价,而且它是提示词注入面(design/11 §10)。 + +// 文档在持久化与历史 DTO 里可能处于的抽取状态(design/24 §6.1),由装配期 +// 与抽取器填入。 +const ( + // DocumentStatusExtracted 表示正文已抽取并进了上下文。 + DocumentStatusExtracted = "extracted" + // DocumentStatusListed 表示只给了模型一份附件清单,正文没有进上下文。 + DocumentStatusListed = "listed" + // DocumentStatusUnreadable 表示读不到正文(抽取失败、类型不可解析)。 + DocumentStatusUnreadable = "unreadable" +) + +// DocumentRef 是一条消息携带的文档引用与抽取统计。它就是 chat_messages.document +// 列里的 JSON 小对象,**同时**也是历史 DTO 里 document 对象的形状:两边共用 +// 同一个结构体,所以「不回传正文」是结构上保证的,而不是靠约定或代码审查。 +type DocumentRef struct { + // Name 是服务端存储名(design/24 §3.2:32 位十六进制 + 白名单扩展名)。 + Name string `json:"name"` + // MIME 是服务端字节嗅探所得,绝不是客户端的声明。 + MIME string `json:"mime"` + // Size 是该文档的字节数。 + Size int64 `json:"size"` + // Chars 是本次抽取进上下文的字符数(已截断者填截断后的值)。 + Chars int `json:"chars,omitempty"` + // Truncated 报告抽取正文是否被预算截断(design/24 §4.3:截断必须诚实 + // 标注,绝不静默)。 + Truncated bool `json:"truncated,omitempty"` + // Status 是抽取状态,取值见 DocumentStatus* 常量。 + Status string `json:"status,omitempty"` +} + +// EncodeDocument 把文档引用编码成落库用的 JSON 小对象。空引用返回 "",使 +// 「这条消息没有附件」与「有附件」在库里、在 DTO 里都有确定区别。 +func EncodeDocument(ref DocumentRef) (string, error) { + if ref == (DocumentRef{}) { + return "", nil + } + b, err := json.Marshal(ref) + if err != nil { + return "", fmt.Errorf("history: encode document ref: %w", err) + } + return string(b), nil +} + +// DecodeDocument 解码落库的文档引用。空串(这条消息没有附件)返回 (nil, nil); +// 非法 JSON 返回错误,由调用方决定如何**可见地**报告 —— 解析失败绝不静默当成 +// 「没有附件」,那正是 F2 要消灭的那种「一方以为传上去了,另一方一无所知」。 +func DecodeDocument(raw string) (*DocumentRef, error) { + if raw == "" { + 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) + } + return &ref, nil +} + +// ensureMessageColumns 把 chat_messages 上「建表之后才引入的列」前滚到既有表。 +// 全新库由 createMessagesTable 一次建全,因此这里对它们都是空操作;早于某列 +// 建出的表则在这里补上(与 memory 包的 ensureProjectIDColumn 同一手法)。 +func ensureMessageColumns(ctx context.Context, db *sql.DB) error { + if err := ensureClientMsgIDColumn(ctx, db); err != nil { + return err + } + return ensureDocumentColumn(ctx, db) +} + +// ensureDocumentColumn 在 chat_messages 早于本列(0015 迁移)建表时把它前滚: +// 缺列则补加,已有则忽略重复列错误。它是幂等的,因此 NewStore 可在全新 schema +// 或既有 schema 上调用。 +func ensureDocumentColumn(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, `ALTER TABLE chat_messages + ADD COLUMN document TEXT NOT NULL DEFAULT ''`) + if err == nil { + return nil + } + // 列已存在时(由 createMessagesTable 建出,或上一次运行补过)SQLite 报 + // "duplicate column name: document"。 + if strings.Contains(err.Error(), "duplicate column name") { + return nil + } + return fmt.Errorf("history: roll forward document column: %w", err) +} diff --git a/internal/runtime/history/document_test.go b/internal/runtime/history/document_test.go new file mode 100644 index 0000000..79689bb --- /dev/null +++ b/internal/runtime/history/document_test.go @@ -0,0 +1,196 @@ +package history + +import ( + "context" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" +) + +// 本文件是文档引用列(chat_messages.document,design/24 §2.4)的存储测试: +// 引用与统计往返一致、库里**任何一列都不含抽取正文**、坏 JSON 不静默当成 +// 「没有附件」,以及早于 0015 建出的旧表能被幂等前滚。 + +// 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, + } + raw, err := EncodeDocument(ref) + if err != nil { + t.Fatalf("encode: %v", err) + } + if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "总结一下", Document: raw, CreatedAt: 1000}); err != nil { + t.Fatalf("append: %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)) + } + got, err := DecodeDocument(msgs[0].Document) + if err != nil { + t.Fatalf("decode: %v", err) + } + if got == nil { + t.Fatal("document ref lost in round trip") + } + if *got != ref { + t.Fatalf("ref = %+v, want %+v", *got, ref) + } +} + +// TestDocumentRefEmptyMeansNoAttachment 验证「没有附件」与「有附件」在库里、 +// 在 DTO 里都有确定区别:空引用编码成空串,空串解码回 nil。 +func TestDocumentRefEmptyMeansNoAttachment(t *testing.T) { + t.Parallel() + raw, err := EncodeDocument(DocumentRef{}) + if err != nil { + t.Fatalf("encode zero ref: %v", err) + } + if raw != "" { + t.Fatalf("raw = %q, want empty column", raw) + } + ref, err := DecodeDocument("") + if err != nil { + t.Fatalf("decode empty: %v", err) + } + if ref != nil { + t.Fatalf("ref = %+v, want nil", ref) + } +} + +// TestDecodeDocumentRejectsMalformed 验证坏 JSON 返回错误而不是 (nil, nil): +// 解析失败绝不能静默变成「这条消息没有附件」,那正是 F2 要消灭的误解。 +func TestDecodeDocumentRejectsMalformed(t *testing.T) { + t.Parallel() + if _, err := DecodeDocument("{not json"); err == nil { + t.Fatal("DecodeDocument(malformed) = nil, want an error") + } +} + +// TestPersistedDocumentHoldsNoBody 断言抽取正文绝不落库(design/24 §2.4 的 +// 红线):正文留在 /extracted 的 sidecar,历史只存引用与统计。 +// 它把 chat_messages 的每一列拼起来扫一遍正文,因此任何一列(包括将来新增的 +// 那些)都逃不掉。 +func TestPersistedDocumentHoldsNoBody(t *testing.T) { + t.Parallel() + st := newStore(t) + ctx := context.Background() + + const body = "抽取正文不可落库·SECRET-BODY-0123456789" + raw, err := EncodeDocument(DocumentRef{ + Name: "aaaabbbbccccdddd.txt", + MIME: "text/plain", + Size: int64(len(body)), + Chars: len(body), + Status: DocumentStatusExtracted, + }) + if err != nil { + t.Fatalf("encode: %v", err) + } + full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "这份文档讲了什么", Document: raw}) + if err != nil { + t.Fatalf("append: %v", err) + } + + const allColumns = `SELECT ifnull(id,'')||ifnull(session_id,'')||ifnull(role,'')||ifnull(content,'')|| + ifnull(mode,'')||ifnull(mood,'')||ifnull(image,'')||ifnull(document,'')||ifnull(thinking,'')|| + ifnull(platform,'')||ifnull(device_id,'')||ifnull(sender_name,'')||ifnull(adapter_id,'')|| + ifnull(persona_id,'')||ifnull(client_msg_id,'')||ifnull(created_at,'') + FROM chat_messages WHERE id = ?` + var all string + if err := st.db.QueryRowContext(ctx, allColumns, full.ID).Scan(&all); err != nil { + t.Fatalf("scan all columns: %v", err) + } + if strings.Contains(all, body) { + t.Fatalf("抽取正文落库了(该行全部列 = %q)", all) + } + if !strings.Contains(all, "aaaabbbbccccdddd.txt") { + t.Fatalf("文档引用应落库,但库里没有(该行全部列 = %q)", all) + } +} + +// TestNewStoreRollsForwardDocumentColumn 验证早于 0015 建出的 chat_messages 表 +// 会被 NewStore 幂等前滚出 document 列(与 client_msg_id 同一手法):全新库由 +// 建表 DDL 一次建全,既有库靠补列,两条路都必须能写能读带引用的消息。 +func TestNewStoreRollsForwardDocumentColumn(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn, err := db.Open(ctx, ":memory:", testLogger()) + if err != nil { + t.Fatalf("open memory db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + const legacyTable = ` +CREATE TABLE chat_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + mode TEXT, + mood TEXT, + image TEXT, + thinking TEXT, + platform TEXT NOT NULL DEFAULT 'web', + device_id TEXT NOT NULL DEFAULT '', + sender_name TEXT NOT NULL DEFAULT '', + adapter_id TEXT NOT NULL DEFAULT '', + persona_id TEXT NOT NULL DEFAULT '', + client_msg_id TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL +)` + if _, err := conn.ExecContext(ctx, legacyTable); err != nil { + t.Fatalf("create legacy table: %v", err) + } + + st, err := NewStore(ctx, conn, testLogger()) + if err != nil { + t.Fatalf("new store: %v", err) + } + raw, err := EncodeDocument(DocumentRef{Name: "aaaabbbbccccdddd.txt", MIME: "text/plain", Status: DocumentStatusListed}) + if err != nil { + t.Fatalf("encode: %v", err) + } + if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "带附件", Document: raw, CreatedAt: 2000}); err != nil { + t.Fatalf("append after roll forward: %v", err) + } + msgs, err := st.List(ctx, MainSessionID, 0, 10, "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(msgs) != 1 || msgs[0].Document != raw { + t.Fatalf("msgs = %+v, want the document ref back", msgs) + } +} + +// TestDocumentStatusConstants 钉住三个状态取值:它们经持久化直通 DTO,客户端 +// 按这些字面量渲染「读过了 / 只有清单 / 读不到」(design/24 §6.1)。 +func TestDocumentStatusConstants(t *testing.T) { + t.Parallel() + want := map[string]string{ + DocumentStatusExtracted: "extracted", + DocumentStatusListed: "listed", + DocumentStatusUnreadable: "unreadable", + } + for got, expected := range want { + if got != expected { + t.Fatalf("status = %q, want %q", got, expected) + } + } +} diff --git a/internal/runtime/history/store.go b/internal/runtime/history/store.go index 120d75f..ee3e93e 100644 --- a/internal/runtime/history/store.go +++ b/internal/runtime/history/store.go @@ -67,82 +67,6 @@ var ( ErrMessageNotFound = errors.New("history: message not found") ) -// Message 是一条已持久化的聊天消息。CreatedAt 是 Unix 毫秒时间戳,同时兼任 -// 分页游标(design/16 §3.1):页按最新优先排序,"before" 就是上一页最旧那条 -// 消息的 created_at。 -type Message struct { - ID string - SessionID string - Role string - Content string - Mode string - Mood string - Image string - Thinking string - // Platform 标注一条消息的来源平台(design/16 §4.4)。Web 主会话是跨平台 - // 共享的:源自 Web 的消息携带 "web",而主人的 IM 私聊消息携带渠道平台 - // (例如 "telegram"、"qq-obv11"、"weixin-ilink")。Platform 为空时默认 - // 为 "web",使历史/显式 Web 写入无需额外赋值就留在 Web 通道。 - Platform string - // DeviceID 是发送该消息的具体设备/APP 的短标识,由客户端产出 - // (design/16 §4.5)—— 例如浏览器的 "web:chrome:a1b2c3"、IM 的 - // "weixin-ilink:conf0:wx_uid_123"。它与 Platform 互补:同一平台上的两个 - // 客户端共享 Platform,但 DeviceID 不同。 - DeviceID string - // SenderName 是消息发送者给人看的显示名(design/16 §4.6)。它为第三方 - // 消息(群聊与陌生人私聊)设置,使 agent 与 IM 适配器日志页能标注说话者 - // 是谁;对主会话上主人的私聊它刻意留空(主人从不被标注)。它只是展示 - // 元数据 —— 身份绝不由该字段判定。 - SenderName string - // AdapterID 是产出该消息的 IM 来源的适配器(平台 + 账号)注册名,例如 - // 多账号 channels.json 注册的 "kook:3bef...",或账号来自环境变量时的裸 - // 平台名(例如 "kook")。IM 适配器来源的消息携带它;Web 主会话留空, - // 因此对 Web 消息它恒为空。 - AdapterID string - // PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以在保留 - // 上下文的同时切换角色,因此作者角色按消息记录,上下文/UI 才能分辨 - // 「第 N 个回合是谁说的」。为空意味着「继承当前全局默认角色」—— 多角色 - // 之前的行、以及任何未显式指定角色写入的消息,因此它们读起来与以往完全 - // 一致。它只是展示/归属元数据:身份与权限由渠道 actor 元数据决定 - // (项目红线)。 - PersonaID string - // ClientMsgID 是客户端为这条消息生成的幂等键(Android 协同 §G4)—— 通常是 - // 一条乐观气泡的本地 id,也正因此它必须原样回填并可见:客户端用它把本地 - // 气泡与服务端真相对齐,而不是靠「文本相等」这类脆弱匹配。同一会话内同一 - // 非空键只允许一条消息(部分唯一索引裁决,见 client_msg_id.go)。空串意味着 - // 「这条消息没有幂等键」(老客户端、IM 镜像、历史行),行为与加入该列之前 - // 逐字相同。 - ClientMsgID string - CreatedAt int64 -} - -// SessionMeta 是一个持久化会话面向线上的元数据。 -type SessionMeta struct { - ID string - Type string // "main" | "sub"(主会话 | 子会话) - Title string - CreatedAt int64 - // Pinned 把会话浮到 ListSessions 顶部。它只是展示/排序元数据;身份与权限 - // 绝不由它推导。 - Pinned bool - // LastMessage 是会话最新消息的纯展示预览,由 ListSessions 在同一次查询中 - // 填充。没有消息的会话为 nil。客户端用它渲染对话行,无需逐会话请求; - // 身份与权限绝不由它推导。 - LastMessage *MessageSummary - // UnreadCount 是比会话读游标更新的消息数(design/16 §4.2,D4),由 - // ListSessions 在同一次查询中算出。游标是会话作用域且单归属:在任一端 - // 已读即为该账号的每一端把该会话标为已读。与 LastMessage 一样,它只是 - // 展示用 —— 身份与权限绝不由它推导。 - UnreadCount int - // PersonaID 是该会话所属的角色(design/22 §2/§4.1)。它是「这个对话里由 - // 哪个角色应答」的唯一事实来源:循环据此解析记忆作用域、系统提示词、 - // 情绪与思考预算。为空意味着「继承当前全局默认角色」 - // (owner.yaml:defaultPersona),因此每个既有会话、以及任何未显式指定 - // 角色创建的会话都保持多角色之前的行为(design/22 §4.7),并且仍跟随 - // 默认角色变更(design/22 §7-6)。 - PersonaID string -} - // HistoryStore 抽象聊天历史存储,使调用方(网关)与测试可以持有接口而不必持有 // 具体的 *Store。它刻意只保留运行时层消费者(channelbridge、backthink)实际 // 会做的调用:D4 读游标(MarkSessionRead)改为声明在消费者侧的接口里 @@ -228,8 +152,8 @@ type rowScanner interface { } // NewStore 在 db 上构建一个 Store,确保 chat_sessions/chat_messages schema 存在 -// 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,见 -// client_msg_id.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与 +// 且固定主会话行在位。该 DDL 与 0003 迁移一致(列清单随后续迁移同步增长,前滚 +// 见 document.go);它是幂等的,因此存储自足、可从任意 *sql.DB 使用,与 // memory 和 session 包一致。db 必须可用;nil 返回 ErrNilDB。log 可以为 nil。 func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { if db == nil { @@ -237,8 +161,9 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) } log = logger.WithSource(discardLogger(log), "history") // 顺序不能反:createMessagesTable 对全新库已含 client_msg_id,随后 - // ensureClientMsgIDColumn 把早于该列建出的既有表前滚(幂等),最后才建索引 - // —— 部分唯一索引引用 client_msg_id,列还不存在时建它必然失败。 + // ensureMessageColumns 把早于这些列(client_msg_id 与 document)建出的既有表 + // 前滚(幂等),最后才建索引 —— 部分唯一索引引用 client_msg_id,列还不存在时 + // 建它必然失败。 for _, ddl := range []string{ createSessionsTable, createMessagesTable, @@ -247,7 +172,7 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) return nil, fmt.Errorf("history: apply schema: %w", err) } } - if err := ensureClientMsgIDColumn(ctx, db); err != nil { + if err := ensureMessageColumns(ctx, db); err != nil { return nil, err } for _, ddl := range []string{ @@ -306,14 +231,15 @@ func (s *Store) insert(ctx context.Context, msg Message) (Message, error) { s.ensureTitle(ctx, msg.SessionID, msg.Content) } query := `INSERT INTO chat_messages - (id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + (id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` if msg.ClientMsgID != "" { query += ` ON CONFLICT DO NOTHING` } res, err := s.db.ExecContext(ctx, query, - msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, - msg.Image, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, msg.PersonaID, msg.ClientMsgID, msg.CreatedAt) + msg.ID, msg.SessionID, msg.Role, msg.Content, msg.Mode, msg.Mood, msg.Image, + msg.Document, msg.Thinking, msg.Platform, msg.DeviceID, msg.SenderName, msg.AdapterID, + msg.PersonaID, msg.ClientMsgID, msg.CreatedAt) if err != nil { return Message{}, fmt.Errorf("history: append %s to %s: %w", msg.Role, msg.SessionID, err) } @@ -418,13 +344,13 @@ func (s *Store) ensureMainSession(ctx context.Context) error { // **唯一**一份列清单(位置顺序必须与 scanMessage 的 Scan 完全一致)。 // 它刻意只写一次:此前七个查询各抄一份,加一列就得改七处,漏一处就只会在 // 运行时以「扫描到的列数与列不清单不符」炸掉;共用常量让漏改成为不可能。 -const messageColumns = `id, session_id, role, content, mode, mood, image, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at` +const messageColumns = `id, session_id, role, content, mode, mood, image, document, thinking, platform, device_id, sender_name, adapter_id, persona_id, client_msg_id, created_at` // scanMessage 读取一行 chat_messages。消息各列按位置扫描,顺序见 messageColumns。 func scanMessage(sc rowScanner) (Message, error) { var m Message var mode, mood, platform, deviceID, senderName, adapterID string - if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.CreatedAt); err != nil { + if err := sc.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &mode, &mood, &m.Image, &m.Document, &m.Thinking, &platform, &deviceID, &senderName, &adapterID, &m.PersonaID, &m.ClientMsgID, &m.CreatedAt); err != nil { return Message{}, err } m.Mode, m.Mood, m.Platform, m.DeviceID, m.SenderName, m.AdapterID = mode, mood, platform, deviceID, senderName, adapterID @@ -483,6 +409,7 @@ CREATE TABLE IF NOT EXISTS chat_messages ( mode TEXT, mood TEXT, image TEXT, + document TEXT NOT NULL DEFAULT '', thinking TEXT, platform TEXT NOT NULL DEFAULT 'web', device_id TEXT NOT NULL DEFAULT '', diff --git a/internal/runtime/history/types.go b/internal/runtime/history/types.go new file mode 100644 index 0000000..ed576d6 --- /dev/null +++ b/internal/runtime/history/types.go @@ -0,0 +1,88 @@ +package history + +// 本文件归属**持久化领域类型**:一条已持久化的聊天消息(Message)与会话面向 +// 线上的元数据(SessionMeta)。它们从 store.go 拆出来有两条理由:store.go 已经 +// 贴着 500 行硬上限(AGENTS §10),而「表里有什么」与「SQL 怎么读写它」本来就 +// 是两件事。SQL 实现仍住在 store.go 及其同族文件里。 + +// Message 是一条已持久化的聊天消息。CreatedAt 是 Unix 毫秒时间戳,同时兼任 +// 分页游标(design/16 §3.1):页按最新优先排序,"before" 就是上一页最旧那条 +// 消息的 created_at。 +type Message struct { + ID string + SessionID string + Role string + Content string + Mode string + Mood string + Image string + // Document 是这条消息携带的文档引用与抽取统计(design/24 §2.4),即 + // DocumentRef 序列化出的小对象 {name,mime,size,chars,truncated,status}。 + // 它刻意只装引用与统计 —— **绝不装抽取正文**:正文一旦落库,每翻一页 + // 历史都要背着它重传一次(G3 的教训),而且它是提示词注入面 + // (design/11 §10)。空串表示这条消息没有附件。 + Document string + Thinking string + // Platform 标注一条消息的来源平台(design/16 §4.4)。Web 主会话是跨平台 + // 共享的:源自 Web 的消息携带 "web",而主人的 IM 私聊消息携带渠道平台 + // (例如 "telegram"、"qq-obv11"、"weixin-ilink")。Platform 为空时默认 + // 为 "web",使历史/显式 Web 写入无需额外赋值就留在 Web 通道。 + Platform string + // DeviceID 是发送该消息的具体设备/APP 的短标识,由客户端产出 + // (design/16 §4.5)—— 例如浏览器的 "web:chrome:a1b2c3"、IM 的 + // "weixin-ilink:conf0:wx_uid_123"。它与 Platform 互补:同一平台上的两个 + // 客户端共享 Platform,但 DeviceID 不同。 + DeviceID string + // SenderName 是消息发送者给人看的显示名(design/16 §4.6)。它为第三方 + // 消息(群聊与陌生人私聊)设置,使 agent 与 IM 适配器日志页能标注说话者 + // 是谁;对主会话上主人的私聊它刻意留空(主人从不被标注)。它只是展示 + // 元数据 —— 身份绝不由该字段判定。 + SenderName string + // AdapterID 是产出该消息的 IM 来源的适配器(平台 + 账号)注册名,例如 + // 多账号 channels.json 注册的 "kook:3bef...",或账号来自环境变量时的裸 + // 平台名(例如 "kook")。IM 适配器来源的消息携带它;Web 主会话留空, + // 因此对 Web 消息它恒为空。 + AdapterID string + // PersonaID 是说出这条消息的角色(design/22 §7-5)。一个会话可以在保留 + // 上下文的同时切换角色,因此作者角色按消息记录,上下文/UI 才能分辨 + // 「第 N 个回合是谁说的」。为空意味着「继承当前全局默认角色」—— 多角色 + // 之前的行、以及任何未显式指定角色写入的消息,因此它们读起来与以往完全 + // 一致。它只是展示/归属元数据:身份与权限由渠道 actor 元数据决定 + // (项目红线)。 + PersonaID string + // ClientMsgID 是客户端为这条消息生成的幂等键(Android 协同 §G4)—— 通常是 + // 一条乐观气泡的本地 id,也正因此它必须原样回填并可见:客户端用它把本地 + // 气泡与服务端真相对齐,而不是靠「文本相等」这类脆弱匹配。同一会话内同一 + // 非空键只允许一条消息(部分唯一索引裁决,见 client_msg_id.go)。空串意味着 + // 「这条消息没有幂等键」(老客户端、IM 镜像、历史行),行为与加入该列之前 + // 逐字相同。 + ClientMsgID string + CreatedAt int64 +} + +// SessionMeta 是一个持久化会话面向线上的元数据。 +type SessionMeta struct { + ID string + Type string // "main" | "sub"(主会话 | 子会话) + Title string + CreatedAt int64 + // Pinned 把会话浮到 ListSessions 顶部。它只是展示/排序元数据;身份与权限 + // 绝不由它推导。 + Pinned bool + // LastMessage 是会话最新消息的纯展示预览,由 ListSessions 在同一次查询中 + // 填充。没有消息的会话为 nil。客户端用它渲染对话行,无需逐会话请求; + // 身份与权限绝不由它推导。 + LastMessage *MessageSummary + // UnreadCount 是比会话读游标更新的消息数(design/16 §4.2,D4),由 + // ListSessions 在同一次查询中算出。游标是会话作用域且单归属:在任一端 + // 已读即为该账号的每一端把该会话标为已读。与 LastMessage 一样,它只是 + // 展示用 —— 身份与权限绝不由它推导。 + UnreadCount int + // PersonaID 是该会话所属的角色(design/22 §2/§4.1)。它是「这个对话里由 + // 哪个角色应答」的唯一事实来源:循环据此解析记忆作用域、系统提示词、 + // 情绪与思考预算。为空意味着「继承当前全局默认角色」 + // (owner.yaml:defaultPersona),因此每个既有会话、以及任何未显式指定 + // 角色创建的会话都保持多角色之前的行为(design/22 §4.7),并且仍跟随 + // 默认角色变更(design/22 §7-6)。 + PersonaID string +} diff --git a/internal/runtime/model/anthropic_encode.go b/internal/runtime/model/anthropic_encode.go index 1539e79..8ffd9ab 100644 --- a/internal/runtime/model/anthropic_encode.go +++ b/internal/runtime/model/anthropic_encode.go @@ -9,8 +9,13 @@ import ( // 字符串与其余按 Anthropic 内容块形式表示的消息。所有 // RoleSystem 消息拼接成单个 system 提示词(Anthropic // 把它放在请求根而非 messages 里);没有内容片段的消息 -// 会被丢弃,因为 Anthropic 拒绝空内容。 +// 会被丢弃,因为 Anthropic 拒绝空内容。挂在非用户回合上的 +// 文档部件在这里就被拒绝(design/24 §2.3-③):文档本期永不 +// 抵达编码器,漏网即编程错误,绝不静默丢块。 func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) { + if err := ValidateDocumentParts(msgs); err != nil { + return "", nil, err + } var sys strings.Builder out := make([]anthropicMessage, 0, len(msgs)) for _, m := range msgs { @@ -97,7 +102,10 @@ func encodeAnthropicToolResult(m Message) ([]any, error) { // encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。 // 文本 / 图片 / tool_use(助手)片段在此处理;tool_result -// 片段由 encodeAnthropicToolResult 在消息级渲染。 +// 片段由 encodeAnthropicToolResult 在消息级渲染。文档片段不走 +// 这里:本期 DeepSeek 的 anthropic 兼容端点明确不支持 document +// 块,所以文档部件落到 default 分支被拒绝(design/24 §2.3-②, +// fail-loud 绝不静默丢块)。 func encodeAnthropicContent(parts []Content) ([]any, error) { blocks := make([]any, 0, len(parts)) for _, p := range parts { diff --git a/internal/runtime/model/document.go b/internal/runtime/model/document.go new file mode 100644 index 0000000..08ddc00 --- /dev/null +++ b/internal/runtime/model/document.go @@ -0,0 +1,73 @@ +package model + +import ( + "errors" + "fmt" +) + +// 本文件归属文档内容片段(design/24 §2.2、§2.3):文档的归一化形状、构造器, +// 以及「文档只允许挂在用户回合上」这条红线的校验。 +// +// 本期硬规则(design/24 §2.3):**文档部件永不抵达编码器**。装配期会把它 +// 确定性替换为抽取所得的文本部件,或保留为「仅清单」条目;因此任何漏网抵达 +// 编码器的文档部件都是编程错误,必须报错,绝不允许静默丢块 —— 静默丢块正是 +// 「传上去了但模型读不到,且双方都不知道」的来源。 + +// Document 描述一份服务端签发的文档引用(design/24 §2.2)。四个字段全部来自 +// 服务端:Name 是服务端存储名、MIME 是字节嗅探所得、URL 是服务端本地引用、 +// Size 是字节数。客户端的原始文件名与声明的 Content-Type 都不在其中 —— +// 按 design/24 §3.2 的「字节嗅探唯一裁决」口径,它们只能作参考且可被否决。 +type Document struct { + // Name 是服务端存储名(32 位十六进制 + 白名单扩展名)。 + Name string + // MIME 是服务端嗅探所得的媒体类型。 + MIME string + // URL 是服务端签发的本地引用(文档读侧)。 + URL string + // Size 是该文档的字节数。 + Size int64 +} + +// ErrDocumentOnNonUser 在文档部件挂到非 RoleUser 回合上时返回。「用户内容 +// 只进用户角色消息」是项目红线(design/11 §10.2、design/24 §2.3-③),所以 +// 这是编程错误,而不是可以忽略的输入问题。 +var ErrDocumentOnNonUser = errors.New("model: document content is only allowed on user messages") + +// DocumentMessage 构建一条携带可选文本与一份文档引用的消息。文本为空则省略 +// 文本部件(与 ImageMessage 同一口径)。 +// +// 构造器刻意不校验角色:它是纯函数,角色红线由 ValidateDocumentParts 在编码 +// 边界上统一裁决,从而保证「校验只有一处、漏不掉」。落位到非 user 回合的 +// 消息会在那里被拒绝,而不会被静默剥掉那块内容。 +func DocumentMessage(role Role, text string, doc Document) Message { + parts := make([]Content, 0, 2) + if text != "" { + parts = append(parts, Content{Type: ContentTypeText, Text: text}) + } + parts = append(parts, Content{ + Type: ContentTypeDocument, + DocumentName: doc.Name, + DocumentMIME: doc.MIME, + DocumentURL: doc.URL, + DocumentSize: doc.Size, + }) + return Message{Role: role, Content: parts} +} + +// ValidateDocumentParts 校验文档部件只出现在 RoleUser 回合上(design/24 +// §2.3-③)。两个编码器都在入口调用它,因此「文档落到了 system/assistant +// 消息上」会当场报错,而不是在那里被悄悄丢掉(Anthropic 的 system 拼接与 +// OpenAI 的文本拼接都会忽略非文本部件,静默丢失是默认行为)。 +func ValidateDocumentParts(msgs []Message) error { + for _, m := range msgs { + if m.Role == RoleUser { + continue + } + for _, p := range m.Content { + if p.Type == ContentTypeDocument { + return fmt.Errorf("model: %w: document %q on %s", ErrDocumentOnNonUser, p.DocumentName, m.Role) + } + } + } + return nil +} diff --git a/internal/runtime/model/document_test.go b/internal/runtime/model/document_test.go new file mode 100644 index 0000000..d3ccd4b --- /dev/null +++ b/internal/runtime/model/document_test.go @@ -0,0 +1,92 @@ +package model + +import ( + "errors" + "testing" +) + +// 本文件是文档内容类型的构造器与两条负例(design/24 §2.2、§2.3): +// ① 文档部件抵达任一编码器 → ErrBadRequest(fail-loud,绝不静默丢块); +// ② 文档部件挂在非 RoleUser 回合上 → ErrDocumentOnNonUser(编程错误)。 +// 断言一律用 errors.Is 对哨兵错误,绝不按错误文本子串。 + +// TestDocumentMessageCarriesReference 验证构造器带上文本与服务端签发的引用。 +func TestDocumentMessageCarriesReference(t *testing.T) { + t.Parallel() + doc := Document{Name: "a1b2.txt", MIME: "text/plain", URL: "/api/v1/documents/a1b2.txt", Size: 42} + msg := DocumentMessage(RoleUser, "读一下", doc) + if msg.Role != RoleUser { + t.Fatalf("role = %q, want %q", msg.Role, RoleUser) + } + if len(msg.Content) != 2 || msg.Content[0].Type != ContentTypeText || msg.Content[0].Text != "读一下" { + t.Fatalf("content = %+v, want text + document", msg.Content) + } + part := msg.Content[1] + if part.Type != ContentTypeDocument { + t.Fatalf("part type = %q, want %q", part.Type, ContentTypeDocument) + } + if part.DocumentName != doc.Name || part.DocumentMIME != doc.MIME || part.DocumentURL != doc.URL || part.DocumentSize != doc.Size { + t.Fatalf("document part = %+v, want %+v", part, doc) + } +} + +// TestDocumentMessageWithoutText 验证文本为空时只留文档部件(与 ImageMessage +// 同一口径:空文本不造空片段)。 +func TestDocumentMessageWithoutText(t *testing.T) { + t.Parallel() + msg := DocumentMessage(RoleUser, "", Document{Name: "a1b2.txt"}) + if len(msg.Content) != 1 || msg.Content[0].Type != ContentTypeDocument { + t.Fatalf("content = %+v, want a single document part", msg.Content) + } +} + +// TestEncodeContentRejectsDocument 是负例①(OpenAI 侧):未解析的文档部件抵达 +// encodeContent 必须返回 ErrBadRequest,而不是被静默跳过(静默丢块会让模型 +// 与用户都不知道附件没送到)。 +func TestEncodeContentRejectsDocument(t *testing.T) { + t.Parallel() + _, err := encodeContent([]Content{{Type: ContentTypeDocument, DocumentName: "a1b2.txt"}}) + if !errors.Is(err, ErrBadRequest) { + t.Fatalf("err = %v, want wrap of ErrBadRequest", err) + } +} + +// TestEncodeAnthropicContentRejectsDocument 是负例①(Anthropic 侧)。 +func TestEncodeAnthropicContentRejectsDocument(t *testing.T) { + t.Parallel() + _, err := encodeAnthropicContent([]Content{{Type: ContentTypeDocument, DocumentName: "a1b2.txt"}}) + if !errors.Is(err, ErrBadRequest) { + t.Fatalf("err = %v, want wrap of ErrBadRequest", err) + } +} + +// TestBuildMessagesRejectsDocumentOnNonUser 是负例②:文档部件挂到 +// assistant/system 回合上是编程错误(design/11 §10.2 的用户内容归属红线), +// 两个编码器都必须报错。这里刻意用 ErrDocumentOnNonUser 而不是 ErrBadRequest +// 断言,以便区分「角色错了」与「类型没人支持」两种失败。 +func TestBuildMessagesRejectsDocumentOnNonUser(t *testing.T) { + t.Parallel() + for _, role := range []Role{RoleAssistant, RoleSystem} { + msg := DocumentMessage(role, "读一下", Document{Name: "a1b2.txt"}) + if _, err := buildMessages([]Message{msg}); !errors.Is(err, ErrDocumentOnNonUser) { + t.Fatalf("openai buildMessages(%s): err = %v, want wrap of ErrDocumentOnNonUser", role, err) + } + if _, _, err := buildAnthropicMessages([]Message{msg}); !errors.Is(err, ErrDocumentOnNonUser) { + t.Fatalf("anthropic buildAnthropicMessages(%s): err = %v, want wrap of ErrDocumentOnNonUser", role, err) + } + } +} + +// TestValidateDocumentPartsAcceptsUserDocument 验证用户回合上的文档部件通过 +// 校验:报错的是「挂错角色」,不是「带了文档」本身。 +func TestValidateDocumentPartsAcceptsUserDocument(t *testing.T) { + t.Parallel() + msgs := []Message{ + TextMessage(RoleSystem, "你是昔涟"), + DocumentMessage(RoleUser, "读一下", Document{Name: "a1b2.txt"}), + TextMessage(RoleAssistant, "好"), + } + if err := ValidateDocumentParts(msgs); err != nil { + t.Fatalf("ValidateDocumentParts = %v, want nil", err) + } +} diff --git a/internal/runtime/model/model.go b/internal/runtime/model/model.go index 6d03ef3..b796bd9 100644 --- a/internal/runtime/model/model.go +++ b/internal/runtime/model/model.go @@ -42,6 +42,10 @@ const ( // ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool // 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。 ContentTypeToolResult ContentType = "tool_result" + // ContentTypeDocument 是文档内容片段(design/24 §2.2)。文档部件只允许 + // 挂在 RoleUser 回合上,且**本期永不抵达编码器**:装配期会把它确定性 + // 替换为抽取所得的文本部件或仅清单条目(design/24 §2.3)。 + ContentTypeDocument ContentType = "document" ) // Content 是消息的单个片段,可能是多模态的一部分。 @@ -66,6 +70,19 @@ type Content struct { // IsError 把 ContentTypeToolResult 片段标记为执行失败, // 以便原生设置提供商的 tool_result.is_error / error 标志。 IsError bool + // DocumentName 是 Type == ContentTypeDocument 时该文档的服务端存储名 + // (design/24 §2.2、§3.2)。它由服务端签发,绝不是客户端传来的原始 + // 文件名——原始文件名永不参与落盘路径。 + DocumentName string + // DocumentMIME 是服务端**字节嗅探**所得的媒体类型(design/24 §3.2: + // 嗅探是唯一裁决),绝不是客户端的声明。 + DocumentMIME string + // DocumentURL 是服务端签发的本地引用(design/24 §6.1 的文档读侧 + // URL)。任意 http(s) URL 一律不接受:那会重开 F3 那种「服务端代取 + // URL」的 SSRF 面。 + DocumentURL string + // DocumentSize 是该文档的字节数。 + DocumentSize int64 } // Message 是单个会话回合,已跨提供商归一化。 diff --git a/internal/runtime/model/openai_encode.go b/internal/runtime/model/openai_encode.go index 0e2557f..44e0b68 100644 --- a/internal/runtime/model/openai_encode.go +++ b/internal/runtime/model/openai_encode.go @@ -128,7 +128,13 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro return buf, nil } +// buildMessages 把归一化消息列表逐条渲染成 OpenAI 线格式,并在入口拒绝 +// 挂在非用户回合上的文档部件(design/24 §2.3-③):文档本期永不抵达编码器, +// 漏网即编程错误,绝不静默丢块。 func buildMessages(msgs []Message) ([]openAIMessage, error) { + if err := ValidateDocumentParts(msgs); err != nil { + return nil, err + } out := make([]openAIMessage, 0, len(msgs)) for _, m := range msgs { wire, err := encodeOpenAIMessage(m) @@ -196,6 +202,10 @@ func encodeToolCalls(parts []Content) []openAIToolCall { return calls } +// encodeContent 把归一化内容片段转成 OpenAI 的内容字段(纯文本、或 +// 文本/图片片段数组)。文档片段不走这里:本期不向提供商发任何文档块 +// (design/24 §2.3-②:形状不对就是 400),因此它落到 default 分支被 +// 拒绝 —— fail-loud,绝不静默丢块。 func encodeContent(parts []Content) (any, error) { if len(parts) == 0 { return "", nil diff --git a/internal/services/gateway/history.go b/internal/services/gateway/history.go index 0886024..8704be0 100644 --- a/internal/services/gateway/history.go +++ b/internal/services/gateway/history.go @@ -61,9 +61,14 @@ type historyMessage struct { Mode string `json:"mode,omitempty"` Mood string `json:"mood,omitempty"` Image string `json:"image,omitempty"` - Thinking string `json:"thinking,omitempty"` - Platform string `json:"platform,omitempty"` - DeviceID string `json:"deviceId,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"` // SenderName 是第三方消息(群聊/陌生人,design/16 §4.6) // 给人看的发送者显示名。对主会话中主人的私聊它为 // 空,因此空意味着「标记为主人」。 @@ -402,6 +407,7 @@ func toHistoryMessage(m history.Message) historyMessage { Mode: m.Mode, Mood: m.Mood, Image: m.Image, + Document: decodeHistoryDocument(m.Document), Thinking: m.Thinking, Platform: m.Platform, DeviceID: m.DeviceID, @@ -418,3 +424,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) + if err != nil { + return &history.DocumentRef{Status: history.DocumentStatusUnreadable} + } + return ref +} diff --git a/internal/services/gateway/history_document_test.go b/internal/services/gateway/history_document_test.go new file mode 100644 index 0000000..fd9b5aa --- /dev/null +++ b/internal/services/gateway/history_document_test.go @@ -0,0 +1,126 @@ +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 取值。 + +// TestHistoryMessageDocumentCarriesReferenceOnly 验证 document 对象只带 +// 引用与统计:字段集被逐个钉住,将来谁往 DocumentRef 里加一个正文/摘要字段, +// 这里就会红。 +func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) { + t.Parallel() + raw, err := history.EncodeDocument(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) + } + doc, ok := decoded["document"].(map[string]any) + if !ok { + t.Fatalf("document object missing 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("document 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) + } +} + +// TestHistoryMessageDocumentAbsentWhenNoColumn 验证没有附件的消息省略该字段: +// 空列与「带附件」必须可区分(省略 = 没有附件)。 +func TestHistoryMessageDocumentAbsentWhenNoColumn(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) + } +} + +// TestHistoryMessageDocumentMalformedColumnIsVisible 验证列里是坏 JSON 时 +// 不静默当成「没有附件」:回一个标着 status=unreadable 的最小对象,让客户端 +// 看得见这条引用读不出来(design/24 §4.4 的「失败必须可见」)。 +func TestHistoryMessageDocumentMalformedColumnIsVisible(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 wire.Document.Status != history.DocumentStatusUnreadable { + t.Fatalf("status = %q, want %q", wire.Document.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) + } + ref, err := history.DecodeDocument(raw) + if err != nil { + t.Fatalf("decode document: %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) + } +} + +// 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) + } +} diff --git a/internal/services/gateway/session_persist.go b/internal/services/gateway/session_persist.go index dadde26..a3414d3 100644 --- a/internal/services/gateway/session_persist.go +++ b/internal/services/gateway/session_persist.go @@ -69,11 +69,17 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string if m.Role != model.RoleUser { continue } - text, image := messageTextImage(m) - if text == "" && image == "" { + text, image, document, err := messageTextImageDocument(m) + if err != nil { + // 文档引用编码失败(理论上不可达:引用只含字符串与整数)只丢那一列 + // 并留下带来源的日志,整条消息照常落库 —— 丢一条消息远比丢一个附件 + // 引用严重,而且这里绝不静默。 + o.log.Error("orchestrator persist document", "err", err, "session", sessionID, "device_id", deviceID) + } + if text == "" && image == "" && document == "" { continue } - msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID} + msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, Document: document, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID} // 幂等键只标注**第一条**用户消息:一个回合至多只有一条用户消息,而把 // 同一个键盖到多条上会让它们互相冲突(唯一索引把它们判成同一条)。 if hint.ClientMsgID != "" { @@ -122,16 +128,49 @@ func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result, o.notifyPersisted(full) } -// messageTextImage 返回消息内容部分拼接后的文本与首个图片 -// URL,供持久化进 chat_messages 各列。 -func messageTextImage(m model.Message) (text, image string) { +// messageTextImageDocument 返回消息内容部分拼接后的文本、首个图片 URL 与首个 +// 文档引用(已编码为落库用的 JSON 小对象),供持久化进 chat_messages 各列。 +// +// 文档那一半只落**引用与统计**(design/24 §2.4):装配期已经把抽取所得的正文 +// 替换成文本部件,因此这里绝不把正文写进 document 列 —— 正文一旦落库,每翻 +// 一页历史都要背着它重传(G3 的教训)。抽取统计(chars/truncated/status)属于 +// 装配期与抽取器,模型层只携带引用本身;编码失败时返回错误,由调用方决定如何 +// 可见地报告,而不是在这里静默当成「没有附件」。 +func messageTextImageDocument(m model.Message) (text, image, document string, err error) { for _, c := range m.Content { - if c.Type == model.ContentTypeText { + switch c.Type { + case model.ContentTypeText: text += c.Text - } else if c.Type == model.ContentTypeImage && image == "" { - image = c.ImageURL + case model.ContentTypeImage: + if image == "" { + image = c.ImageURL + } + case model.ContentTypeDocument: + if document != "" { + continue + } + encoded, err := history.EncodeDocument(documentRefOf(c)) + if err != nil { + return text, image, "", err + } + document = encoded } } + return text, image, document, nil +} + +// documentRefOf 把文档内容片段收成落库用的引用。四个字段全部来自服务端 +// (存储名、嗅探所得 MIME、本地引用、字节数),客户端的原始文件名与声明 +// Content-Type 都不在其中(design/24 §2.2/§3.2)。 +func documentRefOf(c model.Content) history.DocumentRef { + return history.DocumentRef{Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize} +} + +// messageTextImage 是 messageTextImageDocument 的窄视图:只要文本与首个图片 +// URL。session_steer 的转写行配对只按这两项匹配,文档引用不参与;它也就 +// 刻意不浮现文档编码的错误(那属于持久化路径的关切)。 +func messageTextImage(m model.Message) (text, image string) { + text, image, _, _ = messageTextImageDocument(m) return text, image }