Add typed Praxis lifecycle client + acknowledge/resolve/ignore/pin/surface verbs
Maven previously only called Praxis list_attention/list_changes and read untyped maps. Adds a typed praxisItem struct plus GetItem/Search/Surface/ Acknowledge/Resolve/Ignore/Pin client methods, and routes new dialogue verbs (RU + EN aliases) through handlePraxisAct to each. Also fixes a lifecycle-invariant bug: reading attention items aloud now calls Surface, not nothing — per ECOSYSTEM-SPEC.md §2.3 surfaced != acknowledged, and previously the digest path didn't record surfacing at all, so 'Maven mentioned it' left no trace distinguishable from 'never came up'. Vikunja #271. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
@@ -162,6 +163,106 @@ func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string
|
||||
return out, err
|
||||
}
|
||||
|
||||
// praxisItem is the typed shape of a Praxis item, decoded from the tools API's
|
||||
// itemToMap output (pkg/tools/api.go in the praxis repo). Kept as a distinct
|
||||
// type from the raw attention/changes maps above so lifecycle callers get
|
||||
// compile-time field checks instead of map[string]any type assertions.
|
||||
type praxisItem struct {
|
||||
ID string `json:"id"`
|
||||
SourceID string `json:"source_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
Importance int `json:"importance"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
SurfacedAt string `json:"surfaced_at"`
|
||||
AckedAt string `json:"acknowledged_at"`
|
||||
ResolvedAt string `json:"resolved_at"`
|
||||
}
|
||||
|
||||
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
||||
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(map[string]any{"item_id": itemID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("praxis %s: %s", path, http.StatusText(resp.StatusCode))
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// Surface marks an item read/spoken without acknowledging it (surfaced != acknowledged,
|
||||
// ECOSYSTEM-SPEC.md §2.3). Callers that read attention aloud must call this, never
|
||||
// Acknowledge, so "I mentioned it" stays distinguishable from "you told me you saw it".
|
||||
func (c *praxisClient) Surface(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/surface", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/acknowledge", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/resolve", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/ignore", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/tools/pin", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("praxis pin: %s", http.StatusText(resp.StatusCode))
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
var out praxisItem
|
||||
err := c.getJSON(ctx, "/api/v1/tools/items/"+itemID, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) Search(ctx context.Context, query string, limit int) ([]praxisItem, error) {
|
||||
var out []praxisItem
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ecosystemWiring holds the ecosystem service clients.
|
||||
type ecosystemWiring struct {
|
||||
nexus *nexusClient
|
||||
|
||||
@@ -1169,9 +1169,66 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user