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
234 lines
8.5 KiB
Go
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
|
|
}
|