* feat(observability): OpenTelemetry logs+traces with secret redaction Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged. Bootstrap (internal/telemetry): - Setup() builds one shared resource, a TracerProvider (parent-based trace-id ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator from env. It installs NO MeterProvider — metrics stay on Prometheus, and the built-in no-op global MeterProvider keeps the trace instrumentation libs from double-emitting. Shutdown is deferred with a flush timeout. - Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the shared LevelVar and best-effort so a failing collector can't break the console or DB branches. stderr + opslog stay untouched. Secret redaction (internal/logredact): - A slog.Handler masks secret-keyed attributes (password, token, api_key, authorization, cookie, ...) — including .With-bound attrs, nested groups, secret-keyed group subtrees, and values behind a LogValuer — on the console and OTLP sinks, with a no-op fast path when a record has no secret keys. opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one marker list. Rotation is infra-managed (no custom file sink): container runtime for stderr, collector/backend for OTLP, opslog partition-pruning for the DB. Documented in docs/architecture/observability.md. Verification: go build ./..., go vet, gofmt -l — clean; go test ./internal/telemetry/ ./internal/logredact/ -race pass. AI-use disclosure: implemented with AI assistance (Claude Code), including adversarial reviews that hardened the bootstrap and fixed two redaction leak paths; reviewed by the author. * refactor(observability): slog context+component sweep, sloglint gate (phase 3) Part of #265. Builds on the OTel bootstrap + redaction commit. Standardizes every log call site onto the context-carrying slog variants so records correlate with the active OpenTelemetry trace, and locks the standard in with a machine gate so future code (human- or AI-authored) can't drift back. - Call-site sweep: converted the remaining slog.<Level>(...) calls to the slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope (background/init calls with no ctx are left as-is), across 183 files. Applied via a type-aware AST codemod. Log levels and message strings are preserved verbatim; a component attr (canonical per-package name) is added to direct package-level slog calls. Bound-logger calls keep their existing .With bindings. The main.go and telemetry package conversions rode with their file in the previous commit to keep each file within a single commit. - Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg, key-naming-case=snake, no-mixed-args. After the sweep all four report zero violations repo-wide (tests included), so make lint / CI now blocks any regression to the non-context form. The gate ships with the sweep because it cannot be green until the legacy sites are converted. Metrics remain on Prometheus; no behavior change to /metrics or Grafana. Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4 rules) 0 violations repo-wide; log levels verified unchanged. AI-use disclosure: implemented with AI assistance (Claude Code), including the codemod; reviewed by the author. * fix(observability): honor per-signal OTLP protocol and secret WithGroup names Two Codex review findings on PR #290: - telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector setups (e.g. HTTP logs + gRPC traces) build the right exporter. - logredact: entering a group whose name is secret-bearing (e.g. WithGroup("authorization")) now masks every leaf in that subtree, matching how slog.Group("authorization", ...) is masked as a whole. * fix(observability): address review feedback on telemetry bootstrap - Telemetry setup failure no longer kills boot: Setup returns usable no-op providers alongside the error and main logs and continues with telemetry disabled, honoring the best-effort contract. - Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_* variants); unsupported values fall back to parentbased_traceidratio. - Attach node identity as semconv service.instance.id instead of the non-semconv node.name. - Rename opslog retention-scope log attrs to target_component/target_level so they no longer collide with the canonical component routing key, and tag those lines with component=opslog. - Fix stale levelGated comment casing; use WarnContext in the telemetry shutdown defer; document the LogValuer double-resolve on the redaction slow path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
545 lines
13 KiB
Go
545 lines
13 KiB
Go
package recommendations
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
)
|
|
|
|
// weightedAverage computes a weighted average of embedding vectors. Each entry
|
|
// in vecs is scaled by the corresponding weight in weights. The result is
|
|
// L2-normalised. Returns nil when no items contribute a non-zero weight or
|
|
// when all embeddings are nil.
|
|
func weightedAverage(vecs [][]float32, weights []float64) []float32 {
|
|
if len(vecs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var dims int
|
|
for _, v := range vecs {
|
|
if v != nil {
|
|
dims = len(v)
|
|
break
|
|
}
|
|
}
|
|
if dims == 0 {
|
|
return nil
|
|
}
|
|
|
|
sum := make([]float64, dims)
|
|
totalAbsWeight := 0.0
|
|
|
|
for i, v := range vecs {
|
|
if v == nil || i >= len(weights) {
|
|
continue
|
|
}
|
|
w := weights[i]
|
|
if w == 0 {
|
|
continue
|
|
}
|
|
totalAbsWeight += math.Abs(w)
|
|
for j, val := range v {
|
|
sum[j] += float64(val) * w
|
|
}
|
|
}
|
|
|
|
if totalAbsWeight == 0 {
|
|
return nil
|
|
}
|
|
|
|
// L2 normalise.
|
|
var norm float64
|
|
for _, v := range sum {
|
|
norm += v * v
|
|
}
|
|
norm = math.Sqrt(norm)
|
|
if norm == 0 {
|
|
return nil
|
|
}
|
|
|
|
result := make([]float32, dims)
|
|
for i, v := range sum {
|
|
result[i] = float32(v / norm)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// timeDecay computes an exponential decay factor for a signal recorded at signalTime.
|
|
// halfLifeDays controls how fast signals lose weight. Floor is 10% of original.
|
|
func timeDecay(signalTime time.Time, now time.Time, halfLifeDays float64) float64 {
|
|
if halfLifeDays <= 0 {
|
|
return 1.0
|
|
}
|
|
lambda := math.Ln2 / halfLifeDays
|
|
daysSince := now.Sub(signalTime).Hours() / 24.0
|
|
if daysSince < 0 {
|
|
daysSince = 0
|
|
}
|
|
factor := math.Exp(-lambda * daysSince)
|
|
if factor < 0.1 {
|
|
factor = 0.1 // 10% floor
|
|
}
|
|
return factor
|
|
}
|
|
|
|
// contentRatingScore returns a numeric precedence for a content rating so
|
|
// ratings can be compared by strictness. Higher score = more restrictive.
|
|
func contentRatingScore(rating string) int {
|
|
scores := map[string]int{
|
|
"NC-17": 7,
|
|
"TV-MA": 6,
|
|
"R": 5,
|
|
"TV-14": 4,
|
|
"PG-13": 3,
|
|
"TV-PG": 2,
|
|
"PG": 2,
|
|
"G": 1,
|
|
"TV-G": 1,
|
|
"TV-Y": 0,
|
|
}
|
|
if s, ok := scores[rating]; ok {
|
|
return s
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// maxContentRatingFromSets derives the highest content rating among
|
|
// positively-signaled and completed canonical items.
|
|
func maxContentRatingFromSets(ratedSet, completedSet, favSet map[string]struct{}, itemContentRatings map[string]string) string {
|
|
best := ""
|
|
bestScore := -1
|
|
|
|
check := func(id string) {
|
|
cr, ok := itemContentRatings[id]
|
|
if !ok || cr == "" {
|
|
return
|
|
}
|
|
s := contentRatingScore(cr)
|
|
if s > bestScore {
|
|
bestScore = s
|
|
best = cr
|
|
}
|
|
}
|
|
|
|
for id := range ratedSet {
|
|
check(id)
|
|
}
|
|
for id := range completedSet {
|
|
check(id)
|
|
}
|
|
for id := range favSet {
|
|
check(id)
|
|
}
|
|
return best
|
|
}
|
|
|
|
// RefreshTasteProfile rebuilds the taste profile embedding for the given user
|
|
// and profile by aggregating signal weights from ratings, favorites, watchlist,
|
|
// watch progress, and rewatches. Applies time decay and builds clustered
|
|
// sub-profiles.
|
|
func (e *Engine) RefreshTasteProfile(ctx context.Context, userID int, profileID string) error {
|
|
store, err := e.storeProvider.ForUser(ctx, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("get user store for user %d: %w", userID, err)
|
|
}
|
|
|
|
ratings, err := e.ratingsRepo.List(ctx, userID, profileID, 1000, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("list ratings: %w", err)
|
|
}
|
|
|
|
favorites, err := store.ListFavorites(ctx, profileID, 1000, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("list favorites: %w", err)
|
|
}
|
|
|
|
watchlist, err := store.ListWatchlist(ctx, profileID, 1000, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("list watchlist: %w", err)
|
|
}
|
|
|
|
watchProgress, err := e.signalReader().WatchProgressForUser(ctx, userID, profileID)
|
|
if err != nil {
|
|
return fmt.Errorf("get watch progress: %w", err)
|
|
}
|
|
|
|
rewatchCounts, err := e.signalReader().RewatchCounts(ctx, userID, profileID)
|
|
if err != nil {
|
|
return fmt.Errorf("get rewatch counts: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
halfLife := e.cfg.TasteDecayHalfLifeDays
|
|
if halfLife == 0 {
|
|
halfLife = 180
|
|
}
|
|
|
|
rawIDSet := make(map[string]struct{})
|
|
for _, r := range ratings {
|
|
rawIDSet[r.MediaItemID] = struct{}{}
|
|
}
|
|
for _, f := range favorites {
|
|
rawIDSet[f.MediaItemID] = struct{}{}
|
|
}
|
|
for _, w := range watchlist {
|
|
rawIDSet[w.MediaItemID] = struct{}{}
|
|
}
|
|
for _, wp := range watchProgress {
|
|
rawIDSet[wp.MediaItemID] = struct{}{}
|
|
}
|
|
for _, rc := range rewatchCounts {
|
|
rawIDSet[rc.MediaItemID] = struct{}{}
|
|
}
|
|
|
|
rawIDs := make([]string, 0, len(rawIDSet))
|
|
for id := range rawIDSet {
|
|
rawIDs = append(rawIDs, id)
|
|
}
|
|
|
|
refs, err := e.repo.ResolveCanonicalContentRefs(ctx, rawIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve canonical content refs: %w", err)
|
|
}
|
|
|
|
signalCounts := make(map[string]int)
|
|
signals := make(map[string]*canonicalWeightComponents)
|
|
ensureSignal := func(id string) *canonicalWeightComponents {
|
|
if s, ok := signals[id]; ok {
|
|
return s
|
|
}
|
|
s := &canonicalWeightComponents{}
|
|
signals[id] = s
|
|
return s
|
|
}
|
|
ratedSet := make(map[string]struct{})
|
|
|
|
for _, r := range ratings {
|
|
ref, ok := refs[r.MediaItemID]
|
|
if !ok || !ref.isCanonicalTasteItem() {
|
|
continue
|
|
}
|
|
|
|
s := ensureSignal(ref.CanonicalID)
|
|
rating := r.Rating
|
|
s.Rating = &rating
|
|
ratedSet[ref.CanonicalID] = struct{}{}
|
|
|
|
decay := timeDecay(r.RatedAt, now, halfLife)
|
|
switch {
|
|
case r.Rating == 5:
|
|
s.ExplicitWeight += WeightRated5 * decay
|
|
signalCounts["rated_5"]++
|
|
case r.Rating == 4:
|
|
s.ExplicitWeight += WeightRated4 * decay
|
|
signalCounts["rated_4"]++
|
|
case r.Rating == 3:
|
|
s.ExplicitWeight += WeightRated3 * decay
|
|
signalCounts["rated_3"]++
|
|
default:
|
|
s.ExplicitWeight += WeightRatedLow * decay
|
|
signalCounts["rated_low"]++
|
|
}
|
|
}
|
|
|
|
for _, wp := range watchProgress {
|
|
ref, ok := refs[wp.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" {
|
|
continue
|
|
}
|
|
weight, ok := implicitWatchWeight(wp)
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch weight {
|
|
case WeightWatchHigh:
|
|
signalCounts["watch_high"]++
|
|
case WeightWatchMed:
|
|
signalCounts["watch_med"]++
|
|
case WeightWatchLow:
|
|
signalCounts["watch_low"]++
|
|
}
|
|
}
|
|
|
|
for _, rc := range rewatchCounts {
|
|
ref, ok := refs[rc.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" || rc.Count < 2 {
|
|
continue
|
|
}
|
|
signalCounts["rewatch"]++
|
|
}
|
|
|
|
implicitSignals, completedSet := buildCanonicalImplicitSignals(watchProgress, rewatchCounts, refs, now, halfLife)
|
|
for canonicalID, weight := range implicitSignals {
|
|
ensureSignal(canonicalID).ImplicitWeight += weight
|
|
}
|
|
|
|
favSet := make(map[string]struct{}, len(favorites))
|
|
for _, f := range favorites {
|
|
ref, ok := refs[f.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" {
|
|
continue
|
|
}
|
|
favSet[ref.CanonicalID] = struct{}{}
|
|
s := ensureSignal(ref.CanonicalID)
|
|
s.IntentWeight += WeightFavorited * timeDecay(parseSignalTime(f.AddedAt, now), now, halfLife)
|
|
signalCounts["favorited"]++
|
|
}
|
|
|
|
for _, w := range watchlist {
|
|
ref, ok := refs[w.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" {
|
|
continue
|
|
}
|
|
s := ensureSignal(ref.CanonicalID)
|
|
s.IntentWeight += WeightWatchlist * timeDecay(parseSignalTime(w.AddedAt, now), now, halfLife)
|
|
signalCounts["watchlist"]++
|
|
}
|
|
|
|
if len(signals) == 0 {
|
|
return nil
|
|
}
|
|
|
|
allIDs := make([]string, 0, len(signals))
|
|
for id := range signals {
|
|
allIDs = append(allIDs, id)
|
|
}
|
|
|
|
items, err := e.itemRepo.GetByIDs(ctx, allIDs)
|
|
if err != nil {
|
|
slog.ErrorContext(ctx, "taste: failed to fetch items for genre enrichment, cluster labels will be degraded", "component", "recommendations",
|
|
"user_id", userID, "profile_id", profileID,
|
|
"id_count", len(allIDs), "error", err)
|
|
} else {
|
|
itemMap := make(map[string]*models.MediaItem, len(items))
|
|
for _, item := range items {
|
|
itemMap[item.ContentID] = item
|
|
}
|
|
for id, s := range signals {
|
|
if item, ok := itemMap[id]; ok {
|
|
s.Genres = item.Genres
|
|
}
|
|
}
|
|
}
|
|
|
|
embMap, err := e.repo.GetBatchEmbeddings(ctx, allIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("get batch embeddings: %w", err)
|
|
}
|
|
|
|
vecs := make([][]float32, 0, len(signals))
|
|
embWeights := make([]float64, 0, len(signals))
|
|
var positiveItems []clusterItem
|
|
|
|
signalIDs := make([]string, 0, len(signals))
|
|
for id := range signals {
|
|
signalIDs = append(signalIDs, id)
|
|
}
|
|
sort.Strings(signalIDs)
|
|
|
|
for _, id := range signalIDs {
|
|
s := signals[id]
|
|
emb, ok := embMap[id]
|
|
if !ok || emb == nil {
|
|
continue
|
|
}
|
|
finalWeight := combineCanonicalWeight(s.Rating, s.ExplicitWeight, s.ImplicitWeight, s.IntentWeight)
|
|
if finalWeight == 0 {
|
|
continue
|
|
}
|
|
|
|
vecs = append(vecs, emb)
|
|
embWeights = append(embWeights, finalWeight)
|
|
|
|
if finalWeight > 0 {
|
|
positiveItems = append(positiveItems, clusterItem{
|
|
itemID: id,
|
|
embedding: emb,
|
|
weight: finalWeight,
|
|
genres: s.Genres,
|
|
})
|
|
}
|
|
}
|
|
|
|
profile := weightedAverage(vecs, embWeights)
|
|
if profile == nil {
|
|
return nil
|
|
}
|
|
|
|
maxContentRating := ""
|
|
if len(allIDs) > 0 && items != nil {
|
|
crMap := make(map[string]string, len(items))
|
|
for _, item := range items {
|
|
crMap[item.ContentID] = item.ContentRating
|
|
}
|
|
maxContentRating = maxContentRatingFromSets(ratedSet, completedSet, favSet, crMap)
|
|
}
|
|
|
|
if err := e.repo.UpsertTasteProfile(ctx, userID, profileID, profile, signalCounts, maxContentRating); err != nil {
|
|
return fmt.Errorf("upsert taste profile: %w", err)
|
|
}
|
|
|
|
if len(positiveItems) > 0 {
|
|
clusters := buildTasteClusters(positiveItems)
|
|
for i := range clusters {
|
|
clusters[i].UserID = userID
|
|
clusters[i].ProfileID = profileID
|
|
}
|
|
if err := e.repo.UpsertTasteClusters(ctx, userID, profileID, clusters); err != nil {
|
|
return fmt.Errorf("upsert taste clusters: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetTasteProfileSummary returns a human-readable summary of the user's taste
|
|
// profile including top genres and directors.
|
|
func (e *Engine) GetTasteProfileSummary(ctx context.Context, userID int, profileID string) (*TasteProfileSummary, error) {
|
|
meta, err := e.repo.GetTasteProfileMeta(ctx, userID, profileID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get taste profile meta: %w", err)
|
|
}
|
|
if meta == nil {
|
|
return &TasteProfileSummary{
|
|
TopGenres: []string{},
|
|
FavoriteDirectors: []string{},
|
|
SignalCounts: map[string]int{},
|
|
UpdatedAt: "",
|
|
}, nil
|
|
}
|
|
|
|
// Gather top-rated and favorited item IDs to derive genre/director preferences.
|
|
ratings, err := e.ratingsRepo.List(ctx, userID, profileID, 1000, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list ratings for summary: %w", err)
|
|
}
|
|
|
|
store, err := e.storeProvider.ForUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get user store for summary: %w", err)
|
|
}
|
|
|
|
favorites, err := store.ListFavorites(ctx, profileID, 1000, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list favorites for summary: %w", err)
|
|
}
|
|
|
|
rawIDSet := make(map[string]struct{})
|
|
for _, r := range ratings {
|
|
if r.Rating >= 4 {
|
|
rawIDSet[r.MediaItemID] = struct{}{}
|
|
}
|
|
}
|
|
for _, f := range favorites {
|
|
rawIDSet[f.MediaItemID] = struct{}{}
|
|
}
|
|
|
|
rawIDs := make([]string, 0, len(rawIDSet))
|
|
for id := range rawIDSet {
|
|
rawIDs = append(rawIDs, id)
|
|
}
|
|
|
|
refs, err := e.repo.ResolveCanonicalContentRefs(ctx, rawIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve canonical summary refs: %w", err)
|
|
}
|
|
|
|
idSet := make(map[string]struct{})
|
|
for _, r := range ratings {
|
|
if r.Rating < 4 {
|
|
continue
|
|
}
|
|
ref, ok := refs[r.MediaItemID]
|
|
if !ok || !ref.isCanonicalTasteItem() {
|
|
continue
|
|
}
|
|
idSet[ref.CanonicalID] = struct{}{}
|
|
}
|
|
for _, f := range favorites {
|
|
ref, ok := refs[f.MediaItemID]
|
|
if !ok || ref.CanonicalID == "" {
|
|
continue
|
|
}
|
|
idSet[ref.CanonicalID] = struct{}{}
|
|
}
|
|
|
|
topGenres := []string{}
|
|
topDirectors := []string{}
|
|
|
|
if len(idSet) > 0 {
|
|
ids := make([]string, 0, len(idSet))
|
|
for id := range idSet {
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
items, err := e.itemRepo.GetByIDs(ctx, ids)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get items for summary: %w", err)
|
|
}
|
|
|
|
genreCounts := make(map[string]int)
|
|
directorCounts := make(map[string]int)
|
|
|
|
for _, item := range items {
|
|
for _, g := range item.Genres {
|
|
genreCounts[g]++
|
|
}
|
|
for _, p := range item.People {
|
|
if p.Kind == models.PersonKindDirector {
|
|
directorCounts[p.Name]++
|
|
}
|
|
}
|
|
}
|
|
|
|
topGenres = topN(genreCounts, 5)
|
|
topDirectors = topN(directorCounts, 5)
|
|
}
|
|
|
|
return &TasteProfileSummary{
|
|
TopGenres: topGenres,
|
|
FavoriteDirectors: topDirectors,
|
|
SignalCounts: meta.SignalCounts,
|
|
UpdatedAt: meta.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
// topN returns up to n keys from counts, ordered by count descending.
|
|
func topN(counts map[string]int, n int) []string {
|
|
type kv struct {
|
|
key string
|
|
count int
|
|
}
|
|
|
|
pairs := make([]kv, 0, len(counts))
|
|
for k, v := range counts {
|
|
pairs = append(pairs, kv{k, v})
|
|
}
|
|
|
|
// Simple selection sort — n is always small (≤ 5).
|
|
for i := 0; i < len(pairs) && i < n; i++ {
|
|
maxIdx := i
|
|
for j := i + 1; j < len(pairs); j++ {
|
|
if pairs[j].count > pairs[maxIdx].count {
|
|
maxIdx = j
|
|
}
|
|
}
|
|
pairs[i], pairs[maxIdx] = pairs[maxIdx], pairs[i]
|
|
}
|
|
|
|
end := n
|
|
if end > len(pairs) {
|
|
end = len(pairs)
|
|
}
|
|
|
|
result := make([]string, end)
|
|
for i := 0; i < end; i++ {
|
|
result[i] = pairs[i].key
|
|
}
|
|
return result
|
|
}
|