package memory import ( "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector" ) // 本文件是「增量向量化 + 单分片韧性」的**请求计数**测试(M61,2026-10-30)。 // // 判据全部落在**真实的生产路径**上:假服务端是一个 OpenAI 兼容的 // POST /v1/embeddings(应答 {data:[{index,embedding}]}),被测对象是生产用的 // embedding.NewAPI + vector.New(SQLite) + Store.embedIncremental / Tidier / // PreInsertReviewer —— 不是替身。因此这里数出来的请求数就是线上会发出去的请求数。 // // 断言的三件事: // 1. **增量**:索引里已有向量的行不再重嵌(第二轮整理轮 = 0 个请求); // 2. **不放大**:同输入 N 条只发 ⌈N/批上限⌉ 个请求(批上限 = 8); // 3. **韧性**:一条坏/慢分片只损失它自己(缺口),其余照常;整体性故障最多 // 3 个请求就收手(不把放大换韧性)。 const fakeEmbedDim = 8 // fakeDatum / fakeResponse 是 /embeddings 的最小应答形态。 type fakeDatum struct { Index int `json:"index"` Embedding []float32 `json:"embedding"` } type fakeResponse struct { Data []fakeDatum `json:"data"` } // fakeEmbedServer 是**计数的** OpenAI 兼容假服务端。failIf 命中任一输入文本时 // 该请求回 400(模拟「一条坏正文」),failAll 时全部回 503(模拟服务挂死)。 type fakeEmbedServer struct { mu sync.Mutex requests int batches []int failIf string failAll bool dim int srv *httptest.Server } // newFakeEmbedServer 起一个假服务端,并在 t.Cleanup 里关掉它。 func newFakeEmbedServer(t *testing.T) *fakeEmbedServer { t.Helper() f := &fakeEmbedServer{dim: fakeEmbedDim} f.srv = httptest.NewServer(http.HandlerFunc(f.handle)) t.Cleanup(f.srv.Close) return f } // apiURL 返回 OpenAI 兼容的完整端点(与 .env 里 EMBEDDING_API_URL 的口径一致)。 func (f *fakeEmbedServer) apiURL() string { return f.srv.URL + "/v1/embeddings" } // embedder 返回**生产实现**(embedding.NewAPI),只把端点指到假服务端。 func (f *fakeEmbedServer) embedder() *embedding.APIEmbedder { return embedding.NewAPI(config.EmbeddingConfig{APIURL: f.apiURL(), Model: "fake-embed"}) } // count 返回到目前为止收到的请求数。 func (f *fakeEmbedServer) count() int { f.mu.Lock() defer f.mu.Unlock() return f.requests } // handle 记一笔请求并按 failIf / failAll 决定应答。 func (f *fakeEmbedServer) handle(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) var req struct { Input []string `json:"input"` } if err := json.Unmarshal(body, &req); err != nil { http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) return } f.mu.Lock() f.requests++ f.batches = append(f.batches, len(req.Input)) failAll, failIf := f.failAll, f.failIf f.mu.Unlock() if failAll { http.Error(w, `{"error":"unavailable"}`, http.StatusServiceUnavailable) return } if failIf != "" { for _, text := range req.Input { if strings.Contains(text, failIf) { http.Error(w, `{"error":"bad input"}`, http.StatusBadRequest) return } } } resp := fakeResponse{Data: make([]fakeDatum, len(req.Input))} for i, text := range req.Input { resp.Data[i] = fakeDatum{Index: i, Embedding: fakeVector(text, f.dim)} } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { http.Error(w, `{"error":"encode"}`, http.StatusInternalServerError) } } // fakeVector 由正文算一个确定性向量:同一段正文永远得到同一个向量(embedder // 的确定性是复用索引向量的前提),不同正文仍在同一个扇区(余弦 > 召回门)。 func fakeVector(text string, dim int) []float32 { var h uint32 for _, r := range text { h = h*31 + uint32(r) } v := make([]float32, dim) for i := range v { v[i] = 1 } v[0] = float32(1 + h%5) return v } // fakeIndex 用**生产构造路径** vector.New 建一个真实 SQLite 索引(隔离内存库), // embedder 是假服务端背后的生产 API embedder。构造时会探测一次维度 ⇒ 1 个请求。 func fakeIndex(t *testing.T, emb embedding.Embedder) vector.Index { t.Helper() idx, err := vector.New(config.DBConfig{SQLitePath: ":memory:"}, emb, testLogger()) if err != nil { t.Fatalf("vector.New: %v", err) } t.Cleanup(func() { _ = idx.Close() }) return idx } // reviewItems 造 n 条同分区条目并**在接入索引之前**落库(模拟历史存量:写入侧 // 那时还没接索引,所以它们没有向量 —— 这正是整理轮每轮重嵌的那批)。 func reviewItems(t *testing.T, store *Store, n int) []MemoryItem { t.Helper() base := tidyTestTime(t) items := make([]MemoryItem, 0, n) for i := 0; i < n; i++ { items = append(items, seedTidyItem(t, store, MemoryItem{ PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: fmt.Sprintf("存量记忆正文 第%d条", i), Confidence: 0.9, CreatedAt: base.Add(time.Duration(i) * time.Minute), SourceConversation: FormatSourceConversation("web", "m-1"), })) } return items }