package documents import ( "context" "encoding/json" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "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 的 `documents[].reason` 在**摄取层侧**的端到端回归(客户端 // 登记:`status:"unreadable"` 只有状态没有原因,客户端只能显示通用句,不愿自己 // 编服务端的理由)。它与 sidecar_test.go 的 `TestResolver_EndToEndWithLoop` 同族: // 真实文件 → 真实 sidecar → 真实 loop 装配期,只是这里断言的是**回给客户端的原因** // 与**注入模型的原因**是同一个字符串,以及「读不到才给、能读到就没有」这条口径。 // TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim 钉住契约 v0.63 的 ①: // **扫描件 PDF(无文本层)** 经真实链路(真文件 → 真 sidecar → 真 loop 装配期) // 后,`Result.Documents` 上带回**逐字**等于注入模型那份的原因文案(含「OCR」), // 而**文本层 PDF / 纯文本**(extracted)一个 reason 都没有(该键从 DTO 里省略)。 // // 「逐字一致」是本用例的核心断言:客户端展示的理由与模型看到的理由必须是同一个 // 字符串,服务端不为客户端另编一句。 func TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim(t *testing.T) { t.Parallel() layout := newTestLayout(t) body := "季度报告:营收增长 12%。\n" textName := layout.writeUpload(t, txtName, []byte(body)) scanName := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{imageContent}})) r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads}) ad := &recordingAdapter{} ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } l := loop.New(loop.Options{Agent: ag, DocumentResolver: r, MaxRounds: 1}) msg := model.Message{Role: model.RoleUser, Content: []model.Content{ {Type: model.ContentTypeText, Text: "这两份都看看"}, {Type: model.ContentTypeDocument, DocumentName: textName, DocumentMIME: "text/plain", DocumentSize: int64(len(body))}, {Type: model.ContentTypeDocument, DocumentName: scanName, DocumentMIME: PDFMIME, DocumentSize: 2048}, }} res, err := l.Run(context.Background(), "sess-doc-reason", []model.Message{msg}) if err != nil { t.Fatalf("Run: %v", err) } byName := map[string]history.DocumentRef{} for _, ref := range res.Documents { byName[ref.Name] = ref } if got := byName[textName]; got.Status != history.DocumentStatusExtracted || got.Reason != "" { t.Fatalf("纯文本结论 = %+v, want extracted 且无 reason", got) } scan := byName[scanName] if scan.Status != history.DocumentStatusUnreadable { t.Fatalf("扫描件结论 = %+v, want unreadable(这份 PDF 没有文本层)", scan) } if scan.Reason != reasonPDFNoTextLayer { t.Fatalf("扫描件 reason = %q, want %q(必须与注入模型的原因逐字一致)", scan.Reason, reasonPDFNoTextLayer) } if !strings.Contains(scan.Reason, "OCR") { t.Fatalf("扫描件 reason = %q, want 含「OCR」", scan.Reason) } // 模型可见的那一份:同一句话(清单块之后的诚实原因)。 if len(ad.reqs) == 0 { t.Fatal("没有记录到模型请求") } modelSees := false for _, m := range ad.reqs[0].Messages { for _, part := range m.Content { if part.Type == model.ContentTypeText && strings.Contains(part.Text, reasonPDFNoTextLayer) { modelSees = true } } } if !modelSees { t.Fatal("注入模型的原因文案与回给客户端的 reason 不是同一句") } // 反证:原因里没有路径 —— 盘上路径只可能是 `/<存储名>`,原始错误若被 // 透出必然带出它;`uploads` 是真实目录段名,存储名本身则是另一路(DTO 的 name)。 for _, leak := range []string{layout.uploads, layout.dataDir, "uploads", scanName} { if strings.Contains(scan.Reason, leak) { t.Fatalf("reason = %q 含路径/引用片段 %q,原因文案里不得有路径", scan.Reason, leak) } } // DTO 形状:unreadable 带 reason 键、extracted 不带。 raw, err := json.Marshal(scan) if err != nil { t.Fatalf("marshal: %v", err) } if !strings.Contains(string(raw), `"reason":`) { t.Fatalf("扫描件 DTO = %s, want 带 reason 键", raw) } textRaw, err := json.Marshal(byName[textName]) if err != nil { t.Fatalf("marshal: %v", err) } if strings.Contains(string(textRaw), `"reason"`) { t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", textRaw) } }