根因:摄取结果只挂到「内存里的模型可见消息」(inbound.go 的 attachInboundAttachments), 落库时只传了 text、history.Message 没有 Document 字段 ⇒ 该列恒空 ⇒ 读侧 omitempty 导致 documents 键整个缺席 ⇒ 任何客户端只看到一条空消息。Web 侧不这样,是因为 gateway/session_persist.go 有 messageTextImageDocument 把 ContentTypeDocument 部件编码进该列 —— IM 路径缺的就是同一步。 修法:inboundAttachments.documentColumn() 复用 history.EncodeDocuments 同一口径写出该列; persistToMain/persistIsolated 增 document 参数并返回刚落库的消息;新增两段式第二段 persistTurnDocumentStatus 按 id 回写抽取结论(只补结论、按存储名匹配、匹配不到原样留), 与 gateway/session_document_status.go 逐字对齐。 契约零扩张:无新端点/帧/字段;0 附件 ⇒ 列空 ⇒ 键省略;形状即 web 的同一结构体。 测试 6 条(含元素 JSON 逐字断言、unreadable 带 reason、被拒不编造芯片、群聊隔离分支、 真循环端到端);变异两条(还原挂载 / 关闭结论回写)⇒ 各 4 条红。
408 lines
17 KiB
Go
408 lines
17 KiB
Go
package channelbridge
|
||
|
||
// 本文件锁定 **IM 入站附件的用户消息落库**(design/24 §2.4 / §6.1)—— 安卓侧报的
|
||
// P0:字节取回了、落盘了、抽取了、模型也读到了,但**那条用户消息的 `document` 列
|
||
// 是空的**,于是所有客户端(安卓 App、Web 面板)看那条消息都是一片空白
|
||
// (`content:""`、`documents` 键缺席)。缺的就是「摄取结果 → 用户消息落库」之间的
|
||
// 那一步。
|
||
//
|
||
// 四条钉住的事实:
|
||
// - 带附件的回合:落库的用户消息 `document` 列 = 与 Web 上传**同一个结构体**的
|
||
// 引用数组(形状逐字一致),身份取自服务端存储名;
|
||
// - 本回合注入侧的结论(status/chars/truncated/reason)在回合结束后补进同一列;
|
||
// - 无附件的回合:该列为空串 ⇒ 读侧省略 `documents` 键(不是空数组);
|
||
// - 附件读不到:摄取期被拒 ⇒ 没有引用(绝不编造一条指向不存在文件的芯片),
|
||
// 诚实提示照旧只进模型可见视图;落库后抽取失败 ⇒ 引用上带 status=unreadable
|
||
// 与原因。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"unicode/utf8"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
)
|
||
|
||
// documentHistory 是记录型历史存储(在 captureHistory 之上),补齐真存储才有、
|
||
// 而本用例必须依赖的两件事:
|
||
// - **给每条消息分配 id**:真存储总会分配,而「回合后补结论」正是按 id 定位的;
|
||
// - **UpdateMessageDocument**:`document` 列的回写面(design/24 §6.1)。
|
||
//
|
||
// 用替身而不是真 SQLite,是因为本用例只关心桥接器往那一列写了什么,不关心 SQL;
|
||
// 那一列本身的读写语义另有 internal/runtime/history 的用例覆盖。
|
||
type documentHistory struct {
|
||
*captureHistory
|
||
mu sync.Mutex
|
||
seq int
|
||
updates map[string]string
|
||
}
|
||
|
||
func (h *documentHistory) AppendFull(ctx context.Context, msg history.Message) (history.Message, error) {
|
||
if msg.ID == "" {
|
||
h.mu.Lock()
|
||
h.seq++
|
||
msg.ID = fmt.Sprintf("msg-%d", h.seq)
|
||
h.mu.Unlock()
|
||
}
|
||
return h.captureHistory.AppendFull(ctx, msg)
|
||
}
|
||
|
||
func (h *documentHistory) UpdateMessageDocument(_ context.Context, id, document string) error {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if h.updates == nil {
|
||
h.updates = make(map[string]string)
|
||
}
|
||
h.updates[id] = document
|
||
return nil
|
||
}
|
||
|
||
// columnOf 返回 id 所指消息**当前**的 `document` 列:被回写过就是回写值(真存储
|
||
// 就是同一列被更新,这里是它的等价物),否则是落库那一刻写下的值。
|
||
func (h *documentHistory) columnOf(id string) string {
|
||
h.mu.Lock()
|
||
updated, ok := h.updates[id]
|
||
h.mu.Unlock()
|
||
if ok {
|
||
return updated
|
||
}
|
||
for _, m := range h.records() {
|
||
if m.ID == id {
|
||
return m.Document
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// userRecord 返回最先落库的那条用户消息(一个回合只有一条用户消息)。
|
||
func (h *documentHistory) userRecord(t *testing.T) history.Message {
|
||
t.Helper()
|
||
for _, m := range h.records() {
|
||
if m.Role == history.RoleUser {
|
||
return m
|
||
}
|
||
}
|
||
t.Fatal("本回合没有落库任何用户消息")
|
||
return history.Message{}
|
||
}
|
||
|
||
// updateCount 返回回写发生的次数(0 = 这一列从未被补过结论)。
|
||
func (h *documentHistory) updateCount() int {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
return len(h.updates)
|
||
}
|
||
|
||
// loopStatusChatter 是桩 Chatter:它扮演**装配期解析的出口** —— 从用户消息里读出
|
||
// 桥接器挂上去的文档部件,按服务端存储名把本回合注入侧的结论(status/chars/reason)
|
||
// 回给桥接器,形状与真循环 `Result.Documents` 逐字一致(loop/document.go 的
|
||
// recordDocuments)。status 本身的算法由 loop 包的用例覆盖,这里只验证「结论回到
|
||
// 了写库的那一列」这一步。
|
||
type loopStatusChatter struct {
|
||
status string
|
||
chars int
|
||
reason string
|
||
// messages 记下本次传给循环的消息(模型可见视图),供「诚实提示只进可见视图、
|
||
// 绝不进持久化正文」这类断言使用。
|
||
messages []model.Message
|
||
res *loop.Result
|
||
}
|
||
|
||
func (c *loopStatusChatter) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) {
|
||
c.messages = msgs
|
||
c.res = &loop.Result{Text: "看完了"}
|
||
for _, m := range msgs {
|
||
if m.Role != model.RoleUser {
|
||
continue
|
||
}
|
||
for _, part := range m.Content {
|
||
if part.Type != model.ContentTypeDocument {
|
||
continue
|
||
}
|
||
c.res.Documents = append(c.res.Documents, history.DocumentRef{
|
||
Name: part.DocumentName, MIME: part.DocumentMIME, Size: part.DocumentSize,
|
||
Chars: c.chars, Status: c.status, Reason: c.reason,
|
||
})
|
||
}
|
||
}
|
||
return c.res, nil
|
||
}
|
||
|
||
// newPersistEnv 装配最小入站流水线:真 `process` + 给定 Chatter + 记录型 history/
|
||
// adapter + 真文档存储(真落盘)。它是本文件所有用例的夹具。
|
||
func newPersistEnv(t *testing.T, chat Chatter, cfg documents.StoreConfig) (*Bridge, *documentHistory, string) {
|
||
t.Helper()
|
||
if cfg.UploadsDir == "" {
|
||
cfg.UploadsDir = filepath.Join(t.TempDir(), "uploads", "documents")
|
||
}
|
||
hist := &documentHistory{captureHistory: &captureHistory{}}
|
||
b, err := New(Options{
|
||
Loop: chat,
|
||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
|
||
Owner: testOwnerProfile("telegram", "100"),
|
||
History: hist,
|
||
DocumentStore: cfg,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
b.Add(&fakeAdapter{name: "telegram"})
|
||
return b, hist, cfg.UploadsDir
|
||
}
|
||
|
||
// decodeColumn 解码落库的 `document` 列(坏列直接判失败:本用例写下的必须是合法
|
||
// 引用数组)。
|
||
func decodeColumn(t *testing.T, column string) []history.DocumentRef {
|
||
t.Helper()
|
||
refs, err := history.DecodeDocuments(column)
|
||
if err != nil {
|
||
t.Fatalf("解码 document 列 %q: %v", column, err)
|
||
}
|
||
return refs
|
||
}
|
||
|
||
// assertWebDocumentShape 钉住「元素形状与 Web 侧逐字一致」:`documents` 数组的元素
|
||
// 就是 history.DocumentRef 的 JSON 形态(契约 v0.59 的 name/mime/size/chars/
|
||
// truncated/status/reason)。这里比对**完整 JSON 串**,因此键名、键序与省略规则
|
||
// (omitempty)一起被钉住 —— 两侧客户端不可能读到两种形状。
|
||
func assertWebDocumentShape(t *testing.T, ref history.DocumentRef, want string) {
|
||
t.Helper()
|
||
raw, err := json.Marshal(ref)
|
||
if err != nil {
|
||
t.Fatalf("marshal document ref: %v", err)
|
||
}
|
||
if string(raw) != want {
|
||
t.Fatalf("documents 元素 = %s, want %s", raw, want)
|
||
}
|
||
}
|
||
|
||
// TestInboundAttachmentRefsLandOnPersistedUserMessage 是这条 P0 的主验收用例:一条
|
||
// 带附件的主人私聊消息走完真流水线后,**落库的用户消息**必须带着引用数组 —— 与 Web
|
||
// 上传同字段、同形状;本回合的抽取结论在回合结束后补进同一列。
|
||
func TestInboundAttachmentRefsLandOnPersistedUserMessage(t *testing.T) {
|
||
t.Parallel()
|
||
// 与主人报的那条一致:10 字节的 .txt。
|
||
body := []byte("你好,昔涟\n")
|
||
chat := &loopStatusChatter{status: history.DocumentStatusExtracted, chars: 5}
|
||
b, hist, uploads := newPersistEnv(t, chat, documents.StoreConfig{})
|
||
|
||
b.process(context.Background(), fileMessage("看看这个", "1.txt", body, "text/plain"))
|
||
|
||
names := docDirNames(t, uploads)
|
||
if len(names) != 1 {
|
||
t.Fatalf("落盘文件数 = %v, want 1", names)
|
||
}
|
||
name := names[0]
|
||
|
||
user := hist.userRecord(t)
|
||
if user.Content != "看看这个" {
|
||
t.Fatalf("用户消息正文 = %q, want 用户文本(附件引用是另一列,绝不拼进正文)", user.Content)
|
||
}
|
||
if user.Document == "" {
|
||
t.Fatal("用户消息的 document 列为空 —— 客户端看到的就是一片空白(本用例要钉住的 P0)")
|
||
}
|
||
refs := decodeColumn(t, hist.columnOf(user.ID))
|
||
if len(refs) != 1 {
|
||
t.Fatalf("引用条数 = %d, want 1: %+v", len(refs), refs)
|
||
}
|
||
got := refs[0]
|
||
// 身份一律取自服务端存储:存储名就是客户端去读的那一个(内容寻址),
|
||
// MIME 是嗅探所得,大小是落盘字节数 —— 渠道给的原始文件名与声明类型都不在其中。
|
||
if got.Name != name || got.MIME != "text/plain" || got.Size != int64(len(body)) {
|
||
t.Fatalf("引用身份 = %+v, want {%s text/plain %d}", got, name, len(body))
|
||
}
|
||
if got.Status != history.DocumentStatusExtracted || got.Chars != 5 || got.Truncated {
|
||
t.Fatalf("引用结论 = %+v, want extracted/5/未截断(回合结束后回写)", got)
|
||
}
|
||
assertWebDocumentShape(t, got,
|
||
fmt.Sprintf(`{"name":"%s","mime":"text/plain","size":%d,"chars":5,"status":"extracted"}`, name, len(body)))
|
||
}
|
||
|
||
// TestInboundUnreadableAttachmentKeepsStatusAndReason 钉住「读不到」的两个出口同时
|
||
// 成立:落库后抽取失败 ⇒ 引用上带 status=unreadable **且**原因文案还在(契约
|
||
// v0.63:reason 与 status 同批下发,客户端不必自己拼一句服务端没说过的话)。
|
||
func TestInboundUnreadableAttachmentKeepsStatusAndReason(t *testing.T) {
|
||
t.Parallel()
|
||
const reason = "无法抽取正文:读取或解析失败"
|
||
body := []byte("你好,昔涟\n")
|
||
chat := &loopStatusChatter{status: history.DocumentStatusUnreadable, reason: reason}
|
||
b, hist, uploads := newPersistEnv(t, chat, documents.StoreConfig{})
|
||
|
||
b.process(context.Background(), fileMessage("看看这个", "1.txt", body, "text/plain"))
|
||
|
||
name := docDirNames(t, uploads)[0]
|
||
refs := decodeColumn(t, hist.columnOf(hist.userRecord(t).ID))
|
||
if len(refs) != 1 {
|
||
t.Fatalf("引用条数 = %d, want 1: %+v", len(refs), refs)
|
||
}
|
||
if refs[0].Status != history.DocumentStatusUnreadable || refs[0].Reason != reason {
|
||
t.Fatalf("引用结论 = %+v, want unreadable + 原因 %q", refs[0], reason)
|
||
}
|
||
if refs[0].Chars != 0 || refs[0].Truncated {
|
||
t.Fatalf("读不到却带上了注入统计 = %+v(凭空给它一个数字就是编造)", refs[0])
|
||
}
|
||
assertWebDocumentShape(t, refs[0],
|
||
fmt.Sprintf(`{"name":"%s","mime":"text/plain","size":%d,"status":"unreadable","reason":"%s"}`, name, len(body), reason))
|
||
}
|
||
|
||
// TestInboundRejectedAttachmentKeepsNoticeWithoutRefs 钉住「读不到」的另一条出口:
|
||
// 附件在**摄取期**就被共享存储拒掉(不在可读白名单 / 非纯文本 / 超限)⇒ 没有存储名
|
||
// 就没有引用,`document` 列必须为空 —— 绝不编造一条指向不存在文件的芯片。诚实提示
|
||
// 落在**模型可见视图**里(design/24 §4.3:人类可见的来源标记归客户端渲染层,绝不
|
||
// 拼进持久化正文,见本文件对 user.Content 的断言)。
|
||
func TestInboundRejectedAttachmentKeepsNoticeWithoutRefs(t *testing.T) {
|
||
t.Parallel()
|
||
chat := &loopStatusChatter{}
|
||
b, hist, uploads := newPersistEnv(t, chat, documents.StoreConfig{})
|
||
|
||
b.process(context.Background(), fileMessage("跑一下这个", "tool.py", []byte("print('x')\n"), "text/x-python"))
|
||
|
||
if names := docDirNames(t, uploads); len(names) != 0 {
|
||
t.Fatalf("被拒的附件落盘了:%v", names)
|
||
}
|
||
user := hist.userRecord(t)
|
||
if user.Document != "" {
|
||
t.Fatalf("被拒附件的 document 列 = %q, want 空串", user.Document)
|
||
}
|
||
if user.Content != "跑一下这个" {
|
||
t.Fatalf("持久化正文 = %q, want 只有用户文本(提示行绝不拼进正文)", user.Content)
|
||
}
|
||
var visible strings.Builder
|
||
for _, m := range chat.messages {
|
||
for _, part := range m.Content {
|
||
visible.WriteString(part.Text)
|
||
visible.WriteString("\n")
|
||
}
|
||
}
|
||
if !strings.Contains(visible.String(), "tool.py") || !strings.Contains(visible.String(), "无法读取") {
|
||
t.Fatalf("模型可见视图里没有诚实的拒绝原因:%q", visible.String())
|
||
}
|
||
}
|
||
|
||
// TestInboundMessageWithoutAttachmentsOmitsDocumentColumn 钉住边界:无附件的消息
|
||
// 落库时那一列是空串(不是空数组)—— 读侧据此**省略** `documents` 键,与引入附件
|
||
// 之前逐字相同(网关侧 history_document_test.go 钉住省略本身)。
|
||
func TestInboundMessageWithoutAttachmentsOmitsDocumentColumn(t *testing.T) {
|
||
t.Parallel()
|
||
chat := &loopStatusChatter{}
|
||
b, hist, _ := newPersistEnv(t, chat, documents.StoreConfig{})
|
||
|
||
b.process(context.Background(), directMessage())
|
||
|
||
user := hist.userRecord(t)
|
||
if user.Content != "hello" {
|
||
t.Fatalf("用户消息正文 = %q, want hello", user.Content)
|
||
}
|
||
if user.Document != "" {
|
||
t.Fatalf("无附件消息的 document 列 = %q, want 空串", user.Document)
|
||
}
|
||
if n := hist.updateCount(); n != 0 {
|
||
t.Fatalf("无附件却发生了 %d 次 document 回写", n)
|
||
}
|
||
}
|
||
|
||
// TestInboundGroupAttachmentRefsLandOnIsolatedUserMessage 钉住受限(群聊/陌生人)
|
||
// 会话走的是另一个落库钩子(persistIsolated):第三方消息的附件同样要把引用写进
|
||
// 它自己那条日志的用户消息上,且**绝不**镜像进主人主会话。
|
||
func TestInboundGroupAttachmentRefsLandOnIsolatedUserMessage(t *testing.T) {
|
||
t.Parallel()
|
||
uploads := filepath.Join(t.TempDir(), "uploads", "documents")
|
||
chat := &loopStatusChatter{status: history.DocumentStatusListed}
|
||
hist := &documentHistory{captureHistory: &captureHistory{}}
|
||
b, err := New(Options{
|
||
Loop: chat,
|
||
Sessions: &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false}},
|
||
Owner: &user.OwnerProfile{},
|
||
History: hist,
|
||
DocumentStore: documents.StoreConfig{UploadsDir: uploads},
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
msg := adapter.UnifiedMessage{
|
||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
||
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
|
||
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
|
||
Content: "看下这个", ContentType: adapter.ContentText,
|
||
Media: []adapter.Media{{
|
||
Kind: adapter.MediaFile, Name: "third.txt", Size: 4, MIMEType: "text/plain", Data: []byte("hi\n"),
|
||
}},
|
||
}
|
||
|
||
b.process(context.Background(), msg)
|
||
|
||
name := docDirNames(t, uploads)[0]
|
||
user := hist.userRecord(t)
|
||
if user.SessionID != "sess-g" {
|
||
t.Fatalf("用户消息落库会话 = %q, want 隔离会话 sess-g", user.SessionID)
|
||
}
|
||
refs := decodeColumn(t, hist.columnOf(user.ID))
|
||
if len(refs) != 1 || refs[0].Name != name {
|
||
t.Fatalf("隔离会话的引用 = %+v, want 1 条指向 %s", refs, name)
|
||
}
|
||
if refs[0].Status != history.DocumentStatusListed {
|
||
t.Fatalf("隔离会话的引用结论 = %+v, want listed(回合后回写)", refs[0])
|
||
}
|
||
}
|
||
|
||
// TestInboundAttachmentConclusionComesFromRealLoop 是这条链路的端到端验证:真存储 +
|
||
// 真抽取器 + **真循环** + 假模型(夹具沿用 inbound_documents_test.go 的 stubModelAdapter)。
|
||
// 它钉住「一侧的事实」:模型可见视图里出现清单行与正文,与回给客户端同一列的结论
|
||
// (extracted + 注入字符数)必须来自同一次装配 —— 否则「她读到了」与「客户端显示
|
||
// 她读到了」就会分叉。
|
||
func TestInboundAttachmentConclusionComesFromRealLoop(t *testing.T) {
|
||
t.Parallel()
|
||
dataDir := t.TempDir()
|
||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||
modelAdapter := &stubModelAdapter{}
|
||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
|
||
if err != nil {
|
||
t.Fatalf("构造 agent: %v", err)
|
||
}
|
||
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
runner := loop.New(loop.Options{
|
||
Agent: ag,
|
||
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads, Log: discard}),
|
||
MaxRounds: 4,
|
||
Log: discard,
|
||
})
|
||
body := []byte("你好,昔涟\n")
|
||
b, hist, _ := newPersistEnv(t, runner, documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads})
|
||
|
||
b.process(context.Background(), fileMessage("看看这个", "1.txt", body, "text/plain"))
|
||
|
||
name := docDirNames(t, uploads)[0]
|
||
refs := decodeColumn(t, hist.columnOf(hist.userRecord(t).ID))
|
||
if len(refs) != 1 {
|
||
t.Fatalf("引用条数 = %d, want 1: %+v", len(refs), refs)
|
||
}
|
||
got := refs[0]
|
||
if got.Name != name || got.MIME != "text/plain" || got.Size != int64(len(body)) {
|
||
t.Fatalf("引用身份 = %+v, want {%s text/plain %d}", got, name, len(body))
|
||
}
|
||
if got.Status != history.DocumentStatusExtracted {
|
||
t.Fatalf("引用结论 = %+v, want extracted(真循环的装配期结论)", got)
|
||
}
|
||
if want := utf8.RuneCountInString(string(body)); got.Chars != want {
|
||
t.Fatalf("注入字符数 = %d, want %d(chars 是**本回合注入侧**的字符数)", got.Chars, want)
|
||
}
|
||
visible := modelAdapter.visibleText()
|
||
if !strings.Contains(visible, "[附件 "+name+" · ") || !strings.Contains(visible, string(body)) {
|
||
t.Fatalf("模型可见视图里没有该附件的清单行或正文:%q", visible)
|
||
}
|
||
}
|