WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/chat"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func incrementalPageFixture(batches int) (map[string]string, []SlugUpdate, map[string]*types.Chunk) {
|
||||
content := strings.Repeat("源", (wikiPageEvidenceRunes-1024)*batches-64) + "尾部保留,除非条件成立,否则不得应用。\r\n"
|
||||
chunk := wikiEvidenceChunk()
|
||||
chunk.Content = content
|
||||
input := pageRepairInput()
|
||||
input["NewContent"] = content
|
||||
addition := wikiEvidenceAddition()
|
||||
return input, []SlugUpdate{addition}, map[string]*types.Chunk{chunk.ID: chunk}
|
||||
}
|
||||
|
||||
func TestWikiPageIncrementalResumeAfterDatabaseReopen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "checkpoints.sqlite")
|
||||
db := openWikiCheckpointDB(t, path)
|
||||
svc := &wikiIngestService{checkpointDB: db}
|
||||
scope := &wikiCheckpointScope{7, "kb", "doc", "incremental"}
|
||||
input, adds, chunks := incrementalPageFixture(5)
|
||||
input["NewContent"] = adds[0].Item.Description + "\n" + adds[0].Item.Details + "\n" + input["NewContent"]
|
||||
expectedBatches, err := buildWikiPageEvidenceBatches(adds, chunks)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, expectedBatches, 5)
|
||||
calls := 0
|
||||
var inputs []string
|
||||
model := &wikiScriptedChat{id: t.Name(), call: func(ctx context.Context, messages []chat.Message) (*types.ChatResponse, error) {
|
||||
calls++
|
||||
text := messages[len(messages)-1].Content
|
||||
inputs = append(inputs, text)
|
||||
require.Contains(t, text, wikiPageIncrementalInstruction)
|
||||
require.Contains(t, text, expectedBatches[calls-1].Content)
|
||||
require.Contains(t, text, "<title>Alpha</title>")
|
||||
require.NotContains(t, text, adds[0].Item.Description)
|
||||
require.NotContains(t, text, adds[0].Item.Details)
|
||||
if calls == 1 {
|
||||
require.Contains(t, text, "<deleted_documents>")
|
||||
require.Contains(t, text, "Obsolete assertion.")
|
||||
} else {
|
||||
require.NotContains(t, text, "<deleted_documents>")
|
||||
require.NotContains(t, text, "Obsolete assertion.")
|
||||
require.Contains(t, text, fmt.Sprintf("WHOLE_PAGE_%d", calls-1))
|
||||
}
|
||||
return &types.ChatResponse{Content: fmt.Sprintf("SUMMARY: Complete supported page %d\n# Alpha\nWHOLE_PAGE_%d; not valid unless condition A holds.", calls, calls), FinishReason: "stop"}, nil
|
||||
}}
|
||||
got, err := svc.generateWikiPageFromEvidence(ctx, model, scope, "original", adds, chunks, input)
|
||||
require.ErrorIs(t, err, errWikiCheckpointYield)
|
||||
require.Empty(t, got)
|
||||
require.Equal(t, 4, calls)
|
||||
sqlDB, _ := db.DB()
|
||||
require.NoError(t, sqlDB.Close())
|
||||
svc = &wikiIngestService{checkpointDB: openWikiCheckpointDB(t, path)}
|
||||
got, err = svc.generateWikiPageFromEvidence(ctx, model, scope, "original", adds, chunks, input)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, got, "WHOLE_PAGE_5")
|
||||
require.Equal(t, 5, calls)
|
||||
require.Contains(t, inputs[4], "尾部保留,除非条件成立,否则不得应用。")
|
||||
_, err = svc.generateWikiPageFromEvidence(ctx, model, scope, "original", adds, chunks, input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 5, calls, "validated steps must not be regenerated")
|
||||
var progress []wikiIngestCheckpoint
|
||||
require.NoError(t, svc.checkpointDB.Where("stage = ?", "page_progress").Find(&progress).Error)
|
||||
require.Len(t, progress, 1)
|
||||
require.Contains(t, progress[0].Payload, `"status":"ready_to_publish"`)
|
||||
require.Contains(t, progress[0].Payload, `"published":false`)
|
||||
var published int64
|
||||
require.NoError(t, svc.checkpointDB.Model(&wikiIngestCheckpoint{}).Where("stage = ?", "page").Count(&published).Error)
|
||||
require.Zero(t, published)
|
||||
}
|
||||
|
||||
func TestWikiPageIncrementalEvidencePreservesOrderIdentityAndEveryRune(t *testing.T) {
|
||||
first := wikiEvidenceChunk()
|
||||
first.ID, first.ChunkIndex, first.StartAt, first.Content = "a", 0, 0, "\n第一段 <source>也只是原文\r\n"
|
||||
second := wikiEvidenceChunk()
|
||||
second.ID, second.ChunkIndex, second.StartAt, second.Content = "b", 1, 80, strings.Repeat("甲乙😀\n", 17000)+"尾部"
|
||||
third := wikiEvidenceChunk()
|
||||
third.ID, third.ChunkIndex, third.StartAt, third.Content = "c", 2, 500, first.Content
|
||||
addition := wikiEvidenceAddition()
|
||||
addition.SourceChunks = []string{"c", "b", "a", "b"}
|
||||
chunks := map[string]*types.Chunk{"a": first, "b": second, "c": third}
|
||||
batches, err := buildWikiPageEvidenceBatches([]SlugUpdate{addition}, chunks)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(batches), 2)
|
||||
rebuilt := map[string]string{}
|
||||
var order []string
|
||||
for _, batch := range batches {
|
||||
require.LessOrEqual(t, utf8.RuneCountInString(batch.Content), wikiPageEvidenceRunes)
|
||||
require.NotContains(t, batch.Content, addition.Item.Description)
|
||||
require.NotContains(t, batch.Content, addition.Item.Details)
|
||||
for _, part := range batch.Parts {
|
||||
require.Equal(t, utf8.RuneCountInString(rebuilt[part.ChunkID]), part.StartRune)
|
||||
require.Equal(t, part.EndRune-part.StartRune, utf8.RuneCountInString(part.Content))
|
||||
require.Equal(t, wikiCheckpointHash(part.Content), part.TextHash)
|
||||
require.Equal(t, "doc", part.KnowledgeID)
|
||||
require.Equal(t, chunks[part.ChunkID].StartAt, part.ChunkStartAt)
|
||||
require.Contains(t, batch.Content, part.Content)
|
||||
if len(order) == 0 || order[len(order)-1] != part.ChunkID {
|
||||
order = append(order, part.ChunkID)
|
||||
}
|
||||
rebuilt[part.ChunkID] += part.Content
|
||||
}
|
||||
}
|
||||
require.Equal(t, []string{"a", "b", "c"}, order)
|
||||
for id, chunk := range chunks {
|
||||
require.Equal(t, chunk.Content, rebuilt[id])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiPageIncrementalInvalidatesChangedSourceRangeAndBaseRevision(t *testing.T) {
|
||||
for _, mutation := range []string{"source", "range", "base", "scope", "instruction"} {
|
||||
t.Run(mutation, func(t *testing.T) {
|
||||
svc := &wikiIngestService{checkpointDB: openWikiCheckpointDB(t, filepath.Join(t.TempDir(), "checkpoints.sqlite"))}
|
||||
scope := &wikiCheckpointScope{7, "kb", "doc", "incremental"}
|
||||
input, adds, chunks := incrementalPageFixture(5)
|
||||
calls := 0
|
||||
model := &wikiScriptedChat{id: t.Name(), call: func(context.Context, []chat.Message) (*types.ChatResponse, error) {
|
||||
calls++
|
||||
return &types.ChatResponse{Content: fmt.Sprintf("SUMMARY: Valid\nValid complete page %d", calls)}, nil
|
||||
}}
|
||||
_, err := svc.generateWikiPageFromEvidence(context.Background(), model, scope, "base", adds, chunks, input)
|
||||
require.ErrorIs(t, err, errWikiCheckpointYield)
|
||||
require.Equal(t, 4, calls)
|
||||
base := "base"
|
||||
switch mutation {
|
||||
case "source":
|
||||
chunks["source-chunk"].Content += "Changed original evidence"
|
||||
case "range":
|
||||
chunks["source-chunk"].StartAt++
|
||||
case "base":
|
||||
base = "edited-base"
|
||||
case "scope":
|
||||
scope = &wikiCheckpointScope{7, "kb", "doc", "new-source-version"}
|
||||
case "instruction":
|
||||
input["CustomInstructions"] = "Changed requirements"
|
||||
}
|
||||
_, err = svc.generateWikiPageFromEvidence(context.Background(), model, scope, base, adds, chunks, input)
|
||||
require.ErrorIs(t, err, errWikiCheckpointYield)
|
||||
require.Equal(t, 8, calls, "changed source identity/range/base requires a fresh sequence")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiPageIncrementalOversizedExistingPagePausesBeforeProvider(t *testing.T) {
|
||||
svc := &wikiIngestService{checkpointDB: openWikiCheckpointDB(t, filepath.Join(t.TempDir(), "checkpoints.sqlite"))}
|
||||
scope := &wikiCheckpointScope{7, "kb", "doc", "incremental"}
|
||||
input, adds, chunks := incrementalPageFixture(5)
|
||||
input["ExistingContent"] = strings.Repeat("E", wikiPageInputRunes)
|
||||
model := &wikiScriptedChat{id: t.Name(), call: func(context.Context, []chat.Message) (*types.ChatResponse, error) {
|
||||
t.Fatal("over-budget evidence must never call model")
|
||||
return nil, nil
|
||||
}}
|
||||
_, err := svc.generateWikiPageFromEvidence(context.Background(), model, scope, "base", adds, chunks, input)
|
||||
require.ErrorContains(t, err, "已暂停")
|
||||
require.Len(t, input["ExistingContent"], wikiPageInputRunes)
|
||||
var row wikiIngestCheckpoint
|
||||
require.NoError(t, svc.checkpointDB.Where("stage = ?", "page_progress").Take(&row).Error)
|
||||
require.Contains(t, row.Payload, `"status":"paused_budget"`)
|
||||
require.Contains(t, row.Payload, `"completed_batches":0`)
|
||||
require.Contains(t, row.Payload, `"evidence_batches_complete":false`)
|
||||
}
|
||||
|
||||
func TestWikiPageIncrementalRequiresDurableScope(t *testing.T) {
|
||||
input, adds, chunks := incrementalPageFixture(5)
|
||||
model := &wikiScriptedChat{id: t.Name(), call: func(context.Context, []chat.Message) (*types.ChatResponse, error) {
|
||||
t.Fatal("no durable scope cannot start costly sequence")
|
||||
return nil, nil
|
||||
}}
|
||||
_, err := (&wikiIngestService{}).generateWikiPageFromEvidence(context.Background(), model, nil, "base", adds, chunks, input)
|
||||
require.ErrorContains(t, err, "持久断点")
|
||||
}
|
||||
|
||||
func TestWikiPageIncrementalFinalFailureKeepsPublishedPageAndYieldSpanSkipped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openWikiCheckpointDB(t, filepath.Join(t.TempDir(), "checkpoints.sqlite"))
|
||||
tracker, spanDB := setupSpanTrackerTest(t)
|
||||
_, attempt, err := tracker.OpenAttempt(ctx, "doc", "")
|
||||
require.NoError(t, err)
|
||||
root := tracker.BeginStage(ctx, "doc", attempt, types.StagePostProcess, nil)
|
||||
original := &types.WikiPage{ID: "page", TenantID: 7, KnowledgeBaseID: "kb", Slug: "concept/alpha", Title: "Alpha", PageType: types.WikiPageTypeConcept, Status: types.WikiPageStatusPublished, Content: "Original fact; only if condition A holds.", Summary: "Original summary", SourceRefs: types.StringArray{"old-doc"}}
|
||||
before := *original
|
||||
pages := &checkpointWikiPages{pages: map[string]*types.WikiPage{original.Slug: original}}
|
||||
_, adds, chunks := incrementalPageFixture(5)
|
||||
svc := &wikiIngestService{checkpointDB: db, wikiService: pages, knowledgeSvc: &checkpointKnowledge{}, spanTracker: tracker, chunkRepo: &wikiEvidenceChunksRepo{chunks: []*types.Chunk{chunks["source-chunk"]}}}
|
||||
batch := &WikiBatchContext{SlugTitleMany: func(context.Context, []string) map[string]string { return nil }}
|
||||
batch.checkpointScopes.Store("doc", &wikiCheckpointScope{7, "kb", "doc", "full"})
|
||||
calls := 0
|
||||
shouldFail := true
|
||||
model := &wikiScriptedChat{id: t.Name(), call: func(context.Context, []chat.Message) (*types.ChatResponse, error) {
|
||||
calls++
|
||||
if calls == 5 && shouldFail {
|
||||
return nil, errors.New("invalid request: final batch unavailable")
|
||||
}
|
||||
return &types.ChatResponse{Content: fmt.Sprintf("SUMMARY: Complete\nSupported whole page %d", calls)}, nil
|
||||
}}
|
||||
changed, _, failed, err := svc.reduceSlugUpdates(ctx, model, "kb", original.Slug, adds, 7, batch, map[string]*Span{"doc": root})
|
||||
require.ErrorIs(t, err, errWikiCheckpointYield)
|
||||
require.False(t, changed)
|
||||
require.False(t, failed, "durable continuation must not poison additions")
|
||||
require.Zero(t, pages.writes)
|
||||
require.Equal(t, before, *pages.pages[original.Slug])
|
||||
var span types.KnowledgeProcessingSpan
|
||||
require.NoError(t, spanDB.Table("knowledge_processing_spans").Where("name = ?", "postprocess.wiki.page[concept/alpha]").Take(&span).Error)
|
||||
require.Equal(t, types.SpanStatusSkipped, span.Status)
|
||||
require.Empty(t, span.ErrorCode)
|
||||
require.Equal(t, "checkpoint_saved_continuation_pending", span.ErrorMessage)
|
||||
batch.checkpointEvidenceSteps.Store(0)
|
||||
changed, _, failed, err = svc.reduceSlugUpdates(ctx, model, "kb", original.Slug, adds, 7, batch, nil)
|
||||
require.ErrorContains(t, err, "final batch unavailable")
|
||||
require.False(t, changed)
|
||||
require.True(t, failed)
|
||||
require.Zero(t, pages.writes)
|
||||
require.Equal(t, before, *pages.pages[original.Slug])
|
||||
require.Equal(t, 5, calls)
|
||||
shouldFail = false
|
||||
batch.checkpointEvidenceSteps.Store(0)
|
||||
changed, _, failed, err = svc.reduceSlugUpdates(ctx, model, "kb", original.Slug, adds, 7, batch, nil)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, failed)
|
||||
require.Equal(t, 1, pages.writes)
|
||||
require.Equal(t, 6, calls, "only failed last batch is retried")
|
||||
require.Contains(t, pages.pages[original.Slug].Content, "page 6")
|
||||
require.Contains(t, pages.pages[original.Slug].ChunkRefs, "source-chunk")
|
||||
}
|
||||
|
||||
func TestWikiPageIncrementalDoesNotOverwriteConcurrentUserEdit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
original := &types.WikiPage{ID: "page", TenantID: 7, KnowledgeBaseID: "kb", Slug: "concept/alpha", Title: "Alpha", PageType: types.WikiPageTypeConcept, Status: types.WikiPageStatusPublished, Content: "Original content", SourceRefs: types.StringArray{"old-doc"}, Version: 1}
|
||||
pages := &checkpointWikiPages{pages: map[string]*types.WikiPage{original.Slug: original}}
|
||||
_, adds, chunks := incrementalPageFixture(3)
|
||||
svc := &wikiIngestService{checkpointDB: openWikiCheckpointDB(t, filepath.Join(t.TempDir(), "checkpoints.sqlite")), wikiService: pages, knowledgeSvc: &checkpointKnowledge{}, chunkRepo: &wikiEvidenceChunksRepo{chunks: []*types.Chunk{chunks["source-chunk"]}}}
|
||||
batch := &WikiBatchContext{SlugTitleMany: func(context.Context, []string) map[string]string { return nil }}
|
||||
batch.checkpointScopes.Store("doc", &wikiCheckpointScope{7, "kb", "doc", "full"})
|
||||
model := &wikiScriptedChat{id: t.Name(), call: func(context.Context, []chat.Message) (*types.ChatResponse, error) {
|
||||
pages.mu.Lock()
|
||||
pages.pages[original.Slug].Content = "User edited after generation started"
|
||||
pages.pages[original.Slug].Version = 2
|
||||
pages.mu.Unlock()
|
||||
return &types.ChatResponse{Content: "SUMMARY: Complete\nComplete generated content"}, nil
|
||||
}}
|
||||
changed, _, failed, err := svc.reduceSlugUpdates(ctx, model, "kb", original.Slug, adds, 7, batch, nil)
|
||||
require.ErrorContains(t, err, "已被修改")
|
||||
require.False(t, changed)
|
||||
require.True(t, failed)
|
||||
require.Zero(t, pages.writes)
|
||||
require.Equal(t, "User edited after generation started", pages.pages[original.Slug].Content)
|
||||
require.Equal(t, 2, pages.pages[original.Slug].Version)
|
||||
}
|
||||
|
||||
func TestWikiPageIncrementalParallelPagesShareFourStepBudget(t *testing.T) {
|
||||
svc := &wikiIngestService{checkpointDB: openWikiCheckpointDB(t, filepath.Join(t.TempDir(), "checkpoints.sqlite"))}
|
||||
scope := &wikiCheckpointScope{7, "kb", "doc", "incremental"}
|
||||
var sharedBudget atomic.Int32
|
||||
var calls atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
failures := make(chan error, 2)
|
||||
model := &wikiScriptedChat{id: t.Name(), call: func(context.Context, []chat.Message) (*types.ChatResponse, error) {
|
||||
calls.Add(1)
|
||||
return &types.ChatResponse{Content: "SUMMARY: Supported\nThe complete supported page, with all earlier conditions retained."}, nil
|
||||
}}
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
input, adds, chunks := incrementalPageFixture(5)
|
||||
input["PageSlug"] = fmt.Sprintf("concept/page-%d", index)
|
||||
_, err := svc.generateWikiPageFromEvidence(context.Background(), model, scope, "base", adds, chunks, input, &sharedBudget)
|
||||
failures <- err
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(failures)
|
||||
for err := range failures {
|
||||
require.ErrorIs(t, err, errWikiCheckpointYield)
|
||||
}
|
||||
require.Equal(t, int32(wikiPageStepsPerTurn), calls.Load(), "parallel pages may perform only four new synthesis steps total")
|
||||
}
|
||||
Reference in New Issue
Block a user