Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6878e12d37 | |||
| 8d46ee39e0 | |||
| 6eba79b332 | |||
| 3f60ec3994 | |||
| 14ea06712e | |||
| 42d3feadd2 | |||
| 2a2f706b74 | |||
| e082e06868 | |||
| 88dc4e1383 | |||
| 60759a991e | |||
| 9537346441 | |||
| c6be818f13 | |||
| b5575a9402 | |||
| fcda5e3d2c | |||
| bdafc82e35 | |||
| c69023c310 | |||
| 3d8224fb04 | |||
| 990a4a99e9 | |||
| 5b622389c5 | |||
| a820a95ebb |
@@ -57,6 +57,9 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r
|
|||||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||||
// Conversational: build history from dialogue session (prior user turns)
|
// Conversational: build history from dialogue session (prior user turns)
|
||||||
// and let the LLM respond from general knowledge + context.
|
// and let the LLM respond from general knowledge + context.
|
||||||
|
if h.phraser == nil {
|
||||||
|
return "поговорили."
|
||||||
|
}
|
||||||
history := h.chatHistory()
|
history := h.chatHistory()
|
||||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
q.Slots.Value = ""
|
q.Slots.Value = ""
|
||||||
return h.actionQuery(ctx, q)
|
return h.actionQuery(ctx, q)
|
||||||
}
|
}
|
||||||
|
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
||||||
|
// "интернет не работает" were stored as `self` rows at confidence 1.00, and
|
||||||
|
// recall reads a self row back later as if it were still true — the same
|
||||||
|
// class of row that outranked live search in #470. The sentence describes a
|
||||||
|
// moment, so she answers it and stores nothing. An explicit "запомни ..."
|
||||||
|
// and anything about him are both left alone by the test.
|
||||||
|
if router.IsTransientComplaint(dec.Utterance) {
|
||||||
|
log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat",
|
||||||
|
dec.Utterance, dec.Slots.Key)
|
||||||
|
c := dec
|
||||||
|
c.Intent = router.IntentChat
|
||||||
|
c.Slots.Key, c.Slots.HasKey = "", false
|
||||||
|
c.Slots.Value = ""
|
||||||
|
return h.actionChat(ctx, c)
|
||||||
|
}
|
||||||
now := h.now()
|
now := h.now()
|
||||||
req := ipc.WriteFactReq{
|
req := ipc.WriteFactReq{
|
||||||
Ts: now,
|
Ts: now,
|
||||||
|
|||||||
@@ -530,3 +530,23 @@ func TestClarifyIsPerConversation(t *testing.T) {
|
|||||||
func voiceCtx() context.Context {
|
func voiceCtx() context.Context {
|
||||||
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||||||
|
// question dies with the process, and she does not claim to have let it go —
|
||||||
|
// the words that follow are routed as a fresh request. Restarting is modelled
|
||||||
|
// the way the daemon does it, by building a second handler over the same store.
|
||||||
|
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
ctx := voiceCtx()
|
||||||
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question before the restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
restarted, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||||||
|
}
|
||||||
|
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||||||
|
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -514,11 +514,14 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
|
|
||||||
// Resolve the utterance text as an entity reference through Nexus. An
|
// Resolve the utterance text as an entity reference through Nexus. An
|
||||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||||
|
// The name comes from entityReferenceText, not straight from the Text slot:
|
||||||
|
// the model transliterates Latin names as it routes (Vikunja #476).
|
||||||
|
subject := entityReferenceText(dec)
|
||||||
started := h.now()
|
started := h.now()
|
||||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return "экосистема отклоняет доступ, проверь токен."
|
||||||
}
|
}
|
||||||
@@ -535,7 +538,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
map[string]any{"subject": redactSubject(subject)})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||||
@@ -569,10 +572,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
verbLower := strings.ToLower(verb)
|
verbLower := strings.ToLower(verb)
|
||||||
|
|
||||||
|
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
|
||||||
|
// indexer"), which no capability name ever contains. Read it the other way
|
||||||
|
// round then: the phrase is the haystack and the capability name is what we
|
||||||
|
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
|
||||||
|
// fn is a single verb and containment already means what it says.
|
||||||
|
loose := !dec.Slots.HasFn
|
||||||
var matches []*hexisclient.Capability
|
var matches []*hexisclient.Capability
|
||||||
for i, c := range caps {
|
for i, c := range caps {
|
||||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
name := strings.ToLower(c.Name)
|
||||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
hit := strings.Contains(name, verbLower) ||
|
||||||
|
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower))
|
||||||
|
if loose && name != "" && strings.Contains(verbLower, name) {
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
matches = append(matches, &caps[i])
|
matches = append(matches, &caps[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,3 +646,29 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
})
|
})
|
||||||
return "команда выполнена для " + displayName + "."
|
return "команда выполнена для " + displayName + "."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
|
||||||
|
// asks what to do.
|
||||||
|
//
|
||||||
|
// The stage-3 gate thins an act that never matched an allowlisted fn, so
|
||||||
|
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
|
||||||
|
// path was never entered — the capability existed and no utterance could reach
|
||||||
|
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
|
||||||
|
// the verb is matched against the capabilities Hexis registers for the entity,
|
||||||
|
// not against the allowlist.
|
||||||
|
//
|
||||||
|
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
|
||||||
|
// only when Hexis is wired — a box with no ecosystem asks the question it
|
||||||
|
// always asked. A "" back means Nexus knew no such entity or Hexis had no
|
||||||
|
// matching capability, and then she asks after all. Authority is unchanged:
|
||||||
|
// resolution stops on ambiguity and a mutating capability still goes through
|
||||||
|
// the spoken confirm in handleHexisAct.
|
||||||
|
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
|
||||||
|
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.handleHexisAct(ctx, dec)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// latinRun matches a run of Latin-script words — the shape a service, host or
|
||||||
|
// project name takes in a Russian sentence. Digits, dot, dash and underscore
|
||||||
|
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
|
||||||
|
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
|
||||||
|
|
||||||
|
// hasLatin reports whether s carries a Latin letter.
|
||||||
|
func hasLatin(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.In(r, unicode.Latin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityReferenceText is the name Nexus is asked to resolve.
|
||||||
|
//
|
||||||
|
// Normally that is the router's Text slot, which is the verb phrase the model
|
||||||
|
// wrote. But the resident model rewrites a Russian utterance as it routes, and
|
||||||
|
// on the way it transliterates: "перезапусти muzick indexer" came back as
|
||||||
|
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
|
||||||
|
// service nobody has ever named, so the act cannot resolve its target even
|
||||||
|
// with every gate open.
|
||||||
|
//
|
||||||
|
// The recovery is deliberately narrow. Only when the utterance holds a Latin
|
||||||
|
// run and the model's Text holds none has a name certainly been rewritten —
|
||||||
|
// then the longest Latin run in his own words is the reference. Anything else
|
||||||
|
// keeps the Text slot, so an English utterance and a Russian entity name are
|
||||||
|
// both untouched. Un-transliterating the Cyrillic back is not attempted: the
|
||||||
|
// surface form he said is right there, and guessing at a reverse mapping would
|
||||||
|
// invent a second name to be wrong about.
|
||||||
|
func entityReferenceText(dec router.Decision) string {
|
||||||
|
text := dec.Slots.Text
|
||||||
|
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
longest := ""
|
||||||
|
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
||||||
|
if len(m) > len(longest) {
|
||||||
|
longest = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
longest = strings.TrimSpace(longest)
|
||||||
|
// A single stray letter is not a name.
|
||||||
|
if len(longest) < 2 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return longest
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntityReferenceText pins when his own words win over the model's.
|
||||||
|
func TestEntityReferenceText(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
utterance string
|
||||||
|
text string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "the model transliterated the name",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить музик индексер",
|
||||||
|
want: "muzick indexer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "it kept the name, so nothing to repair",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить muzick indexer",
|
||||||
|
want: "перезагрузить muzick indexer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an all-Russian entity name is not a rewrite",
|
||||||
|
utterance: "перезапусти домашний сервер",
|
||||||
|
text: "перезагрузить домашний сервер",
|
||||||
|
want: "перезагрузить домашний сервер",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an English turn never enters the recovery",
|
||||||
|
utterance: "restart muzick indexer",
|
||||||
|
text: "restart muzick indexer",
|
||||||
|
want: "restart muzick indexer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the longest Latin run is the name",
|
||||||
|
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
||||||
|
text: "перезагрузить нгинкс",
|
||||||
|
want: "muzick-indexer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one stray letter is not a name",
|
||||||
|
utterance: "перезапусти сервер a",
|
||||||
|
text: "перезагрузить сервер",
|
||||||
|
want: "перезагрузить сервер",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
||||||
|
if got := entityReferenceText(dec); got != tc.want {
|
||||||
|
t.Fatalf("entityReferenceText = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
|
||||||
|
// router hands over a transliterated Text, and Nexus must still be asked about
|
||||||
|
// the service that exists.
|
||||||
|
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
h.handleHexisAct(ctx, dec)
|
||||||
|
|
||||||
|
reqs := nexus.Requests()
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
t.Fatal("nexus was never asked")
|
||||||
|
}
|
||||||
|
body := string(reqs[0].Body)
|
||||||
|
if !strings.Contains(body, "muzick indexer") {
|
||||||
|
t.Fatalf("nexus resolve body = %s, want the name he said", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
|
||||||
|
// stage-3 gate thins an act with no allowlisted fn, and that question used to
|
||||||
|
// be the whole turn, so the Hexis path was unreachable from voice or chat.
|
||||||
|
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "restart status muzick indexer"},
|
||||||
|
}
|
||||||
|
reply := h.hexisBeforeClarify(ctx, dec)
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
|
||||||
|
}
|
||||||
|
if hexis.Count("", "/api/v1") == 0 {
|
||||||
|
t.Fatal("hexis was never contacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
|
||||||
|
// she asks exactly what she asked before.
|
||||||
|
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер"},
|
||||||
|
}
|
||||||
|
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
|
||||||
|
t.Fatalf("no hexis must mean no reply, got %q", reply)
|
||||||
|
}
|
||||||
|
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
|
||||||
|
t.Fatal("she must still ask what to do")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,3 +123,48 @@ func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
|||||||
}
|
}
|
||||||
return vec
|
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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -351,6 +351,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
// and park the request (clarify.go); otherwise the replier's canned reply
|
||||||
// stands.
|
// stands.
|
||||||
if dec.Clarify {
|
if dec.Clarify {
|
||||||
|
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
if question, asked := h.askClarify(ctx, dec); asked {
|
if question, asked := h.askClarify(ctx, dec); asked {
|
||||||
return withNotice(expiredNotice, question)
|
return withNotice(expiredNotice, question)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||||
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
||||||
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
||||||
// load, never revived. Clarify's parked question stays in memory only.
|
// load, never revived. Clarify's parked question stays in memory only, and
|
||||||
|
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
|
||||||
|
// a restart expires it, so the thread comes back and the open question does
|
||||||
|
// not.
|
||||||
var dialogueSessions *dialogue.SessionStore
|
var dialogueSessions *dialogue.SessionStore
|
||||||
if dataStore != nil {
|
if dataStore != nil {
|
||||||
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
||||||
|
|||||||
@@ -223,6 +223,30 @@ Not alternatives — layers:
|
|||||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||||
|
|
||||||
|
#### A restart expires a parked question
|
||||||
|
|
||||||
|
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||||
|
restart; the clarify question parked behind it does not, and neither do the
|
||||||
|
three yes/no confirms in `voice.go`. `ClarifyStore` stays in memory.
|
||||||
|
|
||||||
|
Three reasons, in the order they settle it:
|
||||||
|
|
||||||
|
- The clock stops meaning anything. A parked question carries a 90s TTL and an
|
||||||
|
attempt count. A restart is a gap of unknown length, so a restored question is
|
||||||
|
either already dead or pretending to be young.
|
||||||
|
- Restoring the question restores the request behind it. He asked for something,
|
||||||
|
she asked back, and then the daemon went away. Acting on that minutes later,
|
||||||
|
against words he has probably given up on, is the misroute the stage 3 gate
|
||||||
|
exists to avoid.
|
||||||
|
- She does not announce it either. The expiry notice needs to know a question
|
||||||
|
was parked, and knowing that across a restart means storing it. One sentence,
|
||||||
|
in the rare window where he speaks within 90s of a restart, does not pay for a
|
||||||
|
marker that outlives the thing it describes. His next words route fresh, which
|
||||||
|
is the correct answer with or without the notice.
|
||||||
|
|
||||||
|
So the notice stays what it is: the in-process TTL case, where she really did
|
||||||
|
wait and really did let go.
|
||||||
|
|
||||||
### save-where — the two-memory routing axis
|
### save-where — the two-memory routing axis
|
||||||
|
|
||||||
One discriminator: **does the loop evaluate a predicate against it?**
|
One discriminator: **does the loop evaluate a predicate against it?**
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ func (q *PendingQuestion) CanAsk() bool {
|
|||||||
|
|
||||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
// ClarifyStore holds the parked questions. Same shape and locking as
|
||||||
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
||||||
|
//
|
||||||
|
// Memory only, deliberately, unlike SessionStore — a restart expires every
|
||||||
|
// parked question and she does not announce that it happened (Vikunja #385,
|
||||||
|
// written down in docs/design.md). The 90s TTL and the attempt count measure a
|
||||||
|
// pause in one conversation, and a restart is a gap of unknown length, so a
|
||||||
|
// restored question would either be dead already or lying about its age. His
|
||||||
|
// next words route fresh, which is the right answer with or without a notice.
|
||||||
|
// Do not give this store a persister without re-arguing that.
|
||||||
type ClarifyStore struct {
|
type ClarifyStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
questions map[string]*PendingQuestion
|
questions map[string]*PendingQuestion
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// transientStems — the states a thing is in for an afternoon. Compared as
|
||||||
|
// prefixes because Russian inflects the ending: "медленн" covers "медленная",
|
||||||
|
// "медленный" and "медленно" without listing them.
|
||||||
|
var transientStems = []string{
|
||||||
|
"медленн", "тормоз", "лаг", "завис", "виснет", "глюч", "барахл",
|
||||||
|
"отвал", "падает", "упал", "сдох", "греется", "перегре",
|
||||||
|
"slow", "laggy", "stuck", "frozen", "flaky", "broken", "down",
|
||||||
|
}
|
||||||
|
|
||||||
|
// brokenVerbs — what "не ..." is denying when the sentence is a complaint.
|
||||||
|
// "не работает", "не грузит", "не открывается". Prefixes again.
|
||||||
|
var brokenVerbs = []string{
|
||||||
|
"работ", "пашет", "груз", "открыва", "включа", "коннект", "подключ",
|
||||||
|
"work", "load", "connect", "respond",
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfMarkers — the words that make a sentence about him rather than about a
|
||||||
|
// thing. Their presence turns the test off, because losing a fact he meant to
|
||||||
|
// store is worse than keeping a complaint: "я сломал руку" is durable, and
|
||||||
|
// "интернет не работает" is not.
|
||||||
|
var selfMarkers = []string{"я", "мне", "меня", "мной", "i", "me", "my"}
|
||||||
|
|
||||||
|
// IsTransientComplaint reports whether text observes a passing state of some
|
||||||
|
// thing rather than recording a fact.
|
||||||
|
//
|
||||||
|
// It exists because "сеть какая-то медленная" and "интернет не работает" were
|
||||||
|
// written to the fact store as `self` rows at confidence 1.00 (Vikunja #481),
|
||||||
|
// where recall reads them back later as if they were still true. A complaint
|
||||||
|
// describes a moment; the fact store describes him.
|
||||||
|
//
|
||||||
|
// Deterministic, offline, and shaped exactly like IsQuestionShaped: an
|
||||||
|
// explicit capture verb wins over everything, because "запомни что интернет
|
||||||
|
// не работает" is an instruction and not a passing remark. A first-person
|
||||||
|
// marker also turns it off — the test is meant to catch a sentence about a
|
||||||
|
// thing, and it errs toward storing.
|
||||||
|
func IsTransientComplaint(text string) bool {
|
||||||
|
t := strings.TrimSpace(text)
|
||||||
|
if t == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
toks := planTokens(strings.ToLower(t))
|
||||||
|
for _, v := range captureVerbs {
|
||||||
|
if hasTok(toks, v) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range selfMarkers {
|
||||||
|
if hasTok(toks, m) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tok := range toks {
|
||||||
|
for _, stem := range transientStems {
|
||||||
|
if strings.HasPrefix(tok, stem) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "не" plus a verb of working, in either order of the two tokens that
|
||||||
|
// follow it — "не работает" and "не очень работает" both deny the same
|
||||||
|
// thing.
|
||||||
|
for i, tok := range toks {
|
||||||
|
if tok != "не" && tok != "not" && tok != "isn" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := i + 1; j < len(toks) && j <= i+2; j++ {
|
||||||
|
for _, v := range brokenVerbs {
|
||||||
|
if strings.HasPrefix(toks[j], v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIsTransientComplaint(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// The two rows from the QA run that named this bug.
|
||||||
|
{"сеть какая-то медленная", true},
|
||||||
|
{"интернет не работает", true},
|
||||||
|
{"вайфай тормозит", true},
|
||||||
|
{"сервер завис", true},
|
||||||
|
{"the wifi is slow", true},
|
||||||
|
|
||||||
|
// An instruction wins: he asked for it to be written down.
|
||||||
|
{"запомни что интернет не работает", false},
|
||||||
|
{"запиши что сеть медленная", false},
|
||||||
|
|
||||||
|
// About him, so it stays a fact even when it sounds like a complaint.
|
||||||
|
{"я сломал руку", false},
|
||||||
|
{"мне медленно думается", false},
|
||||||
|
|
||||||
|
// Ordinary captures must not be touched.
|
||||||
|
{"поужинал", false},
|
||||||
|
{"выпил воды", false},
|
||||||
|
{"машина на парковке", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if got := IsTransientComplaint(tc.text); got != tc.want {
|
||||||
|
t.Errorf("IsTransientComplaint(%q) = %v, want %v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
// event, and the old rows would otherwise be recited as extra meetings.
|
// event, and the old rows would otherwise be recited as extra meetings.
|
||||||
// The filter is exact — it keeps any key whose summary part still has a
|
// The filter is exact — it keeps any key whose summary part still has a
|
||||||
// letter or a digit in it.
|
// letter or a digit in it.
|
||||||
|
|
||||||
|
// #19 — unstick the routines accepted before the fire-forever fix
|
||||||
|
// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
|
||||||
|
// NULL and a live one-shot reminder behind, and the tick loop skips a row
|
||||||
|
// with no accepted_ts, so every non-weekly routine accepted before that fix
|
||||||
|
// has been silent ever since.
|
||||||
|
//
|
||||||
|
// Three statements, in this order, per stuck row: adopt created_ts as the
|
||||||
|
// acceptance time, cancel the reminder that is still holding the schedule,
|
||||||
|
// then let go of it. Cancelling before clearing matters — clearing first
|
||||||
|
// loses the only pointer to the reminder and leaves it to fire on its own.
|
||||||
|
//
|
||||||
|
// created_ts rather than a fresh timestamp because a migration has no
|
||||||
|
// clock, and because the first interval should be measured from when he
|
||||||
|
// said yes. A routine whose interval has already elapsed nudges on the next
|
||||||
|
// tick, which is what being unstuck looks like.
|
||||||
|
//
|
||||||
|
// Weekly rows are included deliberately. Theirs was the case that kept
|
||||||
|
// working, because the cron reminder reschedules itself — so leaving them
|
||||||
|
// alone would give them both a cron reminder and a tick-loop schedule for
|
||||||
|
// one habit, and he would hear it twice.
|
||||||
|
`UPDATE reminders
|
||||||
|
SET status = 'cancelled'
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND id IN (SELECT reminder_id FROM proposed_routines
|
||||||
|
WHERE status = 'accepted' AND accepted_ts IS NULL AND reminder_id IS NOT NULL);
|
||||||
|
UPDATE proposed_routines
|
||||||
|
SET accepted_ts = created_ts, reminder_id = NULL
|
||||||
|
WHERE status = 'accepted' AND accepted_ts IS NULL;`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func userVersion(t *testing.T, s *Store) int {
|
func userVersion(t *testing.T, s *Store) int {
|
||||||
@@ -80,3 +81,69 @@ func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
|
|||||||
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
|
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStuckRoutinesAreBackfilled — routines accepted before the fire-forever
|
||||||
|
// fix have accepted_ts NULL and a live reminder, so the tick loop skips them
|
||||||
|
// and they have been silent ever since (Vikunja #377). The migration touches
|
||||||
|
// live reminders, which is why it is tested against a real store.
|
||||||
|
func TestStuckRoutinesAreBackfilled(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
created := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
rem, err := s.CreateReminder(ctx, created.Add(time.Hour), "полить цветы", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
healthy, err := s.CreateReminder(ctx, created.Add(2*time.Hour), "не трогать", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, reminder_id, accepted_ts)
|
||||||
|
VALUES ('water', 'plants', 7, 'accepted', ?, ?, NULL)`,
|
||||||
|
created.UnixMilli(), rem); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// An already-healthy accepted row, and a still-open proposal: neither is
|
||||||
|
// this migration's business.
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, accepted_ts)
|
||||||
|
VALUES ('feed', 'cat', 1, 'accepted', ?, ?)`,
|
||||||
|
created.UnixMilli(), created.UnixMilli()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.ExecContext(ctx, migrations[18]); err != nil {
|
||||||
|
t.Fatalf("migration 19: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted, err := s.ListAcceptedRoutines(ctx)
|
||||||
|
if err != nil || len(accepted) != 2 {
|
||||||
|
t.Fatalf("ListAcceptedRoutines = %d rows, err=%v, want 2", len(accepted), err)
|
||||||
|
}
|
||||||
|
stuck := accepted[0]
|
||||||
|
if stuck.Object != "plants" {
|
||||||
|
stuck = accepted[1]
|
||||||
|
}
|
||||||
|
if stuck.AcceptedTs == nil || !stuck.AcceptedTs.Equal(created) {
|
||||||
|
t.Fatalf("accepted_ts = %v, want the creation time", stuck.AcceptedTs)
|
||||||
|
}
|
||||||
|
if stuck.ReminderID != nil {
|
||||||
|
t.Fatalf("reminder_id = %v, want it let go", stuck.ReminderID)
|
||||||
|
}
|
||||||
|
// The reminder it was holding is cancelled, and nothing else is.
|
||||||
|
var status string
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, rem).Scan(&status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != ReminderCancelled {
|
||||||
|
t.Fatalf("linked reminder status = %q, want cancelled", status)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, healthy).Scan(&status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "pending" {
|
||||||
|
t.Fatalf("unrelated reminder status = %q, want it untouched", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user