5934110fa8
voice.go is still the biggest file in cmd/mavend and most of what is left has nothing to do with the audio path. The ecosystem integration is one such lump: it talks to Nexus, Praxis and Hexis over HTTP and only touches the handler for its store and clock. Lifting it into ecosystem_acts.go puts it next to ecosystem.go, where the clients it drives already live. Move-only: handlePraxisAct, recordPraxisTrace (called from nowhere else), handleHexisAct and execHexis verbatim, plus the two imports that became unused in voice.go.
257 lines
9.1 KiB
Go
257 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
|
|
hexisclient "github.com/kami/hexis/pkg/client"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
// 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.
|
|
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision) string {
|
|
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
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 ""
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) {
|
|
now := h.now()
|
|
value := operation
|
|
if len(details) > 0 {
|
|
if b, err := json.Marshal(details); err == nil {
|
|
value = operation + " " + string(b)
|
|
}
|
|
}
|
|
_, _ = h.api.WriteFact(ctx, ipc.WriteFactReq{
|
|
Ts: now,
|
|
Kind: "system",
|
|
Key: "praxis:" + operation,
|
|
Value: value,
|
|
Source: "praxis:trace",
|
|
Confidence: 1.0,
|
|
})
|
|
}
|
|
|
|
// handleHexisAct — resolves entity references through Nexus and executes
|
|
// matching capabilities through Hexis. Returns a reply string when handled,
|
|
// or "" to fall through to the system command executor.
|
|
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string {
|
|
if h.ecosystem == nil {
|
|
return ""
|
|
}
|
|
|
|
// Resolve the utterance text as an entity reference through Nexus. An
|
|
// ambiguous match must stop and clarify — never guess a mutation target.
|
|
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
|
if err != nil {
|
|
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
|
// and report degradation rather than silently falling through to the
|
|
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
|
// independently, never a silent all-clear).
|
|
return "экосистема недоступна, попробуй ещё раз."
|
|
}
|
|
if len(ambiguous) > 0 {
|
|
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
|
}
|
|
if entityID == "" {
|
|
return ""
|
|
}
|
|
|
|
// Discover Hexis capabilities for this entity. A resolved entity with a
|
|
// genuine Hexis failure must not be treated as "no capabilities" and
|
|
// fall through to unrelated local execution.
|
|
caps, err := h.ecosystem.discoverCapabilities(ctx, entityID)
|
|
if err != nil {
|
|
return "экосистема недоступна, попробуй ещё раз."
|
|
}
|
|
if len(caps) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Match the user's verb to a capability by name/description. Collect all
|
|
// matches: more than one is itself ambiguous, so we ask rather than pick
|
|
// the first (ecosystem invariant: no arbitrary target for mutation).
|
|
verb := dec.Slots.Fn
|
|
if verb == "" {
|
|
verb = dec.Slots.Text
|
|
}
|
|
verbLower := strings.ToLower(verb)
|
|
|
|
var matches []*hexisclient.Capability
|
|
for i, c := range caps {
|
|
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
|
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
|
matches = append(matches, &caps[i])
|
|
}
|
|
}
|
|
if len(matches) == 0 {
|
|
return ""
|
|
}
|
|
if len(matches) > 1 {
|
|
var names []string
|
|
for _, m := range matches {
|
|
names = append(names, m.Name)
|
|
}
|
|
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
|
}
|
|
matched := matches[0]
|
|
|
|
// Read-only capabilities run immediately; mutating ones are parked for an
|
|
// explicit spoken confirm bound to this capability + target.
|
|
if !matched.ReadOnly {
|
|
h.mu.Lock()
|
|
h.pendingHexis = &pendingHexisExec{
|
|
capabilityID: matched.ID,
|
|
capName: matched.Name,
|
|
entityID: entityID,
|
|
displayName: displayName,
|
|
expiry: h.now().Add(confirmTTL),
|
|
}
|
|
h.mu.Unlock()
|
|
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
|
}
|
|
|
|
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
|
}
|
|
|
|
// execHexis runs a resolved capability and records a cross-service trace with
|
|
// the correlation ID. It reports command success, never operational recovery
|
|
// (Praxis observes recovery independently).
|
|
func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityID, displayName string) string {
|
|
correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil)
|
|
if err != nil {
|
|
log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err)
|
|
return "не получилось выполнить команду для " + displayName + "."
|
|
}
|
|
h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{
|
|
"entity_id": entityID,
|
|
"entity_name": displayName,
|
|
"capability": capName,
|
|
"correlation_id": correlationID,
|
|
})
|
|
return "команда выполнена для " + displayName + "."
|
|
}
|