Files
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

234 lines
8.5 KiB
Go

package postgres
import (
"context"
"fmt"
"go.kenn.io/agentsview/internal/db"
)
// searchContentSemanticPG runs mode "semantic" on the PostgreSQL store,
// mirroring internal/db.searchContentSemantic exactly with PG idioms: it
// over-fetches ranked hits from the wired VectorSearcher, keeps hits whose
// session passes the filter's metadata scope (the sidebar-child exclusion is
// lifted -- f.Scope governs subordinate-unit visibility instead, dropping hits
// db.ScopeExcludes rules out), routes the survivors through the same RRF merge
// hybrid uses as a one-leg fusion (db.ApplySubordinatePenalty, so subordinate
// units are penalized identically while matches keep the searcher's own
// scores), enriches surviving anchor (session_id, ordinal) pairs with
// session/message metadata in one query, and returns them in the fused order,
// truncated to f.Limit. The caller (SearchContent) has already run
// db.ValidateSemanticFilter and confirmed a searcher is wired.
func (s *Store) searchContentSemanticPG(
ctx context.Context, f db.ContentSearchFilter,
) (db.ContentSearchPage, error) {
searcher := s.getVectorSearcher()
if searcher == nil {
return db.ContentSearchPage{}, s.semanticUnavailableError()
}
k := max(f.Limit*4, db.SemanticOverfetchMin)
surviving, err := s.survivingVectorHitsPG(ctx, f, searcher, k)
if err != nil {
return db.ContentSearchPage{}, err
}
if len(surviving) == 0 {
return db.ContentSearchPage{}, nil
}
surviving = db.ApplySubordinatePenalty(surviving)
meta, err := s.enrichSemanticHitsPG(ctx, surviving)
if err != nil {
return db.ContentSearchPage{}, err
}
out := make([]db.ContentMatch, 0, min(len(surviving), f.Limit))
for _, h := range surviving {
info, ok := meta[db.MessageRef{SessionID: h.SessionID, Ordinal: h.Ordinal}]
if !ok {
continue
}
score := float64(h.Score)
out = append(out, db.ContentMatch{
SessionID: h.SessionID,
Project: info.project,
Agent: info.agent,
Location: "message",
Role: info.role,
Ordinal: h.Ordinal,
OrdinalRange: [2]int{h.OrdinalStart, h.OrdinalEnd},
Subordinate: h.Subordinate,
Relationship: info.relationshipType,
ParentSessionID: info.parentSessionID,
Sidechain: info.isSidechain,
Timestamp: info.timestamp,
Snippet: f.SemanticSnippet(info.content, h.Snippet),
Score: &score,
})
if len(out) >= f.Limit {
break
}
}
return db.ContentSearchPage{Matches: out}, nil
}
// survivingVectorHitsPG over-fetches k ranked hits from the searcher and keeps
// only those whose session passes the filter's metadata scope (the
// child-exclusion-lifted lookup) and whose subordinate flag falls inside
// f.Scope, preserving the searcher's rank order. Shared by the semantic mode
// and the hybrid vector leg so both filter the vector candidates identically.
func (s *Store) survivingVectorHitsPG(
ctx context.Context, f db.ContentSearchFilter, searcher db.VectorSearcher, k int,
) ([]db.VectorHit, error) {
hits, err := searcher.SemanticSearch(ctx, f.Pattern, k)
if err != nil {
return nil, err
}
if len(hits) == 0 {
return nil, nil
}
allowed, err := s.semanticAllowedSessionIDsPG(ctx, f, pgUniqueSessionIDs(hits))
if err != nil {
return nil, err
}
surviving := make([]db.VectorHit, 0, len(hits))
for _, h := range hits {
if allowed[h.SessionID] && !db.ScopeExcludes(f.Scope, h.Subordinate) {
surviving = append(surviving, h)
}
}
return surviving, nil
}
// pgUniqueSessionIDs returns the distinct session IDs referenced by hits.
// Order is irrelevant: the result only feeds an ANY(...) array bind.
func pgUniqueSessionIDs(hits []db.VectorHit) []string {
seen := make(map[string]bool, len(hits))
ids := make([]string, 0, len(hits))
for _, h := range hits {
if !seen[h.SessionID] {
seen[h.SessionID] = true
ids = append(ids, h.SessionID)
}
}
return ids
}
// semanticPGSessionFilter maps a ContentSearchFilter for the semantic/hybrid
// session scope: the shared pgSessionFilter mapping plus the child one-shot
// exemption (SessionFilter.ChildExemptOneShot) -- child sessions must not be
// dropped by the one-shot gate in these modes, while top-level one-shots keep
// today's exclusion. It mirrors internal/db.semanticContentSessionFilter.
func semanticPGSessionFilter(f db.ContentSearchFilter) db.SessionFilter {
sf := pgSessionFilter(f)
sf.ChildExemptOneShot = true
return sf
}
// semanticAllowedSessionIDsPG returns the subset of ids whose session passes
// the ContentSearchFilter's metadata scope (project, agent, date range,
// one-shot/automated, ...), reusing buildPGSessionBaseFilter so this path
// cannot drift from the substring/regex scope subquery. Like SQLite's
// semanticAllowedSessionIDs it omits the sidebar-child exclusion and exempts
// child sessions from the one-shot gate (semanticPGSessionFilter): in
// semantic/hybrid modes Scope supersedes IncludeChildren, so subordinate units
// stay visible to the vector leg. The whole id set binds as one array
// parameter (pgx expands ANY natively), so no IN chunking is needed.
func (s *Store) semanticAllowedSessionIDsPG(
ctx context.Context, f db.ContentSearchFilter, ids []string,
) (map[string]bool, error) {
if len(ids) == 0 {
return nil, nil
}
where, args := buildPGSessionBaseFilter(semanticPGSessionFilter(f))
query := fmt.Sprintf(
"SELECT id FROM sessions WHERE %s AND id = ANY($%d)", where, len(args)+1)
args = append(args, ids)
rows, err := s.pg.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("pg semantic search session scope: %w", err)
}
defer func() { _ = rows.Close() }()
allowed := make(map[string]bool, len(ids))
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan pg semantic session id: %w", err)
}
allowed[id] = true
}
if err := rows.Err(); err != nil {
return nil, err
}
return allowed, nil
}
// pgSemanticHitInfo is the session/message metadata enrichSemanticHitsPG
// attaches to a surviving hit. content is the message's full, un-truncated
// content: semantic/hybrid snippets are built from it (SemanticSnippet) rather
// than the searcher's pre-truncated chunk text, so secret redaction sees the
// same whole-body context the lexical paths give it. relationshipType,
// parentSessionID, and isSidechain carry the hit's lineage joined from the
// sessions/messages rows; isSidechain is the ANCHOR ordinal's message flag.
type pgSemanticHitInfo struct {
project, agent, role, timestamp, content string
relationshipType, parentSessionID string
isSidechain bool
}
// enrichSemanticHitsPG looks up session/message metadata for hits' anchor
// (session_id, ordinal) pairs via parallel unnest arrays joined to
// messages/sessions, keyed by db.MessageRef{SessionID, Ordinal}. A hit whose
// anchor message is missing from PG is absent from the map and dropped by the
// caller, matching SQLite's enrichSemanticHits. The whole batch binds as two
// array parameters (session_ids text[], ordinals int[]), so any hit count
// stays well under PG's bind limit.
func (s *Store) enrichSemanticHitsPG(
ctx context.Context, hits []db.VectorHit,
) (map[db.MessageRef]pgSemanticHitInfo, error) {
out := make(map[db.MessageRef]pgSemanticHitInfo, len(hits))
if len(hits) == 0 {
return out, nil
}
sessionIDs := make([]string, len(hits))
ordinals := make([]int32, len(hits))
for i, h := range hits {
sessionIDs[i] = h.SessionID
ordinals[i] = int32(h.Ordinal)
}
const query = `
SELECT m.session_id, s.project, s.agent, m.role, m.ordinal,
COALESCE(m.timestamp::text, ''), m.content,
COALESCE(s.relationship_type, ''), COALESCE(s.parent_session_id, ''),
m.is_sidechain
FROM (SELECT unnest($1::text[]) AS session_id,
unnest($2::int[]) AS ordinal) h
JOIN messages m ON m.session_id = h.session_id AND m.ordinal = h.ordinal
JOIN sessions s ON s.id = m.session_id`
rows, err := s.pg.QueryContext(ctx, query, sessionIDs, ordinals)
if err != nil {
return nil, fmt.Errorf("pg semantic search enrich: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var ref db.MessageRef
var info pgSemanticHitInfo
if err := rows.Scan(&ref.SessionID, &info.project, &info.agent,
&info.role, &ref.Ordinal, &info.timestamp, &info.content,
&info.relationshipType, &info.parentSessionID,
&info.isSidechain); err != nil {
return nil, fmt.Errorf("scan pg semantic hit: %w", err)
}
out[ref] = info
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}