- 缺口①:「一回合最多 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 需重新拉取/回放可见)。
158 lines
5.8 KiB
Go
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
|
|
}
|