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 只存在于盘上路径 // (`/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 }