package service import ( "context" "errors" "fmt" "path/filepath" "strings" "testing" "github.com/Tencent/WeKnora/internal/models/chat" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" "github.com/stretchr/testify/require" "gorm.io/gorm" ) type wikiEvidenceChunksRepo struct { interfaces.ChunkRepository chunks []*types.Chunk err error queries [][]string tenantIDs []uint64 returnAll bool } func (r *wikiEvidenceChunksRepo) ListChunksByID(_ context.Context, tenantID uint64, ids []string) ([]*types.Chunk, error) { r.queries = append(r.queries, append([]string(nil), ids...)) r.tenantIDs = append(r.tenantIDs, tenantID) if r.err != nil { return nil, r.err } if r.returnAll { return r.chunks, nil } wanted := make(map[string]bool) for _, id := range ids { wanted[id] = true } var out []*types.Chunk for _, chunk := range r.chunks { if chunk != nil && wanted[chunk.ID] { out = append(out, chunk) } } return out, nil } func wikiEvidenceChunk() *types.Chunk { return &types.Chunk{ID: "source-chunk", TenantID: 7, KnowledgeBaseID: "kb", KnowledgeID: "doc", IsEnabled: true, Content: "Verbatim source: 42 applies only when condition A holds.", ChunkIndex: 3, StartAt: 80} } func wikiEvidenceAddition() SlugUpdate { return SlugUpdate{KnowledgeID: "doc", SourceRef: "doc", Slug: "concept/alpha", Type: types.WikiPageTypeConcept, DocTitle: "Source", SourceChunks: []string{"source-chunk"}, Item: extractedItem{Name: "Alpha", Slug: "concept/alpha", Description: "UNSUPPORTED_DESCRIPTION_MUST_NOT_REPLACE_SOURCE", Details: "UNSUPPORTED_DETAILS_MUST_NOT_REPLACE_SOURCE"}} } func TestWikiAdditionEvidenceRejectsIncompleteOrForeignSources(t *testing.T) { tests := []struct { name string change func(*SlugUpdate, *wikiEvidenceChunksRepo) }{ {"no citations", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { u.SourceChunks = nil }}, {"blank citation", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { u.SourceChunks = []string{"source-chunk", " "} }}, {"missing document", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { u.KnowledgeID = "" }}, {"wrong source ref", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { u.SourceRef = "other-document" }}, {"partially missing", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { u.SourceChunks = append(u.SourceChunks, "missing-chunk") }}, {"wrong tenant", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.chunks[0].TenantID = 8 }}, {"wrong knowledge base", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.chunks[0].KnowledgeBaseID = "other-kb" }}, {"wrong document", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.chunks[0].KnowledgeID = "other-document" }}, {"disabled", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.chunks[0].IsEnabled = false }}, {"deleted", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.chunks[0].DeletedAt = gorm.DeletedAt{Valid: true} }}, {"empty source", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.chunks[0].Content = " \n" }}, {"lookup failure", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.err = errors.New("database unavailable") }}, {"unexpected source", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.returnAll = true c := wikiEvidenceChunk() c.ID = "unrequested" r.chunks = append(r.chunks, c) }}, {"nil source", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.returnAll = true; r.chunks = append(r.chunks, nil) }}, {"duplicate source", func(u *SlugUpdate, r *wikiEvidenceChunksRepo) { r.chunks = append(r.chunks, wikiEvidenceChunk()) }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { update := wikiEvidenceAddition() repo := &wikiEvidenceChunksRepo{chunks: []*types.Chunk{wikiEvidenceChunk()}} test.change(&update, repo) got, err := (&wikiIngestService{chunkRepo: repo}).resolveWikiAdditionEvidence(context.Background(), 7, "kb", []SlugUpdate{update}) require.ErrorIs(t, err, errWikiCitationEvidence) require.Nil(t, got, "never expose partially verified evidence") }) } } func TestWikiAdditionEvidenceChecksEveryUniqueSourceInBoundedBatches(t *testing.T) { repo := &wikiEvidenceChunksRepo{} update := wikiEvidenceAddition() update.SourceRef = "doc|Legacy title" update.SourceChunks = nil for i := 0; i < 260; i++ { c := wikiEvidenceChunk() c.ID = fmt.Sprintf("source-%03d", i) repo.chunks = append(repo.chunks, c) update.SourceChunks = append(update.SourceChunks, c.ID) } update.SourceChunks = append(update.SourceChunks, update.SourceChunks[0]) got, err := (&wikiIngestService{chunkRepo: repo}).resolveWikiAdditionEvidence(context.Background(), 7, "kb", []SlugUpdate{update, update}) require.NoError(t, err) require.Len(t, got, 260) require.Len(t, repo.queries, 3) require.Equal(t, []uint64{7, 7, 7}, repo.tenantIDs) for _, query := range repo.queries { require.LessOrEqual(t, len(query), 128) } require.Equal(t, repo.chunks[0], got[repo.chunks[0].ID], "source metadata and exact text must be preserved for ordered page synthesis") require.NotSame(t, repo.chunks[0], got[repo.chunks[0].ID], "keep a snapshot while the model runs") other := update other.KnowledgeID, other.SourceRef = "other-document", "other-document" _, err = (&wikiIngestService{chunkRepo: repo}).resolveWikiAdditionEvidence(context.Background(), 7, "kb", []SlugUpdate{update, other}) require.ErrorIs(t, err, errWikiCitationEvidence) require.Len(t, repo.queries, 3, "reject contradictory ownership before any lookup") } func TestWikiEvidenceSaveGuardRejectsConcurrentSourceChanges(t *testing.T) { for _, test := range []struct { name string change func(*wikiEvidenceChunksRepo) }{ {"content", func(r *wikiEvidenceChunksRepo) { r.chunks[0].Content = "Changed source" }}, {"revision", func(r *wikiEvidenceChunksRepo) { r.chunks[0].ContentRevision++ }}, {"enabled", func(r *wikiEvidenceChunksRepo) { r.chunks[0].IsEnabled = false }}, {"deleted", func(r *wikiEvidenceChunksRepo) { r.chunks = nil }}, {"scope", func(r *wikiEvidenceChunksRepo) { r.chunks[0].TenantID = 8 }}, {"ordering", func(r *wikiEvidenceChunksRepo) { r.chunks[0].ChunkIndex++ }}, {"read failure", func(r *wikiEvidenceChunksRepo) { r.err = errors.New("database unavailable") }}, } { t.Run(test.name, func(t *testing.T) { repo := &wikiEvidenceChunksRepo{chunks: []*types.Chunk{wikiEvidenceChunk()}} svc := &wikiIngestService{chunkRepo: repo, knowledgeSvc: &checkpointKnowledge{}} updates := []SlugUpdate{wikiEvidenceAddition()} before, err := svc.resolveWikiAdditionEvidence(context.Background(), 7, "kb", updates) require.NoError(t, err) test.change(repo) err = svc.verifyWikiAdditionEvidenceUnchanged(context.Background(), 7, "kb", updates, before) require.ErrorIs(t, err, errWikiCitationEvidence) }) } } func TestWikiEvidenceSaveGuardAllowsOnlyUnchangedCompleteSources(t *testing.T) { repo := &wikiEvidenceChunksRepo{chunks: []*types.Chunk{wikiEvidenceChunk()}} svc := &wikiIngestService{chunkRepo: repo, knowledgeSvc: &checkpointKnowledge{}} updates := []SlugUpdate{wikiEvidenceAddition()} before, err := svc.resolveWikiAdditionEvidence(context.Background(), 7, "kb", updates) require.NoError(t, err) require.NoError(t, svc.verifyWikiAdditionEvidenceUnchanged(context.Background(), 7, "kb", updates, before)) require.ErrorIs(t, svc.verifyWikiAdditionEvidenceUnchanged(context.Background(), 7, "kb", updates, nil), errWikiCitationEvidence) svc.knowledgeSvc = &checkpointFailingKnowledge{err: gorm.ErrRecordNotFound} require.ErrorIs(t, svc.verifyWikiAdditionEvidenceUnchanged(context.Background(), 7, "kb", updates, before), errWikiCitationEvidence) require.NoError(t, svc.verifyWikiAdditionEvidenceUnchanged(context.Background(), 7, "kb", nil, nil)) } func TestWikiSummaryEvidenceSaveGuardChecksWholeScopedSourceSnapshot(t *testing.T) { for _, test := range []struct { name string change func(*checkpointChunksRepo, *WikiBatchContext) }{ {"content", func(r *checkpointChunksRepo, b *WikiBatchContext) { r.chunks[0].Content = "Changed source" }}, {"new chunk", func(r *checkpointChunksRepo, b *WikiBatchContext) { c := wikiEvidenceChunk() c.ID = "new" r.chunks = append(r.chunks, c) }}, {"deleted chunk", func(r *checkpointChunksRepo, b *WikiBatchContext) { r.chunks = nil }}, {"foreign chunk", func(r *checkpointChunksRepo, b *WikiBatchContext) { r.chunks[0].KnowledgeID = "foreign" }}, {"missing baseline", func(r *checkpointChunksRepo, b *WikiBatchContext) { b.checkpointSourceHashes.Delete("doc") }}, {"wrong scope", func(r *checkpointChunksRepo, b *WikiBatchContext) { b.checkpointScopes.Store("doc", &wikiCheckpointScope{8, "kb", "doc", "fingerprint"}) }}, } { t.Run(test.name, func(t *testing.T) { repo := &checkpointChunksRepo{chunks: []*types.Chunk{wikiEvidenceChunk()}} svc := &wikiIngestService{chunkRepo: repo, knowledgeSvc: &checkpointKnowledge{}} batch := &WikiBatchContext{} batch.checkpointScopes.Store("doc", &wikiCheckpointScope{7, "kb", "doc", "fingerprintprint"}) batch.checkpointSourceHashes.Store("doc", wikiEvidenceSourceHash(repo.chunks)) update := SlugUpdate{KnowledgeID: "doc", Type: types.WikiPageTypeSummary, SourceRef: "doc"} require.NoError(t, svc.verifyWikiSummaryEvidenceUnchanged(context.Background(), 7, "kb", update, batch)) test.change(repo, batch) require.ErrorIs(t, svc.verifyWikiSummaryEvidenceUnchanged(context.Background(), 7, "kb", update, batch), errWikiCitationEvidence) }) } } func TestWikiSourceEvidenceHashIgnoresOnlyResultOrder(t *testing.T) { a, b := wikiEvidenceChunk(), wikiEvidenceChunk() b.ID = "other" require.Equal(t, wikiEvidenceSourceHash([]*types.Chunk{a, b}), wikiEvidenceSourceHash([]*types.Chunk{b, a})) b.ContentRevision++ require.NotEqual(t, wikiEvidenceSourceHash([]*types.Chunk{a}), wikiEvidenceSourceHash([]*types.Chunk{b})) } func TestWikiAdditionEvidenceHonorsCancellationBeforeLookup(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() repo := &wikiEvidenceChunksRepo{chunks: []*types.Chunk{wikiEvidenceChunk()}} _, err := (&wikiIngestService{chunkRepo: repo}).resolveWikiAdditionEvidence(ctx, 7, "kb", []SlugUpdate{wikiEvidenceAddition()}) require.ErrorIs(t, err, context.Canceled) require.Empty(t, repo.queries) } func TestWikiEvidenceGatePreservesPublishedPageAndNeverCheckpointsUncitedCandidate(t *testing.T) { for _, existing := range []bool{false, true} { for _, partial := range []bool{false, true} { t.Run(fmt.Sprintf("existing-%t-partial-%t", existing, partial), func(t *testing.T) { db := openWikiCheckpointDB(t, filepath.Join(t.TempDir(), "checkpoints.sqlite")) original := &types.WikiPage{ID: "page", TenantID: 7, KnowledgeBaseID: "kb", Slug: "concept/alpha", Title: "Alpha", PageType: types.WikiPageTypeConcept, Status: types.WikiPageStatusPublished, Content: "Original supported content", Summary: "Original summary", SourceRefs: types.StringArray{"old-doc"}, ChunkRefs: types.StringArray{"old-chunk"}, Aliases: types.StringArray{"Old alias"}} before := *original pages := &checkpointWikiPages{pages: make(map[string]*types.WikiPage)} if existing { pages.pages[original.Slug] = original } svc := &wikiIngestService{checkpointDB: db, wikiService: pages, knowledgeSvc: &checkpointKnowledge{}, chunkRepo: &wikiEvidenceChunksRepo{chunks: []*types.Chunk{wikiEvidenceChunk()}}} batch := &WikiBatchContext{SlugTitleMany: func(context.Context, []string) map[string]string { return nil }} batch.checkpointScopes.Store("doc", &wikiCheckpointScope{7, "kb", "doc", "fingerprint"}) calls := 0 model := &wikiScriptedChat{id: t.Name(), call: func(context.Context, []chat.Message) (*types.ChatResponse, error) { calls++ return &types.ChatResponse{Content: "SUMMARY: Unsafe page\nUNSUPPORTED_DETAILS_MUST_NOT_REPLACE_SOURCE", FinishReason: "stop"}, nil }} update := wikiEvidenceAddition() if partial { update.SourceChunks = append(update.SourceChunks, "missing") } else { update.SourceChunks = nil } updates := []SlugUpdate{{KnowledgeID: "old-doc", SourceRef: "old-doc", Slug: original.Slug, Type: "retract"}, update} for retry := 0; retry < 2; retry++ { changed, _, failed, err := svc.reduceSlugUpdates(context.Background(), model, "kb", original.Slug, updates, 7, batch, nil) require.ErrorIs(t, err, errWikiCitationEvidence) require.False(t, changed) require.True(t, failed) } require.Zero(t, calls) require.Zero(t, pages.writes) if existing { require.Equal(t, before, *pages.pages[original.Slug]) } else { require.Empty(t, pages.pages) } var checkpoints int64 require.NoError(t, db.Model(&wikiIngestCheckpoint{}).Where("stage = ?", "page").Count(&checkpoints).Error) require.Zero(t, checkpoints) }) } } } func TestWikiEvidenceGateUsesVerbatimSourceWithoutDetailsFallback(t *testing.T) { for _, origin := range []string{"pass-0", "citation-discovery"} { t.Run(origin, func(t *testing.T) { pages := &checkpointWikiPages{pages: make(map[string]*types.WikiPage)} source := wikiEvidenceChunk() source.Content += " Alpha is also called First." update := wikiEvidenceAddition() update.Item.Aliases = nil if origin == "citation-discovery" { batch := splitChunksIntoCitationBatches([]*types.Chunk{source})[0] parsed, err := parseWikiCitationBatch(`{"citations":{},"new_slugs":[{"type":"concept","name":"Alpha","slug":"concept/alpha","aliases":[],"description":"UNSUPPORTED_DESCRIPTION_MUST_NOT_REPLACE_SOURCE","details":"UNSUPPORTED_DETAILS_MUST_NOT_REPLACE_SOURCE","source_chunks":["c000"]}]}`, batch, map[string]bool{}) require.NoError(t, err) _, concepts, uncited := mergeCitationsIntoItems(nil, nil, parsed.Citations, parsed.NewSlugs) require.Zero(t, uncited) require.Len(t, concepts, 1) update.Item = concepts[0] update.SourceChunks = concepts[0].SourceChunks } svc := &wikiIngestService{wikiService: pages, knowledgeSvc: &checkpointKnowledge{}, chunkRepo: &wikiEvidenceChunksRepo{chunks: []*types.Chunk{source}}} calls := 0 model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) { calls++ var prompt strings.Builder for _, message := range messages { prompt.WriteString(message.Content) } require.Contains(t, prompt.String(), "\n"+source.Content+"\n") require.Contains(t, prompt.String(), "concept/alpha") require.Contains(t, prompt.String(), "Alpha") require.NotContains(t, prompt.String(), "") require.NotContains(t, prompt.String(), update.Item.Description) require.NotContains(t, prompt.String(), update.Item.Details) return &types.ChatResponse{Content: "SUMMARY: Supported page\n42 applies only when condition A holds.", FinishReason: "stop"}, nil }} batch := &WikiBatchContext{SlugTitleMany: func(context.Context, []string) map[string]string { return nil }} changed, _, failed, err := svc.reduceSlugUpdatesUncheckpointed(context.Background(), model, "kb", update.Slug, []SlugUpdate{update}, 7, batch, nil) require.NoError(t, err) require.True(t, changed) require.False(t, failed) require.Equal(t, 1, calls) require.Equal(t, 1, pages.writes) require.Equal(t, types.StringArray{"source-chunk"}, pages.pages[update.Slug].ChunkRefs) require.Equal(t, types.StringArray{"doc"}, pages.pages[update.Slug].SourceRefs) require.Empty(t, pages.pages[update.Slug].Aliases) }) } }