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,262 @@
|
||||
package chatpipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// rangeText returns deterministic text of exactly n runes so fixtures satisfy
|
||||
// the splitter invariant runeLen(Content) == EndAt-StartAt.
|
||||
func rangeText(seed rune, n int) string {
|
||||
return strings.Repeat(string(seed), n)
|
||||
}
|
||||
|
||||
// trustedResult builds a SearchResult whose coordinates are trustworthy
|
||||
// (unedited, valid range, splitter-consistent length).
|
||||
func trustedResult(id string, index, start, end int, content string) *types.SearchResult {
|
||||
return &types.SearchResult{
|
||||
ID: id, ChunkIndex: index, StartAt: start, EndAt: end, Content: content, Score: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeGrouped runs the merge stage as the pipeline does: group by knowledge
|
||||
// and chunk type, then merge sequential bodies. Observable behavior only.
|
||||
func mergeGrouped(chunks []*types.SearchResult) []*types.SearchResult {
|
||||
return (&PluginMerge{}).groupAndMergeCurrentContent(context.Background(), chunks)
|
||||
}
|
||||
|
||||
// All fixtures below share KnowledgeID "doc", ChunkType "text" and Score 1.0,
|
||||
// so results form a single group and the deterministic post-merge sort keeps
|
||||
// their relative order by ChunkIndex.
|
||||
func docChunks(chunks ...*types.SearchResult) []*types.SearchResult {
|
||||
for _, c := range chunks {
|
||||
c.KnowledgeID = "doc"
|
||||
c.ChunkType = "text"
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapTrimsAndExtendsRange(t *testing.T) {
|
||||
// 20-rune real overlap between two trusted chunks: the merged body must
|
||||
// contain the overlap exactly once and extend the result range.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 80)+rangeText('b', 20))
|
||||
second := trustedResult("c2", 2, 80, 200, rangeText('b', 20)+rangeText('c', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 80)+rangeText('b', 20)+rangeText('c', 100), results[0].Content)
|
||||
assert.Equal(t, 200, results[0].EndAt, "merged range must extend to the second chunk")
|
||||
assert.ElementsMatch(t, []string{"c2"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapBeyondTextSearchWindow(t *testing.T) {
|
||||
// A real overlap wider than the text matcher's 400-rune window: the position
|
||||
// path knows the exact amount, so the overlap must not survive twice.
|
||||
overlap := rangeText('o', 500)
|
||||
first := trustedResult("c1", 1, 0, 1000, rangeText('a', 500)+overlap)
|
||||
second := trustedResult("c2", 2, 500, 1500, overlap+rangeText('b', 500))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 500)+overlap+rangeText('b', 500), results[0].Content)
|
||||
assert.Equal(t, 1500, results[0].EndAt)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedAdjacentRepeatedTextKeepsEveryRow(t *testing.T) {
|
||||
// Adjacent trusted chunks (positional overlap 0) made of one repeating row:
|
||||
// searching for the longest suffix match would mistake the repetition for an
|
||||
// overlap and drop rows, so the exact-overlap path must concatenate verbatim.
|
||||
row := "| cell | cell |\n"
|
||||
firstBody := rangeText('x', 100) + strings.Repeat(row, 2)
|
||||
secondBody := strings.Repeat(row, 3) + rangeText('y', 60)
|
||||
first := trustedResult("c1", 1, 0, runeLen(firstBody), firstBody)
|
||||
second := trustedResult("c2", 2, runeLen(firstBody), runeLen(firstBody)+runeLen(secondBody), secondBody)
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, firstBody+secondBody, results[0].Content)
|
||||
assert.Equal(t, 5, strings.Count(results[0].Content, row), "no repeated row may be dropped")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedOverlapTextMismatchFallsBackToTextMatch(t *testing.T) {
|
||||
// Length invariant holds but the bodies disagree inside the overlap window
|
||||
// (HTML entities, synthetic headers): the exact path must refuse and the text
|
||||
// fallback must keep both bodies rather than trim on coordinates.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 80)+rangeText('b', 20))
|
||||
second := trustedResult("c2", 2, 80, 200, rangeText('c', 20)+rangeText('d', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 20))
|
||||
assert.Contains(t, results[0].Content, rangeText('c', 20))
|
||||
assert.Contains(t, results[0].Content, rangeText('d', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedAdjacentJoinsSeamlessly(t *testing.T) {
|
||||
// Adjacent trusted chunks are contiguous in the original document: the
|
||||
// merged body must be the exact concatenation, with no separator inserted.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
second := trustedResult("c2", 2, 100, 200, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 100)+rangeText('b', 100), results[0].Content)
|
||||
assert.Equal(t, 200, results[0].EndAt)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedGapStartsNewGroup(t *testing.T) {
|
||||
// A real position gap means content in between is missing: chunks must
|
||||
// stay separate instead of being joined with a fabricated separator.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
second := trustedResult("c2", 2, 150, 250, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, second))
|
||||
|
||||
require.Len(t, results, 2, "position gap must not merge")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_RewrittenResultDoesNotTrustCoordinates(t *testing.T) {
|
||||
// A result whose content was replaced by the pipeline (parent or neighbor
|
||||
// expansion) must never enter the position path, even when the replacement
|
||||
// happens to satisfy the length invariant and share boundary text: its
|
||||
// stale coordinates must not extend the merged group's range.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
rewritten := trustedResult("c2", 2, 80, 200, rangeText('a', 20)+rangeText('b', 100))
|
||||
rewritten.ContentRewritten = true
|
||||
|
||||
results := mergeGrouped(docChunks(first, rewritten))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 100))
|
||||
assert.Equal(t, 100, results[0].EndAt, "rewritten result must not extend the range via stale coordinates")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedContainedSubsumes(t *testing.T) {
|
||||
// Range-contained and text-verified: one result whose content is the
|
||||
// outer body; the inner chunk is recorded, not duplicated.
|
||||
outer := trustedResult("outer", 1, 0, 200, rangeText('a', 200))
|
||||
inner := trustedResult("inner", 2, 50, 100, rangeText('a', 50))
|
||||
|
||||
results := mergeGrouped(docChunks(outer, inner))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, rangeText('a', 200), results[0].Content)
|
||||
assert.ElementsMatch(t, []string{"inner"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_TrustedContainedDistinctKeepsBoth(t *testing.T) {
|
||||
// Range-contained but textually distinct: both bodies must survive; the
|
||||
// inner one must never be dropped on coordinates alone.
|
||||
outer := trustedResult("outer", 1, 0, 100, rangeText('a', 100))
|
||||
inner := trustedResult("inner", 2, 50, 60, rangeText('b', 10))
|
||||
|
||||
results := mergeGrouped(docChunks(outer, inner))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 10))
|
||||
assert.ElementsMatch(t, []string{"inner"}, results[0].SubChunkID)
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_EditedPairKeepsBoth(t *testing.T) {
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
edited := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: "rewritten body", ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, edited))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, "rewritten body")
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_SameLengthRewriteKeepsBoth(t *testing.T) {
|
||||
// An edit that happens to keep the splitter length invariant must still
|
||||
// never cause the rewritten body to be swallowed on coordinates.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
edited := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: rangeText('b', 120), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, edited))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 120))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_InvariantBreakKeepsBoth(t *testing.T) {
|
||||
// ContentRevision 0 but length inconsistent with the range (e.g. zero-width
|
||||
// table headers, HTML entities): both bodies must survive.
|
||||
first := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
stale := &types.SearchResult{
|
||||
ID: "c2", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 2, StartAt: 80, EndAt: 200,
|
||||
Content: "short", Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, stale))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, "short")
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_StartInversionKeepsBoth(t *testing.T) {
|
||||
// A chunk whose StartAt precedes the group's but has a higher ChunkIndex
|
||||
// contradicts document order: both bodies must survive.
|
||||
first := trustedResult("c1", 1, 100, 200, rangeText('a', 100))
|
||||
inverted := trustedResult("c2", 2, 50, 150, rangeText('b', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(first, inverted))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('b', 100))
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_EditedNonSequentialSeparates(t *testing.T) {
|
||||
// Edited chunks that are neither text-contained nor index-sequential stay
|
||||
// separate results.
|
||||
first := &types.SearchResult{
|
||||
ID: "c1", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 1, StartAt: 0, EndAt: 50,
|
||||
Content: rangeText('a', 50), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
third := &types.SearchResult{
|
||||
ID: "c3", KnowledgeID: "doc", ChunkType: "text", ChunkIndex: 3, StartAt: 200, EndAt: 250,
|
||||
Content: rangeText('b', 50), ContentRevision: 1, Score: 1.0,
|
||||
}
|
||||
|
||||
results := mergeGrouped(docChunks(first, third))
|
||||
|
||||
require.Len(t, results, 2, "non-sequential edited chunks must not merge")
|
||||
}
|
||||
|
||||
func TestGroupAndMerge_MidGroupDriftKeepsAllBodies(t *testing.T) {
|
||||
// When an overlap cannot be trimmed the merged body no longer matches its
|
||||
// range; subsequent pairs must still merge safely without losing content.
|
||||
c1 := trustedResult("c1", 1, 0, 100, rangeText('a', 100))
|
||||
c2 := trustedResult("c2", 2, 90, 200, rangeText('c', 110))
|
||||
c3 := trustedResult("c3", 3, 200, 300, rangeText('e', 100))
|
||||
|
||||
results := mergeGrouped(docChunks(c1, c2, c3))
|
||||
|
||||
require.Len(t, results, 1)
|
||||
assert.Contains(t, results[0].Content, rangeText('a', 100))
|
||||
assert.Contains(t, results[0].Content, rangeText('c', 110))
|
||||
assert.Contains(t, results[0].Content, rangeText('e', 100))
|
||||
assert.ElementsMatch(t, []string{"c2", "c3"}, results[0].SubChunkID)
|
||||
}
|
||||
Reference in New Issue
Block a user