feat(gateway): 文档多值化 + 抽取结论回写(design/24 S3.5)
- 缺口①:「一回合最多 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:
1 parent
20c1edb36a
commit
5b2b385524
12 files changed
+1255
-166
No files matched your search
@@ -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 上「建表之后才引入的列」前滚到既有表。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 把已存聊天角色映射为模型的角色词汇,
|
||||
|
||||
Reference in new issue
Block a user