Files
kenn-io--agentsview/internal/postgres/search_content_hybrid_pg_test.go
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

307 lines
13 KiB
Go

//go:build pgtest
package postgres
import (
"context"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/db"
)
// hybridFakeSearcher is a canned db.VectorSearcher for PG hybrid tests: it
// returns a fixed, rank-ordered slice of vector hits and resolves message
// refs against an explicit units list first, then against the units implied
// by hits (the real resolver reads the same mirror the hits come from). This
// isolates the fusion + keyword-leg logic (real ILIKE SQL over messages) from
// the pgvector-backed searcher, which Task 8/9 tests already cover. It mirrors
// internal/db's fakeVectorSearcher so parity between the backends is visible.
type hybridFakeSearcher struct {
hits []db.VectorHit
units []db.UnitRef
}
func (f *hybridFakeSearcher) SemanticSearch(
_ context.Context, _ string, limit int,
) ([]db.VectorHit, error) {
hits := f.hits
if limit > 0 && limit < len(hits) {
hits = hits[:limit]
}
return hits, nil
}
func (f *hybridFakeSearcher) ResolveMessageUnits(
_ context.Context, refs []db.MessageRef,
) ([]db.UnitRef, error) {
out := make([]db.UnitRef, len(refs))
for i, ref := range refs {
out[i] = f.resolveRef(ref)
}
return out, nil
}
func (f *hybridFakeSearcher) resolveRef(ref db.MessageRef) db.UnitRef {
for _, u := range f.units {
if u.SessionID == ref.SessionID &&
ref.Ordinal >= u.OrdinalStart && ref.Ordinal <= u.OrdinalEnd {
return u
}
}
for _, h := range f.hits {
if h.SessionID == ref.SessionID &&
ref.Ordinal >= h.OrdinalStart && ref.Ordinal <= h.OrdinalEnd {
return db.UnitRef{
DocKey: fmt.Sprintf("fake:%s:%d", h.SessionID, h.OrdinalStart),
SessionID: h.SessionID,
OrdinalStart: h.OrdinalStart,
OrdinalEnd: h.OrdinalEnd,
Subordinate: h.Subordinate,
}
}
}
return db.UnitRef{}
}
// wireHybrid seeds a fresh content-search store, wires the fake searcher, and
// returns the store. It reuses setupContentSearch (base schema, no pgvector),
// since the vector leg and resolver are faked and the keyword leg runs real
// ILIKE SQL over the messages table.
func wireHybrid(t *testing.T, f *hybridFakeSearcher) *Store {
t.Helper()
store := setupContentSearch(t)
store.SetVectorSearcher(f)
return store
}
const (
hybridStart = "2026-05-01T10:00:00Z"
hybridEnd = "2026-05-01T10:30:00Z"
)
// TestPGHybridBothLegsFuseKeywordAnchor pins the end-to-end unit fusion: a
// vector hit on a run and a keyword hit inside that same run fuse into ONE
// result whose anchor ordinal is the keyword-matched message (overriding the
// vector leg's chunk anchor) and whose snippet centers on the keyword text.
func TestPGHybridBothLegsFuseKeywordAnchor(t *testing.T) {
f := &hybridFakeSearcher{hits: []db.VectorHit{
{SessionID: "s1", Ordinal: 1, OrdinalStart: 1, OrdinalEnd: 2,
Score: 0.9, Snippet: "first step of the answer"},
}}
store := wireHybrid(t, f)
insertCSSession(t, store, "s1", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "s1", 0, "user", "the question", false, false)
insertCSUnitMessage(t, store, "s1", 1, "assistant", "first step of the answer", false, false)
insertCSUnitMessage(t, store, "s1", 2, "assistant", "second step mentions zebra", false, false)
page, err := store.SearchContent(context.Background(), db.ContentSearchFilter{
Pattern: "zebra", Mode: "hybrid", Limit: 50,
})
require.NoError(t, err, "SearchContent hybrid")
require.Len(t, page.Matches, 1,
"the run's vector hit and its keyword-matched member must fuse into one result")
m := page.Matches[0]
assert.Equal(t, "s1", m.SessionID)
assert.Equal(t, 2, m.Ordinal, "anchor overridden to the keyword-matched message")
assert.Equal(t, "assistant", m.Role, "role of the keyword-matched message")
assert.Equal(t, [2]int{1, 2}, m.OrdinalRange, "range spans the containing unit")
assert.Contains(t, m.Snippet, "zebra", "keyword snippet wins for display")
require.NotNil(t, m.Score)
assert.InDelta(t, 2.0/61.0, *m.Score, 1e-9, "fused score: rank 1 in both legs")
}
// TestPGHybridVectorOnlyAndKeywordOnlyBothAppear pins RRF union semantics: a
// hit found only by the vector leg and a hit found only by the keyword leg
// both survive fusion, not just the leg-overlapping ones.
func TestPGHybridVectorOnlyAndKeywordOnlyBothAppear(t *testing.T) {
f := &hybridFakeSearcher{hits: []db.VectorHit{
{SessionID: "vec-only", Ordinal: 0, Score: 0.9, Snippet: "totally unrelated content"},
}}
store := wireHybrid(t, f)
insertCSSession(t, store, "kw-only", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "kw-only", 0, "user", "needle in a haystack", false, false)
insertCSSession(t, store, "vec-only", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "vec-only", 0, "user", "totally unrelated content", false, false)
page, err := store.SearchContent(context.Background(), db.ContentSearchFilter{
Pattern: "needle", Mode: "hybrid", Limit: 50,
})
require.NoError(t, err, "SearchContent hybrid")
require.Len(t, page.Matches, 2, "the keyword-only and vector-only hits both survive")
var ids []string
for _, m := range page.Matches {
ids = append(ids, m.SessionID)
}
assert.ElementsMatch(t, []string{"kw-only", "vec-only"}, ids)
}
// TestPGHybridKeywordHitOutsideUniverseKeepsMessageGranularity pins the
// no-unit escape hatch: a keyword hit on a message with no containing mirror
// unit survives fusion under its own message-granularity key with its own
// ordinal, carrying a structurally derived unit range (the uncovered hit sits
// in a two-message assistant run) rather than a self-range.
func TestPGHybridKeywordHitOutsideUniverseKeepsMessageGranularity(t *testing.T) {
f := &hybridFakeSearcher{hits: []db.VectorHit{
{SessionID: "covered", Ordinal: 0, Score: 0.9, Snippet: "unrelated content"},
}}
store := wireHybrid(t, f)
insertCSSession(t, store, "uncovered", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "uncovered", 0, "user", "irrelevant lead-in", false, false)
insertCSUnitMessage(t, store, "uncovered", 1, "assistant", "tool output mentions zebra", false, false)
insertCSUnitMessage(t, store, "uncovered", 2, "assistant", "further elaboration", false, false)
insertCSSession(t, store, "covered", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "covered", 0, "user", "unrelated content", false, false)
page, err := store.SearchContent(context.Background(), db.ContentSearchFilter{
Pattern: "zebra", Mode: "hybrid", Limit: 50,
})
require.NoError(t, err, "SearchContent hybrid")
require.Len(t, page.Matches, 2, "the unit-less keyword hit must not vanish")
byID := map[string]db.ContentMatch{}
for _, m := range page.Matches {
byID[m.SessionID] = m
}
uncovered, ok := byID["uncovered"]
require.True(t, ok, "no-unit keyword hit survives")
assert.Equal(t, 1, uncovered.Ordinal, "message-granularity ordinal kept")
assert.Equal(t, [2]int{1, 2}, uncovered.OrdinalRange,
"unit-less hit gets the derived run range, not a self-range")
assert.False(t, uncovered.Subordinate,
"unit-less top-level non-sidechain hit stays non-subordinate")
assert.Contains(t, uncovered.Snippet, "zebra")
}
// TestPGHybridScopeFiltersBothLegs pins that Scope filtering sees both legs:
// scope=top drops the vector-leg subordinate hit AND the keyword-leg
// subordinate hit, while scope=subordinate keeps only them. The keyword-leg
// subordinate flag comes from the resolved unit.
func TestPGHybridScopeFiltersBothLegs(t *testing.T) {
f := &hybridFakeSearcher{
hits: []db.VectorHit{
{SessionID: "topvec", Ordinal: 0, Score: 0.9, Snippet: "topvec body"},
{SessionID: "subvec", Ordinal: 0, Subordinate: true, Score: 0.8, Snippet: "subvec body"},
},
units: []db.UnitRef{
{DocKey: "u:subkw:0", SessionID: "subkw", OrdinalStart: 0, OrdinalEnd: 0, Subordinate: true},
},
}
store := wireHybrid(t, f)
// Vector-only sessions (no keyword match).
insertCSSession(t, store, "topvec", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "topvec", 0, "user", "topvec body", false, false)
insertCSSession(t, store, "subvec", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "subvec", 0, "assistant", "subvec body", false, false)
// Keyword sessions.
insertCSSession(t, store, "topkw", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "topkw", 0, "user", "zebra top level", false, false)
insertCSChildSession(t, store, "subkw", "proj", "claude", "topkw", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "subkw", 0, "assistant", "zebra subordinate", false, false)
ctx := context.Background()
top, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "zebra", Mode: "hybrid", Scope: "top", Limit: 50,
})
require.NoError(t, err, "SearchContent scope=top")
topIDs := hybridSessionIDs(top)
assert.ElementsMatch(t, []string{"topvec", "topkw"}, topIDs,
"scope=top keeps only top-level units from both legs")
sub, err := store.SearchContent(ctx, db.ContentSearchFilter{
Pattern: "zebra", Mode: "hybrid", Scope: "subordinate", Limit: 50,
})
require.NoError(t, err, "SearchContent scope=subordinate")
subIDs := hybridSessionIDs(sub)
assert.ElementsMatch(t, []string{"subvec", "subkw"}, subIDs,
"scope=subordinate keeps only subordinate units from both legs")
for _, m := range sub.Matches {
assert.True(t, m.Subordinate, "surviving matches are subordinate")
}
}
// TestPGHybridRRFOrderParity pins that the page order equals what db.RRFMerge
// produces for the two legs' known ranks: the doubly-ranked unit leads, then a
// tie between the two single-leg units breaks by ascending fusion key (the
// message key sorts before the unit key), independent of session recency.
func TestPGHybridRRFOrderParity(t *testing.T) {
f := &hybridFakeSearcher{hits: []db.VectorHit{
{SessionID: "both", Ordinal: 0, Score: 0.9, Snippet: "needle here"},
{SessionID: "vec", Ordinal: 0, Score: 0.8, Snippet: "no match here"},
}}
store := wireHybrid(t, f)
insertCSSession(t, store, "both", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "both", 0, "user", "needle here", false, false)
insertCSSession(t, store, "vec", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "vec", 0, "user", "no match here", false, false)
insertCSSession(t, store, "kw", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "kw", 0, "user", "needle appears", false, false)
page, err := store.SearchContent(context.Background(), db.ContentSearchFilter{
Pattern: "needle", Mode: "hybrid", Limit: 50,
})
require.NoError(t, err, "SearchContent hybrid")
// The vector leg ranks [both, vec]; the keyword leg ranks [both, kw]
// (both/kw match "needle"; vec does not). "both" resolves to the vector
// unit in both legs; "kw" has no unit, so it keeps a message key.
vecRanked := []db.RankedUnit{
{Key: db.UnitFusionKey("both", 0)}, {Key: db.UnitFusionKey("vec", 0)},
}
kwRanked := []db.RankedUnit{
{Key: db.UnitFusionKey("both", 0)}, {Key: db.MessageFusionKey("kw", 0)},
}
merged := db.RRFMerge([][]db.RankedUnit{vecRanked, kwRanked}, 50)
keyToSession := map[string]string{
db.UnitFusionKey("both", 0): "both",
db.UnitFusionKey("vec", 0): "vec",
db.MessageFusionKey("kw", 0): "kw",
}
want := make([]string, len(merged))
for i, u := range merged {
want[i] = keyToSession[u.Unit.Key]
}
require.Equal(t, want, hybridSessionIDs(page),
"page order must match db.RRFMerge over the two legs")
}
// TestPGHybridKeywordSnippetCentersOnTerm pins the term-aware keyword snippet:
// a two-term query whose terms are non-adjacent in the message (so the raw
// pattern is not a literal substring) centers the snippet on a matched term, not
// the message start. The first matched term sits far past the start window, so
// under the old raw-pattern centering the snippet would not contain it.
func TestPGHybridKeywordSnippetCentersOnTerm(t *testing.T) {
f := &hybridFakeSearcher{}
store := wireHybrid(t, f)
// "alpha" lands at byte 160, past the ~60-char start snippet window; "omega"
// follows later. The raw pattern "alpha omega" is not a substring.
lead := strings.Repeat("x ", 80)
content := lead + "alpha then a stretch of filler words before omega ends it"
insertCSSession(t, store, "s1", "proj", "claude", hybridStart, hybridEnd)
insertCSUnitMessage(t, store, "s1", 0, "user", content, false, false)
page, err := store.SearchContent(context.Background(), db.ContentSearchFilter{
Pattern: "alpha omega", Mode: "hybrid", Limit: 50,
})
require.NoError(t, err, "SearchContent hybrid")
require.Len(t, page.Matches, 1, "the two-term keyword hit survives")
assert.Contains(t, page.Matches[0].Snippet, "alpha",
"snippet centers on a matched term, not the message start")
}
// hybridSessionIDs projects a page's matches to session IDs in order.
func hybridSessionIDs(page db.ContentSearchPage) []string {
out := make([]string, len(page.Matches))
for i, m := range page.Matches {
out[i] = m.SessionID
}
return out
}