Files
xinmi/internal/application/service/wiki_source_identity_repair_test.go
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

370 lines
20 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/agent"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/models/requestbudget"
"github.com/Tencent/WeKnora/internal/types"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
)
func TestWikiIdentityRepairPatchesOnlyRejectedNamesUnderExistingBudget(t *testing.T) {
guard := &candidateRepairTestGuard{}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
ctx = requestbudget.WithGuard(ctx, guard)
deadline, _ := ctx.Deadline()
source := "Alpha and LH are separate. FSH has a different role."
data := map[string]string{"Content": source, "Language": "English", "PreviousSlugs": "entity/existing"}
original := combinedExtraction{
Entities: []extractedItem{
{Name: "Alpha", Slug: "entity/alpha", Aliases: []string{}, Description: "Valid description", Details: "Keep uncertainty", SourceChunks: []string{"original-1"}},
{Name: "Luteinizing hormone", Slug: "entity/lh", Description: "Original LH description", Details: "Do not edit this", SourceChunks: []string{"original-2"}},
{Name: "Expanded FSH", Slug: "entity/fsh", Description: "Original FSH description", Details: "Keep conditions"},
},
Concepts: []extractedItem{{Name: "Separate roles", Slug: "concept/roles", Description: "Concept need not be literal"}},
}
encoded, err := json.Marshal(original)
require.NoError(t, err)
before := string(encoded)
calls := 0
var initialPrompt string
model := &wikiScriptedChat{id: t.Name(), call: func(callCtx context.Context, messages []chat.Message) (*types.ChatResponse, error) {
calls++
require.Same(t, guard, requestbudget.FromContext(callCtx))
require.Equal(t, "wiki_candidate_slug", requestbudget.Phase(callCtx))
actualDeadline, ok := callCtx.Deadline()
require.True(t, ok)
require.Equal(t, deadline, actualDeadline)
require.Contains(t, messages[0].Content, source)
if calls == 1 {
initialPrompt = strings.TrimSuffix(messages[0].Content, "\n\n"+wikiCandidateGenerationInstruction)
return &types.ChatResponse{Content: before}, nil
}
require.Equal(t, 2, calls)
require.True(t, strings.HasPrefix(messages[0].Content, initialPrompt+"\n\n"+wikiIdentityRepairInstruction))
require.Contains(t, messages[0].Content, wikiSourceIdentityRepairVersion)
require.Contains(t, messages[0].Content, `"index":1`)
require.Contains(t, messages[0].Content, `"index":2`)
require.NotContains(t, messages[0].Content, "Valid description")
return &types.ChatResponse{Content: `{"corrections":[{"kind":"entity","index":2,"name":"FSH","source_quote":"FSH has a different role."},{"kind":"entity","index":1,"name":"LH","source_quote":"Alpha and LH are separate."}]}`}, nil
}}
got, err := (&wikiIngestService{}).generateWikiFreshExtraction(ctx, model, agent.WikiCandidateSlugPrompt, data)
require.NoError(t, err)
require.Equal(t, 2, calls)
require.Equal(t, "LH", got.Entities[1].Name)
require.Equal(t, "FSH", got.Entities[2].Name)
got.Entities[1].Name, got.Entities[2].Name = original.Entities[1].Name, original.Entities[2].Name
require.Equal(t, original, got, "only two rejected names may change; no metadata or candidate can disappear")
after, err := json.Marshal(original)
require.NoError(t, err)
require.Equal(t, before, string(after))
require.Equal(t, source, data["Content"])
require.GreaterOrEqual(t, guard.checks, 4)
}
func TestWikiIdentityRepairRejectsPartialOrUnsupportedPatchesWithoutMutation(t *testing.T) {
source := "LH and FSH are separate from Alpha."
initial := combinedExtraction{Entities: []extractedItem{
{Name: "Expanded LH", Slug: "entity/lh", Description: "Retain description", SourceChunks: []string{"chunk"}},
{Name: "Expanded FSH", Slug: "entity/fsh"},
{Name: "Alpha", Slug: "entity/alpha"},
}, Concepts: []extractedItem{}}
valid := `{"corrections":[{"kind":"entity","index":0,"name":"LH","source_quote":"LH and FSH"},{"kind":"entity","index":1,"name":"FSH","source_quote":"LH and FSH"}]}`
for name, test := range map[string]struct{ raw, code string }{
"whole extraction": {`{"entities":[],"concepts":[]}`, "json_decode_failed"},
"missing correction": {`{"corrections":[{"kind":"entity","index":0,"name":"LH","source_quote":"LH and FSH"}]}`, "correction_count_mismatch"},
"empty corrections": {`{"corrections":[]}`, "correction_count_mismatch"},
"duplicate position": {strings.Replace(valid, `"index":1`, `"index":0`, 1), "duplicate_position"},
"valid candidate edited": {strings.Replace(valid, `"index":1`, `"index":2`, 1), "unknown_position"},
"new candidate": {strings.Replace(valid, `"index":1`, `"index":3`, 1), "unknown_position"},
"missing index": {strings.Replace(valid, `"index":0,`, "", 1), "missing_index"},
"negative index": {strings.Replace(valid, `"index":0`, `"index":-1`, 1), "unknown_position"},
"wrong kind": {strings.Replace(valid, `"kind":"entity"`, `"kind":"concept"`, 1), "unknown_position"},
"metadata field": {strings.Replace(valid, `"name":"LH"`, `"name":"LH","description":"rewritten"`, 1), "json_decode_failed"},
"slug field": {strings.Replace(valid, `"name":"LH"`, `"name":"LH","slug":"entity/new"`, 1), "json_decode_failed"},
"aliases field": {strings.Replace(valid, `"name":"LH"`, `"name":"LH","aliases":[]`, 1), "json_decode_failed"},
"empty name": {strings.Replace(valid, `"name":"LH"`, `"name":""`, 1), "empty_name"},
"name whitespace": {strings.Replace(valid, `"name":"LH"`, `"name":" LH"`, 1), "name_outer_whitespace"},
"missing quote": {strings.Replace(valid, `,"source_quote":"LH and FSH"`, "", 1), "empty_source_quote"},
"fabricated quote": {strings.Replace(valid, `"source_quote":"LH and FSH"`, `"source_quote":"LH and different text"`, 1), "quote_not_in_source"},
"name absent from quote": {strings.Replace(valid, `"source_quote":"LH and FSH"`, `"source_quote":"FSH are separate"`, 1), "name_not_in_quote"},
"expanded name": {strings.Replace(valid, `"name":"LH"`, `"name":"Expanded LH"`, 1), "name_not_in_quote"},
"case changed": {strings.Replace(valid, `"name":"LH"`, `"name":"lh"`, 1), "name_not_in_quote"},
"trailing output": {valid + " {}", "trailing_json"},
"unfinished json": {strings.TrimSuffix(valid, "}"), "json_decode_failed"},
"collides with valid": {strings.Replace(strings.Replace(valid, `"name":"LH"`, `"name":"Alpha"`, 1), `"source_quote":"LH and FSH"`, `"source_quote":"Alpha"`, 1), "identity_collision"},
"two repairs collide": {strings.Replace(valid, `"name":"FSH"`, `"name":"LH"`, 1), "identity_collision"},
} {
t.Run(name, func(t *testing.T) {
before, err := json.Marshal(initial)
require.NoError(t, err)
got, err := applyWikiSourceIdentityCorrections(initial, source, wikiRejectedSourceIdentities(initial, source), test.raw)
var diagnostic *wikiIdentityRepairError
require.ErrorAs(t, err, &diagnostic)
require.Equal(t, test.code, diagnostic.Code)
require.Empty(t, got.Entities)
after, err := json.Marshal(initial)
require.NoError(t, err)
require.Equal(t, before, after)
})
}
}
func TestWikiIdentityRepairNeverDeletesUnsupportedAliases(t *testing.T) {
for _, kind := range []string{"entity", "concept"} {
for _, aliases := range [][]string{{"Alpha"}, {"LH"}, {""}} {
result := combinedExtraction{Entities: []extractedItem{}, Concepts: []extractedItem{}}
item := extractedItem{Name: "LH", Slug: kind + "/lh", Aliases: aliases}
if kind == "entity" {
result.Entities = append(result.Entities, item)
} else {
result.Concepts = append(result.Concepts, item)
}
before, err := json.Marshal(result)
require.NoError(t, err)
patch := `{"corrections":[{"kind":"` + kind + `","index":0,"name":"LH","source_quote":"LH and Alpha"}]}`
got, err := applyWikiSourceIdentityCorrections(result, "LH and Alpha", wikiRejectedSourceIdentities(result, "LH and Alpha"), patch)
var diagnostic *wikiIdentityRepairError
require.ErrorAs(t, err, &diagnostic)
require.Equal(t, "unsupported_aliases", diagnostic.Code)
require.Equal(t, kind, diagnostic.Kind)
require.NotNil(t, diagnostic.Index)
require.Zero(t, *diagnostic.Index)
require.Empty(t, got.Entities)
require.Empty(t, got.Concepts)
after, err := json.Marshal(result)
require.NoError(t, err)
require.Equal(t, before, after)
}
}
}
func TestWikiIdentityRepairDiagnosticPositionsAndRedaction(t *testing.T) {
initial := combinedExtraction{Entities: []extractedItem{{Name: "Private expanded LH", Slug: "entity/lh"}}}
source := "Private source LH"
valid := `{"corrections":[{"kind":"entity","index":0,"name":"LH","source_quote":"LH"}]}`
for name, test := range map[string]struct {
raw, code, kind string
index *int
}{
"unknown kind": {strings.Replace(valid, `"kind":"entity"`, `"kind":"PRIVATE_MODEL_KIND"`, 1), "unknown_position", "unknown", new(int)},
"empty name": {strings.Replace(valid, `"name":"LH"`, `"name":""`, 1), "empty_name", "entity", new(int)},
"schema": {strings.Replace(valid, `"name":"LH"`, `"PRIVATE_MODEL_FIELD":"PRIVATE_MODEL_VALUE"`, 1), "json_decode_failed", "", nil},
"missing index": {strings.Replace(valid, `"index":0,`, "", 1), "missing_index", "entity", nil},
} {
t.Run(name, func(t *testing.T) {
_, err := applyWikiSourceIdentityCorrections(initial, source, wikiRejectedSourceIdentities(initial, source), test.raw)
var diagnostic *wikiIdentityRepairError
require.ErrorAs(t, err, &diagnostic)
require.Equal(t, test.code, diagnostic.Code)
require.Equal(t, test.kind, diagnostic.Kind)
require.Equal(t, test.index, diagnostic.Index)
var fields map[string]any
require.NoError(t, json.Unmarshal([]byte(err.Error()), &fields))
require.NotContains(t, err.Error(), "PRIVATE_MODEL")
require.NotContains(t, err.Error(), "Private")
require.NotContains(t, err.Error(), "LH")
})
}
}
func TestWikiIdentityRepairRejectsInconsistentTargetsWithSpecificCodes(t *testing.T) {
initial := combinedExtraction{Entities: []extractedItem{{Name: "Expanded LH", Slug: "entity/lh"}}}
valid := `{"corrections":[{"kind":"entity","index":0,"name":"LH","source_quote":"LH"}]}`
for name, test := range map[string]struct {
result combinedExtraction
target wikiIdentityRepairTarget
raw string
code string
}{
"unsupported error": {initial, wikiIdentityRepairTarget{Kind: "entity", Index: 0, Code: "wiki_identity_slug_conflict"}, valid, "unsupported_identity_error"},
"target aliases": {initial, wikiIdentityRepairTarget{Kind: "entity", Index: 0, Code: "wiki_identity_name_not_in_source", Candidate: extractedItem{Aliases: []string{"Private alias"}}}, valid, "unsupported_aliases"},
"unsupported kind": {initial, wikiIdentityRepairTarget{Kind: "concept", Index: 0, Code: "wiki_identity_name_not_in_source"}, strings.Replace(valid, `"kind":"entity"`, `"kind":"concept"`, 1), "unsupported_kind"},
"out of range": {initial, wikiIdentityRepairTarget{Kind: "entity", Index: 1, Code: "wiki_identity_name_not_in_source"}, strings.Replace(valid, `"index":0`, `"index":1`, 1), "candidate_index_out_of_range"},
"unlisted invalid candidate": {combinedExtraction{Entities: []extractedItem{{Name: "Expanded LH", Slug: "entity/lh"}, {Name: "Private unsupported candidate", Slug: "entity/other"}}}, wikiIdentityRepairTarget{Kind: "entity", Index: 0, Code: "wiki_identity_name_not_in_source"}, valid, "source_revalidation_failed"},
} {
t.Run(name, func(t *testing.T) {
before, err := json.Marshal(test.result)
require.NoError(t, err)
got, err := applyWikiSourceIdentityCorrections(test.result, "LH", []wikiIdentityRepairTarget{test.target}, test.raw)
var diagnostic *wikiIdentityRepairError
require.ErrorAs(t, err, &diagnostic)
require.Equal(t, test.code, diagnostic.Code)
if name == "unlisted invalid candidate" {
require.Equal(t, "entity", diagnostic.Kind)
require.NotNil(t, diagnostic.Index)
require.Equal(t, 1, *diagnostic.Index)
}
require.Empty(t, got.Entities)
after, err := json.Marshal(test.result)
require.NoError(t, err)
require.Equal(t, before, after)
})
}
_, err := applyWikiSourceIdentityCorrections(initial, "LH", nil, `{"corrections":[]}`)
var diagnostic *wikiIdentityRepairError
require.ErrorAs(t, err, &diagnostic)
require.Equal(t, "no_rejected_candidates", diagnostic.Code)
}
func TestWikiIdentityRepairLogsOnlyBoundedDiagnostics(t *testing.T) {
const privateText = "PRIVATE_PROVIDER_OR_SOURCE_BODY"
for name, test := range map[string]struct {
response *types.ChatResponse
failure error
stage, code string
}{
"accepted": {&types.ChatResponse{Content: `{"corrections":[{"kind":"entity","index":0,"name":"LH","source_quote":"LH"}]}`}, nil, "none", "none"},
"validation": {&types.ChatResponse{Content: `{"corrections":[{"kind":"` + privateText + `","index":0,"name":"LH","source_quote":"LH"}]}`}, nil, "validation", "unknown_position"},
"malformed": {&types.ChatResponse{Content: privateText}, nil, "validation", "json_decode_failed"},
"truncated": {&types.ChatResponse{Content: privateText, FinishReason: "length"}, nil, "output", "model_output_incomplete"},
"empty output": {&types.ChatResponse{Content: ""}, nil, "output", "model_output_invalid"},
"provider": {nil, errors.New(privateText), "transport", "provider_error"},
"timeout": {nil, context.DeadlineExceeded, "control", "deadline_exceeded"},
"cancelled": {nil, context.Canceled, "control", "cancelled"},
"budget": {nil, requestbudget.ErrStopped, "control", "budget_stopped"},
} {
t.Run(name, func(t *testing.T) {
var log bytes.Buffer
localLogger := logrus.New()
localLogger.SetOutput(&log)
ctx := context.WithValue(context.Background(), types.LoggerContextKey, logrus.NewEntry(localLogger))
calls := 0
model := &wikiScriptedChat{id: t.Name(), call: func(context.Context, []chat.Message) (*types.ChatResponse, error) {
calls++
return test.response, test.failure
}}
source := "LH " + privateText
_, err := (&wikiIngestService{}).repairWikiFreshSourceIdentities(ctx, model, "{{.Content}}", map[string]string{"Content": source}, combinedExtraction{Entities: []extractedItem{{Name: "Expanded LH", Slug: "entity/lh"}}})
require.Equal(t, 1, calls)
stage, code, _, _ := wikiIdentityRepairFailureDetails(err)
require.Equal(t, test.stage, stage)
require.Equal(t, test.code, code)
if test.failure != nil {
require.ErrorIs(t, err, test.failure)
}
require.Contains(t, log.String(), "stage="+test.stage+" code="+test.code)
require.Contains(t, log.String(), "source_sha256="+wikiFailureTextHash(source))
require.Contains(t, log.String(), "rejected=1 entities=1 concepts=0")
require.NotContains(t, log.String(), privateText)
require.NotContains(t, log.String(), "Expanded LH")
if name == "validation" {
require.Contains(t, log.String(), "kind=unknown index=0")
}
})
}
}
func TestWikiIdentityRepairFailureKeepsOriginalErrorAndNeverRetriesProvider(t *testing.T) {
for _, failure := range []string{"timeout", "provider", "truncated", "invalid", "cancel", "budget"} {
t.Run(failure, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
guard := &candidateRepairTestGuard{}
ctx = requestbudget.WithGuard(ctx, guard)
calls := 0
source := strings.Repeat("LH is recorded. ", 1000)
model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) {
calls++
if calls == 1 {
if failure == "cancel" {
cancel()
} else if failure == "budget" {
guard.stopped = true
}
return &types.ChatResponse{Content: `{"entities":[{"name":"Unsupported expansion","slug":"entity/lh"}],"concepts":[]}`}, nil
}
require.Equal(t, 2, calls, "no provider retry, whole output regeneration or splitting after correction")
require.Contains(t, messages[0].Content, wikiIdentityRepairInstruction)
switch failure {
case "timeout":
return nil, context.DeadlineExceeded
case "provider":
return nil, errors.New("API request failed with status 503: private provider detail")
case "truncated":
return &types.ChatResponse{Content: `{"corrections":[`, FinishReason: "length"}, nil
default:
return &types.ChatResponse{Content: `{"corrections":[]}`}, nil
}
}}
got, err := (&wikiIngestService{}).generateWikiFreshExtraction(ctx, model, "{{.Content}}", map[string]string{"Content": source})
identityErr := requireWikiSourceIdentityError(t, err, "wiki_identity_name_not_in_source")
require.Equal(t, wikiCheckpointHash([]string{source}), identityErr.SourceHash)
require.Equal(t, "entity", identityErr.Kind)
require.Zero(t, identityErr.Index)
require.NotContains(t, err.Error(), "private provider detail")
require.Empty(t, got.Entities)
switch failure {
case "timeout":
require.ErrorIs(t, err, context.DeadlineExceeded)
case "cancel":
require.ErrorIs(t, err, context.Canceled)
case "budget":
require.ErrorIs(t, err, requestbudget.ErrStopped)
}
wantCalls := 2
if failure == "cancel" || failure == "budget" {
wantCalls = 1
}
require.Equal(t, wantCalls, calls)
})
}
}
func TestWikiIdentityRepairCompletesWindowAndPreservesEarlierCheckpoint(t *testing.T) {
svc := &wikiIngestService{checkpointDB: openWikiCheckpointDB(t, filepath.Join(t.TempDir(), "db"))}
scope := &wikiCheckpointScope{7, "kb", "doc", "identity-repair"}
source := wikiFullReadTestSource(2)
calls := 0
model := &wikiScriptedChat{id: t.Name(), call: func(_ context.Context, messages []chat.Message) (*types.ChatResponse, error) {
calls++
if strings.Contains(messages[0].Content, "WINDOW_000") {
return &types.ChatResponse{Content: `{"entities":[{"name":"WINDOW_000","slug":"entity/first"}],"concepts":[]}`}, nil
}
if !strings.Contains(messages[0].Content, "WINDOW_001") {
return &types.ChatResponse{Content: `{"entities":[],"concepts":[]}`}, nil
}
if !strings.Contains(messages[0].Content, wikiIdentityRepairInstruction) {
return &types.ChatResponse{Content: `{"entities":[{"name":"Expanded WINDOW_001","slug":"entity/second","description":"Do not replace metadata"}],"concepts":[]}`}, nil
}
require.Equal(t, 4, calls)
require.NotContains(t, messages[0].Content, "WINDOW_000", "repair uses exactly the failed source window")
return &types.ChatResponse{Content: `{"corrections":[{"kind":"entity","index":0,"name":"WINDOW_001","source_quote":"WINDOW_001"}]}`}, nil
}}
got, concepts, _, err := svc.extractWikiFullCandidates(context.Background(), model, "kb", source, "English", nil, &WikiBatchContext{}, scope)
require.NoError(t, err)
require.Len(t, got, 2)
require.Empty(t, concepts)
require.Equal(t, 5, calls)
for index, window := range splitWikiReadWindows(source, maxContentForWiki) {
var cached combinedExtraction
hit, err := svc.loadWikiCheckpoint(context.Background(), scope, "read_candidate", wikiCheckpointHash([]any{wikiFullReadVersion, window.Text}), &cached)
require.NoError(t, err)
require.True(t, hit)
require.Len(t, cached.Entities, 1)
if index == 1 {
require.Equal(t, "WINDOW_001", cached.Entities[0].Name)
require.Equal(t, "Do not replace metadata", cached.Entities[0].Description)
}
}
var progress wikiReadProgress
hit, err := svc.loadWikiCheckpoint(context.Background(), scope, "read_progress", "candidates", &progress)
require.NoError(t, err)
require.True(t, hit)
require.Equal(t, 2, progress.CompletedWindows)
require.Equal(t, maxContentForWiki*2, progress.ProcessedRunes)
require.True(t, progress.StageComplete)
require.True(t, progress.SourceWindowsComplete)
}