// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package component import ( "context" "errors" "fmt" "os" "strings" "sync/atomic" "testing" "time" "ragflow/internal/agent/runtime" "ragflow/internal/tokenizer" ) var tokenizerPoolInitErr error // TestMain initializes the tokenizer pool before any test runs. // The tokenizer package needs the C++ RAGAnalyzer dictionaries // (see internal/tokenizer.Init) for `Tokenize` / // `FineGrainedTokenize`; without it, `tokenizeChunks` errors out // with "tokenizer pool not initialized". Tests in other packages // initialize the pool at startup; this package must do the same // because the Tokenizer component touches tokenizer.Tokenize. // // If Init fails (e.g., dict path missing in some CI sandboxes), // we log the failure but still run the tests. Cases that exercise // tokenizeChunks will fail rather than skip when the pool is not // initialized. func TestMain(m *testing.M) { cfg := &tokenizer.PoolConfig{ DictPath: os.Getenv("RAGFLOW_DICT_PATH"), MinSize: 1, MaxSize: 2, IdleTimeout: 30 * time.Second, AcquireTimeout: 5 * time.Second, } if cfg.DictPath == "" { cfg.DictPath = "/usr/share/infinity/resource" } tokenizerPoolInitErr = tokenizer.Init(cfg) if tokenizerPoolInitErr != nil { fmt.Fprintf(os.Stderr, "tokenizer pool init failed (tests will skip tokenize-dependent cases): %v\n", tokenizerPoolInitErr) } os.Exit(m.Run()) } func requireTokenizerPool(t *testing.T) { t.Helper() if tokenizerPoolInitErr != nil { t.Skipf("tokenizer pool unavailable: %v", tokenizerPoolInitErr) } } // stubEmbedder records every call and returns canned vectors. // Matches the Embedder contract: len(vectors) == len(texts). type stubEmbedder struct { calls atomic.Int32 dim int delay time.Duration err error } func (s *stubEmbedder) Encode(texts []string) ([][]float64, error) { s.calls.Add(1) if s.delay > 0 { time.Sleep(s.delay) } if s.err != nil { return nil, s.err } out := make([][]float64, len(texts)) for i := range texts { v := make([]float64, s.dim) v[0] = float64(i + 1) // mark the index so callers can verify alignment out[i] = v } return out, nil } // withStubEmbedder installs a stub Embedder and restores the previous // EncodeFunc on cleanup. Returns the stub so the test can assert on // call count / latency. func withStubEmbedder(t *testing.T, dim int) *stubEmbedder { t.Helper() stub := &stubEmbedder{dim: dim} prev := EncodeFunc EncodeFunc = func(_, _ string) Embedder { return stub } t.Cleanup(func() { EncodeFunc = prev }) return stub } // TestTokenizerComponent_Registered verifies init() enrollment // under runtime.CategoryIngestion (Phase 4 / API endpoint depends // on this contract). func TestTokenizerComponent_Registered(t *testing.T) { factory, cat, md, ok := runtime.DefaultRegistry.Lookup("Tokenizer") if !ok { t.Fatal("Tokenizer not registered in runtime.DefaultRegistry") } if cat != runtime.CategoryIngestion { t.Errorf("category = %q, want %q", cat, runtime.CategoryIngestion) } if factory == nil { t.Error("factory is nil") } if len(md.Inputs) == 0 { t.Error("metadata.Inputs empty") } if len(md.Outputs) == 0 { t.Error("metadata.Outputs empty") } } // TestTokenizerComponent_Invoke_HappyPath drives three chunks // through both full_text tokenization and embedding. Verifies that // every chunk gains `content_ltks`, `content_sm_ltks`, and a // `q__vec` vector keyed by the embedder's vector dimension. func TestTokenizerComponent_Invoke_HappyPath(t *testing.T) { requireTokenizerPool(t) const dim = 4 withStubEmbedder(t, dim) c, err := NewTokenizerComponent(map[string]any{}) if err != nil { t.Fatalf("NewTokenizerComponent: %v", err) } chunks := []map[string]any{ {"text": "alpha chunk text"}, {"text": "bravo chunk text"}, {"text": "charlie chunk text"}, } out, err := c.Invoke(context.Background(), map[string]any{ "tenant_id": "t1", "model_id": "embd-1", "name": "doc.pdf", "output_format": "chunks", "chunks": chunks, }) if err != nil { t.Fatalf("Invoke: %v", err) } gotChunks, ok := out["chunks"].([]map[string]any) if !ok { t.Fatalf("chunks type = %T, want []map[string]any", out["chunks"]) } if len(gotChunks) != 3 { t.Fatalf("len(chunks) = %d, want 3", len(gotChunks)) } for i, ck := range gotChunks { if ck["content_ltks"] == nil { t.Errorf("chunk[%d].content_ltks missing", i) } if ck["content_sm_ltks"] == nil { t.Errorf("chunk[%d].content_sm_ltks missing", i) } if ck["title_tks"] == nil { t.Errorf("chunk[%d].title_tks missing", i) } key := "q_4_vec" v, ok := ck[key].([]float64) if !ok { t.Errorf("chunk[%d].%s missing or wrong type: %T", i, key, ck[key]) continue } if len(v) != dim { t.Errorf("chunk[%d].%s len = %d, want %d", i, key, len(v), dim) } if v[0] != float64(i+1) { t.Errorf("chunk[%d].%s[0] = %v, want %d (index alignment)", i, key, v[0], i+1) } } if out["output_format"] != "chunks" { t.Errorf("output_format = %v, want chunks", out["output_format"]) } if out["embedding_token_consumption"] == nil { t.Error("embedding_token_consumption missing") } if out["_elapsed_time"] == nil { t.Error("_elapsed_time missing") } } // TestTokenizerComponent_Invoke_EmptyChunks covers the no-op branch: // empty chunk list → empty output, no panic, no encoder call. func TestTokenizerComponent_Invoke_EmptyChunks(t *testing.T) { stub := withStubEmbedder(t, 4) c, err := NewTokenizerComponent(map[string]any{}) if err != nil { t.Fatalf("NewTokenizerComponent: %v", err) } out, err := c.Invoke(context.Background(), map[string]any{ "output_format": "chunks", "chunks": []map[string]any{}, }) if err != nil { t.Fatalf("Invoke: %v", err) } chunks, _ := out["chunks"].([]map[string]any) if len(chunks) != 0 { t.Errorf("chunks len = %d, want 0", len(chunks)) } if stub.calls.Load() != 0 { t.Errorf("embedder called %d times on empty input, want 0", stub.calls.Load()) } if out["output_format"] != "chunks" { t.Errorf("output_format = %v, want chunks", out["output_format"]) } } // TestTokenizerComponent_Invoke_NilChunks covers the nil-input // branch: nil chunks list is treated as zero-length (matches // python `kwargs.get("chunks")` with None). func TestTokenizerComponent_Invoke_NilChunks(t *testing.T) { withStubEmbedder(t, 4) c, _ := NewTokenizerComponent(map[string]any{}) out, err := c.Invoke(context.Background(), map[string]any{ "output_format": "chunks", }) if err != nil { t.Fatalf("Invoke: %v", err) } chunks, _ := out["chunks"].([]map[string]any) if len(chunks) != 0 { t.Errorf("chunks len = %d, want 0", len(chunks)) } } // TestTokenizerComponent_Invoke_Unicode asserts CJK input // produces finite, non-negative token counts (plan §8 Q2: Go // `NumTokensFromString` over-counts CJK on tiktoken-init failure; // Python returns 0 — both are valid as long as the count is // finite). func TestTokenizerComponent_Invoke_Unicode(t *testing.T) { requireTokenizerPool(t) withStubEmbedder(t, 4) c, _ := NewTokenizerComponent(map[string]any{}) inputs := []string{ "中文测试文本", "こんにちは世界", "한국어 텍스트", "English mixed 中文 français 日本語", } chunks := make([]map[string]any, len(inputs)) for i, txt := range inputs { chunks[i] = map[string]any{"text": txt} } out, err := c.Invoke(context.Background(), map[string]any{ "output_format": "chunks", "chunks": chunks, }) if err != nil { t.Fatalf("Invoke: %v", err) } gotChunks, _ := out["chunks"].([]map[string]any) if len(gotChunks) != len(inputs) { t.Fatalf("chunks len = %d, want %d", len(gotChunks), len(inputs)) } for i, ck := range gotChunks { // Direct call to verify the count contract. tokens := tokenizer.NumTokensFromString(inputs[i]) if tokens < 0 { t.Errorf("chunk[%d] token count negative: %d", i, tokens) } if ck["content_ltks"] == nil { t.Errorf("chunk[%d].content_ltks missing", i) } } } func TestTokenizerComponent_Invoke_TextPayload(t *testing.T) { requireTokenizerPool(t) withStubEmbedder(t, 4) c, _ := NewTokenizerComponent(map[string]any{ "search_method": []any{"full_text"}, }) out, err := c.Invoke(context.Background(), map[string]any{ "name": "note.txt", "output_format": "text", "text": "plain payload", }) if err != nil { t.Fatalf("Invoke: %v", err) } got, _ := out["chunks"].([]map[string]any) if len(got) != 1 { t.Fatalf("chunks len = %d, want 1", len(got)) } if got[0]["text"] != "plain payload" { t.Errorf("text = %v, want plain payload", got[0]["text"]) } if got[0]["content_ltks"] == nil { t.Errorf("content_ltks missing: %v", got[0]) } } func TestTokenizerComponent_Invoke_JSONPayload(t *testing.T) { requireTokenizerPool(t) withStubEmbedder(t, 4) c, _ := NewTokenizerComponent(map[string]any{ "search_method": []any{"full_text"}, }) out, err := c.Invoke(context.Background(), map[string]any{ "name": "note.pdf", "output_format": "json", "json": []map[string]any{{"text": "row one"}, {"text": "row two"}}, }) if err != nil { t.Fatalf("Invoke: %v", err) } got, _ := out["chunks"].([]map[string]any) if len(got) != 2 { t.Fatalf("chunks len = %d, want 2", len(got)) } if got[0]["content_ltks"] == nil || got[1]["content_ltks"] == nil { t.Errorf("content_ltks missing: %v", got) } } // TestTokenizerComponent_Invoke_BatchedEmbedding asserts the // embedding client is called ONCE with all chunks (not fanned per // chunk — plan §AD-5a). 3 chunks → 1 call. func TestTokenizerComponent_Invoke_BatchedEmbedding(t *testing.T) { requireTokenizerPool(t) stub := withStubEmbedder(t, 8) c, _ := NewTokenizerComponent(map[string]any{}) chunks := []map[string]any{ {"text": "one"}, {"text": "two"}, {"text": "three"}, } if _, err := c.Invoke(context.Background(), map[string]any{ "output_format": "chunks", "chunks": chunks, }); err != nil { t.Fatalf("Invoke: %v", err) } if got := stub.calls.Load(); got != 1 { t.Errorf("embedder calls = %d, want 1 (single batched call)", got) } } // TestTokenizerComponent_Invoke_FullTextOnly covers the // search_method=["full_text"] branch: no embedding, no encoder // call, but tokenized fields present. func TestTokenizerComponent_Invoke_FullTextOnly(t *testing.T) { requireTokenizerPool(t) stub := withStubEmbedder(t, 4) c, _ := NewTokenizerComponent(map[string]any{ "search_method": []any{"full_text"}, }) out, err := c.Invoke(context.Background(), map[string]any{ "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha bravo"}}, }) if err != nil { t.Fatalf("Invoke: %v", err) } if stub.calls.Load() != 0 { t.Errorf("embedder should not be called, got %d", stub.calls.Load()) } if out["embedding_token_consumption"] != nil { t.Errorf("embedding_token_consumption should be absent, got %v", out["embedding_token_consumption"]) } got, _ := out["chunks"].([]map[string]any) if len(got) == 0 || got[0]["content_ltks"] == nil { t.Errorf("content_ltks missing: %v", got) } } // TestTokenizerComponent_Invoke_EmbedNoEncodeFunc covers the // "embedding requested but EncodeFunc is nil" branch — must // return a clear error, not panic. func TestTokenizerComponent_Invoke_EmbedNoEncodeFunc(t *testing.T) { requireTokenizerPool(t) prev := EncodeFunc EncodeFunc = nil t.Cleanup(func() { EncodeFunc = prev }) c, _ := NewTokenizerComponent(map[string]any{}) _, err := c.Invoke(context.Background(), map[string]any{ "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}, }) if err == nil { t.Fatal("expected error when embedding requested without EncodeFunc, got nil") } if !strings.Contains(err.Error(), "EncodeFunc") { t.Errorf("error should mention EncodeFunc: %v", err) } } // TestTokenizerComponent_Invoke_EmbedderError covers the // propagation of an error from the embedding driver. func TestTokenizerComponent_Invoke_EmbedderError(t *testing.T) { requireTokenizerPool(t) stub := withStubEmbedder(t, 4) stub.err = errors.New("simulated upstream error") c, _ := NewTokenizerComponent(map[string]any{}) _, err := c.Invoke(context.Background(), map[string]any{ "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}, }) if err == nil { t.Fatal("expected error from embedder, got nil") } if !strings.Contains(err.Error(), "simulated upstream error") { t.Errorf("error should chain embedder error: %v", err) } } // TestTokenizerComponent_Invoke_EncoderCountMismatch covers the // "embedder returned wrong number of vectors" defensive branch. func TestTokenizerComponent_Invoke_EncoderCountMismatch(t *testing.T) { requireTokenizerPool(t) stub := withStubEmbedder(t, 4) // Inject an embedder that returns the wrong number of vectors // regardless of input. wrong := &countMismatchedEmbedder{want: 1} prev := EncodeFunc EncodeFunc = func(_, _ string) Embedder { return wrong } t.Cleanup(func() { EncodeFunc = prev _ = stub }) c, _ := NewTokenizerComponent(map[string]any{}) _, err := c.Invoke(context.Background(), map[string]any{ "output_format": "chunks", "chunks": []map[string]any{{"text": "a"}, {"text": "b"}, {"text": "c"}}, }) if err == nil { t.Fatal("expected error from count mismatch, got nil") } if !strings.Contains(err.Error(), "vectors") { t.Errorf("error should mention vectors: %v", err) } } type countMismatchedEmbedder struct{ want int } func (c *countMismatchedEmbedder) Encode(texts []string) ([][]float64, error) { out := make([][]float64, c.want) for i := range out { out[i] = make([]float64, 4) } return out, nil } // TestTokenizerComponent_Invoke_HonorsTimeout installs an // embedder that blocks past a (test-shrunk) tokenizerTimeout and // asserts the component returns context.DeadlineExceeded. func TestTokenizerComponent_Invoke_HonorsTimeout(t *testing.T) { requireTokenizerPool(t) prevTimeout := tokenizerTimeout tokenizerTimeout = 50 * time.Millisecond t.Cleanup(func() { tokenizerTimeout = prevTimeout }) stub := withStubEmbedder(t, 4) stub.delay = 500 * time.Millisecond c, _ := NewTokenizerComponent(map[string]any{}) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := c.Invoke(ctx, map[string]any{ "output_format": "chunks", "chunks": []map[string]any{{"text": "alpha"}}, }) if err == nil { t.Fatal("expected timeout error, got nil") } if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("expected context.DeadlineExceeded, got %v", err) } } // TestTokenizerComponent_InputsOutputs_NonEmpty verifies Phase 4 // API metadata shape. func TestTokenizerComponent_InputsOutputs_NonEmpty(t *testing.T) { c, _ := NewTokenizerComponent(map[string]any{}) ins := c.(*TokenizerComponent).Inputs() outs := c.(*TokenizerComponent).Outputs() if len(ins) == 0 { t.Error("Inputs() empty") } if len(outs) == 0 { t.Error("Outputs() empty") } for _, key := range []string{"chunks", "output_format"} { if _, ok := outs[key]; !ok { t.Errorf("Outputs() missing %q", key) } } for _, key := range []string{"chunks", "name"} { if _, ok := ins[key]; !ok { t.Errorf("Inputs() missing %q", key) } } } // TestTokenizerComponent_Parallelism locks the fan-out to 1 (plan // §AD-5a: "embedding calls batched, not fanned"). func TestTokenizerComponent_Parallelism(t *testing.T) { c, _ := NewTokenizerComponent(map[string]any{}) if got := c.(*TokenizerComponent).Parallelism(); got != 1 { t.Errorf("Parallelism() = %d, want 1", got) } } // TestTokenizerComponent_NewTokenizerComponent_Defaults verifies // the Python default param values propagate. func TestTokenizerComponent_NewTokenizerComponent_Defaults(t *testing.T) { c, err := NewTokenizerComponent(nil) if err != nil { t.Fatalf("NewTokenizerComponent(nil): %v", err) } tc := c.(*TokenizerComponent) if tc.param.FilenameEmbdWeight != 0.1 { t.Errorf("filename_embd_weight = %v, want 0.1", tc.param.FilenameEmbdWeight) } if len(tc.param.Fields) != 1 || tc.param.Fields[0] != "text" { t.Errorf("fields = %v, want [text]", tc.param.Fields) } if len(tc.param.SearchMethod) != 2 { t.Errorf("search_method len = %d, want 2", len(tc.param.SearchMethod)) } } // TestTokenizerComponent_NewTokenizerComponent_BadParam covers // the param-validation branch (invalid search_method value). func TestTokenizerComponent_NewTokenizerComponent_BadParam(t *testing.T) { _, err := NewTokenizerComponent(map[string]any{ "search_method": []any{"unknown"}, }) if err == nil { t.Fatal("expected param validation error, got nil") } } // TestTokenizerComponent_Smoke_EndToEnd is the BLOCKER smoke test // (plan §8 R3). Drives 1 chunk of ~1000 tokens through the real // tokenizer and a stub embedder with no artificial latency, then // asserts: // // - non-zero vector returned // - latency well under 5s (real stub returns in <1ms; we assert // < 5s as the §R3 ceiling) // - no panic // // Documented result: this stub embedding completes in well under // 5s (typical observed latency < 5ms on the test host). The // production path against a real embedding API was not exercised // in this CI sandbox; the helper `withStubEmbedder` deliberately // avoids the network round-trip while still exercising the full // wiring (TrackElapsed, WithTimeout, batched Encode, vector // stamping). func TestTokenizerComponent_Smoke_EndToEnd(t *testing.T) { requireTokenizerPool(t) const dim = 1024 withStubEmbedder(t, dim) // Build a chunk of ~1000 tokens. Each word ≈ 1 token for English // under cl100k_base. We pad with a recognizable sentinel so we // can later check tokenization fidelity if desired. words := make([]string, 0, 1000) for i := 0; i < 1000; i++ { words = append(words, "ragflow") } chunkText := strings.Join(words, " ") // Sanity-check the count is in the expected ballpark (cl100k_base // may over- or under-count; we only assert the order of magnitude). preflightTokens := tokenizer.NumTokensFromString(chunkText) if preflightTokens < 100 || preflightTokens > 5000 { t.Logf("preflight token count = %d (acceptable range 100-5000)", preflightTokens) } c, _ := NewTokenizerComponent(map[string]any{}) chunks := []map[string]any{ {"text": chunkText}, } start := time.Now() out, err := c.Invoke(context.Background(), map[string]any{ "tenant_id": "tenant-smoke", "model_id": "embd-smoke", "name": "smoke.pdf", "output_format": "chunks", "chunks": chunks, }) elapsed := time.Since(start) if err != nil { t.Fatalf("Invoke: %v", err) } if elapsed >= 5*time.Second { t.Errorf("elapsed %v exceeds §R3 ceiling of 5s", elapsed) } got, ok := out["chunks"].([]map[string]any) if !ok || len(got) != 1 { t.Fatalf("chunks output malformed: %v", out["chunks"]) } vec, ok := got[0]["q_1024_vec"].([]float64) if !ok { t.Fatalf("q_1024_vec missing or wrong type: %T", got[0]["q_1024_vec"]) } if len(vec) != dim { t.Errorf("vector len = %d, want %d", len(vec), dim) } // "non-zero vector" assertion: at least one element is non-zero. nonZero := 0 for _, x := range vec { if x != 0 { nonZero++ } } if nonZero == 0 { t.Error("vector is all zeros (smoke contract: non-zero vector returned)") } // No panic == pass; explicitly assert log message. t.Logf("smoke complete: chunks=%d elapsed=%v tokens≈%d vec_dim=%d", len(got), elapsed, preflightTokens, len(vec)) }