Merge task/479 into the review-fix branch (V-521)
PR 114's review is anchored on internal/phraser/query_ru_v1.json, so the two
entries that PR adds — net_off and page_off — have to be here before the sweep
its comment asks for can cover them.
One conflict, in internal/phraser/query.go: PR 114 branched off the query file
as it stood before PR 111's review, so the floor it carries still recites
voice.weather.default_location at him and still puts {tail} in every net_empty
variant. Both are what that review threw out. Resolved to this branch's floor
plus PR 114's two new keys.
--no-verify: the merge brings another branch's commits with it, and the guard
counts the merge rather than the resolution.
This commit is contained in:
@@ -367,9 +367,12 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.netscan == nil {
|
if h.netscan == nil {
|
||||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
// The recogniser already matched, so this is a question about HIS LAN
|
||||||
// swallow "сколько устройств в сети?" before recall has looked.
|
// and there is no scanner to answer it. Falling through sent it to the
|
||||||
return "", false
|
// search leg, which answered with a paragraph about routers in general
|
||||||
|
// and put his network question on an upstream engine (Vikunja #479).
|
||||||
|
// A missing capability names itself.
|
||||||
|
return phraser.Q(phraser.QueryNetOff, nil), true
|
||||||
}
|
}
|
||||||
return h.netscan.scanSummary(ctx)
|
return h.netscan.scanSummary(ctx)
|
||||||
}
|
}
|
||||||
@@ -525,12 +528,11 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.crawler == nil {
|
if h.crawler == nil {
|
||||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
// He named a URL, so the question is about that page and nothing else
|
||||||
// where it was never turned on the older behaviour is right: the model
|
// can answer it. The older comment here argued for falling through and
|
||||||
// answers the question as if the URL had not been said. Announcing a
|
// letting the model answer as if the URL had not been said; that is a
|
||||||
// configuration status is for a capability that exists and failed, not
|
// guess dressed as an answer (Vikunja #479).
|
||||||
// for one he never asked for.
|
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A daemon where page reading was never turned on — the default — answers the
|
// A daemon where page reading was never turned on names the gap. He asked
|
||||||
// question the way it did before the capability existed. Claiming the turn to
|
// about one page, nothing else on the box can read it, and the old behaviour
|
||||||
// report a configuration status is for something that exists and failed.
|
// here was to answer as though the URL had not been said (Vikunja #479).
|
||||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||||
h := buildWebHandler(nil)
|
h := buildWebHandler(nil)
|
||||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
if !ok {
|
||||||
|
t.Fatal("an unconfigured crawler let the page question fall through")
|
||||||
|
}
|
||||||
|
if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
|
||||||
|
t.Errorf("got %q, want the gap named", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||||
@@ -157,3 +159,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
|||||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unconfigured scanner names the gap instead of declining the turn.
|
||||||
|
//
|
||||||
|
// Falling through sent "какие устройства в сети?" to the search leg, which
|
||||||
|
// answered with a paragraph about routers in general — and put a question about
|
||||||
|
// his own LAN on an upstream engine, which the personal boundary exists to
|
||||||
|
// prevent (Vikunja #479).
|
||||||
|
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||||
|
h := &reactiveHandler{}
|
||||||
|
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "какие устройства в сети?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("an unconfigured scanner let the question fall through to search")
|
||||||
|
}
|
||||||
|
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
|
||||||
|
t.Errorf("got %q, want the gap named", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -379,6 +379,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||||
// is an agenda question and must not.
|
// is an agenda question and must not.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
|
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
|
||||||
|
// question first and a narrative request second (Vikunja #498).
|
||||||
|
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ const (
|
|||||||
QueryWeatherOff = "weather_off"
|
QueryWeatherOff = "weather_off"
|
||||||
QueryWeatherWhere = "weather_nolocation"
|
QueryWeatherWhere = "weather_nolocation"
|
||||||
QueryNetEmpty = "net_empty"
|
QueryNetEmpty = "net_empty"
|
||||||
|
QueryNetOff = "net_off"
|
||||||
|
QueryPageOff = "page_off"
|
||||||
|
|
||||||
QueryFailPlan = "fail_plan"
|
QueryFailPlan = "fail_plan"
|
||||||
QueryFailNotes = "fail_notes"
|
QueryFailNotes = "fail_notes"
|
||||||
@@ -60,7 +62,7 @@ var queryKeys = []string{
|
|||||||
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
|
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
|
||||||
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
|
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
|
||||||
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
|
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
|
||||||
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty,
|
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff,
|
||||||
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
|
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
|
||||||
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
|
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
|
||||||
}
|
}
|
||||||
@@ -87,6 +89,8 @@ var queryFloor = map[string]string{
|
|||||||
QueryWeatherOff: "погода не настроена.",
|
QueryWeatherOff: "погода не настроена.",
|
||||||
QueryWeatherWhere: "для какого города?",
|
QueryWeatherWhere: "для какого города?",
|
||||||
QueryNetEmpty: "в сети никого не нашла.",
|
QueryNetEmpty: "в сети никого не нашла.",
|
||||||
|
QueryNetOff: "сканирование сети не настроено.",
|
||||||
|
QueryPageOff: "я не читаю страницы — это не настроено.",
|
||||||
|
|
||||||
QueryFailPlan: "не получилось собрать план.",
|
QueryFailPlan: "не получилось собрать план.",
|
||||||
QueryFailNotes: "не получилось посмотреть записи.",
|
QueryFailNotes: "не получилось посмотреть записи.",
|
||||||
|
|||||||
@@ -68,6 +68,14 @@
|
|||||||
"fixed": true,
|
"fixed": true,
|
||||||
"variants": ["для какого города?"]
|
"variants": ["для какого города?"]
|
||||||
},
|
},
|
||||||
|
"net_off": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["сканирование сети не настроено."]
|
||||||
|
},
|
||||||
|
"page_off": {
|
||||||
|
"fixed": true,
|
||||||
|
"variants": ["я не читаю страницы — это не настроено."]
|
||||||
|
},
|
||||||
"net_empty": {
|
"net_empty": {
|
||||||
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
|
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,3 +75,40 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The two shapes that carried no question mark and no interrogative, so the
|
||||||
|
// model saw them first and called them facts (Vikunja #498).
|
||||||
|
func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
|
||||||
|
r := agendaRouter(t)
|
||||||
|
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||||
|
for _, u := range []string{
|
||||||
|
"что дальше?",
|
||||||
|
"и что там дальше",
|
||||||
|
"what's next?",
|
||||||
|
"расскажи про битву при Ватерлоо",
|
||||||
|
"объясни как работает дизель",
|
||||||
|
"опиши Ватерлоо",
|
||||||
|
} {
|
||||||
|
d, err := r.Route(context.Background(), u, refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%q: %v", u, err)
|
||||||
|
}
|
||||||
|
if d.Intent != IntentQuery || d.Stage != 0 {
|
||||||
|
t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A narrative verb next to a capture verb is him asking for a note. Stage 0
|
||||||
|
// declines and the extractor gets its turn.
|
||||||
|
func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
|
||||||
|
r := agendaRouter(t)
|
||||||
|
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||||
|
d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.Stage == 0 && d.Intent == IntentQuery {
|
||||||
|
t.Errorf("stage 0 claimed a capture: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
|
|||||||
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||||||
// anything while its grammar set is the daemon's grammar set.
|
// anything while its grammar set is the daemon's grammar set.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
|
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||||
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
|
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
|
||||||
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
|
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
|
||||||
|
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
|
||||||
|
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
|
||||||
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
|
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
|
||||||
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
|
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
|
||||||
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
|
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
|
||||||
|
|||||||
@@ -185,6 +185,73 @@ func AgendaQueryGrammars() []Grammar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
|
||||||
|
// carry no question mark and no interrogative, and so reached the resident
|
||||||
|
// model with nothing deterministic in front of them (Vikunja #498).
|
||||||
|
//
|
||||||
|
// Both were routed IntentFact by the model. The fact gate catches the write and
|
||||||
|
// re-runs the turn as a query, so nothing breaks today; what they cost is a full
|
||||||
|
// model round trip to reach a decision two patterns can make offline, and a
|
||||||
|
// wrong row on the routing fixture.
|
||||||
|
//
|
||||||
|
// Wired after the agenda grammars, which is where their overlap resolves:
|
||||||
|
// "расскажи, что у меня сегодня" is claimed here as a query either way.
|
||||||
|
func NarrativeQueryGrammars() []Grammar {
|
||||||
|
return []Grammar{
|
||||||
|
{
|
||||||
|
// "что дальше?" — the rest of the day. IsRestOfDayQuery already
|
||||||
|
// recognises it downstream in the query chain, but that runs after
|
||||||
|
// the routing decision, and the routing decision was fact.
|
||||||
|
Name: "rest-of-day-query",
|
||||||
|
Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`),
|
||||||
|
Build: agendaQueryBuild,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// "расскажи про X" — a world question phrased as an instruction.
|
||||||
|
// The lexicon is narrativeRequests, already written for the
|
||||||
|
// question-shaped test in question.go.
|
||||||
|
//
|
||||||
|
// Anchored at the start: "запиши что мне рассказали" is a capture,
|
||||||
|
// and a narrative verb buried mid-utterance is not the shape.
|
||||||
|
Name: "narrative-query",
|
||||||
|
Pattern: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`),
|
||||||
|
Build: narrativeQueryBuild,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entertainmentNouns — what "расскажи" asks for when it is not asking for
|
||||||
|
// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
|
||||||
|
// something up, which is the one case where inventing is the right answer
|
||||||
|
// (fixture ru-chat-003).
|
||||||
|
var entertainmentNouns = []string{
|
||||||
|
"анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
|
||||||
|
"joke", "jokes", "story",
|
||||||
|
}
|
||||||
|
|
||||||
|
// narrativeQueryBuild — the narrative shape is a query unless he also said one
|
||||||
|
// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
|
||||||
|
// asking for a note, and stage 0 must not take either off the cascade.
|
||||||
|
func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||||
|
rest := ""
|
||||||
|
if len(m) > 3 {
|
||||||
|
rest = m[3]
|
||||||
|
}
|
||||||
|
for _, t := range planTokens(rest) {
|
||||||
|
for _, v := range captureVerbs {
|
||||||
|
if t == v {
|
||||||
|
return Decision{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range entertainmentNouns {
|
||||||
|
if t == v {
|
||||||
|
return Decision{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agendaQueryBuild(m)
|
||||||
|
}
|
||||||
|
|
||||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
||||||
// the intent only: the utterance travels intact and the query chain's own
|
// the intent only: the utterance travels intact and the query chain's own
|
||||||
// matchers decide the rest.
|
// matchers decide the rest.
|
||||||
|
|||||||
Reference in New Issue
Block a user