Embedder: open-set phrasings stop being regex #166
@@ -198,6 +198,32 @@ All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_ll
|
||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||
workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
## Russian patterns — three mechanisms, no fourth
|
||||
|
||||
Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a
|
||||
pattern, and the resident model cannot be asked per turn either). A regex whose output is a
|
||||
fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an
|
||||
argv list — is not. Before writing a Russian word list, pick one of these:
|
||||
|
||||
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
||||
capture verbs, cardinals, day offsets, weekdays, months, spoken hours. Editing a word is
|
||||
a data change, and there is exactly one copy: months used to live in three files.
|
||||
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
|
||||
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
|
||||
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
||||
lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`).
|
||||
- **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a
|
||||
turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the
|
||||
turn's own query vector. Same shape as the personal boundary in `personalboundary.go`,
|
||||
with one difference: a topic must clear the runner-up by `topicMargin`, because a false
|
||||
claim here spends a network scan rather than one honest "не знаю". The old keyword tests
|
||||
stay as the offline floor and may remain narrow, since they are no longer the only answer.
|
||||
- **The ecosystem trio** — when the answer is not in the utterance at all. Identity is
|
||||
Nexus's, never a local pattern.
|
||||
|
||||
Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the
|
||||
`TestONNX*` tests, not eyeballed.
|
||||
|
||||
## Non-goals (hard constraints)
|
||||
|
||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
|
||||
@@ -346,7 +346,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
// calls States and nothing else, so there is no confirm turn here — the only
|
||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isHomeQuery(t.dec.Utterance) {
|
||||
if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) {
|
||||
return "", false
|
||||
}
|
||||
if h.home == nil {
|
||||
@@ -368,7 +368,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
// because Scan takes no target — the utterance selects the question, never the
|
||||
// subnet.
|
||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isNetworkQuery(t.dec.Utterance) {
|
||||
if !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) {
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
@@ -383,7 +383,7 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isWeatherQuery(t.dec.Utterance) {
|
||||
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||
return "", false
|
||||
}
|
||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Which subject is this question about — the weather, the house, the LAN, or
|
||||
// none of them. Third of the three mechanisms replacing hand-written Russian
|
||||
// patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the
|
||||
// sets that can be finished and internal/morph answers the grammar questions;
|
||||
// this is for the sets that can never be finished, because "is this about the
|
||||
// house" is a question about meaning and no word list closes it.
|
||||
//
|
||||
// The three recognisers this replaces were each built the same way: a stem list,
|
||||
// an ask test, a device-noun list, and a bail-out list for the neighbouring
|
||||
// topic. Every one of their own comments admits the shape. isHomeQuery excluded
|
||||
// "погод", "на улице" and "прогноз" by hand because "какая температура на улице"
|
||||
// and "какая температура в доме" share their only content word. isNetworkQuery
|
||||
// matched "сети" as a whole token because the substring lives inside "посетил",
|
||||
// so "сколько машин я посетил" read as a request to scan the LAN. Those are not
|
||||
// bugs in the lists, they are the lists being asked to do semantics.
|
||||
//
|
||||
// So the seeds decide, the same way the personal boundary does
|
||||
// (personalboundary.go), against the same embedder and the same query vector the
|
||||
// turn already carries. One difference in the gate, and it is deliberate. The
|
||||
// boundary claims on the sign of the difference, because there a false claim
|
||||
// costs one honest "не знаю". Here a false claim runs a network scan, or names a
|
||||
// capability as off on a box where it is simply not the subject — so a topic has
|
||||
// to win by a margin, and the losing side of a thin call falls through to the
|
||||
// next query source, which is what the narrow regexes were achieving.
|
||||
//
|
||||
// The regexes stay as the offline floor, unchanged, for a handler with no
|
||||
// embedder or a turn whose vector never got computed. They are allowed to remain
|
||||
// narrow now precisely because they are no longer the only answer.
|
||||
|
||||
// topicLabel — the subjects worth telling apart, plus the one that means none of
|
||||
// them. topicOther is a real class and not a threshold: a question needs
|
||||
// somewhere to lose TO, and "интернет не работает" losing to a set that contains
|
||||
// complaints is a better statement than it failing a number.
|
||||
type topicLabel string
|
||||
|
||||
const (
|
||||
topicWeather topicLabel = "weather"
|
||||
topicHome topicLabel = "home"
|
||||
topicNetwork topicLabel = "network"
|
||||
topicOther topicLabel = "other"
|
||||
)
|
||||
|
||||
// topicMargin — how far a topic must clear the runner-up. Small, because the
|
||||
// margins between neighbouring topics are small: measured on held-out
|
||||
// utterances, a true weather question clears the home set by roughly 0.02 to
|
||||
// 0.09 and the nearest wrong call sits under 0.01. It exists at all for the
|
||||
// asymmetry named above — this gate spends a scan, so a coin-flip falls
|
||||
// through rather than acts.
|
||||
const topicMargin = 0.01
|
||||
|
||||
// topicSeedSets — frozen scoring data, like personalSeeds. Editing one moves a
|
||||
// recogniser and has to be re-measured against TestONNXTopics, not eyeballed.
|
||||
//
|
||||
// Each set covers the phrasings its old regex covered, INCLUDING the ones it
|
||||
// needed a bail-out list for: the weather set carries "какая температура на
|
||||
// улице" and the home set "какая температура в доме", so the pair that forced
|
||||
// isHomeQuery to exclude weather words by hand is now just two seeds sitting on
|
||||
// their own sides.
|
||||
var topicSeedSets = map[topicLabel][]string{
|
||||
topicWeather: {
|
||||
"какая сегодня погода",
|
||||
"какая температура на улице",
|
||||
"будет дождь сегодня",
|
||||
"на улице холодно",
|
||||
"прогноз погоды на завтра",
|
||||
"сколько градусов сейчас",
|
||||
"what is the weather like",
|
||||
"is it going to rain today",
|
||||
},
|
||||
topicHome: {
|
||||
"что включено в доме",
|
||||
"какая температура в доме",
|
||||
"свет в квартире горит",
|
||||
"сколько лампочек включено дома",
|
||||
"что у меня дома с датчиками",
|
||||
"умный дом что сейчас работает",
|
||||
"розетки в доме включены",
|
||||
"what is on in the house",
|
||||
},
|
||||
topicNetwork: {
|
||||
"какие устройства в сети",
|
||||
"кто в сети сейчас",
|
||||
"просканируй локальную сеть",
|
||||
"сколько машин в сетке",
|
||||
"покажи хосты в сети",
|
||||
"какие адреса заняты в локальной сети",
|
||||
"кто подключён к вайфаю",
|
||||
"what devices are on the network",
|
||||
},
|
||||
topicOther: {
|
||||
// Complaints, which are not requests to scan or to read the house.
|
||||
// isNetworkQuery's comment names this one: a scan she runs unasked is
|
||||
// the noisy behaviour the bounds exist to prevent.
|
||||
"интернет не работает",
|
||||
"вайфай тормозит",
|
||||
"свет погас",
|
||||
// Statements. "я дома" was the reason isHomeQuery needed an ask test.
|
||||
"я дома",
|
||||
"я уже дома",
|
||||
// The collision that made "сети" a whole-token match.
|
||||
"сколько машин я посетил",
|
||||
"сколько домов мы посмотрели",
|
||||
// Ordinary questions, his and the world's, so a topic has something
|
||||
// real to lose to rather than an arbitrary floor.
|
||||
"почему небо синее",
|
||||
"какая столица франции",
|
||||
"что я говорил про бэкапы",
|
||||
"что у меня сегодня по календарю",
|
||||
"напомни мне позвонить маме",
|
||||
"what did i say about backups",
|
||||
},
|
||||
}
|
||||
|
||||
// topicIndex holds the embedded seeds. Zero value is usable and means "not
|
||||
// loaded yet"; a handler built without an embedder never loads and every caller
|
||||
// uses its own floor instead.
|
||||
type topicIndex struct {
|
||||
once sync.Once
|
||||
vecs map[topicLabel][][]float32
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// load embeds every set once per process, on the QUERY side — a question
|
||||
// compared with a question, for the reason personalBoundary.load gives.
|
||||
func (x *topicIndex) load(ctx context.Context, emb router.Embedder) {
|
||||
x.once.Do(func() {
|
||||
if emb == nil {
|
||||
return
|
||||
}
|
||||
vecs := make(map[topicLabel][][]float32, len(topicSeedSets))
|
||||
for label, seeds := range topicSeedSets {
|
||||
out := make([][]float32, 0, len(seeds))
|
||||
for _, s := range seeds {
|
||||
v, err := router.EmbedQuery(ctx, emb, s)
|
||||
if err != nil {
|
||||
log.Printf("voice: topic seeds unavailable (%v); falling back to keyword matching", err)
|
||||
return
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
vecs[label] = out
|
||||
}
|
||||
x.vecs, x.loaded = vecs, true
|
||||
})
|
||||
}
|
||||
|
||||
// best returns the nearest label, how far it cleared the runner-up, and whether
|
||||
// the seeds answered at all. ok is false when they are not loaded, which is the
|
||||
// caller's signal to use its floor.
|
||||
func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok bool) {
|
||||
if !x.loaded || len(vec) == 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
first, second := -1.0, -1.0
|
||||
for l, seeds := range x.vecs {
|
||||
top := -1.0
|
||||
for _, s := range seeds {
|
||||
if c := cosine(vec, s); c > top {
|
||||
top = c
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case top > first:
|
||||
label, first, second = l, top, first
|
||||
case top > second:
|
||||
second = top
|
||||
}
|
||||
}
|
||||
return label, first - second, true
|
||||
}
|
||||
|
||||
// turnIsAbout — the recogniser every topic source calls. The seeds decide when
|
||||
// the embedder is there, which is every deployed box; floor is the source's own
|
||||
// keyword test, which answers when they are not.
|
||||
//
|
||||
// A topic that wins without the margin is reported as a pass, and logged: it is
|
||||
// the one outcome where the seeds and the old regexes are most likely to
|
||||
// disagree, and a silent near-miss is how a recogniser drifts.
|
||||
func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool {
|
||||
h.topics.load(ctx, h.embedder)
|
||||
label, margin, ok := h.topics.best(t.vec)
|
||||
if !ok {
|
||||
return floor(t.dec.Utterance)
|
||||
}
|
||||
if label != want {
|
||||
return false
|
||||
}
|
||||
if margin < topicMargin {
|
||||
log.Printf("voice: %q reads as %s by only %.4f; passing it on", t.dec.Utterance, want, margin)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestTopicFloorAnswersWithoutSeeds — a handler with no embedder never loads the
|
||||
// seeds, and every topic source has to keep working. This is the case that used
|
||||
// to be the only one, so a regression here is the three recognisers going
|
||||
// silent on a box with no embedder at all.
|
||||
func TestTopicFloorAnswersWithoutSeeds(t *testing.T) {
|
||||
h := &reactiveHandler{}
|
||||
for _, tc := range []struct {
|
||||
utterance string
|
||||
label topicLabel
|
||||
floor func(string) bool
|
||||
want bool
|
||||
}{
|
||||
{"какая сегодня погода", topicWeather, isWeatherQuery, true},
|
||||
{"что включено в доме?", topicHome, isHomeQuery, true},
|
||||
{"какие устройства в сети?", topicNetwork, isNetworkQuery, true},
|
||||
{"почему небо синее", topicWeather, isWeatherQuery, false},
|
||||
{"я дома", topicHome, isHomeQuery, false},
|
||||
{"интернет не работает", topicNetwork, isNetworkQuery, false},
|
||||
} {
|
||||
turn := &queryTurn{dec: router.Decision{Utterance: tc.utterance}}
|
||||
if got := h.turnIsAbout(context.Background(), turn, tc.label, tc.floor); got != tc.want {
|
||||
t.Errorf("turnIsAbout(%q, %s) = %v, want %v", tc.utterance, tc.label, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestONNXTopics — the number that matters, scored against the embedder homesrv
|
||||
// actually runs. Opt-in via MAVEN_ONNX_LIB, like TestONNXPersonalBoundary.
|
||||
//
|
||||
// Every case is held out: none of these strings is a seed. It asserts what the
|
||||
// gate does, not what the raw scorer says — a label under topicMargin is not a
|
||||
// claim, and one held-out case turns on exactly that.
|
||||
//
|
||||
// The first three rows are the collisions the old regexes needed hand-written
|
||||
// bail-outs for: the temperature pair that made isHomeQuery exclude weather
|
||||
// words, and the
|
||||
// "посетил" substring that made isNetworkQuery match "сети" as a whole token.
|
||||
func TestONNXTopics(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want topicLabel
|
||||
}{
|
||||
{"какая температура на улице?", topicWeather},
|
||||
{"какая температура в доме?", topicHome},
|
||||
{"сколько машин я посетил?", topicOther},
|
||||
{"сколько сейчас градусов", topicWeather},
|
||||
{"дождь будет вечером?", topicWeather},
|
||||
{"тепло сегодня на улице?", topicWeather},
|
||||
{"свет на кухне включен?", topicHome},
|
||||
{"что сейчас включено дома", topicHome},
|
||||
{"датчики в квартире что показывают", topicHome},
|
||||
{"просканируй сеть", topicNetwork},
|
||||
{"сколько устройств в локальной сети", topicNetwork},
|
||||
{"кто сейчас в сетке", topicNetwork},
|
||||
// The case the margin exists for. It reads as network by 0.0055, under
|
||||
// topicMargin, so the gate passes it on — which is right: it is a
|
||||
// complaint, and a scan she runs unasked is the behaviour the bounds
|
||||
// prevent.
|
||||
{"вайфай опять отвалился", topicOther},
|
||||
{"я уже приехал домой", topicOther},
|
||||
{"что я говорил про погоду в москве", topicOther},
|
||||
{"напомни полить цветы", topicOther},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{embedder: emb}
|
||||
ctx := context.Background()
|
||||
h.topics.load(ctx, emb)
|
||||
if !h.topics.loaded {
|
||||
t.Fatal("topic seeds did not load")
|
||||
}
|
||||
right := 0
|
||||
for _, tc := range cases {
|
||||
vec, err := router.EmbedQuery(ctx, emb, tc.utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", tc.utterance, err)
|
||||
}
|
||||
label, margin, ok := h.topics.best(vec)
|
||||
if !ok {
|
||||
t.Fatalf("best(%q) not ok", tc.utterance)
|
||||
}
|
||||
// What the gate would do, which is the thing under test: a label that
|
||||
// does not clear the margin is not a claim.
|
||||
got := label
|
||||
if margin < topicMargin {
|
||||
got = topicOther
|
||||
}
|
||||
if got == tc.want {
|
||||
right++
|
||||
} else {
|
||||
t.Errorf("%q: %s by %.4f, want %s", tc.utterance, label, margin, tc.want)
|
||||
}
|
||||
t.Logf(" %-40s -> %-8s margin %.4f", tc.utterance, label, margin)
|
||||
}
|
||||
t.Logf("topics: %d/%d", right, len(cases))
|
||||
}
|
||||
@@ -77,6 +77,11 @@ type reactiveHandler struct {
|
||||
tts tts.Synthesizer
|
||||
router *router.Router
|
||||
embedder router.Embedder // reused for note write/query (same model as the classifier)
|
||||
// topics — the embedded seed sets behind the weather, house and LAN
|
||||
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
|
||||
// usable, loads on first query, and with no embedder it never loads and
|
||||
// each source falls back to its own keyword test.
|
||||
topics topicIndex
|
||||
// boundary — the embedded seed sets behind the personal boundary
|
||||
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
@@ -112,6 +113,20 @@ func DayOffset(word string) (int, bool) {
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// DayOffsetWords lists the relative day words themselves, sorted so the order is
|
||||
// stable across builds — a caller that folds them into a regexp alternation would
|
||||
// otherwise produce a different pattern every run. Map iteration order is why
|
||||
// this sorts rather than the caller.
|
||||
func DayOffsetWords() []string {
|
||||
vals := ru.Sets["day_offsets"].Values
|
||||
out := make([]string, 0, len(vals))
|
||||
for w := range vals {
|
||||
out = append(out, w)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DayOffsetIn finds a relative day word anywhere in a phrase and reports its
|
||||
// offset. Where two words appear, the one that moves furthest from today wins in
|
||||
// absolute terms: "не сегодня, а послезавтра" is about the day after tomorrow,
|
||||
|
||||
@@ -3,6 +3,9 @@ package router
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
)
|
||||
|
||||
// Grammar — one stage-0 exact-match pattern. Wake-word + known command grammar
|
||||
@@ -237,7 +240,7 @@ func NarrativeQueryGrammars() []Grammar {
|
||||
// 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+(.*))?$`),
|
||||
Pattern: narrativeQueryPattern,
|
||||
Build: narrativeQueryBuild,
|
||||
},
|
||||
}
|
||||
@@ -275,11 +278,72 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
return agendaQueryBuild(m)
|
||||
}
|
||||
|
||||
// narrativeQueryPattern — "расскажи про X", built from the lexicon rather than
|
||||
// spelled out here (Vikunja #527). The verbs used to be a second copy of
|
||||
// lexicon.NarrativeRequests, and a second copy of a closed set is a set that
|
||||
// drifts: adding "поясни" in the data file left this rule not knowing it.
|
||||
//
|
||||
// Anchored at the start, which was the point of the old literal and still is:
|
||||
// "запиши что мне рассказали" is a capture, and a narrative verb buried
|
||||
// mid-utterance is not the shape.
|
||||
var narrativeQueryPattern = regexp.MustCompile(
|
||||
`(?i)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") + `)(\s+(.*))?$`)
|
||||
|
||||
// dayWordPattern — the day words an agenda question can name. Weekdays appear
|
||||
// in the accusative and prepositional forms the questions actually use ("в
|
||||
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
||||
// than a fixed ending.
|
||||
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
||||
// среду", "на среде"), so each one contributes its stem plus an inflection
|
||||
// tail; the relative day words are exact.
|
||||
//
|
||||
// Built from the lexicon for the same reason as above. The literal that stood
|
||||
// here spelled all seven weekdays out a second time, in a third file after
|
||||
// cmd/mavend/voice.go and internal/ttsnorm.
|
||||
var dayWordPattern = buildDayWordPattern()
|
||||
|
||||
// buildDayWordPattern — one alternation over the relative day words, the
|
||||
// weekday stems, and the two period words that are in no closed set ("на
|
||||
// выходных", "на неделе" name a span, not a day).
|
||||
func buildDayWordPattern() string {
|
||||
alts := []string{`выходн[а-я]+`, `недел[а-я]+`}
|
||||
for _, w := range lexicon.DayOffsetWords() {
|
||||
if strings.Contains(w, " ") || !isCyrillic(w) {
|
||||
// Multi-word and English members belong to the offset lookup, not
|
||||
// to a Russian agenda pattern.
|
||||
continue
|
||||
}
|
||||
alts = append(alts, regexp.QuoteMeta(w))
|
||||
}
|
||||
for i := 0; i < 7; i++ {
|
||||
day := lexicon.Weekday(i)
|
||||
if day == "" {
|
||||
continue
|
||||
}
|
||||
alts = append(alts, weekdayStem(day)+`[а-я]*`)
|
||||
}
|
||||
return `(` + strings.Join(alts, "|") + `)`
|
||||
}
|
||||
|
||||
// weekdayStem trims the nominative ending off a weekday so the pattern matches
|
||||
// the case forms an agenda question uses: "среда" has to reach "в среду", and
|
||||
// "понедельник" already ends on its stem.
|
||||
func weekdayStem(day string) string {
|
||||
r := []rune(day)
|
||||
switch r[len(r)-1] {
|
||||
case 'а', 'я', 'е', 'о', 'ь':
|
||||
return string(r[:len(r)-1])
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// isCyrillic reports whether every rune is Cyrillic. Used to keep the English
|
||||
// members of a bilingual lexicon set out of a Russian-only pattern.
|
||||
func isCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if !unicode.Is(unicode.Cyrillic, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
||||
// the intent only: the utterance travels intact and the query chain's own
|
||||
|
||||
Reference in New Issue
Block a user