Files
xinmi/internal/application/service/wiki_structured_output_test.go
T

428 lines
21 KiB
Go
Raw Normal View History

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, `<c id="c001"`) {
t.Fatal("retry did not rebuild the local handle table")
}
return &types.ChatResponse{Content: `{"citations":{"entity/alpha":["c000"]},"new_slugs":[{"type":"concept","slug":"concept/new","name":"New","source_chunks":["c000"]}]}`}, nil
}}
chunks := []*types.Chunk{{ID: "first", Content: strings.Repeat("first", 600), ChunkIndex: 0}, {ID: "second", Content: strings.Repeat("second", 500), ChunkIndex: 1}}
citations, newSlugs, completed, err := s.classifyChunkCitations(context.Background(), model, "entity/alpha", chunks, "English", &WikiBatchContext{})
if err != nil || completed != 1 || len(inputs) != 3 || strings.Join(citations["entity/alpha"], ",") != "first,second" {
t.Fatalf("failed bounded citation recovery: completed=%d calls=%d citations=%v err=%v", completed, len(inputs), citations, err)
}
if len(newSlugs) != 2 || strings.Join(newSlugs[0].SourceChunks, ",") != "first" || strings.Join(newSlugs[1].SourceChunks, ",") != "second" {
t.Fatalf("new slug provenance lost: %+v", newSlugs)
}
if !strings.Contains(inputs[1], chunks[0].Content) || strings.Contains(inputs[1], chunks[1].Content) || !strings.Contains(inputs[2], chunks[1].Content) || strings.Contains(inputs[2], chunks[0].Content) {
t.Fatal("retry did not partition all source chunks")
}
}
func TestWikiCitationOversizedChunkRetryPreservesEveryRuneAndSourceID(t *testing.T) {
s := &wikiIngestService{}
content := strings.Repeat("前", 1200) + "\n" + strings.Repeat("后", 1200)
var seen []string
model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) {
prompt := messages[0].Content
start, end := strings.Index(prompt, "<chunks>\n"), strings.Index(prompt, "\n</chunks>")
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</chunks>")]), &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, `<c id="c001"`) {
handles = `["c000","c001"]`
}
return &types.ChatResponse{Content: `{"citations":{"entity/alpha":` + handles + `},"new_slugs":[]}`}, nil
}}
var chunks []*types.Chunk
var expected []string
for i := 0; i < 12; i++ {
for j := 0; j < 2; j++ {
id := fmt.Sprintf("chunk-%02d-%d", i, j)
prefix := id
if i == 9 {
prefix = []string{"target-left", "target-right"}[j]
}
chunks = append(chunks, &types.Chunk{ID: id, Content: prefix + strings.Repeat("x", 6000-len(prefix)), ChunkIndex: 2*i + j})
expected = append(expected, id)
}
}
citations, _, completed, err := s.classifyChunkCitations(context.Background(), model, "entity/alpha", chunks, "English", &WikiBatchContext{})
if err != nil || calls.Load() != 14 || completed != 12 || strings.Join(citations["entity/alpha"], ",") != strings.Join(expected, ",") {
t.Fatalf("batch 10/12 recovery lost coverage: calls=%d completed=%d citations=%v err=%v", calls.Load(), completed, citations, err)
}
}
func TestWikiCitationRepeatedTruncationStopsAtFirstFailedPartition(t *testing.T) {
s := &wikiIngestService{}
var calls atomic.Int32
model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) {
calls.Add(1)
return &types.ChatResponse{Content: `{"citations":`, FinishReason: "length"}, nil
}}
chunks := []*types.Chunk{{ID: "first", Content: strings.Repeat("x", 3000)}, {ID: "second", Content: strings.Repeat("y", 3000)}}
citations, _, completed, err := s.classifyChunkCitations(context.Background(), model, "entity/alpha", chunks, "English", &WikiBatchContext{})
if err == nil || calls.Load() != 2 || completed != 0 || citations != nil || !strings.Contains(err.Error(), "partition 1/2") {
t.Fatalf("persistent truncation exceeded retry bound: calls=%d completed=%d err=%v", calls.Load(), completed, err)
}
}
func (m *wikiScriptedChat) GetModelID() string { return m.id }
func (m *wikiScriptedChat) Chat(ctx context.Context, messages []chat.Message, _ *chat.ChatOptions) (*types.ChatResponse, error) {
return m.call(ctx, messages)
}
func TestWikiExtractionSplitsTruncatedOutputWithoutDroppingEitherHalf(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: `{"entities":[`, FinishReason: "length"}, nil
}
if len(inputs) == 2 {
return &types.ChatResponse{Content: `{"entities":[{"name":"Alpha","slug":"entity/alpha"}],"concepts":[]}`}, nil
}
return &types.ChatResponse{Content: `{"entities":[],"concepts":[{"name":"Beta","slug":"concept/beta"}]}`}, nil
}}
content := "Alpha " + strings.Repeat("a", 1194) + "\n" + strings.Repeat("b", 1200)
result, err := s.generateWikiExtraction(context.Background(), model, "{{.Content}}", map[string]string{"Content": content})
if err != nil {
t.Fatal(err)
}
if len(inputs) != 3 || inputs[1]+inputs[2] != content || len(result.Entities) != 1 || len(result.Concepts) != 1 {
t.Fatalf("partition recovery lost data: calls=%d entities=%d concepts=%d", len(inputs), len(result.Entities), len(result.Concepts))
}
}
func TestWikiExtractionInvalidJSONIsBoundedAndDoesNotReturnPartialOutput(t *testing.T) {
s := &wikiIngestService{}
var calls atomic.Int32
model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, _ []chat.Message) (*types.ChatResponse, error) {
calls.Add(1)
return &types.ChatResponse{Content: `{"entities":[`}, nil
}}
result, err := s.generateWikiExtraction(context.Background(), model, "{{.Content}}", map[string]string{"Content": strings.Repeat("x", 4000)})
if err == nil || calls.Load() != 4 || len(result.Entities) != 0 {
t.Fatalf("invalid output must fail after first failing half, calls=%d err=%v", calls.Load(), err)
}
}
func TestWikiExtractionLargeTruncatedHalfGetsOneAdditionalSplit(t *testing.T) {
s := &wikiIngestService{}
var leaves []string
calls := 0
model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) {
calls++
content := messages[0].Content
if len([]rune(content)) > 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)
}
}
}