package loop import ( "context" "encoding/json" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件钉住契约 v0.63 的 `documents[].reason`(客户端登记:`status:"unreadable"` // 只有状态没有原因,客户端只能显示通用句、不愿自己编服务端的理由): // // - **只在读不到时**下发(`extracted` / `listed` 时该键从 DTO 里消失); // - 文案**逐字**等于注入给模型的那份原因 —— 两处同源于一个字符串,服务端因此 // 不为客户端另编一句,两个出口也不可能分叉; // - 解析器整条消息失败时也有诚实的兜底理由(绝不「读不到但没有理由」)。 // // 真实的「扫描件 PDF ⇒ 含 OCR 的原因」链路在 `internal/runtime/documents` 的 // `TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim` 里跑(那里有 PDF 夹具)。 // docC 是第三个合法的 S1 存储名形态(32 位十六进制 + 扩展名),供本文件使用。 const docC = "00112233445566778899aabbccddeeff.csv" // TestResolveDocuments_ReasonOnlyWhenUnreadable 钉住 reason 的三个出口:能读到与 // 只给清单时为空、读不到时逐字等于注入模型的那份原因。 func TestResolveDocuments_ReasonOnlyWhenUnreadable(t *testing.T) { t.Parallel() res := &fakeDocumentResolver{ refs: map[string]history.DocumentRef{ docA: {Status: history.DocumentStatusExtracted}, docB: {Status: history.DocumentStatusUnreadable}, docC: {Status: history.DocumentStatusListed}, }, bodies: map[string]string{ docA: "抽到的正文", docB: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)", docC: "不该被注入的正文", }, } l := New(Options{DocumentResolver: res}) in := []model.Message{ documentMessage(docA, "text/plain", 10), documentMessage(docB, "application/pdf", 20), documentMessage(docC, "text/plain", 30), } out, refs := l.resolveDocuments(context.Background(), in) byName := map[string]history.DocumentRef{} for _, ref := range refs { byName[ref.Name] = ref } if got := byName[docA].Reason; got != "" { t.Fatalf("extracted 的 reason = %q, want 空(该键必须缺席)", got) } if got := byName[docC].Reason; got != "" { t.Fatalf("listed 的 reason = %q, want 空", got) } want := res.bodies[docB] if got := byName[docB].Reason; got != want { t.Fatalf("unreadable 的 reason = %q, want %q(必须与注入模型的原因逐字一致)", got, want) } // 同一份文案确实进了模型可见上下文 —— 两处同源因此不可能分叉。 if !strings.Contains(messageTextOf(out[1]), want) { t.Fatalf("注入块 = %q, want 含同一份原因文案", messageTextOf(out[1])) } // DTO 形状:unreadable 带 reason 键,extracted 不带。 raw, err := json.Marshal(byName[docB]) if err != nil { t.Fatalf("marshal: %v", err) } if !strings.Contains(string(raw), `"reason":`) { t.Fatalf("unreadable 的 DTO = %s, want 带 reason 键", raw) } extractedRaw, err := json.Marshal(byName[docA]) if err != nil { t.Fatalf("marshal: %v", err) } if strings.Contains(string(extractedRaw), `"reason"`) { t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", extractedRaw) } } // TestResolveDocuments_ResolverErrorCarriesHonestReason 钉住兜底路径也带原因:解析器 // 整条消息失败时,客户端拿到的是同一句诚实兜底(`documentUnreadableBody`)—— // 「读不到但没有理由」正是本字段要消灭的状态。 func TestResolveDocuments_ResolverErrorCarriesHonestReason(t *testing.T) { t.Parallel() l := New(Options{DocumentResolver: &fakeDocumentResolver{err: errDocResolve}}) _, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 10)}) if len(refs) != 1 { t.Fatalf("结论 = %+v, want 一条", refs) } if refs[0].Reason != documentUnreadableBody { t.Fatalf("reason = %q, want %q(诚实兜底,绝不空着)", refs[0].Reason, documentUnreadableBody) } }