Files
Cyrene_v2/internal/services/gateway/session_document_e2e_test.go
T
AskaEth 5b2b385524
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 需重新拉取/回放可见)。
2026-10-04 17:42:16 +08:00

158 lines
5.8 KiB
Go

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
}