- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。
- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。
- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。
- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。
- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。
- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
212 lines
8.9 KiB
Go
212 lines
8.9 KiB
Go
package gateway
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"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/agent"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||
"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"
|
||
)
|
||
|
||
// 本文件是契约 v0.63 的 **HTTP 层端到端**回归:`documents[].reason`(「读不到」
|
||
// 的原因)从**真实文件**出发,经真实摄取层 → 真实 loop 装配期 → 回合后回写 →
|
||
// 历史读接口,一路抵达客户端;且**只在读不到时**出现、**不含任何路径**。
|
||
//
|
||
// 与 session_document_e2e_test.go(S3.5 的结论回写)的分工:那边用假 runner 验证
|
||
// 「每个附件都落库 + 结论都回来」,这里用**真实 loop + 真实摄取层**验证新增的
|
||
// reason 字段本身的产生与运送,并额外钉住「extracted 时该键缺席」。
|
||
|
||
// reasonLeakMarker 是反证用的**独特**目录名:它只出现在真实盘上路径里,
|
||
// 因此「响应体里搜不到它」就是「原因文案没有把路径透给客户端」的直接证据。
|
||
const reasonLeakMarker = "cyrene-reason-leak-marker"
|
||
|
||
// reasonStubAdapter 是最小模型适配器:记下请求、回一句文本,使真实 loop 能走完
|
||
// 一个回合(与 documents 包的 recordingAdapter 同职责,网关侧独立一份)。
|
||
type reasonStubAdapter struct{ reqs []model.Request }
|
||
|
||
func (a *reasonStubAdapter) Name() string { return "reason-stub" }
|
||
|
||
func (a *reasonStubAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||
a.reqs = append(a.reqs, req)
|
||
return model.Response{Model: "stub", Content: "看过了"}, nil
|
||
}
|
||
|
||
func (a *reasonStubAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||
return a.Chat(ctx, req, model.Options{})
|
||
}
|
||
|
||
// scanPDFBytes 现场拼出一份**合法但无文本层**的 PDF(一页;内容流只画一个矩形,
|
||
// 不含任何文本算子)—— 它就是「图片型/扫描件 PDF」在抽取器眼里的样子。
|
||
// 不下载、不引入二进制资源(与 documents 包的 buildPDF 同一思路,这里只需这一种
|
||
// 形态,故不搬那套完整夹具)。
|
||
func scanPDFBytes(t *testing.T) []byte {
|
||
t.Helper()
|
||
// 页内容流:q 保存状态 → 设填充色 → 画矩形 → Q 恢复状态。没有任何 Tj/TJ。
|
||
content := "q 0 0 1 rg 100 100 200 200 re f Q"
|
||
objs := []string{
|
||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>",
|
||
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content),
|
||
}
|
||
var b bytes.Buffer
|
||
b.WriteString("%PDF-1.4\n")
|
||
offsets := make([]int, len(objs)+1)
|
||
for i, body := range objs {
|
||
offsets[i+1] = b.Len()
|
||
fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", i+1, body)
|
||
}
|
||
xrefAt := b.Len()
|
||
fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1)
|
||
for i := 1; i <= len(objs); i++ {
|
||
fmt.Fprintf(&b, "%010d 00000 n \n", offsets[i])
|
||
}
|
||
fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xrefAt)
|
||
return b.Bytes()
|
||
}
|
||
|
||
// documentReasonEnv 搭一套**全真**的装配:真数据目录、真摄取层、真 loop、真
|
||
// 历史存储、真 HTTP 服务。返回服务地址、token 与两份文档的存储名。
|
||
func documentReasonEnv(t *testing.T) (baseURL, tok, scanName, textName, dataDir string) {
|
||
t.Helper()
|
||
dataDir = filepath.Join(t.TempDir(), reasonLeakMarker)
|
||
scanName = storeGatewayDocument(t, dataDir, string(scanPDFBytes(t)), ".pdf")
|
||
textName = storeGatewayDocument(t, dataDir, "季度报告:营收增长 12%。\n", ".txt")
|
||
|
||
resolver := documents.NewResolver(documents.Config{
|
||
DataDir: dataDir,
|
||
UploadsDir: filepath.Join(dataDir, "uploads", documentSubDir),
|
||
})
|
||
ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(&reasonStubAdapter{}, nil))
|
||
if err != nil {
|
||
t.Fatalf("agent.New: %v", err)
|
||
}
|
||
run := loop.New(loop.Options{Agent: ag, DocumentResolver: resolver, MaxRounds: 1})
|
||
|
||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: run, History: newGatewayHistoryStore(t)})
|
||
if err != nil {
|
||
t.Fatalf("构造网关失败: %v", err)
|
||
}
|
||
httpSrv := httptest.NewServer(srv.Handler())
|
||
t.Cleanup(httpSrv.Close)
|
||
t.Cleanup(srv.Close)
|
||
tok, err = jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||
if err != nil {
|
||
t.Fatalf("签发 token 失败: %v", err)
|
||
}
|
||
return httpSrv.URL, tok, scanName, textName, dataDir
|
||
}
|
||
|
||
// TestDocumentReasonReachesMessagesWire 是本契约 ① 的端到端断言:扫描件 PDF ⇒
|
||
// `status:"unreadable"` **且**带回原因(文案含「OCR」);纯文本 ⇒ `extracted` 且
|
||
// **没有** `reason` 键;整份响应体里搜不到任何路径(反证见 reasonLeakMarker)。
|
||
func TestDocumentReasonReachesMessagesWire(t *testing.T) {
|
||
t.Parallel()
|
||
ts, tok, scanName, textName, _ := documentReasonEnv(t)
|
||
|
||
resp, body := doReq(t, http.MethodPost, ts+"/api/v1/chat", map[string]any{
|
||
"sessionId": history.MainSessionID,
|
||
"text": "这两份都看看",
|
||
"documents": []chatDocumentRef{{Name: scanName}, {Name: textName}},
|
||
}, tok)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
|
||
resp, body = doReq(t, http.MethodGet, ts+"/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)
|
||
}
|
||
docs := userMessageDocuments(t, body, "这两份都看看")
|
||
if len(docs) != 2 {
|
||
t.Fatalf("历史接口返回的 documents = %+v, want 2 条", docs)
|
||
}
|
||
|
||
got := map[string]map[string]any{}
|
||
for _, d := range docs {
|
||
name, _ := d["name"].(string)
|
||
got[name] = d
|
||
}
|
||
scan, ok := got[scanName]
|
||
if !ok {
|
||
t.Fatalf("扫描件的引用不在文档数组里: %+v", docs)
|
||
}
|
||
if scan["status"] != history.DocumentStatusUnreadable {
|
||
t.Fatalf("扫描件 status = %v, want unreadable(这份 PDF 没有文本层)", scan["status"])
|
||
}
|
||
reason, ok := scan["reason"].(string)
|
||
if !ok || reason == "" {
|
||
t.Fatalf("扫描件的 documents[].reason 缺失或不是字符串: %+v(契约 v0.63 要求读不到时给出原因)", scan)
|
||
}
|
||
if !strings.Contains(reason, "OCR") {
|
||
t.Fatalf("扫描件 reason = %q, want 含「OCR」(需要 OCR 才是这份 PDF 的真实原因)", reason)
|
||
}
|
||
|
||
text, ok := got[textName]
|
||
if !ok {
|
||
t.Fatalf("纯文本的引用不在文档数组里: %+v", docs)
|
||
}
|
||
if text["status"] != history.DocumentStatusExtracted {
|
||
t.Fatalf("纯文本 status = %v, want extracted", text["status"])
|
||
}
|
||
if _, present := text["reason"]; present {
|
||
t.Fatalf("extracted 的文档不得带 reason(该键必须缺席): %+v", text)
|
||
}
|
||
|
||
// 反证:原因文案(乃至整份响应)里没有任何路径信息。marker 只存在于盘上路径
|
||
// (`<TempDir>/cyrene-reason-leak-marker/uploads/documents/…`),一旦原始错误
|
||
// (「open …\cyrene-reason-leak-marker\uploads\documents\….pdf:」)被透出,这里
|
||
// 必然命中。**刻意不断言「不含 `/`」**:诚实原因文案本身就用它分隔
|
||
// 「图片型/无文本层」,那是文案的一部分,不是路径。
|
||
if strings.Contains(string(body), reasonLeakMarker) {
|
||
t.Fatalf("响应体里出现了盘上路径片段: %s", body)
|
||
}
|
||
for _, leak := range []string{reasonLeakMarker, "uploads", string(filepath.Separator) + "documents"} {
|
||
if strings.Contains(reason, leak) {
|
||
t.Fatalf("reason = %q 含路径片段 %q,原因文案里不得有路径", reason, leak)
|
||
}
|
||
}
|
||
if strings.Contains(reason, scanName) {
|
||
t.Fatalf("reason = %q 含存储名之外的引用信息(存储名本身已在同一元素的 name 键里)", reason)
|
||
}
|
||
}
|
||
|
||
// userMessageDocuments 取出内容为 content 的那条用户消息的 documents 数组(**按
|
||
// 原始 JSON 对象**解,使「键到底在不在」可断言 —— 解进结构体会把「缺席」与
|
||
// 「空串」抹成同一个值)。
|
||
func userMessageDocuments(t *testing.T, body []byte, content string) []map[string]any {
|
||
t.Helper()
|
||
var envelope struct {
|
||
Data []struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
Documents []map[string]any `json:"documents"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||
t.Fatalf("unmarshal messages: %v (body=%s)", err, body)
|
||
}
|
||
for _, m := range envelope.Data {
|
||
if m.Role == history.RoleUser && m.Content == content {
|
||
return m.Documents
|
||
}
|
||
}
|
||
t.Fatalf("历史里没有找到内容为 %q 的用户消息 (body=%s)", content, body)
|
||
return nil
|
||
}
|