Add entity-aware fact resolution against Nexus (Vikunja #279)
facts gain a Subject/EntityID/ResolutionState triple and an async enrichment worker that resolves free-text subjects to canonical Nexus entity_ids, mirroring Praxis's enrichment-worker pattern. Ambiguous or unreachable Nexus never guesses an entity_id — the fact stays pending or terminal-ambiguous instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user