diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index e269ed5..d395540 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -12,6 +12,68 @@ import ( "github.com/kami/maven/internal/router" ) +// praxisCapability is one arm of the Praxis act dispatch. This is an interface +// rather than a map[string]func because each arm carries its own state: the +// verb aliases it answers to, the trace name it records, and its own reply +// formatting. The dispatch grows an arm per Praxis capability, so a new one is +// added to praxisCapabilities below and nothing else changes. +type praxisCapability interface { + // aliases are the verbs (router fn slots, EN and RU) this capability answers to. + aliases() []string + // handle runs the capability and returns the user-facing reply. + handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string +} + +// praxisCapabilities is the registry handlePraxisAct consults, in order. +var praxisCapabilities = []praxisCapability{ + listAttentionCapability{}, + praxisItemAction{ + verbs: []string{"acknowledge_item", "принято", "понял", "поняла"}, + ask: "какой пункт отметить принятым?", + op: "acknowledge", + failure: "не получилось отметить принятым.", + success: "принято.", + call: func(ctx context.Context, px *praxisClient, id string) error { + _, err := px.Acknowledge(ctx, id) + return err + }, + }, + praxisItemAction{ + verbs: []string{"resolve_item", "сделано", "готово", "решено"}, + ask: "какой пункт отметить сделанным?", + op: "resolve", + failure: "не получилось отметить сделанным.", + success: "отмечено как сделано.", + call: func(ctx context.Context, px *praxisClient, id string) error { + _, err := px.Resolve(ctx, id) + return err + }, + }, + praxisItemAction{ + verbs: []string{"ignore_item", "игнорировать", "неважно"}, + ask: "какой пункт игнорировать?", + op: "ignore", + failure: "не получилось проигнорировать.", + success: "проигнорировано.", + call: func(ctx context.Context, px *praxisClient, id string) error { + _, err := px.Ignore(ctx, id) + return err + }, + }, + praxisItemAction{ + verbs: []string{"pin_item", "закрепить"}, + ask: "какой пункт закрепить?", + op: "pin", + failure: "не получилось закрепить.", + success: "закреплено.", + call: func(ctx context.Context, px *praxisClient, id string) error { + _, err := px.Pin(ctx, id, true) + return err + }, + }, + listChangesCapability{}, +} + // handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API. // Returns "" when the act is not a Praxis verb (the caller falls through to the // system command executor). Returns a reply string otherwise. @@ -20,118 +82,112 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi return "" } px := h.ecosystem.praxis - fn := dec.Slots.Fn - - // Map verbs and Russian aliases to Praxis tool calls. - // Each case: if the verb matches, call the tool and return a user-facing reply. - switch fn { - case "list_attention", "attention", "внимание", "что требует внимания", "что нового": - items, err := px.ListAttention(ctx, 20) - if err != nil { - log.Printf("ecosystem: praxis attention: %v", err) - return "не могу сейчас узнать, что требует внимания." - } - if len(items) == 0 { - return "ничего не требует внимания." - } - h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)}) - var parts []string - for _, item := range items { - title, _ := item["title"].(string) - // importance arrives as JSON number ⇒ float64 over the HTTP contract. - importance, _ := item["importance"].(float64) - rule, _ := item["rule"].(string) - s := title - if importance > 0 { - s += fmt.Sprintf(" (важность %d", int(importance)) - if rule != "" { - s += ": " + rule - } - s += ")" - } - parts = append(parts, s) - - // Speaking an item surfaces it, it does not acknowledge it - // (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort: - // a failed surface call must not block delivering the 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) - } + for _, capability := range praxisCapabilities { + for _, alias := range capability.aliases() { + if alias == dec.Slots.Fn { + return capability.handle(ctx, h, px, dec) } } - return "требует внимания: " + strings.Join(parts, "; ") - - case "acknowledge_item", "принято", "понял", "поняла": - id := dec.Slots.Value - if id == "" { - return "какой пункт отметить принятым?" - } - if _, err := px.Acknowledge(ctx, id); err != nil { - log.Printf("ecosystem: praxis acknowledge %s: %v", id, err) - return "не получилось отметить принятым." - } - h.recordPraxisTrace(ctx, "acknowledge", map[string]any{"item_id": id}) - return "принято." - - case "resolve_item", "сделано", "готово", "решено": - id := dec.Slots.Value - if id == "" { - return "какой пункт отметить сделанным?" - } - if _, err := px.Resolve(ctx, id); err != nil { - log.Printf("ecosystem: praxis resolve %s: %v", id, err) - return "не получилось отметить сделанным." - } - h.recordPraxisTrace(ctx, "resolve", map[string]any{"item_id": id}) - return "отмечено как сделано." - - case "ignore_item", "игнорировать", "неважно": - id := dec.Slots.Value - if id == "" { - return "какой пункт игнорировать?" - } - if _, err := px.Ignore(ctx, id); err != nil { - log.Printf("ecosystem: praxis ignore %s: %v", id, err) - return "не получилось проигнорировать." - } - h.recordPraxisTrace(ctx, "ignore", map[string]any{"item_id": id}) - return "проигнорировано." - - case "pin_item", "закрепить": - id := dec.Slots.Value - if id == "" { - return "какой пункт закрепить?" - } - if _, err := px.Pin(ctx, id, true); err != nil { - log.Printf("ecosystem: praxis pin %s: %v", id, err) - return "не получилось закрепить." - } - h.recordPraxisTrace(ctx, "pin", map[string]any{"item_id": id}) - return "закреплено." - - case "list_changes", "changes", "изменения", "что изменилось": - changes, err := px.ListChanges(ctx, 20) - if err != nil { - log.Printf("ecosystem: praxis changes: %v", err) - return "не могу сейчас узнать об изменениях." - } - if len(changes) == 0 { - return "нет изменений." - } - h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)}) - var parts []string - for _, c := range changes { - title, _ := c["title"].(string) - typ, _ := c["change_type"].(string) - parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) - } - return "изменения: " + strings.Join(parts, "; ") - - default: - // Not a Praxis verb — let the caller fall through. - return "" } + // Not a Praxis verb — let the caller fall through. + return "" +} + +// praxisItemAction is the shared shape of the item-lifecycle capabilities: take +// an item id from the value slot, call one Praxis endpoint, trace the result. +type praxisItemAction struct { + verbs []string + ask string // reply when no item id was given + op string // trace + log name of the operation + failure string // reply when the Praxis call errors + success string + call func(ctx context.Context, px *praxisClient, id string) error +} + +func (a praxisItemAction) aliases() []string { return a.verbs } + +func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string { + id := dec.Slots.Value + if id == "" { + return a.ask + } + if err := a.call(ctx, px, id); err != nil { + log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err) + return a.failure + } + h.recordPraxisTrace(ctx, a.op, map[string]any{"item_id": id}) + return a.success +} + +// listAttentionCapability reads the attention digest and surfaces every item it speaks. +type listAttentionCapability struct{} + +func (listAttentionCapability) aliases() []string { + return []string{"list_attention", "attention", "внимание", "что требует внимания", "что нового"} +} + +func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string { + items, err := px.ListAttention(ctx, 20) + if err != nil { + log.Printf("ecosystem: praxis attention: %v", err) + return "не могу сейчас узнать, что требует внимания." + } + if len(items) == 0 { + return "ничего не требует внимания." + } + h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)}) + var parts []string + for _, item := range items { + title, _ := item["title"].(string) + // importance arrives as JSON number ⇒ float64 over the HTTP contract. + importance, _ := item["importance"].(float64) + rule, _ := item["rule"].(string) + s := title + if importance > 0 { + s += fmt.Sprintf(" (важность %d", int(importance)) + if rule != "" { + s += ": " + rule + } + s += ")" + } + parts = append(parts, s) + + // Speaking an item surfaces it, it does not acknowledge it + // (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort: + // a failed surface call must not block delivering the 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) + } + } + } + return "требует внимания: " + strings.Join(parts, "; ") +} + +// listChangesCapability reads the recent-changes feed. +type listChangesCapability struct{} + +func (listChangesCapability) aliases() []string { + return []string{"list_changes", "changes", "изменения", "что изменилось"} +} + +func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string { + changes, err := px.ListChanges(ctx, 20) + if err != nil { + log.Printf("ecosystem: praxis changes: %v", err) + return "не могу сейчас узнать об изменениях." + } + if len(changes) == 0 { + return "нет изменений." + } + h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)}) + var parts []string + for _, c := range changes { + title, _ := c["title"].(string) + typ, _ := c["change_type"].(string) + parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) + } + return "изменения: " + strings.Join(parts, "; ") } // recordPraxisTrace — writes a fact recording a cross-service ecosystem call.