//
// 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"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
eschema "github.com/cloudwego/eino/schema"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"ragflow/internal/agent/runtime"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"ragflow/internal/ingestion/component/schema"
"ragflow/internal/tokenizer"
"ragflow/internal/utility"
)
// stubExtractorChatInvoker is the test seam for the package-level
// extractorChatInvoker. It records every call (for assertions) and
// returns canned responses configured per-test. Concurrent-safe so
// it can backstop concurrent test cases without rewriting.
type stubExtractorChatInvoker struct {
mu sync.Mutex
// responses is consumed in order; remaining entries are returned
// as the wrap-error. tests set entries == call count they expect.
responses []stubResponse
// requests records every call in order. Callers read via lastRequest().
requests []extractorChatRequest
calls atomic.Int32
}
// stubResponse couples a Content value and an Err. tests populate
// either field — Err takes precedence over Content when non-nil.
type stubResponse struct {
Content string
Err error
}
func (s *stubExtractorChatInvoker) Chat(_ context.Context, req extractorChatRequest) (*extractorChatResponse, error) {
s.calls.Add(1)
s.mu.Lock()
s.requests = append(s.requests, req)
var resp stubResponse
if len(s.responses) > 0 {
resp = s.responses[0]
s.responses = s.responses[1:]
}
s.mu.Unlock()
if resp.Err != nil {
return nil, resp.Err
}
return &extractorChatResponse{Content: resp.Content}, nil
}
// lastRequest returns the most recent recorded request. Callers must hold s.mu.
func (s *stubExtractorChatInvoker) lastRequest() extractorChatRequest {
if len(s.requests) == 0 {
return extractorChatRequest{}
}
return s.requests[len(s.requests)-1]
}
func (s *stubExtractorChatInvoker) Calls() int32 { return s.calls.Load() }
// withStubChatInvoker installs a stub invoker for the duration of
// the test and restores the production invoker on cleanup.
func withStubChatInvoker(t *testing.T, responses ...stubResponse) *stubExtractorChatInvoker {
t.Helper()
prev := defaultExtractorChatInvoker
stub := &stubExtractorChatInvoker{responses: responses}
SetExtractorChatInvoker(stub)
t.Cleanup(func() {
SetExtractorChatInvoker(prev)
})
return stub
}
// TestExtractorComponent_Registered verifies the init() registration
// is visible to the runtime registry.
func TestExtractorComponent_Registered(t *testing.T) {
factory, cat, md, ok := runtime.DefaultRegistry.Lookup("Extractor")
if !ok {
t.Fatal("Extractor 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 md.Inputs == nil || len(md.Inputs) == 0 {
t.Errorf("metadata.Inputs empty: %v", md.Inputs)
}
if md.Outputs == nil || len(md.Outputs) == 0 {
t.Errorf("metadata.Outputs empty: %v", md.Outputs)
}
if _, has := md.Outputs["chunks"]; !has {
t.Errorf("metadata.Outputs missing %q", "chunks")
}
if _, has := md.Outputs["output_format"]; !has {
t.Errorf("metadata.Outputs missing %q", "output_format")
}
}
// TestExtractorComponent_Invoke_HappyPath covers the per-chunk
// auto-extraction (e.g. summary).
func TestExtractorComponent_Invoke_HappyPath(t *testing.T) {
withStubChatInvoker(t,
stubResponse{Content: "answer for chunk 1"},
stubResponse{Content: "answer for chunk 2"},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
LLMID: "gpt-4o-mini",
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
out, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{
{"text": "first text"},
{"text": "second text"},
},
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok {
t.Fatalf("chunks key missing or wrong shape: %T", out["chunks"])
}
if len(chunks) != 2 {
t.Fatalf("chunks len = %d, want 2", len(chunks))
}
s0, _ := chunks[0]["summary"].(string)
s1, _ := chunks[1]["summary"].(string)
if !((s0 == "answer for chunk 1" && s1 == "answer for chunk 2") || (s0 == "answer for chunk 2" && s1 == "answer for chunk 1")) {
t.Errorf("unexpected summaries: chunk0=%q, chunk1=%q", s0, s1)
}
if out["output_format"] != "chunks" {
t.Errorf("output_format = %v, want chunks", out["output_format"])
}
}
// TestExtractorComponent_Invoke_LLMError verifies a mock LLM
// error is surfaced through Invoke with the component-name prefix.
func TestExtractorComponent_Invoke_LLMError(t *testing.T) {
prevMax, prevDelay := extractorRetryMax, extractorRetryDelay
extractorRetryMax, extractorRetryDelay = 3, time.Millisecond
t.Cleanup(func() {
extractorRetryMax, extractorRetryDelay = prevMax, prevDelay
})
errSentinel := errors.New("upstream llm unavailable")
withStubChatInvoker(t,
stubResponse{Err: errSentinel},
stubResponse{Err: errSentinel},
stubResponse{Err: errSentinel},
stubResponse{Err: errSentinel},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
LLMID: "gpt-4o-mini",
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
_, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{{"text": "x"}},
})
if err == nil {
t.Fatal("Invoke returned nil error")
}
if !strings.HasPrefix(err.Error(), "extractor:") {
t.Errorf("error should be wrapped with 'extractor:' prefix, got %v", err)
}
if !strings.Contains(err.Error(), "upstream llm unavailable") {
t.Errorf("error should chain underlying error, got %v", err)
}
}
// TestExtractorComponent_Invoke_RetrySucceeds verifies that a transient
// LLM error is retried (RetryWithBackoff), and the invocation succeeds
// once the LLM recovers.
func TestExtractorComponent_Invoke_RetrySucceeds(t *testing.T) {
prevMax, prevDelay := extractorRetryMax, extractorRetryDelay
extractorRetryMax, extractorRetryDelay = 3, time.Millisecond
t.Cleanup(func() {
extractorRetryMax, extractorRetryDelay = prevMax, prevDelay
})
stub := withStubChatInvoker(t,
stubResponse{Err: errors.New("transient")},
stubResponse{Err: errors.New("transient")},
stubResponse{Content: "recovered"},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
LLMID: "gpt-4o-mini",
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
out, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{{"text": "x"}},
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, _ := out["chunks"].([]map[string]any)
if s, _ := chunks[0]["summary"].(string); s != "recovered" {
t.Errorf("summary = %q, want recovered", s)
}
if calls := stub.Calls(); calls != 3 {
t.Errorf("calls = %d, want 3 (2 transient + 1 success)", calls)
}
}
// TestExtractorComponent_Invoke_UnknownProvider asserts the
// production (eino) chat invoker handles an unregistered driver
// without panicking.
func TestExtractorComponent_Invoke_UnknownProvider(t *testing.T) {
inv := &einoExtractorChatInvoker{}
resp, err := inv.Chat(context.Background(), extractorChatRequest{
Driver: "definitely-not-a-real-provider-xyz",
ModelName: "anything",
})
if err == nil && resp == nil {
t.Fatal("production invoker returned nil error AND nil response for unknown driver — silent no-op")
}
if err != nil {
if !strings.Contains(err.Error(), "definitely-not-a-real-provider-xyz") &&
!strings.Contains(err.Error(), "no driver") &&
!strings.Contains(err.Error(), "unknown") &&
!strings.Contains(err.Error(), "not implemented") {
t.Errorf("unknown-driver error should mention the driver name or a typed/typed-sentinel substring; got: %v", err)
}
}
}
// TestExtractorComponent_Invoke_EmptyChunksReturnsEmpty verifies that
// when len(in.chunks) == 0, Invoke immediately returns empty chunks.
func TestExtractorComponent_Invoke_EmptyChunksReturnsEmpty(t *testing.T) {
c := &ExtractorComponent{Param: schema.ExtractorParam{
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
out, err := c.Invoke(t.Context(), nil, map[string]any{})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok {
t.Fatalf("chunks missing or wrong shape")
}
if len(chunks) != 0 {
t.Fatalf("chunks len = %d, want 0", len(chunks))
}
}
// TestExtractorComponent_Invoke_JSONListInput verifies that the chunks list
// can be provided under the "json" key.
func TestExtractorComponent_Invoke_JSONListInput(t *testing.T) {
withStubChatInvoker(t,
stubResponse{Content: "json chunk summary"},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
out, err := c.Invoke(t.Context(), nil, map[string]any{
"json": []map[string]any{{"text": "json payload chunk"}},
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok || len(chunks) != 1 {
t.Fatalf("chunks malformed: %v", out["chunks"])
}
if chunks[0]["summary"] != "json chunk summary" {
t.Errorf("summary = %v, want %q", chunks[0]["summary"], "json chunk summary")
}
}
// TestExtractorComponent_Invoke_PerCallLLMIDOverride verifies an
// inputs["llm_id"] override wins over Param.LLMID.
func TestExtractorComponent_Invoke_PerCallLLMIDOverride(t *testing.T) {
stub := withStubChatInvoker(t,
stubResponse{Content: "summary result"},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
LLMID: "static-llm",
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
_, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{{"text": "sample text"}},
"llm_id": "override-llm",
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
stub.mu.Lock()
defer stub.mu.Unlock()
if stub.lastRequest().ModelName != "override-llm" {
t.Errorf("ModelName = %q, want override-llm", stub.lastRequest().ModelName)
}
}
// TestExtractorComponent_Invoke_CompositeLLMID verifies the
// composite "gpt-4o-mini@openai" form is split into driver and model.
func TestExtractorComponent_Invoke_CompositeLLMID(t *testing.T) {
stub := withStubChatInvoker(t,
stubResponse{Content: "summary result"},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
LLMID: "gpt-4o-mini@openai",
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
if _, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{{"text": "sample text"}},
}); err != nil {
t.Fatalf("Invoke: %v", err)
}
stub.mu.Lock()
defer stub.mu.Unlock()
if stub.lastRequest().Driver != "openai" {
t.Errorf("Driver = %q, want openai", stub.lastRequest().Driver)
}
if stub.lastRequest().ModelName != "gpt-4o-mini" {
t.Errorf("ModelName = %q, want gpt-4o-mini", stub.lastRequest().ModelName)
}
}
// TestExtractorComponent_Invoke_ChunkIndexInError verifies the
// error message includes the failing chunk index.
func TestExtractorComponent_Invoke_ChunkIndexInError(t *testing.T) {
prevMax, prevDelay := extractorRetryMax, extractorRetryDelay
extractorRetryMax, extractorRetryDelay = 3, time.Millisecond
t.Cleanup(func() {
extractorRetryMax, extractorRetryDelay = prevMax, prevDelay
})
errBoom := errors.New("chunk-1-boom")
withStubChatInvoker(t,
stubResponse{Content: "ok for chunk 0"},
stubResponse{Err: errBoom},
stubResponse{Err: errBoom},
stubResponse{Err: errBoom},
stubResponse{Err: errBoom},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
_, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{
{"text": "first"},
{"text": "second"},
},
})
if err == nil {
t.Fatal("Invoke returned nil error")
}
if !strings.Contains(err.Error(), "chunk 0") && !strings.Contains(err.Error(), "chunk 1") {
t.Errorf("error should mention failing chunk index: %v", err)
}
if !strings.Contains(err.Error(), "chunk-1-boom") {
t.Errorf("error should chain underlying error: %v", err)
}
}
func TestExtractorComponent_NewExtractorComponent_ParamCheck(t *testing.T) {
c, err := NewExtractorComponent(map[string]any{})
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if c == nil {
t.Fatal("expected non-nil component")
}
}
func TestExtractorComponent_NewExtractorComponent_Happy(t *testing.T) {
c, err := NewExtractorComponent(map[string]any{
"llm_id": "openai/gpt-4o-mini",
"summary": map[string]any{
"enabled": true,
},
})
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
ext := c.(*ExtractorComponent)
if !ext.Param.Summary.Enabled || ext.Param.LLMID != "openai/gpt-4o-mini" {
t.Errorf("unexpected params: %+v", ext.Param)
}
}
// TestExtractorComponent_InputsOutputs_NonEmpty verifies Inputs and Outputs shapes.
func TestExtractorComponent_InputsOutputs_NonEmpty(t *testing.T) {
c := &ExtractorComponent{}
ins := c.Inputs()
outs := c.Outputs()
if len(ins) == 0 {
t.Error("Inputs() returned empty map")
}
if _, ok := ins["chunks"]; !ok {
t.Errorf("Inputs() missing %q", "chunks")
}
if _, ok := ins["llm_id"]; !ok {
t.Errorf("Inputs() missing %q", "llm_id")
}
if _, ok := ins["prompt"]; ok {
t.Errorf("Inputs() should not contain deprecated %q", "prompt")
}
if _, ok := ins["system_prompt"]; ok {
t.Errorf("Inputs() should not contain deprecated %q", "system_prompt")
}
if len(outs) == 0 {
t.Error("Outputs() returned empty map")
}
if _, ok := outs["chunks"]; !ok {
t.Errorf("Outputs() missing %q", "chunks")
}
if _, ok := outs["output_format"]; !ok {
t.Errorf("Outputs() missing %q", "output_format")
}
}
// TestSplitExtractorLLID covers the composite-id parser in isolation.
func TestSplitExtractorLLID(t *testing.T) {
cases := []struct {
in string
wantModel string
wantProvider string
wantOK bool
}{
{"gpt-4o-mini@openai", "gpt-4o-mini", "openai", true},
{"bare-model", "bare-model", "", false},
{"trailing@", "trailing", "", true},
{"@leading", "", "leading", true},
{"", "", "", false},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
model, provider, ok := splitExtractorLLIDPair(tc.in)
if ok != tc.wantOK {
t.Errorf("ok = %v, want %v", ok, tc.wantOK)
}
if model != tc.wantModel {
t.Errorf("model = %q, want %q", model, tc.wantModel)
}
if provider != tc.wantProvider {
t.Errorf("provider = %q, want %q", provider, tc.wantProvider)
}
})
}
}
// TestTryParseJSONObject covers the JSON parser.
func TestTryParseJSONObject(t *testing.T) {
cases := []struct {
name string
in string
wantOK bool
wantKey string
}{
{name: "object", in: `{"a":1}`, wantOK: true, wantKey: "a"},
{name: "object with fence", in: "```json\n{\"a\":1}\n```", wantOK: true, wantKey: "a"},
{name: "fence without json tag", in: "```\n{\"a\":1}\n```", wantOK: true, wantKey: "a"},
{name: "json tag on own line", in: "```\njson\n{\"a\":1}\n```", wantOK: true, wantKey: "a"},
{name: "JSON tag on own line", in: "```\nJSON\n{\"a\":1}\n```", wantOK: true, wantKey: "a"},
{name: "leading prose no fence", in: "Here is the result: {\"a\":1}", wantOK: false},
{name: "plain string", in: "hello", wantOK: false},
{name: "array", in: `[1,2]`, wantOK: false},
{name: "empty object", in: `{}`, wantOK: false},
{name: "empty", in: ``, wantOK: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
parsed, ok := tryParseJSONObject(tc.in)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v (got %v)", ok, tc.wantOK, parsed)
}
if ok && tc.wantKey != "" {
if _, has := parsed[tc.wantKey]; !has {
t.Errorf("parsed map missing %q: %v", tc.wantKey, parsed)
}
}
})
}
}
// TestCleanExtractionResult covers think tag and error marker stripping.
func TestCleanExtractionResult(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{name: "plain", in: `{"a":1}`, want: `{"a":1}`},
{name: "thinks stripped", in: "let me thinkreasoning\n{\"a\":1}", want: `{"a":1}`},
{name: "thinks no json", in: "thinkingno json here", want: "no json here"},
{name: "error marker rejected", in: "**ERROR** could not extract", want: ""},
{name: "error after think", in: "x**ERROR** boom", want: ""},
{name: "whitespace trimmed", in: " {\"a\":1} ", want: `{"a":1}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := cleanExtractionResult(tc.in); got != tc.want {
t.Errorf("cleanExtractionResult(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// newMetadataExtractor returns an ExtractorComponent wired for doc-level
// metadata extraction with the given field definitions.
func newMetadataExtractor(fields ...common.MetadataFieldDef) *ExtractorComponent {
return &ExtractorComponent{Param: schema.ExtractorParam{
Metadata: schema.MetadataExtractConfig{
Enabled: true,
Metadata: fields,
},
}}
}
// TestExtractorComponent_runEnableMetadata_MergesIntoChunkMetadata verifies a
// JSON object from the LLM is parsed and merged into the chunk's metadata map.
func TestExtractorComponent_runEnableMetadata_MergesIntoChunkMetadata(t *testing.T) {
withStubChatInvoker(t, stubResponse{Content: `{"category":"finance","region":"east"}`})
c := newMetadataExtractor(
common.MetadataFieldDef{Key: "category", Type: "string"},
common.MetadataFieldDef{Key: "region", Type: "string"},
)
ck := map[string]any{}
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, "chunk text"); err != nil {
t.Fatalf("runEnableMetadata: %v", err)
}
meta, ok := ck["metadata"].(map[string]any)
if !ok {
t.Fatalf("ck[metadata] missing or wrong type: %T", ck["metadata"])
}
if meta["category"] != "finance" || meta["region"] != "east" {
t.Errorf("metadata = %v, want category=finance region=east", meta)
}
}
// TestExtractorComponent_runEnableMetadata_StripsJSONFence verifies the
// extraction path tolerates a fenced ```json response.
func TestExtractorComponent_runEnableMetadata_StripsJSONFence(t *testing.T) {
withStubChatInvoker(t, stubResponse{Content: "```json\n{\"category\":\"law\"}\n```"})
c := newMetadataExtractor(common.MetadataFieldDef{Key: "category", Type: "string"})
ck := map[string]any{}
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, "chunk text"); err != nil {
t.Fatalf("runEnableMetadata: %v", err)
}
meta, ok := ck["metadata"].(map[string]any)
if !ok {
t.Fatalf("ck[metadata] missing: %T", ck["metadata"])
}
if meta["category"] != "law" {
t.Errorf("metadata = %v, want category=law", meta)
}
}
// TestExtractorComponent_runEnableMetadata_MidTextThink verifies the full
// metadata path tolerates a mid-text reasoning block preceded by a preamble.
func TestExtractorComponent_runEnableMetadata_MidTextThink(t *testing.T) {
withStubChatInvoker(t, stubResponse{Content: `preamblereasoning{"category":"finance"}`})
c := newMetadataExtractor(common.MetadataFieldDef{Key: "category", Type: "string"})
ck := map[string]any{}
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, "chunk text"); err != nil {
t.Fatalf("runEnableMetadata: %v", err)
}
meta, ok := ck["metadata"].(map[string]any)
if !ok {
t.Fatalf("ck[metadata] missing: %T", ck["metadata"])
}
if meta["category"] != "finance" {
t.Errorf("metadata = %v, want category=finance", meta)
}
}
// TestExtractorComponent_runEnableMetadata_DegradesGracefully verifies that an
// empty / **ERROR** / unparseable / think-only LLM response does NOT block ingestion.
func TestExtractorComponent_runEnableMetadata_DegradesGracefully(t *testing.T) {
cases := []struct {
name string
content string
}{
{"empty", ""},
{"error_marker", "**ERROR** something went wrong"},
{"garbage", "I could not find any metadata in this text."},
{"not_json", "{\"category\": } partial"},
{"think_only", "let me think"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withStubChatInvoker(t, stubResponse{Content: tc.content})
c := newMetadataExtractor(common.MetadataFieldDef{Key: "category", Type: "string"})
ck := map[string]any{"metadata": map[string]any{"preexisting": "keep"}}
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, tc.name); err != nil {
t.Fatalf("runEnableMetadata returned error: %v", err)
}
meta, ok := ck["metadata"].(map[string]any)
if !ok {
t.Fatalf("ck[metadata] should remain a map, got %T", ck["metadata"])
}
if meta["preexisting"] != "keep" {
t.Errorf("preexisting metadata must be preserved: %v", meta)
}
if _, has := meta["category"]; has {
t.Errorf("category should not be set on degraded response: %v", meta)
}
})
}
}
// TestExtractorComponent_runEnableMetadata_CrossChunkUnion simulates two chunks
// whose extraction returns overlapping list values for the same key.
func TestExtractorComponent_runEnableMetadata_CrossChunkUnion(t *testing.T) {
withStubChatInvoker(t,
stubResponse{Content: `{"people":["关羽","张辽"]}`},
stubResponse{Content: `{"people":["张辽","刘备"]}`},
)
c := newMetadataExtractor(common.MetadataFieldDef{Key: "people", Type: "string"})
ck1 := map[string]any{}
ck2 := map[string]any{}
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck1, "chunk one"); err != nil {
t.Fatalf("ck1: %v", err)
}
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck2, "chunk two"); err != nil {
t.Fatalf("ck2: %v", err)
}
m1, ok := ck1["metadata"].(map[string]any)
if !ok {
t.Fatalf("ck1[metadata] missing: %T", ck1["metadata"])
}
m2, ok := ck2["metadata"].(map[string]any)
if !ok {
t.Fatalf("ck2[metadata] missing: %T", ck2["metadata"])
}
docMeta := map[string]any{}
docMeta = utility.UpdateMetadataTo(docMeta, m1)
docMeta = utility.UpdateMetadataTo(docMeta, m2)
people, ok := docMeta["people"].([]string)
if !ok {
t.Fatalf("people = %T, want []string", docMeta["people"])
}
want := map[string]bool{"关羽": true, "张辽": true, "刘备": true}
if len(people) != len(want) {
t.Fatalf("people = %v, want union of %v", people, want)
}
for _, p := range people {
if !want[p] {
t.Errorf("unexpected person %q", p)
}
}
}
// TestExtractorComponent_runEnableMetadata_CombinedValueSplit verifies a value
// the LLM combines with Chinese/comma delimiters is split when passed through
// common.SplitCombinedMetadataValues.
func TestExtractorComponent_runEnableMetadata_CombinedValueSplit(t *testing.T) {
withStubChatInvoker(t, stubResponse{Content: `{"people":["关羽、张辽、刘备"]}`})
c := newMetadataExtractor(common.MetadataFieldDef{Key: "people", Type: "string"})
ck := map[string]any{}
if err := c.runEnableMetadata(t.Context(), nil, extractorInputs{llmID: "m"}, ck, "chunk text"); err != nil {
t.Fatalf("runEnableMetadata: %v", err)
}
rawMeta, ok := ck["metadata"].(map[string]any)
if !ok {
t.Fatalf("ck[metadata] missing: %T", ck["metadata"])
}
raw, ok := rawMeta["people"].([]any)
if !ok || len(raw) != 1 {
t.Fatalf("raw people = %v, want 1 combined element", rawMeta["people"])
}
split := common.SplitCombinedMetadataValues(ck["metadata"].(map[string]any))
people, ok := split["people"].([]string)
if !ok {
t.Fatalf("people = %T, want []string", split["people"])
}
want := map[string]bool{"关羽": true, "张辽": true, "刘备": true}
if len(people) != len(want) {
t.Fatalf("people = %v, want 3 split elements", people)
}
for _, p := range people {
if !want[p] {
t.Errorf("unexpected %q", p)
}
}
}
// TestExtractorComponent_ConcurrentInvoke verifies the chat
// invoker swap is safe under concurrent Invoke calls.
func TestExtractorComponent_ConcurrentInvoke(t *testing.T) {
withStubChatInvoker(t,
stubResponse{Content: "1"},
stubResponse{Content: "2"},
stubResponse{Content: "3"},
stubResponse{Content: "4"},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
Summary: schema.SummaryExtractConfig{Enabled: true},
}}
chunks := []map[string]any{
{"text": "a"}, {"text": "b"}, {"text": "c"}, {"text": "d"},
}
var wg sync.WaitGroup
errs := make(chan error, len(chunks))
for _, ck := range chunks {
wg.Add(1)
go func() {
defer wg.Done()
_, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{ck},
})
if err != nil {
errs <- err
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("Invoke error under concurrency: %v", err)
}
}
var _ = eschema.Message{}
// TestIsBareTenantModelID verifies UUID detection.
func TestIsBareTenantModelID(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"9e819c2442b14f9dab46062916e29195", true},
{"ABCDEFabcdef01234567890123456789", true},
{"9e819c2442b14f9dab46062916e2919", false},
{"9e819c2442b14f9dab46062916e29195X", false},
{"gpt-4o-mini@openai", false},
{"", false},
{"not-a-uuid", false},
}
for _, tc := range tests {
got := isBareTenantModelID(tc.input)
if got != tc.want {
t.Errorf("isBareTenantModelID(%q) = %v, want %v", tc.input, got, tc.want)
}
}
}
// TestResolveExtractorChatTarget_AtSplitFallback verifies the @ split fallback.
func TestResolveExtractorChatTarget_AtSplitFallback(t *testing.T) {
ctx := t.Context()
driver, modelName, apiKey, baseURL, err := resolveExtractorChatTarget(
ctx, dao.DB, "gpt-4o-mini@openai")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if driver != "openai" {
t.Errorf("driver = %q, want openai", driver)
}
if modelName != "gpt-4o-mini" {
t.Errorf("modelName = %q, want gpt-4o-mini", modelName)
}
if apiKey != "" || baseURL != "" {
t.Errorf("apiKey/baseURL should be empty in fallback path")
}
}
// TestResolveExtractorChatTarget_NoDriver verifies a non-@ plain string returns no driver.
func TestResolveExtractorChatTarget_NoDriver(t *testing.T) {
ctx := t.Context()
driver, modelName, _, _, err := resolveExtractorChatTarget(
ctx, dao.DB, "plain-name")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if driver != "" {
t.Errorf("driver should be empty for plain name, got %q", driver)
}
if modelName != "plain-name" {
t.Errorf("modelName = %q, want plain-name", modelName)
}
}
// TestExtractorComponent_Invoke_TemperatureSet verifies keyword extraction receives Temperature=0.2.
func TestExtractorComponent_Invoke_TemperatureSet(t *testing.T) {
stub := withStubChatInvoker(t,
stubResponse{Content: "keyword, extraction"},
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
LLMID: "gpt-4o-mini",
Keywords: schema.KeywordExtractConfig{TopN: 3},
}}
_, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{{"text": "document content"}},
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
stub.mu.Lock()
defer stub.mu.Unlock()
if stub.lastRequest().Temperature == nil {
t.Fatal("Temperature is nil, want 0.2")
}
if *stub.lastRequest().Temperature != 0.2 {
t.Errorf("Temperature = %v, want 0.2", *stub.lastRequest().Temperature)
}
if stub.calls.Load() != 1 {
t.Errorf("expected exactly 1 LLM call (keyword), got %d", stub.calls.Load())
}
}
// TestIsRetryableLLMError tests the retry classification heuristic.
func TestIsRetryableLLMError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "nil is retryable", err: nil, want: true},
{name: "context canceled is terminal", err: context.Canceled, want: false},
{name: "deadline exceeded is terminal", err: context.DeadlineExceeded, want: false},
{
name: "wrapped deadline with 400ms must stay retryable",
err: errors.New("context deadline exceeded after 400ms"),
want: true,
},
{name: "429 stays retryable", err: errors.New("429 Too Many Requests"), want: true},
{name: "503 stays retryable", err: errors.New("503 Service Unavailable"), want: true},
{name: "401 unauthorized is terminal", err: errors.New("HTTP 401 Unauthorized"), want: false},
{name: "403 forbidden is terminal", err: errors.New("403 forbidden"), want: false},
{name: "404 not found is terminal", err: errors.New("HTTP 404 Not Found"), want: false},
{name: "405 method not allowed is terminal", err: errors.New("405 Method Not Allowed"), want: false},
{name: "422 unprocessable is terminal", err: errors.New("422 Unprocessable Entity"), want: false},
{name: "bad request is terminal", err: errors.New("400 Bad Request: malformed"), want: false},
{name: "api key phrase is terminal", err: errors.New("invalid api key"), want: false},
{name: "no driver phrase is terminal", err: errors.New("no driver resolved for llm_id"), want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRetryableLLMError(tt.err); got != tt.want {
t.Errorf("isRetryableLLMError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// TestCleanExtractionResult_LastThinkTag verifies think tag removal.
func TestCleanExtractionResult_LastThinkTag(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "single think block", in: "reasoningthe answer", want: "the answer"},
{name: "nested think blocks", in: "outermidinnerfinal output", want: "final output"},
{name: "no think tag", in: "plain answer", want: "plain answer"},
{name: "think tag without close", in: "unclosed", want: "unclosed"},
{name: "error sentinel", in: "valid output**ERROR**extra", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cleanExtractionResult(tt.in)
if got != tt.want {
t.Errorf("cleanExtractionResult(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
// TestCleanLLMText verifies cleanLLMText reasoning tag and tool call removal.
func TestCleanLLMText(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "think block stripped",
in: "reasoningthe answer",
want: "the answer",
},
{
name: "close without open kept",
in: "abcdef",
want: "abcdef",
},
{
name: "prefix before think kept",
in: "prefixreasonanswer",
want: "prefixreasonanswer",
},
{
name: "open without close kept",
in: "unclosed",
want: "unclosed",
},
{
name: "tool_call block removed",
in: "before{\"name\":\"x\"}after",
want: "beforeafter",
},
{
name: "consecutive tool_call blocks",
in: "a1b2c",
want: "abc",
},
{
name: "think then tool_call",
in: "routtend",
want: "outend",
},
{
name: "plain text",
in: " plain answer ",
want: "plain answer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := cleanLLMText(tt.in); got != tt.want {
t.Errorf("cleanLLMText(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
// TestExtractorComponent_callStructured verifies structured parsing.
func TestExtractorComponent_callStructured(t *testing.T) {
withStubChatInvoker(t, stubResponse{Content: `{"a": 1}`})
c := &ExtractorComponent{}
got, err := c.callStructured(t.Context(), nil, extractorInputs{llmID: "m"}, "system", "")
if err != nil {
t.Fatalf("callStructured: %v", err)
}
if got["a"].(float64) != 1 {
t.Errorf("parsed = %v, want map with a=1", got)
}
// Non-JSON response → (nil, nil), not an error.
withStubChatInvoker(t, stubResponse{Content: "this is not JSON"})
got, err = c.callStructured(t.Context(), nil, extractorInputs{llmID: "m"}, "system", "")
if err != nil {
t.Fatalf("callStructured on non-JSON: %v", err)
}
if got != nil {
t.Errorf("non-JSON response should yield nil map, got %v", got)
}
}
// TestExtractorComponent_callStructured_MidTextThink verifies think trailing stripping.
func TestExtractorComponent_callStructured_MidTextThink(t *testing.T) {
withStubChatInvoker(t, stubResponse{Content: `preamblereasoning{"a": 1}`})
c := &ExtractorComponent{}
got, err := c.callStructured(t.Context(), nil, extractorInputs{llmID: "m"}, "system", "")
if err != nil {
t.Fatalf("callStructured: %v", err)
}
if got == nil || got["a"].(float64) != 1 {
t.Errorf("parsed = %v, want map with a=1", got)
}
}
// TestExtractorComponent_Invoke_ConcurrentKeywordsAndQuestions verifies
// keyword and question extraction on chunks.
func TestExtractorComponent_Invoke_ConcurrentKeywordsAndQuestions(t *testing.T) {
stub := withStubChatInvoker(t,
stubResponse{Content: "alpha, beta"}, // chunk 0 keywords
stubResponse{Content: "what is it?\nwhy?"}, // chunk 0 questions
stubResponse{Content: "gamma, delta"}, // chunk 1 keywords
stubResponse{Content: "how?\nwhen?"}, // chunk 1 questions
)
c := &ExtractorComponent{Param: schema.ExtractorParam{
LLMID: "gpt-4o-mini",
Keywords: schema.KeywordExtractConfig{TopN: 2},
Questions: schema.QuestionExtractConfig{TopN: 2},
}}
out, err := c.Invoke(t.Context(), nil, map[string]any{
"chunks": []map[string]any{
{"text": "first doc"},
{"text": "second doc"},
},
})
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok || len(chunks) != 2 {
t.Fatalf("expected 2 chunks, got %v", out["chunks"])
}
for i, ck := range chunks {
kwds, hasKW := ck["important_kwd"].([]string)
if !hasKW || len(kwds) == 0 {
t.Errorf("chunk %d: missing important_kwd", i)
}
qs, hasQ := ck["question_kwd"].([]string)
if !hasQ || len(qs) == 0 {
t.Errorf("chunk %d: missing question_kwd", i)
}
}
if calls := stub.Calls(); calls != 4 {
t.Errorf("expected 4 LLM calls (2 chunks × 2 types), got %d", calls)
}
}
// TestResolveExtractorChatTarget_EmptyLLMID verifies default fallback when llmID is empty.
func TestResolveExtractorChatTarget_EmptyLLMID(t *testing.T) {
ctx := t.Context()
driver, modelName, _, _, err := resolveExtractorChatTarget(ctx, dao.DB, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if driver != "" {
t.Logf("resolved empty llmID: driver=%q model=%q", driver, modelName)
}
}
// TestFitExtractorMessages_RejectsEmptyUserTurn verifies rejection of emptied user turn.
func TestFitExtractorMessages_RejectsEmptyUserTurn(t *testing.T) {
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 500 })
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
msgs := []eschema.Message{
{Role: eschema.System, Content: strings.Repeat("s ", 1000)},
{Role: eschema.User, Content: strings.Repeat("u ", 400)},
}
if _, err := fitExtractorMessages(t.Context(), nil, "test@test", msgs); err == nil {
t.Fatal("expected an error when fitting empties the user turn")
}
}
// TestFitExtractorMessages_KeepsUserTurn verifies prompt trimming happy path.
func TestFitExtractorMessages_KeepsUserTurn(t *testing.T) {
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 2000 })
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
msgs := []eschema.Message{
{Role: eschema.System, Content: "you are a helpful assistant"},
{Role: eschema.User, Content: strings.Repeat("u ", 3000)},
}
fitted, err := fitExtractorMessages(t.Context(), nil, "test@test", msgs)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fitted) != 2 {
t.Fatalf("got %d messages, want 2", len(fitted))
}
if strings.TrimSpace(fitted[1].Content) == "" {
t.Fatal("user turn was emptied")
}
}
// TestFitExtractorMessages_NoSystemPromptKeepsUserTurn verifies user-only message fitting.
func TestFitExtractorMessages_NoSystemPromptKeepsUserTurn(t *testing.T) {
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 2000 })
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
msgs := []eschema.Message{
{Role: eschema.User, Content: strings.Repeat("u ", 3000)},
}
fitted, err := fitExtractorMessages(t.Context(), nil, "test@test", msgs)
if err != nil {
t.Fatalf("unexpected error for user-only prompt: %v", err)
}
if len(fitted) != 1 || fitted[0].Role != eschema.User {
t.Fatalf("got %d messages, want the single user turn: %+v", len(fitted), fitted)
}
if strings.TrimSpace(fitted[0].Content) == "" {
t.Fatal("user turn was emptied")
}
}
// TestExtractorComponent_CallRaw_FitsBeforeInvoke verifies message fitting end to end.
func TestExtractorComponent_CallRaw_FitsBeforeInvoke(t *testing.T) {
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 200 })
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
stub := withStubChatInvoker(t, stubResponse{Content: `{"ok": true}`})
c := &ExtractorComponent{}
chunkBody := strings.Repeat("chunk text with lots of tokens. ", 500)
_, err := c.callText(t.Context(), nil, extractorInputs{
llmID: "test@test",
}, "extract fields", chunkBody)
if err != nil {
t.Fatalf("callText: %v", err)
}
stub.mu.Lock()
req := stub.lastRequest()
stub.mu.Unlock()
if len(req.Messages) == 0 {
t.Fatal("invoker was not called")
}
if req.Messages[0].Role != eschema.System || strings.TrimSpace(req.Messages[0].Content) == "" {
t.Fatalf("system prompt lost or emptied before invoke: %+v", req.Messages[0])
}
total := 0
for _, m := range req.Messages {
total += tokenizer.NumTokensFromString(m.Content)
}
if total > extractorContextFitBudget(200) {
t.Fatalf("sent messages total %d exceed the fitting budget %d", total, extractorContextFitBudget(200))
}
if !strings.Contains(req.Messages[len(req.Messages)-1].Content, "chunk text") {
t.Fatal("chunk text lost from the user turn")
}
}
// TestExtractorComponent_CallRaw_CustomContextOverride verifies tenant-configured context override.
func TestExtractorComponent_CallRaw_CustomContextOverride(t *testing.T) {
db := openExtractorContextTestDB(t)
seedExtractorContextModel(t, db, "")
if err := db.Create(&entity.TenantModelInstance{
ID: "instance-1",
ProviderID: "provider-openai",
InstanceName: "default",
Status: "active",
}).Error; err != nil {
t.Fatalf("create instance: %v", err)
}
if err := db.Model(&entity.TenantModel{}).
Where("id = ?", "0123456789abcdef0123456789abcdef").
Update("extra", `{"max_tokens": 2000}`).Error; err != nil {
t.Fatalf("set model extra: %v", err)
}
ctx := extractorStateCtx(t, "tenant-1")
stub := withStubChatInvoker(t, stubResponse{Content: `{"ok": true}`})
c := &ExtractorComponent{}
_, err := c.callText(ctx, db, extractorInputs{
llmID: "gpt-4o@OpenAI",
}, "extract fields", strings.Repeat("chunk text with lots of tokens. ", 500))
if err != nil {
t.Fatalf("callText: %v", err)
}
stub.mu.Lock()
req := stub.lastRequest()
stub.mu.Unlock()
if len(req.Messages) == 0 {
t.Fatal("invoker was not called")
}
if req.Messages[0].Role != eschema.System || strings.TrimSpace(req.Messages[0].Content) == "" {
t.Fatalf("system prompt lost or emptied: %+v", req.Messages[0])
}
total := 0
for _, m := range req.Messages {
total += tokenizer.NumTokensFromString(m.Content)
}
if total > 2000 {
t.Fatalf("sent messages total %d exceed the custom 2000-token context window", total)
}
}
func openExtractorContextTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&entity.Tenant{}, &entity.TenantModelProvider{}, &entity.TenantModelInstance{}, &entity.TenantModel{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func seedExtractorContextModel(t *testing.T, db *gorm.DB, tenantLLMID string) {
t.Helper()
status := "1"
tenant := entity.Tenant{
ID: "tenant-1",
LLMID: "gpt-4o@openai",
Status: &status,
}
if tenantLLMID != "" {
tenant.TenantLLMID = &tenantLLMID
}
if err := db.Create(&tenant).Error; err != nil {
t.Fatalf("create tenant: %v", err)
}
if err := db.Create(&entity.TenantModelProvider{
ID: "provider-openai",
ProviderName: "OpenAI",
TenantID: "tenant-1",
}).Error; err != nil {
t.Fatalf("create provider: %v", err)
}
if err := db.Create(&entity.TenantModel{
ID: "0123456789abcdef0123456789abcdef",
ProviderID: "provider-openai",
InstanceID: "instance-1",
ModelName: "gpt-4o",
ModelType: int(entity.ModelTypeChat),
Status: "active",
}).Error; err != nil {
t.Fatalf("create model: %v", err)
}
}
func extractorStateCtx(t *testing.T, tenantID string) context.Context {
t.Helper()
state := runtime.NewCanvasState("run-1", "session-1")
state.SetGlobal("tenant_id", tenantID)
return runtime.WithState(t.Context(), state)
}
func TestExtractorContextLength_TenantModelUUID(t *testing.T) {
db := openExtractorContextTestDB(t)
seedExtractorContextModel(t, db, "")
ctx := extractorStateCtx(t, "tenant-1")
if got := extractorContextLength(ctx, db, "0123456789abcdef0123456789abcdef"); got != 128000 {
t.Fatalf("extractorContextLength(uuid) = %d, want 128000", got)
}
}
func TestExtractorContextLength_DefaultChatModelPinned(t *testing.T) {
db := openExtractorContextTestDB(t)
seedExtractorContextModel(t, db, "0123456789abcdef0123456789abcdef")
ctx := extractorStateCtx(t, "tenant-1")
if got := extractorContextLength(ctx, db, ""); got != 128000 {
t.Fatalf("extractorContextLength(default pinned uuid) = %d, want 128000", got)
}
}
func TestExtractorContextLength_DefaultChatModelComposite(t *testing.T) {
db := openExtractorContextTestDB(t)
seedExtractorContextModel(t, db, "")
ctx := extractorStateCtx(t, "tenant-1")
if got := extractorContextLength(ctx, db, ""); got != 128000 {
t.Fatalf("extractorContextLength(default composite) = %d, want 128000", got)
}
}
func TestExtractorContextLength_UnknownModelSkips(t *testing.T) {
db := openExtractorContextTestDB(t)
seedExtractorContextModel(t, db, "")
ctx := extractorStateCtx(t, "tenant-1")
if got := extractorContextLength(ctx, db, "no-such-model@no-such-provider"); got != 0 {
t.Fatalf("extractorContextLength(unknown) = %d, want 0", got)
}
}
func TestExtractorContextFitBudget(t *testing.T) {
if got := extractorContextFitBudget(128000); got != 124160 {
t.Fatalf("extractorContextFitBudget(128000) = %d, want 124160", got)
}
if got := extractorContextFitBudget(1); got != 1 {
t.Fatalf("extractorContextFitBudget(1) = %d, want 1", got)
}
}
func TestFitExtractorMessages_RejectsSystemPromptLoss(t *testing.T) {
SetExtractorContextLengthOverride(func(_ context.Context, _ string) int { return 300 })
t.Cleanup(func() { SetExtractorContextLengthOverride(nil) })
msgs := []eschema.Message{
{Role: eschema.System, Content: strings.Repeat("s ", 5000)},
{Role: eschema.User, Content: strings.Repeat("u ", 400)},
}
if _, err := fitExtractorMessages(t.Context(), nil, "test@test", msgs); err == nil {
t.Fatal("expected an error when fitting empties the system prompt")
}
}
func TestExtractorContextLength_NilDBGraceful(t *testing.T) {
ctx := extractorStateCtx(t, "tenant-1")
if got := extractorContextLength(ctx, nil, ""); got != 0 {
t.Fatalf("extractorContextLength(nil db, default model) = %d, want 0", got)
}
}
func TestBuildExtractorMessages(t *testing.T) {
msgs := buildExtractorMessages("System rule", "Chunk content")
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))
}
if msgs[0].Role != eschema.System || msgs[0].Content != "System rule" {
t.Errorf("msgs[0] = %#v, want system message", msgs[0])
}
if msgs[1].Role != eschema.User || msgs[1].Content != "Chunk content" {
t.Errorf("msgs[1] = %#v, want user message", msgs[1])
}
// Empty system prompt omitted
msgsNoSys := buildExtractorMessages("", "Chunk content")
if len(msgsNoSys) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgsNoSys))
}
if msgsNoSys[0].Role != eschema.User || msgsNoSys[0].Content != "Chunk content" {
t.Errorf("msgsNoSys[0] = %#v, want user message", msgsNoSys[0])
}
// Empty user chunk text normalized to single space
msgsEmptyUser := buildExtractorMessages("System rule", "")
if len(msgsEmptyUser) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgsEmptyUser))
}
if msgsEmptyUser[1].Role != eschema.User || msgsEmptyUser[1].Content != " " {
t.Errorf("msgsEmptyUser[1] = %#v, want single space", msgsEmptyUser[1])
}
}
func TestExtractorModularParams(t *testing.T) {
params := map[string]any{
"llm_id": "test-llm-1",
"keywords": map[string]any{
"top_n": 5,
},
"questions": map[string]any{
"top_n": 3,
},
"tags": map[string]any{
"top_n": 2,
"tag_file_id": "tag_file_abc",
},
"summary": map[string]any{
"enabled": true,
},
"metadata": map[string]any{
"enabled": true,
"metadata": []any{
map[string]any{"key": "category", "type": "string"},
},
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent failed: %v", err)
}
ext, ok := comp.(*ExtractorComponent)
if !ok {
t.Fatalf("expected *ExtractorComponent, got %T", comp)
}
if ext.Param.Keywords.TopN != 5 {
t.Errorf("Keywords config mismatch: %+v", ext.Param.Keywords)
}
if ext.Param.Questions.TopN != 3 {
t.Errorf("Questions config mismatch: %+v", ext.Param.Questions)
}
if ext.Param.Tags.TopN != 2 || ext.Param.Tags.TagFileID != "tag_file_abc" {
t.Errorf("Tags config mismatch: %+v", ext.Param.Tags)
}
if !ext.Param.Summary.Enabled {
t.Errorf("Summary config mismatch: %+v", ext.Param.Summary)
}
if !ext.Param.Metadata.Enabled || len(ext.Param.Metadata.Metadata) != 1 {
t.Errorf("Metadata config mismatch: %+v", ext.Param.Metadata)
}
}
func TestExtractorModularPromptsExecution(t *testing.T) {
stub := withStubChatInvoker(t,
stubResponse{Content: "kw1, kw2"},
stubResponse{Content: "question 1?\nquestion 2?"},
stubResponse{Content: "Summary of chunk"},
)
params := map[string]any{
"llm_id": "llm-1",
"keywords": map[string]any{
"top_n": 2,
},
"questions": map[string]any{
"top_n": 2,
},
"summary": map[string]any{
"enabled": true,
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
in := map[string]any{
"chunks": []map[string]any{
{"content_with_weight": "Hello world content"},
},
}
out, err := comp.Invoke(t.Context(), nil, in)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok || len(chunks) != 1 {
t.Fatalf("expected 1 chunk output, got %v", out)
}
ck := chunks[0]
if kwds, ok := ck["important_kwd"].([]string); !ok || len(kwds) != 2 {
t.Errorf("expected important_kwd = [kw1, kw2], got %v", ck["important_kwd"])
}
if qs, ok := ck["question_kwd"].([]string); !ok || len(qs) != 2 {
t.Errorf("expected question_kwd = [question 1?, question 2?], got %v", ck["question_kwd"])
}
if sum, ok := ck["summary"].(string); !ok || sum != "Summary of chunk" {
t.Errorf("expected summary = 'Summary of chunk', got %v", ck["summary"])
}
reqs := stub.requests
if len(reqs) != 3 {
t.Fatalf("expected 3 LLM calls, got %d", len(reqs))
}
}
func TestExtractorModularMetadataConfig(t *testing.T) {
params := map[string]any{
"llm_id": "llm-1",
"metadata": map[string]any{
"enabled": true,
"metadata": []any{
map[string]any{
"key": "author",
"type": "string",
"description": "The author name",
"enum": []any{"Alice", "Bob"},
},
map[string]any{
"key": "year",
"type": "integer",
},
},
"built_in_metadata": []any{
map[string]any{
"key": "file_name",
"type": "string",
},
},
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
ext, ok := comp.(*ExtractorComponent)
if !ok {
t.Fatalf("expected *ExtractorComponent, got %T", comp)
}
if !ext.Param.Metadata.Enabled {
t.Errorf("Metadata enabled mismatch: %+v", ext.Param.Metadata.Enabled)
}
if len(ext.Param.Metadata.Metadata) != 2 {
t.Fatalf("Metadata fields mismatch: %d", len(ext.Param.Metadata.Metadata))
}
if ext.Param.Metadata.Metadata[0].Key != "author" || ext.Param.Metadata.Metadata[0].Description != "The author name" || len(ext.Param.Metadata.Metadata[0].Enum) != 2 {
t.Errorf("Metadata field 0 mismatch: %+v", ext.Param.Metadata.Metadata[0])
}
if ext.Param.Metadata.Metadata[1].Key != "year" {
t.Errorf("Metadata field 1 mismatch: %+v", ext.Param.Metadata.Metadata[1])
}
if len(ext.Param.Metadata.BuiltInMetadata) != 1 || ext.Param.Metadata.BuiltInMetadata[0].Key != "file_name" {
t.Errorf("BuiltInMetadata mismatch: %+v", ext.Param.Metadata.BuiltInMetadata)
}
}
func TestExtractorModularMetadataExecution(t *testing.T) {
withStubChatInvoker(t,
stubResponse{Content: `{"author": "Alice", "year": 2026}`},
)
params := map[string]any{
"llm_id": "llm-1",
"metadata": map[string]any{
"enabled": true,
"metadata": []any{
map[string]any{
"key": "author",
"type": "string",
},
map[string]any{
"key": "year",
"type": "integer",
},
},
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
in := map[string]any{
"chunks": []map[string]any{
{"content_with_weight": "Written by Alice in 2026."},
},
}
out, err := comp.Invoke(t.Context(), nil, in)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok || len(chunks) != 1 {
t.Fatalf("expected 1 chunk output, got %v", out)
}
ck := chunks[0]
meta, ok := ck["metadata"].(map[string]any)
if !ok {
t.Fatalf("expected chunk metadata map, got %T: %v", ck["metadata"], ck["metadata"])
}
if meta["author"] != "Alice" {
t.Errorf("expected metadata.author = Alice, got %v", meta["author"])
}
if fmt.Sprintf("%v", meta["year"]) != "2026" {
t.Errorf("expected metadata.year = 2026, got %v", meta["year"])
}
}
func TestExtractorDefaultSummaryPromptInjection(t *testing.T) {
stub := withStubChatInvoker(t, stubResponse{Content: "A concise summary."})
params := map[string]any{
"llm_id": "llm-1",
"summary": map[string]any{
"enabled": true,
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
in := map[string]any{
"chunks": []map[string]any{
{"content_with_weight": "This is a detailed paragraph about artificial intelligence."},
},
}
out, err := comp.Invoke(t.Context(), nil, in)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok || len(chunks) != 1 {
t.Fatalf("expected 1 chunk output, got %v", out)
}
if chunks[0]["summary"] != "A concise summary." {
t.Errorf("expected summary = 'A concise summary.', got %v", chunks[0]["summary"])
}
if stub.Calls() != 1 {
t.Fatalf("expected 1 LLM call, got %d", stub.Calls())
}
lastReq := stub.lastRequest()
msgs := lastReq.Messages
if len(msgs) != 2 {
t.Fatalf("expected 2 messages (system + user), got %d: %+v", len(msgs), msgs)
}
if msgs[0].Role != "system" || !strings.Contains(msgs[0].Content, "You are a precise and faithful text summarizer") {
t.Errorf("expected autoSummaryPrompt in system message, got: %+v", msgs[0])
}
if msgs[1].Role != "user" || !strings.Contains(msgs[1].Content, "This is a detailed paragraph about artificial intelligence.") {
t.Errorf("expected chunk text in user message, got: %+v", msgs[1])
}
}
func TestExtractorCustomSummarySystemPrompt(t *testing.T) {
stub := withStubChatInvoker(t, stubResponse{Content: "Custom summary."})
params := map[string]any{
"llm_id": "llm-1",
"summary": map[string]any{
"enabled": true,
"system_prompt": "Custom system prompt for summarization.",
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
in := map[string]any{
"chunks": []map[string]any{
{"content_with_weight": "Text to summarize."},
},
}
out, err := comp.Invoke(t.Context(), nil, in)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok || len(chunks) != 1 {
t.Fatalf("expected 1 chunk output, got %v", out)
}
if chunks[0]["summary"] != "Custom summary." {
t.Errorf("expected summary = 'Custom summary.', got %v", chunks[0]["summary"])
}
if stub.Calls() != 1 {
t.Fatalf("expected 1 LLM call, got %d", stub.Calls())
}
lastReq := stub.lastRequest()
msgs := lastReq.Messages
if len(msgs) != 2 {
t.Fatalf("expected 2 messages (system + user), got %d: %+v", len(msgs), msgs)
}
if msgs[0].Role != "system" || msgs[0].Content != "Custom system prompt for summarization." {
t.Errorf("expected custom system prompt in system message, got: %+v", msgs[0])
}
}
func TestExtractorCustomKeywordsAndQuestionsSystemPrompt(t *testing.T) {
stub := withStubChatInvoker(t,
stubResponse{Content: "custom, keywords"},
stubResponse{Content: "Custom question 1?\nCustom question 2?"},
)
params := map[string]any{
"llm_id": "llm-1",
"keywords": map[string]any{
"top_n": 2,
"system_prompt": "Custom keywords system prompt.",
},
"questions": map[string]any{
"top_n": 2,
"system_prompt": "Custom questions system prompt.",
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
in := map[string]any{
"chunks": []map[string]any{
{"content_with_weight": "Content text."},
},
}
out, err := comp.Invoke(t.Context(), nil, in)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok || len(chunks) != 1 {
t.Fatalf("expected 1 chunk output, got %v", out)
}
reqs := stub.requests
if len(reqs) != 2 {
t.Fatalf("expected 2 LLM calls, got %d", len(reqs))
}
if reqs[0].Messages[0].Content != "Custom keywords system prompt." {
t.Errorf("expected custom keywords system prompt, got: %q", reqs[0].Messages[0].Content)
}
if reqs[1].Messages[0].Content != "Custom questions system prompt." {
t.Errorf("expected custom questions system prompt, got: %q", reqs[1].Messages[0].Content)
}
}
func TestExtractorDisabledSummarySkipsCall(t *testing.T) {
stub := withStubChatInvoker(t, stubResponse{Content: "Not expected"})
params := map[string]any{
"llm_id": "llm-1",
"summary": map[string]any{
"enabled": false,
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
in := map[string]any{
"chunks": []map[string]any{
{"content_with_weight": "Some text."},
},
}
out, err := comp.Invoke(t.Context(), nil, in)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if stub.Calls() != 0 {
t.Errorf("expected 0 LLM calls when summary is disabled, got %d", stub.Calls())
}
chunks, ok := out["chunks"].([]map[string]any)
if !ok || len(chunks) != 1 {
t.Fatalf("expected 1 chunk output, got %v", out)
}
if _, has := chunks[0]["summary"]; has {
t.Errorf("expected no summary key in chunk, got %v", chunks[0]["summary"])
}
}
func TestExtractor_ModularConfiguration(t *testing.T) {
paramsDisabled := map[string]any{
"metadata": map[string]any{
"enabled": false,
"fields": []any{
map[string]any{"key": "category", "type": "string"},
},
},
"summary": map[string]any{
"enabled": false,
},
"keywords": map[string]any{
"top_n": 0,
},
"questions": map[string]any{
"top_n": 0,
},
"tags": map[string]any{
"top_n": 0,
},
}
compRawA, err := NewExtractorComponent(paramsDisabled)
if err != nil {
t.Fatalf("NewExtractorComponent A: %v", err)
}
compA := compRawA.(*ExtractorComponent)
if compA.Param.Metadata.Enabled != false {
t.Errorf("expected metadata disabled, got %v", compA.Param.Metadata.Enabled)
}
if compA.Param.Summary.Enabled != false {
t.Errorf("expected summary disabled, got %v", compA.Param.Summary.Enabled)
}
if compA.Param.Keywords.TopN != 0 {
t.Errorf("expected keywords disabled (0), got %v", compA.Param.Keywords.TopN)
}
if compA.Param.Questions.TopN != 0 {
t.Errorf("expected questions disabled (0), got %v", compA.Param.Questions.TopN)
}
if compA.Param.Tags.TopN != 0 {
t.Errorf("expected tags disabled (0), got %v", compA.Param.Tags.TopN)
}
// Explicitly enabled
paramsEnabled := map[string]any{
"metadata": map[string]any{
"enabled": true,
"metadata": []any{
map[string]any{"key": "author", "type": "string"},
},
},
"summary": map[string]any{
"enabled": true,
"system_prompt": "Custom summary prompt",
},
"keywords": map[string]any{
"top_n": 4,
"system_prompt": "Custom keywords prompt",
},
"questions": map[string]any{
"top_n": 2,
"system_prompt": "Custom questions prompt",
},
"tags": map[string]any{
"top_n": 3,
"tag_file_id": "file-123",
},
}
compRawB, err := NewExtractorComponent(paramsEnabled)
if err != nil {
t.Fatalf("NewExtractorComponent B: %v", err)
}
compB := compRawB.(*ExtractorComponent)
if compB.Param.Metadata.Enabled != true {
t.Errorf("expected metadata enabled, got %v", compB.Param.Metadata.Enabled)
}
if len(compB.Param.Metadata.Metadata) != 1 || compB.Param.Metadata.Metadata[0].Key != "author" {
t.Errorf("expected metadata fields with author, got %+v", compB.Param.Metadata.Metadata)
}
if compB.Param.Summary.Enabled != true || compB.Param.Summary.SystemPrompt != "Custom summary prompt" {
t.Errorf("expected summary enabled with custom prompt, got %+v", compB.Param.Summary)
}
if compB.Param.Keywords.TopN != 4 || compB.Param.Keywords.SystemPrompt != "Custom keywords prompt" {
t.Errorf("expected keywords 4 with custom prompt, got %+v", compB.Param.Keywords)
}
if compB.Param.Questions.TopN != 2 || compB.Param.Questions.SystemPrompt != "Custom questions prompt" {
t.Errorf("expected questions 2 with custom prompt, got %+v", compB.Param.Questions)
}
if compB.Param.Tags.TopN != 3 || compB.Param.Tags.TagFileID != "file-123" {
t.Errorf("expected tags 3 / file-123, got %+v", compB.Param.Tags)
}
}
func TestExtractor_ParseMetadataFieldDefs_MapSlice(t *testing.T) {
inputMapSlice := []map[string]any{
{"key": "author", "type": "string", "description": "Author name"},
}
defs := parseMetadataFieldDefs(inputMapSlice)
if len(defs) != 1 || defs[0].Key != "author" || defs[0].Type != "string" || defs[0].Description != "Author name" {
t.Errorf("parseMetadataFieldDefs failed for []map[string]any: %#v", defs)
}
inputDefs := []common.MetadataFieldDef{
{Key: "tag", Type: "string"},
}
directDefs := parseMetadataFieldDefs(inputDefs)
if len(directDefs) != 1 || directDefs[0].Key != "tag" {
t.Errorf("parseMetadataFieldDefs failed for []common.MetadataFieldDef: %#v", directDefs)
}
}
func TestExtractorBuiltInDoesNotCallLLM(t *testing.T) {
// 1b39355c regressed by making runEnableMetadata fire when only built_in_metadata was configured.
// With the modular shape, BuiltInMetadata must never trigger an LLM call; it is applied by the finalizer.
stub := withStubChatInvoker(t)
params := map[string]any{
"llm_id": "llm-1",
"metadata": map[string]any{
"enabled": true,
"built_in_metadata": []any{
map[string]any{"key": "file_name", "type": "string"},
map[string]any{"key": "update_time", "type": "time"},
},
"metadata": []any{},
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
in := map[string]any{
"chunks": []map[string]any{{"text": "hello world"}},
}
out, err := comp.Invoke(t.Context(), nil, in)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if stub.calls.Load() != 0 {
t.Fatalf("built_in-only must not call LLM, got %d calls, requests=%v", stub.calls.Load(), stub.requests)
}
chunks, _ := out["chunks"].([]map[string]any)
if len(chunks) != 1 {
t.Fatalf("expected 1 chunk, got %v", out)
}
if _, ok := chunks[0]["metadata"]; ok {
t.Fatalf("built_in must not produce chunk metadata, got %v", chunks[0]["metadata"])
}
}
func TestExtractorEnabledFalseDoesNotCallLLM(t *testing.T) {
stub := withStubChatInvoker(t)
params := map[string]any{
"llm_id": "llm-1",
"metadata": map[string]any{
"enabled": false,
"metadata": []any{
map[string]any{"key": "author", "type": "string"},
},
"built_in_metadata": []any{
map[string]any{"key": "file_name", "type": "string"},
},
},
}
comp, err := NewExtractorComponent(params)
if err != nil {
t.Fatalf("NewExtractorComponent: %v", err)
}
in := map[string]any{"chunks": []map[string]any{{"text": "hello"}}}
out, err := comp.Invoke(t.Context(), nil, in)
if err != nil {
t.Fatalf("Invoke: %v", err)
}
if stub.calls.Load() != 0 {
t.Fatalf("enabled=false must not call LLM, got %d", stub.calls.Load())
}
if chunks, _ := out["chunks"].([]map[string]any); len(chunks) != 1 {
t.Fatalf("expected 1 chunk, got %v", out)
}
}