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

311 lines
15 KiB
Go
Raw Normal View History

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(), "<content>\n"+source.Content+"\n</content>")
require.Contains(t, prompt.String(), "<slug>concept/alpha</slug>")
require.Contains(t, prompt.String(), "<title>Alpha</title>")
require.NotContains(t, prompt.String(), "<aliases>")
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)
})
}
}