package service import ( "context" "encoding/xml" "errors" "fmt" "strings" "sync/atomic" "testing" "github.com/Tencent/WeKnora/internal/models/chat" "github.com/Tencent/WeKnora/internal/types" "github.com/stretchr/testify/require" ) type wikiScriptedChat struct { templateCaptureChatModel id string call func(context.Context, []chat.Message) (*types.ChatResponse, error) } func TestWikiCitationTruncationRetriesTwoSmallerBatchesWithLocalHandles(t *testing.T) { s := &wikiIngestService{} var inputs []string model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) { prompt := messages[0].Content inputs = append(inputs, prompt) if len(inputs) == 1 { return &types.ChatResponse{Content: `{"citations":{}}`, FinishReason: "length"}, nil } if strings.Contains(prompt, `\n"), strings.Index(prompt, "\n") var rendered struct { Chunks []struct { Content string `xml:",chardata"` } `xml:"c"` } if start < 0 || end < 0 { t.Fatal("missing chunks block") } if err := xml.Unmarshal([]byte(prompt[start:end+len("\n")]), &rendered); err != nil { t.Fatal(err) } if len(rendered.Chunks) != 1 { t.Fatalf("unexpected retry chunks: %+v", rendered) } seen = append(seen, strings.TrimSuffix(strings.TrimPrefix(rendered.Chunks[0].Content, "\n"), "\n")) if len(seen) == 1 { return &types.ChatResponse{Content: `{"citations":`, FinishReason: "max_tokens"}, nil } return &types.ChatResponse{Content: `{"citations":{"entity/alpha":["c000"]},"new_slugs":[]}`}, nil }} chunk := &types.Chunk{ID: "source", Content: content} citations, _, completed, err := s.classifyChunkCitations(context.Background(), model, "entity/alpha", []*types.Chunk{chunk}, "Chinese", &WikiBatchContext{}) if err != nil || completed != 1 || len(seen) != 3 || seen[1]+seen[2] != content || chunk.Content != content || strings.Join(citations["entity/alpha"], ",") != "source" { t.Fatalf("singleton split lost source or duplicated citation: calls=%d result=%v err=%v", len(seen), citations, err) } } func TestWikiCitationPartitionFailureCannotReturnPartialCoverage(t *testing.T) { for _, failure := range []struct{ name, output, reason string }{ {"truncated", `{"citations":{}}`, "max_output_tokens"}, {"malformed", `{"citations":`, "stop"}, {"missing-schema", `{}`, "stop"}, {"unknown-handle", `{"citations":{"entity/alpha":["c999"]}}`, "stop"}, } { t.Run(failure.name, func(t *testing.T) { s := &wikiIngestService{} calls := 0 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { calls++ if calls == 1 { return &types.ChatResponse{Content: `{"citations":`, FinishReason: "length"}, nil } if calls == 2 { return &types.ChatResponse{Content: `{"citations":{"entity/alpha":["c000"]},"new_slugs":[]}`}, nil } return &types.ChatResponse{Content: failure.output, FinishReason: failure.reason}, nil }} chunks := []*types.Chunk{{ID: "first", Content: "first", ChunkIndex: 0}, {ID: "second", Content: "second", ChunkIndex: 1}} citations, slugs, completed, err := s.classifyChunkCitations(context.Background(), model, "entity/alpha", chunks, "English", &WikiBatchContext{}) if err == nil || calls != 3 || completed != 0 || citations != nil || slugs != nil || !strings.Contains(err.Error(), "partition 2/2") { t.Fatalf("partial citation accepted or retry budget exceeded: calls=%d completed=%d err=%v", calls, completed, err) } }) } } func TestWikiCitationDoesNotPartitionProviderFailures(t *testing.T) { for _, status := range []int{401, 402, 403, 429} { t.Run(fmt.Sprint(status), func(t *testing.T) { s := &wikiIngestService{} ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(77)) var calls atomic.Int32 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { calls.Add(1) return nil, fmt.Errorf("API request failed with status %d: private text", status) }} key := wikiProviderCircuitKey(77, model.id) t.Cleanup(func() { wikiProviderBlocks.Delete(key); wikiProviderSlots.Delete(key) }) chunks := []*types.Chunk{{ID: "first", Content: "first"}, {ID: "second", Content: "second"}} citations, _, completed, err := s.classifyChunkCitations(ctx, model, "entity/alpha", chunks, "English", &WikiBatchContext{}) if err == nil || calls.Load() != 1 || citations != nil || completed != 0 || strings.Contains(err.Error(), "private text") { t.Fatalf("provider failure split or leaked: calls=%d err=%v", calls.Load(), err) } }) } } func TestWikiCitationCancellationStopsBeforeRetryPartition(t *testing.T) { s := &wikiIngestService{} ctx, cancel := context.WithCancel(context.Background()) defer cancel() calls := 0 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { calls++ if calls == 1 { return &types.ChatResponse{Content: `{"citations":`, FinishReason: "length"}, nil } cancel() return &types.ChatResponse{Content: `{"citations":{"entity/alpha":["c000"]},"new_slugs":[]}`}, nil }} citations, _, completed, err := s.classifyChunkCitations(ctx, model, "entity/alpha", []*types.Chunk{{ID: "first", Content: "first"}, {ID: "second", Content: "second"}}, "English", &WikiBatchContext{}) if err == nil || !errors.Is(err, context.Canceled) || calls != 2 || completed != 0 || citations != nil { t.Fatalf("cancelled partition continued: calls=%d completed=%d err=%v", calls, completed, err) } } func TestWikiCitationTwelveBatchesCompleteOnlyAfterTruncatedBatchRecovers(t *testing.T) { s := &wikiIngestService{} var calls atomic.Int32 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) { calls.Add(1) prompt := messages[0].Content if strings.Contains(prompt, "target-left") && strings.Contains(prompt, "target-right") { return &types.ChatResponse{Content: `{"citations":`, FinishReason: "length"}, nil } handles := `["c000"]` if strings.Contains(prompt, ` 8000 { return &types.ChatResponse{Content: `{"entities":[`, FinishReason: "length"}, nil } leaves = append(leaves, content) return &types.ChatResponse{Content: `{"entities":[{"name":"Valid","slug":"entity/valid"}],"concepts":[]}`, FinishReason: "stop"}, nil }} source := "Valid" + strings.Repeat("甲", 7995) + "Valid" + strings.Repeat("乙", 7995) + "Valid" + strings.Repeat("丙", 7995) + "Valid" + strings.Repeat("丁", 7995) result, err := s.generateWikiExtraction(context.Background(), model, "{{.Content}}", map[string]string{"Content": source}) require.NoError(t, err) require.Equal(t, 7, calls) require.Equal(t, source, strings.Join(leaves, ""), "every source character must reach a successful leaf") require.Len(t, leaves, 4) require.Len(t, result.Entities, 1, "identical fresh candidates coalesce only after all leaves succeed") } func TestWikiExtractionAdditionalSplitIsBoundedAndCannotPublishPartialResult(t *testing.T) { for _, lastFailure := range []string{"length", "invalid"} { t.Run(lastFailure, func(t *testing.T) { s := &wikiIngestService{} calls := 0 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { calls++ if calls == 3 { return &types.ChatResponse{Content: `{"entities":[{"name":"x","slug":"entity/valid"}],"concepts":[]}`}, nil } if calls == 4 && lastFailure == "invalid" { return &types.ChatResponse{Content: `{"entities":[`}, nil } return &types.ChatResponse{Content: `{"entities":[`, FinishReason: "length"}, nil }} result, err := s.generateWikiExtraction(context.Background(), model, "{{.Content}}", map[string]string{"Content": strings.Repeat("x", 32000)}) require.Error(t, err) expected := 4 if lastFailure == "invalid" { expected++ } require.Equal(t, expected, calls) require.Empty(t, result.Entities, "a successful first leaf must not publish when its sibling fails") }) } } func TestWikiExtractionNestedProviderFailureStopsFurtherPartitions(t *testing.T) { s := &wikiIngestService{} ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(77)) calls := 0 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { calls++ if calls < 3 { return &types.ChatResponse{Content: `{"entities":[`, FinishReason: "length"}, nil } return nil, errors.New("API request failed with status 429: private provider detail") }} key := wikiProviderCircuitKey(77, model.id) t.Cleanup(func() { wikiProviderBlocks.Delete(key); wikiProviderSlots.Delete(key) }) result, err := s.generateWikiExtraction(ctx, model, "{{.Content}}", map[string]string{"Content": strings.Repeat("x", 32000)}) require.Error(t, err) require.Equal(t, 3, calls) require.Empty(t, result.Entities) require.NotContains(t, err.Error(), "private provider detail") } func TestWikiExtractionInvalidCandidatesUseOneWholeSourceCorrection(t *testing.T) { for _, invalid := range []string{ `{"entities":[{"name":" ","slug":"entity/private"}],"concepts":[]}`, `{"entities":[{"name":"Private","slug":"concept/private"}],"concepts":[]}`, `{"entities":[],"concepts":[{"name":"Private","slug":"concept/ "}]}`, } { t.Run(invalid, func(t *testing.T) { s := &wikiIngestService{} var inputs []string model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) { inputs = append(inputs, messages[0].Content) if len(inputs) == 1 { return &types.ChatResponse{Content: invalid}, nil } return &types.ChatResponse{Content: `{"entities":[{"name":"Valid","slug":"entity/valid"}],"concepts":[]}`}, nil }} source := "Valid " + strings.Repeat("a", 1194) + "\n" + strings.Repeat("b", 1200) result, err := s.generateWikiExtraction(context.Background(), model, "{{.Content}}", map[string]string{"Content": source}) if err != nil || len(inputs) != 2 || !strings.HasPrefix(inputs[1], source+"\n\n"+wikiCandidateRepairInstruction) || len(result.Entities) != 1 { t.Fatalf("invalid candidate repair lost source or exceeded budget: calls=%d err=%v", len(inputs), err) } }) } } func TestWikiExtractionInvalidCandidateInSecondHalfCannotPublishFirstHalf(t *testing.T) { s := &wikiIngestService{} calls := 0 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { calls++ if calls == 1 { return &types.ChatResponse{Content: `{"entities":[`, FinishReason: "length"}, nil } if calls == 2 { return &types.ChatResponse{Content: `{"entities":[{"name":"x","slug":"entity/valid"}],"concepts":[]}`}, nil } return &types.ChatResponse{Content: `{"entities":[],"concepts":[{"name":"private-source-name","slug":"entity/private-source-slug"}]}`}, nil }} result, err := s.generateWikiExtraction(context.Background(), model, "{{.Content}}", map[string]string{"Content": strings.Repeat("x", 4000)}) if err == nil || calls != 4 || len(result.Entities) != 0 || len(result.Concepts) != 0 || !strings.Contains(err.Error(), "partition 2/2") || !strings.Contains(err.Error(), `"field":"concepts.slug","index":0`) || strings.Contains(err.Error(), "private-source") { t.Fatalf("invalid candidate must preserve scope and report only field path: calls=%d err=%v", calls, err) } } func TestWikiExtractionDoesNotSplitOrImmediatelyRetryRateLimit(t *testing.T) { s := &wikiIngestService{} ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(77)) var calls atomic.Int32 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { calls.Add(1) return nil, errors.New("API request failed with status 429: private text") }} key := wikiProviderCircuitKey(77, model.id) t.Cleanup(func() { wikiProviderBlocks.Delete(key); wikiProviderSlots.Delete(key) }) _, err := s.generateWikiExtraction(ctx, model, "{{.Content}}", map[string]string{"Content": strings.Repeat("x", 4000)}) if err == nil || calls.Load() != 1 || strings.Contains(err.Error(), "private text") { t.Fatalf("rate limit retry storm or raw provider leak: calls=%d err=%v", calls.Load(), err) } } func TestWikiRejectsTruncatedEvenSyntacticallyValidOutput(t *testing.T) { for _, reason := range []string{"length", "max_tokens", "max_output_tokens", "incomplete"} { for _, content := range []string{`{"entities":[],"concepts":[]}`, "", " "} { err := validateWikiResponse(&types.ChatResponse{Content: content, FinishReason: reason}) var outputErr *wikiOutputError if !errors.As(err, &outputErr) || !outputErr.incomplete { t.Fatalf("lost incomplete response reason %s: %v", reason, err) } } } if err := validateWikiResponse(&types.ChatResponse{Content: " ", FinishReason: "stop"}); err == nil { t.Fatal("accepted empty output") } } func TestWikiCitationFailureCannotBecomePartialSuccess(t *testing.T) { s := &wikiIngestService{} model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) { if strings.Contains(messages[0].Content, "BROKEN") { return &types.ChatResponse{Content: `{"citations":`}, nil } return &types.ChatResponse{Content: `{"citations":{"entity/alpha":["c000"]},"new_slugs":[]}`}, nil }} chunks := []*types.Chunk{ {ID: "first", Content: strings.Repeat("a", 12000), ChunkIndex: 0}, {ID: "second", Content: "BROKEN", ChunkIndex: 1}, } citations, slugs, _, err := s.classifyChunkCitations(context.Background(), model, "entity/alpha", chunks, "English", &WikiBatchContext{}) if err == nil || citations != nil || slugs != nil { t.Fatalf("partial classification returned as success: %v", err) } } func TestWikiCitationValidBatchesTranslateHandlesAndIgnoreNilChunks(t *testing.T) { s := &wikiIngestService{} model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { return &types.ChatResponse{Content: `{"citations":{"entity/alpha":["c000"]},"new_slugs":[]}`}, nil }} chunks := []*types.Chunk{nil, {ID: "first", Content: strings.Repeat("a", 12000), ChunkIndex: 0}, {ID: "second", Content: "b", ChunkIndex: 1}} citations, _, completed, err := s.classifyChunkCitations(context.Background(), model, "entity/alpha", chunks, "English", &WikiBatchContext{}) if err != nil || completed != 2 || strings.Join(citations["entity/alpha"], ",") != "first,second" { t.Fatalf("bad citation coverage: completed=%d result=%v err=%v", completed, citations, err) } } func TestWikiCitationRejectsMissingSchemaAndUnknownSourceHandles(t *testing.T) { for _, output := range []string{`null`, `{}`, `{"citations":{"entity/alpha":["c999"]}}`, `{"citations":{},"new_slugs":[{"slug":"concept/new","name":"New","source_chunks":["c999"]}]}`} { s := &wikiIngestService{} model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) { return &types.ChatResponse{Content: output}, nil }} citations, _, completed, err := s.classifyChunkCitations(context.Background(), model, "entity/alpha", []*types.Chunk{{ID: "first", Content: "Source text"}}, "English", &WikiBatchContext{}) if err == nil || citations != nil || completed != 0 { t.Fatalf("accepted citations without valid source coverage: completed=%d err=%v", completed, err) } } }