feat(runtime): document 内容类型与持久化(design/24 S2)
- 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 负例红。
This commit is contained in:
1 parent
407c934a05
commit
6b2b07cbef
15 files changed
+895
-101
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user