// factenrichment.go — resolves the Subject of entity-aware facts against // Nexus, turning free-text ("the espresso machine", "Kate") into a canonical // entity_id (Vikunja #279). Mirrors Praxis's async enrichment worker // (internal/enrichment/worker.go there): a plain poll-queue-update loop, no // shared state with the tick loop beyond the store and ecosystemWiring both // already hold. package main import ( "context" "log" "time" "github.com/kami/maven/internal/store" ) // factEnrichmentWorker polls store.PendingFactResolutions and resolves each // one's Subject through ecosystemWiring.resolveEntityReference. A nil nexus // client (ecosystem.nexus unconfigured) makes every tick a no-op rather than // erroring — entity-aware memory degrades to "facts just aren't tagged", // never to a crash loop. type factEnrichmentWorker struct { store *store.Store eco *ecosystemWiring interval time.Duration batch int // facts resolved per tick; keeps a single slow tick bounded } func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval time.Duration) *factEnrichmentWorker { return &factEnrichmentWorker{store: st, eco: eco, interval: interval, batch: 20} } func (w *factEnrichmentWorker) run(ctx context.Context) { if w.eco == nil || w.eco.nexus == nil { log.Printf("factenrichment: nexus not configured, worker idle") <-ctx.Done() return } ticker := time.NewTicker(w.interval) defer ticker.Stop() w.tick(ctx) for { select { case <-ctx.Done(): return case <-ticker.C: w.tick(ctx) } } } func (w *factEnrichmentWorker) tick(ctx context.Context) { pending, err := w.store.PendingFactResolutions(ctx, w.batch) if err != nil { log.Printf("factenrichment: list pending: %v", err) return } for _, f := range pending { w.resolveOne(ctx, f) } } func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) { entityID, _, ambiguous, err := w.eco.resolveEntityReference(ctx, f.Subject, nil) if err != nil { // Transient (Nexus unreachable) — leave pending, retry next tick. log.Printf("factenrichment: resolve fact %d subject %q: %v", f.ID, f.Subject, err) return } state := store.ResolutionNotFound switch { case entityID != "": state = store.ResolutionResolved case len(ambiguous) > 0: state = store.ResolutionAmbiguous } if err := w.store.ResolveFactEntity(ctx, f.ID, entityID, state); err != nil { log.Printf("factenrichment: record resolution for fact %d: %v", f.ID, err) } }