EmbedAll previously ran the full text-staleness CTE on every page, paying five item_people LATERAL joins per eligible row just to detect whether an item's canonical text had drifted - even when the real work was embedding brand-new (missing) items during active backfill. Restructure EmbedAll into two passes: - Pass 1 (cheap): drain missing/model-stale items via the repurposed ItemsNeedingEmbedding query (single LEFT JOIN, no LATERAL), paged by a content_id cursor so a failed/skipped item is retried next run instead of stalling the page. - Pass 2 (expensive): only once Pass 1 fully drains, run one bounded ListEmbeddingTextCandidates scan (LIMIT embeddingTextStaleQuotaPerRun=200) to re-embed text-drifted items. Re-embedding refreshes canonical_text, so handled rows drop out next run - no Pass 2 cursor needed. Coverage-first tradeoff (documented in EmbedAll): under steady state Pass 1 drains every run so text-stale items stay fresh; only under pathological continuous heavy ingest does Pass 2 get skipped, deliberately prioritizing covering new items over re-embedding changed ones. Supporting changes: - ItemsNeedingEmbedding gains an afterID cursor + ORDER BY; SQL extracted to buildItemsNeedingEmbeddingSQL for a cheap-shape unit test (no item_people / LATERAL). - Add an `embedder` interface seam (Engine.embClient) so EmbedAll is testable with a fake; *embeddings.Client still satisfies it. - Extract embedBatch to DRY both passes, preserving quota/billing early-return, single-item fallback, ensureEmbeddingLock-before-upsert, and skip-on-store-error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
132 lines
3.5 KiB
Go
132 lines
3.5 KiB
Go
package recommendations
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
"github.com/Silo-Server/silo-server/internal/recommendations/embeddings"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
// embedder is the minimal embedding-client seam the Engine depends on. The
|
|
// concrete *embeddings.Client satisfies it; tests substitute a fake so the
|
|
// backfill loop (EmbedAll) and query-vector path can run without a real
|
|
// embedding API.
|
|
type embedder interface {
|
|
Embed(ctx context.Context, texts []string) ([][]float32, error)
|
|
}
|
|
|
|
// Engine implements the Recommender interface.
|
|
type Engine struct {
|
|
repo *Repo
|
|
ratingsRepo *catalog.RatingsRepo
|
|
itemRepo *catalog.ItemRepository
|
|
personRepo *catalog.PersonRepository
|
|
storeProvider userstore.UserStoreProvider
|
|
signals *SignalReader
|
|
embClient embedder
|
|
cfg config.RecommendationsConfig
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewEngine creates a new recommendation Engine.
|
|
func NewEngine(
|
|
pool *pgxpool.Pool,
|
|
ratingsRepo *catalog.RatingsRepo,
|
|
itemRepo *catalog.ItemRepository,
|
|
personRepo *catalog.PersonRepository,
|
|
storeProvider userstore.UserStoreProvider,
|
|
cfg config.RecommendationsConfig,
|
|
) *Engine {
|
|
repo := NewRepo(pool)
|
|
|
|
embCfg := embeddings.ClientConfig{
|
|
BaseURL: cfg.EmbeddingBaseURL,
|
|
Model: cfg.EmbeddingModel,
|
|
APIKey: cfg.EmbeddingAuthToken,
|
|
}
|
|
|
|
return &Engine{
|
|
repo: repo,
|
|
ratingsRepo: ratingsRepo,
|
|
itemRepo: itemRepo,
|
|
personRepo: personRepo,
|
|
storeProvider: storeProvider,
|
|
signals: NewSignalReader(repo, storeProvider),
|
|
embClient: embeddings.NewClient(embCfg),
|
|
cfg: cfg,
|
|
pool: pool,
|
|
}
|
|
}
|
|
|
|
// ActiveEmbeddingModel returns the embedding model currently locked for this
|
|
// installation, or "" when no lock is established.
|
|
func (e *Engine) ActiveEmbeddingModel(ctx context.Context) (string, error) {
|
|
lock, err := e.repo.GetEmbeddingLock(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if lock == nil {
|
|
return "", nil
|
|
}
|
|
return lock.Model, nil
|
|
}
|
|
|
|
func (e *Engine) watchedItemIDSet(ctx context.Context, userID int, profileID string) (map[string]struct{}, error) {
|
|
return e.signalReader().WatchedItemIDSet(ctx, userID, profileID)
|
|
}
|
|
|
|
func (e *Engine) signalReader() *SignalReader {
|
|
if e.signals != nil {
|
|
return e.signals
|
|
}
|
|
return NewSignalReader(e.repo, e.storeProvider)
|
|
}
|
|
|
|
func (e *Engine) mmrLambda(defaultLambda float64) float64 {
|
|
if e == nil {
|
|
return defaultLambda
|
|
}
|
|
if e.cfg.DiversityLambda >= 0 && e.cfg.DiversityLambda <= 1 {
|
|
return e.cfg.DiversityLambda
|
|
}
|
|
return defaultLambda
|
|
}
|
|
|
|
func (e *Engine) profileAccessFilter(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
|
|
filter := catalog.AccessFilter{UserID: userID, ProfileID: profileID}
|
|
if e == nil || e.storeProvider == nil || profileID == "" {
|
|
return filter
|
|
}
|
|
|
|
store, err := e.storeProvider.ForUser(ctx, userID)
|
|
if err != nil || store == nil {
|
|
return filter
|
|
}
|
|
profile, err := store.GetProfile(ctx, profileID)
|
|
if err != nil || profile == nil {
|
|
return filter
|
|
}
|
|
|
|
filter.MaxContentRating = profile.MaxContentRating
|
|
if profile.LibraryRestrictionsEnabled {
|
|
filter.AllowedLibraryIDs = append([]int(nil), profile.AllowedLibraryIDs...)
|
|
}
|
|
return filter
|
|
}
|
|
|
|
func scoredItemIDsFromSet(set map[string]struct{}) []string {
|
|
if len(set) == 0 {
|
|
return nil
|
|
}
|
|
|
|
ids := make([]string, 0, len(set))
|
|
for id := range set {
|
|
ids = append(ids, id)
|
|
}
|
|
return ids
|
|
}
|