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 主会话是跨平台
@@ -436,9 +436,9 @@ func TestHubStreamChatResolvesDocuments(t *testing.T) {
func TestTranscriptMessageKeepsDocumentReference(t *testing.T) {
t.Parallel()
const name = "0123456789abcdef0123456789abcdef.txt"
raw, err := history.EncodeDocument(history.DocumentRef{
raw, err := history.EncodeDocuments([]history.DocumentRef{{
Name: name, MIME: documentSniffOKMIME, Size: 12345, Status: history.DocumentStatusExtracted,
})
}})
if err != nil {
t.Fatalf("encode document: %v", err)
}
+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
}
@@ -14,19 +14,19 @@ import (
// 引用与统计往返、**不回传抽取正文**、坏列可见,以及 session_persist 的
// messageTextImageDocument 取值。
// TestHistoryMessageDocumentCarriesReferenceOnly 验证 document 对象只带
// TestHistoryMessageDocumentsCarryReferenceOnly 验证 documents 数组的元素只带
// 引用与统计:字段集被逐个钉住,将来谁往 DocumentRef 里加一个正文/摘要字段,
// 这里就会红。
func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) {
func TestHistoryMessageDocumentsCarryReferenceOnly(t *testing.T) {
t.Parallel()
raw, err := history.EncodeDocument(history.DocumentRef{
raw, err := history.EncodeDocuments([]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)
}
@@ -40,9 +40,13 @@ func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) {
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
doc, ok := decoded["document"].(map[string]any)
docs, ok := decoded["documents"].([]any)
if !ok || len(docs) != 1 {
t.Fatalf("documents array missing in %s", b)
}
doc, ok := docs[0].(map[string]any)
if !ok {
t.Fatalf("document object missing in %s", b)
t.Fatalf("documents[0] is not an object in %s", b)
}
keys := make([]string, 0, len(doc))
for k := range doc {
@@ -50,38 +54,61 @@ func TestHistoryMessageDocumentCarriesReferenceOnly(t *testing.T) {
}
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)
t.Fatalf("documents[0] 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)
t.Fatalf("documents[0] = %v, want the persisted reference", doc)
}
}
// TestHistoryMessageDocumentAbsentWhenNoColumn 验证没有附件的消息省略该字段:
// TestHistoryMessageDocumentsAbsentWhenNoColumn 验证没有附件的消息省略该字段:
// 空列与「带附件」必须可区分(省略 = 没有附件)。
func TestHistoryMessageDocumentAbsentWhenNoColumn(t *testing.T) {
func TestHistoryMessageDocumentsAbsentWhenNoColumn(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)
if strings.Contains(string(b), `"documents"`) {
t.Fatalf("empty column should omit documents, got %s", b)
}
}
// TestHistoryMessageDocumentMalformedColumnIsVisible 验证列里是坏 JSON 时
// 不静默当成「没有附件」:回一个标着 status=unreadable 的最小对象,让客户端
// TestHistoryMessageDocumentsMultipleKeepOrder 验证一回合多个附件原样回传
// (缺口①):数组长度与顺序都一致,客户端因此不会只看到第一个附件。
func TestHistoryMessageDocumentsMultipleKeepOrder(t *testing.T) {
t.Parallel()
raw, err := history.EncodeDocuments([]history.DocumentRef{
{Name: "aaa.txt", MIME: "text/plain", Size: 1},
{Name: "bbb.pdf", MIME: "application/pdf", Size: 2, Status: history.DocumentStatusUnreadable},
{Name: "ccc.csv", MIME: "text/csv", Size: 3, Chars: 9, Status: history.DocumentStatusExtracted},
})
if err != nil {
t.Fatalf("encode: %v", err)
}
wire := toHistoryMessage(history.Message{ID: "m1", Role: "user", Content: "三个附件", Document: raw})
if len(wire.Documents) != 3 {
t.Fatalf("Documents = %+v, want 3 条", wire.Documents)
}
for i, want := range []string{"aaa.txt", "bbb.pdf", "ccc.csv"} {
if wire.Documents[i].Name != want {
t.Fatalf("Documents[%d].Name = %q, want %q(顺序必须与发送一致)", i, wire.Documents[i].Name, want)
}
}
}
// TestHistoryMessageDocumentsMalformedColumnIsVisible 验证列里是坏内容时
// 不静默当成「没有附件」:回一条标着 status=unreadable 的最小引用,让客户端
// 看得见这条引用读不出来(design/24 §4.4 的「失败必须可见」)。
func TestHistoryMessageDocumentMalformedColumnIsVisible(t *testing.T) {
func TestHistoryMessageDocumentsMalformedColumnIsVisible(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 len(wire.Documents) != 1 {
t.Fatalf("Documents = %+v, want 一条 unreadable 的最小引用", wire.Documents)
}
if wire.Document.Status != history.DocumentStatusUnreadable {
t.Fatalf("status = %q, want %q", wire.Document.Status, history.DocumentStatusUnreadable)
if wire.Documents[0].Status != history.DocumentStatusUnreadable {
t.Fatalf("status = %q, want %q", wire.Documents[0].Status, history.DocumentStatusUnreadable)
}
}
@@ -103,17 +130,17 @@ func TestMessageTextImageDocument(t *testing.T) {
if image != "data:image/png;base64,abc" {
t.Fatalf("image = %q", image)
}
ref, err := history.DecodeDocument(raw)
refs, err := history.DecodeDocuments(raw)
if err != nil {
t.Fatalf("decode document: %v", err)
t.Fatalf("decode documents: %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)
if len(refs) != 1 || refs[0].Name != doc.Name || refs[0].MIME != doc.MIME || refs[0].Size != doc.Size {
t.Fatalf("refs = %+v, want name/mime/size of %+v", refs, doc)
}
}
// TestMessageTextImageDocumentWithoutDocument 验证没有文档部件时文档列为空
// (而不是 "{}"):拼写空对象会让读取侧把它当成一条读不出来的附件。
// (而不是 "[]"/"{}"):拼一个空形状会让读取侧把它当成一条读不出来的附件。
func TestMessageTextImageDocumentWithoutDocument(t *testing.T) {
t.Parallel()
_, _, raw, err := messageTextImageDocument(model.TextMessage(model.RoleUser, "只有文本"))
@@ -0,0 +1,157 @@
package gateway
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"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"
)
// 本文件是 S3.5 在 **HTTP 层**的端到端回归(design/24 §6.1、§9.7):一次 `chat`
// 带上 3 个附件 → 全部落库 → 回合结束后抽取结论回写 → 历史接口按 `documents`
// 数组把引用与结论一起交回客户端。它补齐单测覆盖不到的那一段接线:REST 入口 →
// 编排器 → 真实 history 存储 → 历史读端点。
// docStatusRunner 是端到端的假回合驱动器:它把这一回合用户消息里的**每一个**
// 文档部件都当成「已抽取」的结论回给网关(正是真实抽取层 reshape 后的形状),
// 并记录它实际看到的文档部件数。
type docStatusRunner struct {
mu sync.Mutex
docs []model.Content
calls int
}
func (r *docStatusRunner) Run(_ context.Context, _ string, msgs []model.Message) (*loop.Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls++
refs := make([]history.DocumentRef, 0, len(r.docs))
for _, m := range msgs {
for _, c := range m.Content {
if c.Type != model.ContentTypeDocument {
continue
}
r.docs = append(r.docs, c)
refs = append(refs, history.DocumentRef{
Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize,
Chars: 12, Status: history.DocumentStatusExtracted,
})
}
}
return &loop.Result{Text: "读完了", Documents: refs, Steps: 1, StopReason: "stop"}, nil
}
// documentCount 返回该回合被驱动时看到的文档部件数。
func (r *docStatusRunner) documentCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.docs)
}
// TestChatDocumentsEndToEndCarriesEveryAttachmentAndConclusion 是 S3.5 两个缺口的
// 端到端回归:3 个附件全部进入模型视图、全部落库,且落库的每一行都带回
// status/chars/truncated(此前只有第一个附件落库,且结论永远到不了客户端)。
func TestChatDocumentsEndToEndCarriesEveryAttachmentAndConclusion(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
runner := &docStatusRunner{}
st := newGatewayHistoryStore(t)
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: runner, History: st})
if err != nil {
t.Fatalf("构造网关失败: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
t.Cleanup(srv.Close)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token 失败: %v", err)
}
names := []string{
storeGatewayDocument(t, dataDir, "第一份", ".txt"),
storeGatewayDocument(t, dataDir, "第二份", ".md"),
storeGatewayDocument(t, dataDir, "第三份", ".csv"),
}
refs := make([]chatDocumentRef, 0, len(names))
for _, name := range names {
refs = append(refs, chatDocumentRef{Name: name})
}
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/chat", map[string]any{
"sessionId": history.MainSessionID, "text": "这三份都看看", "documents": refs,
}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if got := runner.documentCount(); got != 3 {
t.Fatalf("进入模型可见视图的文档部件 = %d 个, want 3(缺口①:附件不能丢)", got)
}
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("messages status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var envelope struct {
Data []struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Documents []history.DocumentRef `json:"documents"`
} `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("unmarshal: %v (body=%s)", err, body)
}
var userDocs []history.DocumentRef
for _, m := range envelope.Data {
if m.Role == history.RoleUser && m.Content == "这三份都看看" {
userDocs = m.Documents
}
}
if len(userDocs) != 3 {
t.Fatalf("历史接口返回的 documents = %+v, want 3 条(缺口①)", userDocs)
}
for i, name := range names {
if userDocs[i].Name != name {
t.Fatalf("documents[%d].Name = %q, want %q(顺序必须与发送一致)", i, userDocs[i].Name, name)
}
if userDocs[i].Status != history.DocumentStatusExtracted || userDocs[i].Chars != 12 {
t.Fatalf("documents[%d] = %+v, want extracted/chars=12(缺口②)", i, userDocs[i])
}
}
}
// storeGatewayDocument 按 S1 的内容寻址规则(sha256 前 32 位 + 扩展名)在
// `<DataDir>/uploads/documents` 下落一份文档并返回其存储名(与 chat_documents_test
// 的 helper 同规则;这里在 HTTP 端到端用例里独立使用,避免依赖那边的 env)。
func storeGatewayDocument(t *testing.T, dataDir, content, ext string) string {
t.Helper()
sum := sha256.Sum256([]byte(content))
name := hex.EncodeToString(sum[:])[:documentHashHexLen] + ext
if !validDocumentName(name) {
t.Fatalf("构造的存储名 %q 不是合法形态", name)
}
dir := filepath.Join(dataDir, "uploads", documentSubDir)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("创建文档目录失败: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatalf("写入文档失败: %v", err)
}
return name
}
@@ -0,0 +1,190 @@
package gateway
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 本文件归属**文档抽取结论的回写闭环**(design/24 §4.4/§6.1,S3.5 缺口②)。
//
// 用户消息在回合**开始前**落库(session_persist.go 的 persistUserMessages),
// 那时文档抽取还没发生,document 列里只有引用、没有结论。抽取结论要等回合跑
// 起来(loop 的装配期解析)才存在,于是本文件负责在**回合结束后**把它补回同一
// 列 —— 两段式是刻意的,也是客户端能看到「昔涟读不到这个文件」的唯一路径。
//
// 三条纪律:
// - 只补结论(status/chars/truncated),身份与大小逐字保留;
// - 按**服务端存储名**匹配,匹配不到就原样留着(凭空给它一个结论就是编造);
// - 找不到行、存储未实现该视图、写库失败一律带来源 WARN,绝不静默。
//
// 它从 session_persist.go 拆出来,使那两个文件都留在「单文件 ≤500 行」的硬约束
// 内(与 session_events.go / session_tail.go 同一理由)。
// documentStatusLogSource 是文档状态回写的日志来源标签,使这条冷路径上的
// WARN 能被追溯到具体子功能(AGENTS §10 的「日志必须标来源」)。
const documentStatusLogSource = "gateway/session-persist"
// turnDocuments 配对一次回合同它已落库的用户消息与所属代次(gen)。代次是
// 必须的:被打断或被新回合取代的陈旧回合在结算时不该去写别人的那一行。
type turnDocuments struct {
gen uint64
msgs []history.Message
}
// documentStatusWriter 是 HistoryStore 的一个**可选**更窄视图(与
// clientMsgIDReader / transcriptReader 同一做法):按消息 id 更新 document 列。
// 未实现该视图的存储替身或后端退回「不回写」并留下带来源的 WARN,不因此让
// 回合失败 —— 状态回传是增强,绝不能反过来卡住会话。
type documentStatusWriter interface {
UpdateMessageDocument(ctx context.Context, id, document string) error
}
// rememberTurnDocuments 记下本回合已落库的用户消息(按代次配对),供回合结束
// 后把抽取结论回写进它们的 document 列。空列表是空操作(这条回合没有落库任何
// 用户消息,也就没有结论可补)。
func (o *Orchestrator) rememberTurnDocuments(sessionID string, gen uint64, msgs []history.Message) {
if len(msgs) == 0 {
return
}
o.mu.Lock()
defer o.mu.Unlock()
if st := o.states[sessionID]; st != nil {
st.turnDocs = turnDocuments{gen: gen, msgs: msgs}
}
}
// attachPendingDocuments 把刚落库的用户消息挂到队列里 seq 所指的那条待处理回合
// 上,使它的续跑回合结算时能回写结论(它在入队那一刻就落了库,而那时还没有
// 任何结论)。
//
// 队列里已经找不到那条(被中断清空、已被弹出或已被续跑消费)时什么都不做:
// 那一行的结论没有谁能再补,也没有必要为此刷一条 WARN —— 它不是缺陷,是中断
// 的正常后果。
func (o *Orchestrator) attachPendingDocuments(sessionID string, seq uint64, msgs []history.Message) {
if len(msgs) == 0 {
return
}
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[sessionID]
if st == nil {
return
}
for i := range st.pending {
if st.pending[i].seq == seq {
st.pending[i].persisted = msgs
return
}
}
}
// takeTurnDocuments 取走与 gen 配对的已落库用户消息并清空记录。第二个返回值
// 报告这次代次是否仍是当代:非当代说明这是一次陈旧回合的迟到结算(中断/取代),
// 调用方据此静默返回 —— 迟到的结算是正常竞态,不是缺陷。
func (o *Orchestrator) takeTurnDocuments(sessionID string, gen uint64) ([]history.Message, bool) {
o.mu.Lock()
defer o.mu.Unlock()
st := o.states[sessionID]
if st == nil {
return nil, true
}
if st.turnDocs.gen != gen {
return nil, false
}
msgs := st.turnDocs.msgs
st.turnDocs = turnDocuments{}
return msgs, true
}
// persistDocumentStatus 在回合结束后把装配期解析到的文档结论回写进该回合用户
// 消息的 document 列(design/24 §4.4/§6.1)。它由 completeTurn 在助手回复落库
// 之后、状态机迁移之前调用,因此结论与该回合的对话内容一起同批入账。
//
// 找不到对应行(ErrMessageNotFound)、存储未接该视图、列被写坏、写库失败时都
// **不静默**:打一条带来源的 WARN 说明「状态未回写」。日志只带会话、消息 id、
// 条数与错误,**绝不带正文**,也不带列内容。
//
// 写入使用全新的后台 context(与 persistAssistantReply 同一理由):被取消回合
// 的 ctx 绝不该中断这次补写。
func (o *Orchestrator) persistDocumentStatus(sessionID string, gen uint64, res *loop.Result) {
if res == nil || len(res.Documents) == 0 {
return
}
log := logger.WithSource(o.log, documentStatusLogSource)
if o.history == nil {
log.Warn("document status not persisted", "reason", "no history store", "session", sessionID, "documents", len(res.Documents))
return
}
writer, ok := o.history.(documentStatusWriter)
if !ok {
log.Warn("document status not persisted", "reason", "history store has no document update path", "session", sessionID, "documents", len(res.Documents))
return
}
msgs, current := o.takeTurnDocuments(sessionID, gen)
if !current {
return
}
if len(msgs) == 0 {
log.Warn("document status not persisted", "reason", "no persisted user message for this turn", "session", sessionID, "documents", len(res.Documents))
return
}
ctx := context.Background()
for _, m := range msgs {
if m.Document == "" {
continue
}
merged, changed, err := mergeDocumentStatus(m.Document, res.Documents)
if err != nil {
log.Warn("document status merge", "err", err, "session", sessionID, "message", m.ID)
continue
}
if !changed {
continue
}
if err := writer.UpdateMessageDocument(ctx, m.ID, merged); err != nil {
log.Warn("document status update", "err", err, "session", sessionID, "message", m.ID)
}
}
}
// mergeDocumentStatus 把本回合解析到的结论按存储名并进已落库的引用数组:匹配到
// 的补上 status/chars/truncated,没匹配到的原样保留(那意味着本回合没有解析到
// 它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 ——
// 没变化就不必写库、也不必刷一条「什么都没变」的日志。
//
// 列被写坏时返回包装了 history 哨兵的错误,由调用方按「不静默」处理。
func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bool, error) {
refs, err := history.DecodeDocuments(raw)
if err != nil {
return "", false, err
}
conclusion := make(map[string]history.DocumentRef, len(resolved))
for _, ref := range resolved {
if ref.Name != "" {
conclusion[ref.Name] = ref
}
}
changed := false
for i := range refs {
got, ok := conclusion[refs[i].Name]
if !ok {
continue
}
if refs[i].Status == got.Status && refs[i].Chars == got.Chars && refs[i].Truncated == got.Truncated {
continue
}
refs[i].Status, refs[i].Chars, refs[i].Truncated = got.Status, got.Chars, got.Truncated
changed = true
}
if !changed {
return raw, false, nil
}
merged, err := history.EncodeDocuments(refs)
if err != nil {
return "", false, err
}
return merged, true, nil
}
@@ -0,0 +1,448 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"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"
)
// 本文件是 S3.5 两个契约缺口的回归测试(design/24 §6.1、§9.7):
// - 缺口①:一个回合的**全部**附件都要落库、都要从历史还原(此前只剩第一个);
// - 缺口②:抽取结论(status/chars/truncated)要在回合结束后回写进该回合用户
// 消息的 document 列,使历史接口与客户端看得到「读到了 / 读不到」。
//
// 测试断言一律哨兵 + errors.Is(绝不按错误文本子串断言);日志断言看的是记录
// 本身的 message/属性,而不是拼接后的文本。
// TestMessageTextImageDocumentCollectsEveryDocument 是缺口①的直接回归:一回合
// 挂 3 个附件时,落库的引用数组必须恰好是 3 条且顺序与部件顺序一致(此前的实现
// 对第二个及以后的文档部件直接 continue,只剩第一个)。
func TestMessageTextImageDocumentCollectsEveryDocument(t *testing.T) {
t.Parallel()
m := model.Message{Role: model.RoleUser, Content: []model.Content{{Type: model.ContentTypeText, Text: "看这三个"}}}
for _, name := range []string{"aaaabbbbccccdddd.txt", "eeff001122334455.md", "0011223344556677.csv"} {
m.Content = append(m.Content, model.DocumentMessage(model.RoleUser, "", model.Document{Name: name, MIME: "text/plain", Size: 7}).Content...)
}
text, _, raw, err := messageTextImageDocument(m)
if err != nil {
t.Fatalf("messageTextImageDocument: %v", err)
}
if text != "看这三个" {
t.Fatalf("text = %q", text)
}
refs, err := history.DecodeDocuments(raw)
if err != nil {
t.Fatalf("decode documents: %v", err)
}
if len(refs) != 3 {
t.Fatalf("refs = %+v, want 3 条(全部附件都要落库)", refs)
}
for i, want := range []string{"aaaabbbbccccdddd.txt", "eeff001122334455.md", "0011223344556677.csv"} {
if refs[i].Name != want {
t.Fatalf("refs[%d].Name = %q, want %q(顺序必须与部件一致)", i, refs[i].Name, want)
}
}
}
// TestTranscriptRestoresAllDocumentParts 是缺口①的另一半,且走真实存储:一条带
// 3 个引用的用户消息落库、读回、还原为 3 个文档部件(顺序一致);附件绝不能
// 在发起它的回合之后凭空消失。
func TestTranscriptRestoresAllDocumentParts(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newGatewayHistoryStore(t)
names := []string{"aaaabbbbccccdddd.txt", "eeff001122334455.md", "0011223344556677.csv"}
refs := make([]history.DocumentRef, 0, len(names))
for _, name := range names {
refs = append(refs, history.DocumentRef{Name: name, MIME: "text/plain", Size: 11, Status: history.DocumentStatusExtracted})
}
raw, err := history.EncodeDocuments(refs)
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "读这三份", Document: raw}); err != nil {
t.Fatalf("append: %v", err)
}
recent, err := st.Recent(ctx, history.MainSessionID, 10)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(recent) != 1 {
t.Fatalf("recent = %+v, want 1 条", recent)
}
parts := chatDocumentsOf([]model.Message{transcriptMessage(recent[0])})
if len(parts) != 3 {
t.Fatalf("还原出的文档部件 = %d 个, want 3(全部附件都要回到上下文)", len(parts))
}
for i, want := range names {
if parts[i].DocumentName != want {
t.Fatalf("parts[%d].DocumentName = %q, want %q", i, parts[i].DocumentName, want)
}
if parts[i].DocumentURL != documentFilePrefix+want {
t.Fatalf("parts[%d].DocumentURL = %q", i, parts[i].DocumentURL)
}
}
}
// TestTranscriptRestoresLegacySingleObjectColumn 钉住旧行兼容:S2/S3 早期写下的
// 单对象列仍能还原出那一个部件,绝不因为形状老就被当成坏数据丢掉。
func TestTranscriptRestoresLegacySingleObjectColumn(t *testing.T) {
t.Parallel()
const legacy = `{"name":"aaaabbbbccccdddd.txt","mime":"text/plain","size":1234,"status":"extracted"}`
parts := chatDocumentsOf([]model.Message{
transcriptMessage(history.Message{ID: "m1", Role: history.RoleUser, Content: "旧行", Document: legacy}),
})
if len(parts) != 1 || parts[0].DocumentName != "aaaabbbbccccdddd.txt" {
t.Fatalf("旧单对象列还原出 = %+v, want 那一条引用", parts)
}
}
// TestMergeDocumentStatus 覆盖合并规则:按存储名补结论(身份与大小不动)、没匹配
// 到的原样保留、无变化时报「不必写库」、坏列报哨兵错误。
func TestMergeDocumentStatus(t *testing.T) {
t.Parallel()
raw, err := history.EncodeDocuments([]history.DocumentRef{
{Name: "aaa.txt", MIME: "text/plain", Size: 1},
{Name: "bbb.pdf", MIME: "application/pdf", Size: 2},
})
if err != nil {
t.Fatalf("encode: %v", err)
}
merged, changed, err := mergeDocumentStatus(raw, []history.DocumentRef{
{Name: "aaa.txt", Status: history.DocumentStatusExtracted, Chars: 42, Truncated: true},
})
if err != nil {
t.Fatalf("merge: %v", err)
}
if !changed {
t.Fatal("changed = false, want 补进了新结论")
}
refs, err := history.DecodeDocuments(merged)
if err != nil {
t.Fatalf("decode merged: %v", err)
}
if len(refs) != 2 {
t.Fatalf("refs = %+v, want 2 条(没匹配到的原样保留)", refs)
}
if refs[0].Status != history.DocumentStatusExtracted || refs[0].Chars != 42 || !refs[0].Truncated || refs[0].MIME != "text/plain" || refs[0].Size != 1 {
t.Fatalf("refs[0] = %+v, want 结论补上且身份不变", refs[0])
}
if refs[1].Status != "" || refs[1].Name != "bbb.pdf" {
t.Fatalf("refs[1] = %+v, want 原样保留(本回合没解析到它)", refs[1])
}
if _, changed, err := mergeDocumentStatus(merged, []history.DocumentRef{{Name: "aaa.txt", Status: history.DocumentStatusExtracted, Chars: 42, Truncated: true}}); err != nil || changed {
t.Fatalf("changed = %v, err = %v, want 无变化且无错(不必写库)", changed, err)
}
if _, _, err := mergeDocumentStatus("{not json", nil); !errors.Is(err, history.ErrBadDocuments) {
t.Fatalf("merge(坏列) = %v, want history.ErrBadDocuments", err)
}
}
// TestOrchestratorWritesBackDocumentStatus 是缺口②的端到端回归(走真实存储):
// 回合结束后,该回合用户消息的 document 列里 status/chars/truncated 由空变为
// 装配期解析出的结论,其它行一个字都不动。
func TestOrchestratorWritesBackDocumentStatus(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newGatewayHistoryStore(t)
const nameA = "aaaabbbbccccdddd.txt"
const nameB = "eeff001122334455.pdf"
resA := &loop.Result{Text: "读完了", Documents: []history.DocumentRef{{
Name: nameA, MIME: "text/plain", Size: 2048, Chars: 900, Truncated: true, Status: history.DocumentStatusExtracted,
}}}
resB := &loop.Result{Text: "读不到", Documents: []history.DocumentRef{{
Name: nameB, MIME: "application/pdf", Size: 4096, Status: history.DocumentStatusUnreadable,
}}}
answers := []*loop.Result{resA, resB}
call := 0
o := NewOrchestrator(OrchestratorOptions{
Run: func(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
res := answers[call]
call++
return res, nil
},
Log: testLogger(),
History: st,
})
for _, tc := range []struct{ text, name string }{{"读一下 A", nameA}, {"读一下 B", nameB}} {
docs := []model.Document{{Name: tc.name, MIME: "text/plain", URL: documentFilePrefix + tc.name, Size: 2048}}
o.SubmitSync(ctx, history.MainSessionID, chatTurnMessages(tc.text, "", docs), loop.SessionEnv{})
}
msgs, err := st.List(ctx, history.MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
got := make(map[string][]history.DocumentRef, len(msgs))
for _, m := range msgs {
if m.Document == "" {
continue
}
refs, err := history.DecodeDocuments(m.Document)
if err != nil {
t.Fatalf("decode %s: %v", m.ID, err)
}
got[m.Content] = refs
}
first, ok := got["读一下 A"]
if !ok || len(first) != 1 {
t.Fatalf("读一下 A 的引用 = %+v, want 1 条", first)
}
if first[0].Status != history.DocumentStatusExtracted || first[0].Chars != 900 || !first[0].Truncated {
t.Fatalf("A 的结论 = %+v, want extracted/chars=900/truncated", first[0])
}
second, ok := got["读一下 B"]
if !ok || len(second) != 1 || second[0].Status != history.DocumentStatusUnreadable {
t.Fatalf("B 的结论 = %+v, want unreadable(「昔涟读不到这个文件」必须回写到列里)", second)
}
if len(got) != 2 {
t.Fatalf("带回结论的行 = %d 条, want 2(助手回复不该有 document 列)", len(got))
}
}
// TestPersistDocumentStatusIsNotSilent 覆盖「不静默」的四条冷路径:存储未接线、
// 存储没有回写视图、按 id 找不到行、列被写坏 —— 每一条都要留下带来源的 WARN,
// 而回合本身绝不因此失败(不卡会话)。
func TestPersistDocumentStatusIsNotSilent(t *testing.T) {
t.Parallel()
ctx := context.Background()
res := &loop.Result{Text: "ok", Documents: []history.DocumentRef{{Name: "aaa.txt", Status: history.DocumentStatusExtracted}}}
msgs := chatTurnMessages("读一下", "", []model.Document{{Name: "aaa.txt", MIME: "text/plain", Size: 1}})
t.Run("未接线历史存储", func(t *testing.T) {
cap := newCaptureHandler()
o := NewOrchestrator(OrchestratorOptions{Run: fixedRunner(res), Log: slog.New(cap)})
o.SubmitSync(ctx, "web", msgs, loop.SessionEnv{})
if !cap.has(`document status not persisted`, "no history store") {
t.Fatal("未接线时没有留下带来源的 WARN(静默丢结论)")
}
})
t.Run("存储没有回写视图", func(t *testing.T) {
cap := newCaptureHandler()
o := NewOrchestrator(OrchestratorOptions{Run: fixedRunner(res), Log: slog.New(cap), History: &fakeHistory{}})
o.SubmitSync(ctx, "web", msgs, loop.SessionEnv{})
if !cap.has(`document status not persisted`, "history store has no document update path") {
t.Fatal("存储缺少回写视图时没有留下带来源的 WARN(静默丢结论)")
}
})
t.Run("找不到对应行", func(t *testing.T) {
cap := newCaptureHandler()
writer := &fakeDocumentWriter{fakeHistory: &fakeHistory{}, err: history.ErrMessageNotFound}
o := NewOrchestrator(OrchestratorOptions{Run: fixedRunner(res), Log: slog.New(cap), History: writer})
o.SubmitSync(ctx, "web", msgs, loop.SessionEnv{})
if !cap.has(`document status update`, "") {
t.Fatal("回写失败时没有留下带来源的 WARN(静默丢结论)")
}
if len(writer.snapshot()) != 0 {
t.Fatal("失败的写入不该被记成功")
}
})
t.Run("列被写坏", func(t *testing.T) {
cap := newCaptureHandler()
writer := &fakeDocumentWriter{fakeHistory: &fakeHistory{}}
o := NewOrchestrator(OrchestratorOptions{Run: fixedRunner(res), Log: slog.New(cap), History: writer})
o.SetMode("web", ModeIdle)
o.rememberTurnDocuments("web", 1, []history.Message{{ID: "m1", SessionID: "web", Role: history.RoleUser, Content: "坏列", Document: "{not json"}})
o.persistDocumentStatus("web", 1, res)
if !cap.has(`document status merge`, "") {
t.Fatal("坏列没有留下带来源的 WARN(静默丢结论)")
}
if len(writer.snapshot()) != 0 {
t.Fatal("坏列不该被写回")
}
})
}
// TestHistoryEndpointReturnsDocumentStatusArray 钉住线上契约(§4.5):历史接口
// 按 `documents` 数组回传引用与结论,且没有附件的消息**省略**该字段(省略 =
// 没有附件,与引入文档之前逐字一致)。
func TestHistoryEndpointReturnsDocumentStatusArray(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, ts, jwt, st := newHistoryEnv(t)
raw, err := history.EncodeDocuments([]history.DocumentRef{
{Name: "aaa.txt", MIME: "text/plain", Size: 1, Status: history.DocumentStatusExtracted, Chars: 7},
{Name: "bbb.pdf", MIME: "application/pdf", Size: 2, Status: history.DocumentStatusUnreadable},
})
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "两个附件", Document: raw, CreatedAt: 1000}); err != nil {
t.Fatalf("append: %v", err)
}
if err := st.Append(ctx, history.Message{SessionID: history.MainSessionID, Role: history.RoleUser, Content: "没有附件", CreatedAt: 2000}); err != nil {
t.Fatalf("append: %v", err)
}
tok := signAny(t, jwt, "acct", "owner")
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var envelope struct {
Data []map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("unmarshal: %v (body=%s)", err, body)
}
if len(envelope.Data) != 2 {
t.Fatalf("messages = %d 条, want 2 (body=%s)", len(envelope.Data), body)
}
// 最新优先:第一条是「没有附件」的那条 —— 它必须省略 documents。
if _, ok := envelope.Data[0]["documents"]; ok {
t.Fatalf("没有附件的消息不该出现 documents 字段 (body=%s)", body)
}
var docs []history.DocumentRef
if err := json.Unmarshal(envelope.Data[1]["documents"], &docs); err != nil {
t.Fatalf("decode documents: %v (body=%s)", err, body)
}
if len(docs) != 2 {
t.Fatalf("documents = %+v, want 2 条", docs)
}
if docs[0].Name != "aaa.txt" || docs[0].Status != history.DocumentStatusExtracted || docs[0].Chars != 7 {
t.Fatalf("documents[0] = %+v", docs[0])
}
if docs[1].Name != "bbb.pdf" || docs[1].Status != history.DocumentStatusUnreadable {
t.Fatalf("documents[1] = %+v", docs[1])
}
}
// newGatewayHistoryStore 在隔离的内存 SQLite 上建一个真实 history 存储(测试用
// 临时库,绝不碰 data/cyrene.db)。
func newGatewayHistoryStore(t *testing.T) *history.Store {
t.Helper()
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() })
st, err := history.NewStore(ctx, conn, testLogger())
if err != nil {
t.Fatalf("new history store: %v", err)
}
return st
}
// fixedRunner 返回一个总是产出同一结果的回合驱动器。
func fixedRunner(res *loop.Result) TurnRunner {
return func(context.Context, string, []model.Message, loop.SessionEnv) (*loop.Result, error) {
return res, nil
}
}
// fakeDocumentWriter 在 fakeHistory 之上加按 id 更新 document 列的能力(回写
// 视图),记录每次成功调用,并可按需让写入失败。
type fakeDocumentWriter struct {
*fakeHistory
err error
mu sync.Mutex
updates []string
}
// UpdateMessageDocument 记录一次成功的回写(id 与列内容),err 非 nil 时失败。
func (f *fakeDocumentWriter) UpdateMessageDocument(_ context.Context, id, document string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.err != nil {
return f.err
}
f.updates = append(f.updates, id+"|"+document)
return nil
}
// snapshot 返回已成功回写的记录副本。
func (f *fakeDocumentWriter) snapshot() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.updates...)
}
// captureHandler 把日志记录收进内存,使「不静默」可以被断言:断言的是记录本身的
// message 与属性,而不是拼接后的文本(错误一律按类型/哨兵判定,绝不匹配文本)。
//
// WithAttrs 必须把预置属性(尤其是 logger.WithSource 盖上的 source)带进记录,
// 否则断言「带来源的 WARN」时就看不到 source —— 那正是这条纪律要盯的东西。
type captureHandler struct {
state *captureState
attrs []slog.Attr
}
type captureState struct {
mu sync.Mutex
recs []slog.Record
}
// newCaptureHandler 建一个空的记录处理器。
func newCaptureHandler() *captureHandler {
return &captureHandler{state: &captureState{}}
}
func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
if len(h.attrs) > 0 {
r.AddAttrs(h.attrs...)
}
h.state.mu.Lock()
defer h.state.mu.Unlock()
h.state.recs = append(h.state.recs, r.Clone())
return nil
}
func (h *captureHandler) WithAttrs(as []slog.Attr) slog.Handler {
if len(as) == 0 {
return h
}
attrs := make([]slog.Attr, 0, len(h.attrs)+len(as))
attrs = append(attrs, h.attrs...)
attrs = append(attrs, as...)
return &captureHandler{state: h.state, attrs: attrs}
}
func (h *captureHandler) WithGroup(string) slog.Handler { return h }
// has 报告是否存在一条 message 为 msg、reason 属性为 reason(空串表示不限定)且
// 带 source 属性的记录 —— 也就是说这条 WARN 是**有来源**的。
func (h *captureHandler) has(msg, reason string) bool {
h.state.mu.Lock()
defer h.state.mu.Unlock()
for _, r := range h.state.recs {
if r.Message != msg {
continue
}
var gotReason, gotSource string
r.Attrs(func(a slog.Attr) bool {
switch a.Key {
case "reason":
gotReason = a.Value.String()
case "source":
gotSource = a.Value.String()
}
return true
})
if gotSource == "" {
continue
}
if reason == "" || gotReason == reason {
return true
}
}
return false
}
@@ -142,6 +142,10 @@ type sessionState struct {
// 队列是一条只在队尾追加、可能被整体弹出/清空的先进先出队列,因此「第几条」
// 不稳定;按 seq 认领已注入的消息,才不会被同一时刻的追加或清空误伤。
pendingSeq uint64
// turnDocs 是本回合已落库的用户消息(id + 只有引用的 document 列),供回合
// 结算时把抽取结论回写进那一列(design/24 §6.1)。它按代次配对,因此被中断/
// 取代的陈旧回合读不到本回合的条目。见 session_document_status.go。
turnDocs turnDocuments
}
// pendingMsg 是一条已排队用户回合。它携带自己的 env,使续跑
@@ -161,6 +165,10 @@ type pendingMsg struct {
// 它只在状态锁下分配,是转向注入「注入=已消费」认领的依据:队列会被追加、
// 被弹出、被中断清空,序号却能唯一定位这一条。
seq uint64
// persisted 是这条待处理回合已落库的用户消息,供它的续跑回合结算时回写
// 文档抽取结论(design/24 §6.1):它在入队那一刻就落了库,而那时抽取还没
// 发生。见 session_document_status.go。
persisted []history.Message
}
// Orchestrator 拥有每会话的消息状态机。它对并发安全
@@ -263,11 +271,14 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
ctx = withClientMsgHint(ctx, a.hint)
if st.mode != ModeIdle {
st.pendingSeq++
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now(), seq: st.pendingSeq})
seq := st.pendingSeq
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, deviceID: deviceID, mode: msgMode, at: time.Now(), seq: seq})
depth := len(st.pending)
mode := st.mode
o.mu.Unlock()
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
// 落库在锁外(慢操作)完成,随后把它挂回那条待处理回合:抽取结论要等
// 这个回合真正跑起来才有,只能在它的续跑结算时回写(design/24 §6.1)。
o.attachPendingDocuments(sessionID, seq, o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID))
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth, MessageID: a.hint.MessageID}, nil
}
@@ -279,16 +290,7 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
// 消息不会抢在被取消回合落定旧快照之前开跑(session_turn_fence.go)。
fence := newTurnFence(st.fence.done)
st.fence = fence
// 新回合延续该会话的对话:持久化转录被播种在
// 刚发送的消息之前(design/15 §2.2/§4,design/22
// §7-5),且其助手话语保持说话时的角色。该读取
// 在此发生,在下面 persistUserMessages 写入新消息之前,因此刚
// 发送的消息绝不在上下文中重复。没有历史(或空会话)的
// 存储让该回合只使用调用方给出的消息。它是在状态锁下
// 的有界索引读取(transcriptContextCount 行),而状态锁
// 从不会被运行中的回合持有,因此它无法把 Submit 堵在回合后面。
turn := o.startTurnMessages(ctx, sessionID, messages)
st.tail = o.boundedTail(turn)
turn := o.seedTurnLocked(ctx, st, sessionID, messages)
runCtx, cancel := context.WithCancel(ctx)
st.cancel = cancel
o.mu.Unlock()
@@ -299,9 +301,10 @@ func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, mes
runCtx = loop.WithSteerSource(runCtx, o.beginSteer(sessionID, gen))
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
// 只有此刻到达的消息会被持久化:已播种的转录
// 已经持久,重新追加它会重复整个对话。
o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID)
// 只有此刻到达的消息会被持久化(已播种的转录已持久,重追加会重复整个对话),
// 且落库只写**引用** —— 抽取还发生在后面,结论由回合结算时补进同一列
// (两段式见 session_document_status.go)。
o.rememberTurnDocuments(sessionID, gen, o.persistUserMessages(ctx, sessionID, messages, deviceID, msgMode, env.PersonaID))
if sync {
return o.runImmediateSync(runCtx, sessionID, gen, turn, env, fence, deviceID, msgMode, a.hint.MessageID)
@@ -351,8 +354,13 @@ func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Mes
// deviceID 是产出回合的客户端 id,mode 是其行为模式,
// personaID 是它说话时的角色,都用于标注持久化的回复
// (design/16 §4.5、§5.1;design/22 §7-5)。
//
// 它同时是文档抽取结论回写的落点(design/24 §6.1):本回合用户消息的
// document 列里此前只有引用,装配期解析出的 status/chars/truncated 在这里补上
// —— 见 session_document_status.go 的两段式说明。
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error, deviceID, mode, personaID string) error {
o.persistAssistantReply(sessionID, res, deviceID, mode, personaID)
o.persistDocumentStatus(sessionID, gen, res)
o.mu.Lock()
st := o.states[sessionID]
if st == nil || st.gen != gen {
@@ -378,6 +386,9 @@ func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Resu
// 的回合无需再等任何在途回合:围栏只负责放行它后面的那一个。
fence := newTurnFence(nil)
st.fence = fence
// 续跑的这一轮同样有「回合前已落库的用户消息」,因此把它们的文档状态
// 记到新代次上(design/24 §6.1)。
st.turnDocs = turnDocuments{gen: gen, msgs: next.persisted}
depth := len(st.pending)
resume := append([]model.Message(nil), st.tail...)
env := next.env
@@ -471,20 +482,3 @@ func (o *Orchestrator) notifyPersisted(msg history.Message) {
o.onPersisted(msg)
}
}
// deviceIDContextKey 是携带消息客户端 deviceID 的 context 键
// (design/16 §4.5)从 HTTP/WS 边界引入编排器的持久化。
type deviceIDContextKey struct{}
// withDeviceID 返回携带 deviceID 的 context,使编排器能标注
// 持久化的用户消息。空 deviceID 是空操作(不写入标签)。
func withDeviceID(ctx context.Context, deviceID string) context.Context {
return context.WithValue(ctx, deviceIDContextKey{}, deviceID)
}
// deviceIDFrom 返回 ctx 携带的 deviceID,未设置时返回 ""。它
// 只返回格式良好的非空值;其他一律视为缺失。
func deviceIDFrom(ctx context.Context) string {
v, _ := ctx.Value(deviceIDContextKey{}).(string)
return v
}
+50 -22
View File
@@ -60,26 +60,32 @@ func modeFrom(ctx context.Context) string {
// ctx 上若带着客户端的幂等键(G4,clientMsgHintFrom),第一条用户消息会带上它
// 与编排器预分配的消息 id:写入因此可被 (session_id, client_msg_id) 的唯一索引
// 判重。省略幂等键时 ctx 上没有该值,写入与加入这一列之前逐字相同。
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) {
//
// 它返回刚落库的那些消息(含存储分配的 id 与**只有引用**的 document 列),
// 使调用方能把它们挂到本回合上:抽取结论要等回合跑起来才有,只能在回合结束后
// 回写进同一列(design/24 §6.1,见 session_document_status.go)。这就是那条
// 「回合前落引用、回合后补状态」的两段式。
func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string, messages []model.Message, deviceID, mode, personaID string) []history.Message {
if o.history == nil {
return
return nil
}
hint := clientMsgHintFrom(ctx)
persisted := make([]history.Message, 0, len(messages))
for _, m := range messages {
if m.Role != model.RoleUser {
continue
}
text, image, document, err := messageTextImageDocument(m)
text, image, documents, err := messageTextImageDocument(m)
if err != nil {
// 文档引用编码失败(理论上不可达:引用只含字符串与整数)只丢那一列
// 并留下带来源的日志,整条消息照常落库 —— 丢一条消息远比丢一个附件
// 引用严重,而且这里绝不静默。
// 文档引用编码失败(只可能是坏数据或超限)只丢那一列并留下带来源的
// 日志,整条消息照常落库 —— 丢一条消息远比丢一个附件引用严重,而且
// 这里绝不静默。
o.log.Error("orchestrator persist document", "err", err, "session", sessionID, "device_id", deviceID)
}
if text == "" && image == "" && document == "" {
if text == "" && image == "" && documents == "" {
continue
}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, Document: document, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}
msg := history.Message{SessionID: sessionID, Role: history.RoleUser, Content: text, Image: image, Document: documents, DeviceID: deviceID, Mode: modeOrDefault(mode), PersonaID: personaID}
// 幂等键只标注**第一条**用户消息:一个回合至多只有一条用户消息,而把
// 同一个键盖到多条上会让它们互相冲突(唯一索引把它们判成同一条)。
if hint.ClientMsgID != "" {
@@ -96,6 +102,7 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
}
continue
}
persisted = append(persisted, full)
if msg.ClientMsgID != "" && full.ID != msg.ID {
// 存储侧唯一索引裁决出既有行(例如本进程重启后另一次写入已落库):
// 把权威 id 覆盖进认领表,别让后续重复请求拿到一个悬空的 messageId。
@@ -103,6 +110,7 @@ func (o *Orchestrator) persistUserMessages(ctx context.Context, sessionID string
}
o.notifyPersisted(full)
}
return persisted
}
// persistAssistantReply 把已结算回合的最终助手文本写入
@@ -128,15 +136,21 @@ func (o *Orchestrator) persistAssistantReply(sessionID string, res *loop.Result,
o.notifyPersisted(full)
}
// messageTextImageDocument 返回消息内容部分拼接后的文本、首个图片 URL 与首个
// 文档引用(已编码为落库用的 JSON 小对象),供持久化进 chat_messages 各列。
// 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) {
// 装配期与抽取器,模型层只携带引用本身,它们由回合结束后的回写补进同一列
// (见 session_document_status.go)。
//
// 一个回合可以挂多个附件(design/24 §6.1 的上限 5),因此这里按**部件顺序**收集
// 全部引用:此前「只留第一个」的写法会让其余几个从历史与后续回合的上下文里凭空
// 消失(缺口①)。编码失败(含超限)时返回错误,由调用方决定如何可见地报告,
// 而不是在这里静默当成「没有附件」。
func messageTextImageDocument(m model.Message) (text, image, documents string, err error) {
refs := make([]history.DocumentRef, 0, len(m.Content))
for _, c := range m.Content {
switch c.Type {
case model.ContentTypeText:
@@ -146,17 +160,14 @@ func messageTextImageDocument(m model.Message) (text, image, document string, er
image = c.ImageURL
}
case model.ContentTypeDocument:
if document != "" {
continue
}
encoded, err := history.EncodeDocument(documentRefOf(c))
if err != nil {
return text, image, "", err
}
document = encoded
refs = append(refs, documentRefOf(c))
}
}
return text, image, document, nil
encoded, err := history.EncodeDocuments(refs)
if err != nil {
return text, image, "", err
}
return text, image, encoded, nil
}
// documentRefOf 把文档内容片段收成落库用的引用。四个字段全部来自服务端
@@ -359,3 +370,20 @@ func (o *Orchestrator) releaseClientMsg(sessionID, clientMsgID, msgID string) {
}
}
}
// deviceIDContextKey 是携带消息客户端 deviceID 的 context 键
// (design/16 §4.5)从 HTTP/WS 边界引入编排器的持久化。
type deviceIDContextKey struct{}
// withDeviceID 返回携带 deviceID 的 context,使编排器能标注
// 持久化的用户消息。空 deviceID 是空操作(不写入标签)。
func withDeviceID(ctx context.Context, deviceID string) context.Context {
return context.WithValue(ctx, deviceIDContextKey{}, deviceID)
}
// deviceIDFrom 返回 ctx 携带的 deviceID,未设置时返回 ""。它
// 只返回格式良好的非空值;其他一律视为缺失。
func deviceIDFrom(ctx context.Context) string {
v, _ := ctx.Value(deviceIDContextKey{}).(string)
return v
}
+40 -15
View File
@@ -58,6 +58,23 @@ func (o *Orchestrator) startTurnMessages(ctx context.Context, sessionID string,
return concatMessages(seed, messages...)
}
// seedTurnLocked 为新回合播种对话(持久化转录在刚发送的消息之前),并把播种后的
// 尾部记进会话状态。**必须在 o.mu 下调用**(它读写 st.tail;与 admitLocked /
// claimClientMsgLocked 同一约定),调用方负责解锁 —— 因此它从
// session_orchestrator.go 的 submitInternal 里拆出来,让那个函数留在「函数 ≤80
// 行」的硬约束内。
//
// 播种的时刻是刻意的:它在 persistUserMessages 写入新消息**之前**,因此刚发送的
// 消息绝不在上下文中重复(design/15 §2.2/§4、design/22 §7-5)。没有历史(或空
// 会话)的存储让该回合只使用调用方给出的消息。它是在状态锁下的有界索引读取
// (transcriptContextCount 行),而状态锁从不会被运行中的回合持有,因此它无法把
// Submit 堵在回合后面。
func (o *Orchestrator) seedTurnLocked(ctx context.Context, st *sessionState, sessionID string, messages []model.Message) []model.Message {
turn := o.startTurnMessages(ctx, sessionID, messages)
st.tail = o.boundedTail(turn)
return turn
}
// sessionTranscript 读取该会话最近的持久化消息并把它们
// 映射为模型消息(最旧优先)。nil/不可读的存储或读错误
// 都得到 nil,因此聊天绝不被元数据查找阻塞;没有
@@ -88,7 +105,7 @@ func (o *Orchestrator) sessionTranscript(ctx context.Context, sessionID string)
// persona_id)作为其说话者标签,因此即使会话被切换过,
// 上下文也能把它归属到那个角色;非助手消息保持其
// 已存发送者标签(design/16 §4.6 的第三方显示名),绝不
// 被重新标为说话角色。文档引用一并还原(design/24 §9.5):
// 被重新标为说话角色。文档引用**全部**一并还原(design/24 §9.5):
// 少了它,附件会在发起它的那一回合之后的所有回合里消失。
func transcriptMessage(h history.Message) model.Message {
role := historyRoleToModel(h.Role)
@@ -108,9 +125,13 @@ func transcriptMessage(h history.Message) model.Message {
return msg
}
// transcriptDocumentParts 把已存 document 列还原为该回合的文档部件(design/24
// §9.5)。部件只交给 RoleUser 回合(§2.3-③):文档在任何别的角色上都会让编码器
// fail-loud,而一行坏数据不该把整个会话卡死。
// transcriptDocumentParts 把已存 document 列里的**全部**引用还原为该回合的文档
// 部件(design/24 §9.5)。部件只交给 RoleUser 回合(§2.3-③):文档在任何别的
// 角色上都会让编码器 fail-loud,而一行坏数据不该把整个会话卡死。
//
// 列里是引用数组(一个回合最多 5 个附件,缺口①),因此这里逐条还原、保持部件
// 顺序 —— 少还原一条,那个附件就会在发起它的回合之后的所有回合里消失,与
// 「清单恒在」的承诺自相矛盾。
//
// 列被写坏(或附件落在非用户行上)时**不静默**:打一条带来源的 WARN 并省掉
// 该部件,回合照常继续 —— 附件凭空消失正是这条清单要修的缺陷,因此必须留下
@@ -123,22 +144,26 @@ func transcriptDocumentParts(role model.Role, h history.Message) []model.Content
transcriptDocumentLog().Warn("transcript document on non-user row", "message", h.ID, "role", h.Role)
return nil
}
ref, err := history.DecodeDocument(h.Document)
refs, err := history.DecodeDocuments(h.Document)
if err != nil {
transcriptDocumentLog().Warn("transcript document decode", "message", h.ID, "err", err)
return nil
}
if ref == nil || ref.Name == "" {
transcriptDocumentLog().Warn("transcript document without name", "message", h.ID)
return nil
parts := make([]model.Content, 0, len(refs))
for _, ref := range refs {
if ref.Name == "" {
transcriptDocumentLog().Warn("transcript document without name", "message", h.ID)
continue
}
doc := model.Document{
Name: ref.Name,
MIME: ref.MIME,
URL: documentFilePrefix + ref.Name,
Size: ref.Size,
}
parts = append(parts, model.DocumentMessage(model.RoleUser, "", doc).Content...)
}
doc := model.Document{
Name: ref.Name,
MIME: ref.MIME,
URL: documentFilePrefix + ref.Name,
Size: ref.Size,
}
return model.DocumentMessage(model.RoleUser, "", doc).Content
return parts
}
// historyRoleToModel 把已存聊天角色映射为模型的角色词汇,