Query Praxis by canonical entity ref and back off enrichment retries (#272)

Add an entity-scoped attention capability: the subject is resolved to a
canonical Nexus entity_id, the id travels to Praxis as a query scope
instead of being dropped after resolution, and Maven's own facts already
tagged with the same id join the answer. Ambiguous, unknown, degraded and
no-Nexus cases each get a distinct reply and never a scoped query without
a scope.

Give the fact-enrichment worker per-fact exponential backoff capped at an
hour and a status report of pending/in-backoff/worst-attempt counts, so a
long Nexus outage shows as a visible backlog rather than facts that
silently never got tagged. Nothing is ever given up on.
This commit is contained in:
kami
2026-08-01 06:52:25 +04:00
parent 69e2800ef3
commit 08f3db318f
3 changed files with 413 additions and 5 deletions
+101
View File
@@ -72,6 +72,7 @@ var praxisCapabilities = []praxisCapability{
},
},
listChangesCapability{},
entityAttentionCapability{},
}
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
@@ -190,6 +191,106 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
return "изменения: " + strings.Join(parts, "; ")
}
// entityAttentionCapability answers "what's going on with X" by resolving X to
// a canonical Nexus entity and asking Praxis for that entity's attention items
// (Vikunja #272). Unlike listAttentionCapability it is scoped: the entity_id
// travels to Praxis as a query parameter instead of Maven filtering an unscoped
// list client-side, which is what makes the ref canonical end to end.
//
// It also folds in what Maven herself knows about the same entity — facts the
// enrichment worker has already resolved to that entity_id — so one question
// gets one answer across both stores.
type entityAttentionCapability struct{}
func (entityAttentionCapability) aliases() []string {
return []string{"entity_attention", "что с", "как дела у", "статус"}
}
func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
subject := dec.Slots.Value
if subject == "" {
subject = dec.Slots.Text
}
if subject == "" {
return "про что именно спросить?"
}
if h.ecosystem == nil || h.ecosystem.nexus == nil {
// Without Nexus there is no canonical ref to scope by. Say so rather
// than quietly answering about something else.
return "не могу связать это с сущностью — Nexus не настроен."
}
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
if err != nil {
log.Printf("ecosystem: entity attention resolve %q: %v", subject, err)
return "экосистема недоступна, попробуй ещё раз."
}
if len(ambiguous) > 0 {
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
}
if entityID == "" {
return "не знаю такой сущности."
}
if displayName == "" {
displayName = subject
}
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
if err != nil {
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
}
h.recordPraxisTrace(ctx, "entity_attention", map[string]any{
"entity_id": entityID, "count": len(items),
})
var parts []string
for _, item := range items {
title, _ := item["title"].(string)
if title == "" {
continue
}
parts = append(parts, title)
// Same surfaced != acknowledged rule as the unscoped digest.
if id, ok := item["id"].(string); ok && id != "" {
if _, err := px.Surface(ctx, id); err != nil {
log.Printf("ecosystem: praxis surface %s: %v", id, err)
}
}
}
if known := h.localFactsForEntity(ctx, entityID); known != "" {
parts = append(parts, known)
}
if len(parts) == 0 {
return "по «" + displayName + "» ничего нет."
}
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
}
// localFactsForEntity summarises Maven's own facts already resolved to this
// canonical entity. Empty when the store is unavailable or nothing matched —
// entity-scoped memory is an enrichment of the answer, never a precondition.
func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID string) string {
if h.dataStore == nil || entityID == "" {
return ""
}
facts, err := h.dataStore.FactsByEntity(ctx, entityID, 3)
if err != nil {
log.Printf("ecosystem: facts by entity %s: %v", entityID, err)
return ""
}
var parts []string
for _, f := range facts {
if f.Value != "" {
parts = append(parts, f.Value)
}
}
if len(parts) == 0 {
return ""
}
return "я помню: " + strings.Join(parts, ", ")
}
// recordPraxisTrace — writes a fact recording a cross-service ecosystem call.
// The fact is stored with source "praxis:trace" so the proactive loop can
// reference it and the dashboard can display recent ecosystem activity.