85a3397bf4
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
8.6 KiB
Go
246 lines
8.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/memory"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/tool"
|
|
"github.com/kami/maven/internal/voice"
|
|
)
|
|
|
|
func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.CoreAPI) {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
api := ipc.NewStoreAPI(st)
|
|
emb := router.NewHashEmbedder(1024)
|
|
h := &reactiveHandler{
|
|
api: api,
|
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
|
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
|
replier: voice.NewStubReplier(),
|
|
now: func() time.Time { return now },
|
|
dataStore: st,
|
|
}
|
|
return h, api
|
|
}
|
|
|
|
// The write half of #470: a question routed to IntentFact must not become a
|
|
// fact about him, and must not leave a vector behind for recall to serve.
|
|
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
|
ctx := context.Background()
|
|
h, api := newFactGateHandler(t, time.Now())
|
|
searchH, seen := searchHandler(t,
|
|
`{"answers":["Актуальная версия Go — 1.25."],"results":[]}`,
|
|
http.StatusOK)
|
|
h.search = searchH.search
|
|
|
|
reply := h.actionFact(ctx, router.Decision{
|
|
Intent: router.IntentFact,
|
|
Utterance: "какая последняя версия языка Go?",
|
|
Slots: router.Slots{Key: "go_version", HasKey: true, Value: `"1.20"`},
|
|
})
|
|
|
|
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
|
t.Fatal("a question was stored as a fact about him")
|
|
}
|
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
|
if err != nil {
|
|
t.Fatalf("memory search: %v", err)
|
|
}
|
|
if len(hits) != 0 {
|
|
t.Fatalf("the question was indexed for recall: %+v", hits)
|
|
}
|
|
// It went down the world query chain instead. This asserts the actual
|
|
// destination, not merely that the write was refused: the regression was
|
|
// the personal boundary claiming this question before search.
|
|
if !strings.Contains(reply, "1.25") {
|
|
t.Fatalf("reply = %q, want live world evidence", reply)
|
|
}
|
|
if !strings.Contains(*seen, "q="+url.QueryEscape("какая последняя версия языка Go?")) {
|
|
t.Fatalf("search query = %q; world source was not reached verbatim", *seen)
|
|
}
|
|
}
|
|
|
|
// The capture that must survive the gate: an explicit instruction to record,
|
|
// even though it contains an interrogative.
|
|
func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
|
ctx := context.Background()
|
|
h, api := newFactGateHandler(t, time.Now())
|
|
|
|
h.actionFact(ctx, router.Decision{
|
|
Intent: router.IntentFact,
|
|
Utterance: "запиши что я пил воду",
|
|
Slots: router.Slots{Key: "water", HasKey: true, Value: `"вода"`},
|
|
})
|
|
|
|
f, err := api.LatestFact(ctx, "water")
|
|
if err != nil {
|
|
t.Fatalf("an explicit capture was refused: %v", err)
|
|
}
|
|
if f.Confidence != 1.0 {
|
|
t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence)
|
|
}
|
|
// #493: what recall reads back is the fact, not the sentence he said.
|
|
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
|
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
|
if err != nil {
|
|
t.Fatalf("memory search: %v", err)
|
|
}
|
|
if len(hits) != 1 {
|
|
t.Fatalf("the fact was not indexed once: %+v", hits)
|
|
}
|
|
if got := hits[0].Meta["text"]; got != "water — вода" {
|
|
t.Errorf("indexed text = %q, want the fact", got)
|
|
}
|
|
if got := hits[0].Meta["utterance"]; got != "запиши что я пил воду" {
|
|
t.Errorf("utterance provenance = %q, want it kept alongside", got)
|
|
}
|
|
}
|
|
|
|
func TestFactConfidence(t *testing.T) {
|
|
cases := []struct {
|
|
utterance, value string
|
|
want float64
|
|
}{
|
|
{"запиши что я пил воду", `"вода"`, 1.0},
|
|
{"я выпил кофе", `"кофе"`, 1.0},
|
|
{"поужинал", "", 1.0},
|
|
{"отметь что я полил кактус", `"полил кактус"`, 1.0},
|
|
{"какая последняя версия языка Go", `"1.20"`, ungroundedConfidence},
|
|
{"кто премьер Японии", `"Тонио Озаки"`, ungroundedConfidence},
|
|
}
|
|
for _, c := range cases {
|
|
if got := factConfidence(c.utterance, c.value); got != c.want {
|
|
t.Errorf("factConfidence(%q, %q) = %v, want %v", c.utterance, c.value, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
|
t.Helper()
|
|
vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text)
|
|
if err != nil {
|
|
t.Fatalf("embed %q: %v", text, err)
|
|
}
|
|
return vec
|
|
}
|
|
|
|
// The write half of #481: a complaint about a thing is a state of the
|
|
// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00
|
|
// it comes back on recall as if the network were still down.
|
|
func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
|
|
ctx := context.Background()
|
|
h, api := newFactGateHandler(t, time.Now())
|
|
|
|
reply := h.actionFact(ctx, router.Decision{
|
|
Intent: router.IntentFact,
|
|
Utterance: "сеть какая-то медленная",
|
|
Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"},
|
|
})
|
|
|
|
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
|
|
t.Fatal("a passing complaint was stored as a fact about him")
|
|
}
|
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
|
|
if err != nil {
|
|
t.Fatalf("memory search: %v", err)
|
|
}
|
|
if len(hits) != 0 {
|
|
t.Fatalf("the complaint was indexed for recall: %+v", hits)
|
|
}
|
|
if reply == "" {
|
|
t.Fatal("the turn was neither stored nor answered")
|
|
}
|
|
}
|
|
|
|
// And the complaint he asked her to keep: the capture verb wins, as it does
|
|
// over the question gate.
|
|
func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
|
ctx := context.Background()
|
|
h, api := newFactGateHandler(t, time.Now())
|
|
|
|
h.actionFact(ctx, router.Decision{
|
|
Intent: router.IntentFact,
|
|
Utterance: "запомни что интернет не работает",
|
|
Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"},
|
|
})
|
|
|
|
if _, err := api.LatestFact(ctx, "internet"); err != nil {
|
|
t.Fatalf("an explicit capture was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// A second tap of the same key supersedes the first, so recall must hold one
|
|
// vector and it must be the new value (Vikunja #493). Before this the id
|
|
// carried a timestamp, both rows stayed, and the superseded value went on
|
|
// competing for the turn.
|
|
func TestActionFact_ARetapSupersedesTheOldVector(t *testing.T) {
|
|
ctx := context.Background()
|
|
h, _ := newFactGateHandler(t, time.Now())
|
|
|
|
h.actionFact(ctx, router.Decision{
|
|
Intent: router.IntentFact,
|
|
Utterance: "запиши что я пил воду",
|
|
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
|
})
|
|
// A later tap of the same key. The clock moves, so the old id and the new
|
|
// one differ — which is exactly what used to leave two rows behind.
|
|
h.now = func() time.Time { return time.Now().Add(time.Hour) }
|
|
h.actionFact(ctx, router.Decision{
|
|
Intent: router.IntentFact,
|
|
Utterance: "запиши что я пил воду",
|
|
Slots: router.Slots{Key: "water", HasKey: true, Value: `"500мл"`},
|
|
})
|
|
|
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 5)
|
|
if err != nil {
|
|
t.Fatalf("memory search: %v", err)
|
|
}
|
|
if len(hits) != 1 {
|
|
t.Fatalf("want one vector for the key, got %d: %+v", len(hits), hits)
|
|
}
|
|
if got := hits[0].Meta["text"]; got != "water — 500мл" {
|
|
t.Errorf("indexed text = %q, want the current value", got)
|
|
}
|
|
}
|
|
|
|
// Another key is not this key. A prefix delete that widened would take the
|
|
// whole index with it.
|
|
func TestActionFact_ARetapLeavesOtherKeysAlone(t *testing.T) {
|
|
ctx := context.Background()
|
|
h, _ := newFactGateHandler(t, time.Now())
|
|
|
|
h.actionFact(ctx, router.Decision{
|
|
Intent: router.IntentFact,
|
|
Utterance: "запиши что я обедал",
|
|
Slots: router.Slots{Key: "meal", HasKey: true, Value: `"суп"`},
|
|
})
|
|
h.actionFact(ctx, router.Decision{
|
|
Intent: router.IntentFact,
|
|
Utterance: "запиши что я пил воду",
|
|
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
|
})
|
|
|
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "обед"), 5)
|
|
if err != nil {
|
|
t.Fatalf("memory search: %v", err)
|
|
}
|
|
var found bool
|
|
for _, hit := range hits {
|
|
if hit.Meta["text"] == "meal — суп" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("writing water dropped the meal vector: %+v", hits)
|
|
}
|
|
}
|