Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 765ed36340 | |||
| feca776077 | |||
| d79b30a1a6 | |||
| 16d94894b7 | |||
| ae8d38fc31 |
+33
-28
@@ -147,9 +147,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// The previous question cannot be re-asked for another day. Saying so
|
||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||
// truth is that she never looked.
|
||||
return "про другой день так не отвечу — спроси целиком."
|
||||
return phraser.Q(phraser.QueryOtherDay, nil)
|
||||
}
|
||||
return "не знаю."
|
||||
return phraser.Q(phraser.QueryUnknown, nil)
|
||||
}
|
||||
|
||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||
@@ -167,11 +167,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
if dec.Slots.HasTime {
|
||||
// The query asks about timing — the fact's own timestamp is the
|
||||
// answer it's looking for. Format as a natural reply.
|
||||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
||||
return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
||||
}
|
||||
// General fact reference: describe what we know.
|
||||
if dec.Utterance == "" {
|
||||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
||||
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
|
||||
}
|
||||
// The utterance still carries the question; fall through to normal RAG
|
||||
// with the resolved key in context.
|
||||
@@ -197,7 +197,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
plan, err := h.api.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
return phraser.Q(phraser.QueryFailPlan, nil), true
|
||||
}
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
@@ -242,7 +242,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
return phraser.Q(phraser.QueryFailNotes, nil), true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
@@ -281,7 +281,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
||||
}
|
||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||
// used to push the newest headline out of the window, and she answered "в
|
||||
@@ -289,7 +289,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return "не получилось посмотреть ленты.", true
|
||||
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
@@ -306,11 +306,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
return "по этой теме в лентах пока ничего.", true
|
||||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||
}
|
||||
return "в лентах пока ничего нового.", true
|
||||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||
}
|
||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
|
||||
}
|
||||
|
||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||
@@ -324,7 +324,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
log.Printf("voice: calendar events: %v", err)
|
||||
return "не получилось проверить календарь.", true
|
||||
return phraser.Q(phraser.QueryFailCalendar, nil), true
|
||||
}
|
||||
// Provenance travels with each event. A work meeting relayed off a phone
|
||||
// notification (source ambient:notif, #126) is stored below full confidence
|
||||
@@ -382,19 +382,24 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
if loc == "" {
|
||||
// He named no city and voice.weather.default_location is unset. Saying
|
||||
// so is the only honest answer; picking a city would be inventing one.
|
||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
return "погода не настроена.", true
|
||||
return phraser.Q(phraser.QueryWeatherOff, nil), true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("voice: weather: %v", err)
|
||||
return "не получилось узнать погоду.", true
|
||||
return phraser.Q(phraser.QueryFailWeather, nil), true
|
||||
}
|
||||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
||||
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||||
"location": w.Location,
|
||||
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||||
"word": phraser.Degrees(w.Temperature),
|
||||
"condition": w.Condition,
|
||||
}), true
|
||||
}
|
||||
|
||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||
@@ -404,7 +409,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
}
|
||||
t.vec = vec
|
||||
return "", false
|
||||
@@ -473,7 +478,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||
if err != nil {
|
||||
log.Printf("voice: query notes: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
}
|
||||
t.notes = notes
|
||||
noteScores := make([]float64, len(notes))
|
||||
@@ -498,7 +503,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: phrase query: %v", err)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = "вот что я нашла: " + texts[0]
|
||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -532,13 +537,13 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
if errors.Is(err, crawl.ErrRobots) {
|
||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
||||
}
|
||||
log.Printf("voice: web: %v", err)
|
||||
return "не получилось прочитать страницу.", true
|
||||
return phraser.Q(phraser.QueryFailPage, nil), true
|
||||
}
|
||||
if page.Text == "" {
|
||||
return "страница открылась, но читать там нечего.", true
|
||||
return phraser.Q(phraser.QueryPageEmpty, nil), true
|
||||
}
|
||||
// The page is handed to the phraser the same way a note is: as context for
|
||||
// the question he actually asked. She answers the question, she does not
|
||||
@@ -548,7 +553,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -614,7 +619,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// pretend the search did not happen.
|
||||
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -695,7 +700,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// the search did not happen.
|
||||
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -725,7 +730,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri
|
||||
return "", false
|
||||
}
|
||||
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
||||
return "не знаю — не нашла у тебя такой записи.", true
|
||||
return phraser.Q(phraser.QueryPersonalNone, nil), true
|
||||
}
|
||||
|
||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||
@@ -793,7 +798,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
|
||||
if h.phraser == nil {
|
||||
// No model of any size. That is not the workstation being asleep, so it
|
||||
// is not that gap: it is simply not knowing.
|
||||
return "не знаю.", true
|
||||
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||
}
|
||||
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||
@@ -801,7 +806,7 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
|
||||
return worldGap(), true
|
||||
}
|
||||
if err != nil || reply == "" {
|
||||
return "не знаю.", true
|
||||
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
|
||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||
}
|
||||
if reply != "не получилось собрать план." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
||||
t.Errorf("reply = %q, want the honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||
if !strings.Contains(reply, "ничего") {
|
||||
if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
|
||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||
}
|
||||
}
|
||||
@@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||
// answered by the model inventing a bulletin.
|
||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||
// Against the entries, not against a substring: both of these have several
|
||||
// wordings, so "ничего нового" passed only on the turns the picker happened
|
||||
// to choose the first one.
|
||||
off := buildFeedHandler(t, false)
|
||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "не настроены") {
|
||||
if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
on := buildFeedHandler(t, true)
|
||||
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||
if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
|
||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||
t.Fatalf("an empty feed answered as an unconfigured one: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||
|
||||
+5
-20
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||
@@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
return "не получилось просканировать сеть.", true
|
||||
return phraser.Q(phraser.QueryFailNetscan, nil), true
|
||||
}
|
||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||
// устройств" after stopping two thirds of the way through the range is a
|
||||
@@ -116,9 +117,9 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
if len(res.Hosts) == 0 {
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
out += ", " + shape
|
||||
}
|
||||
@@ -179,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
if w.api == nil {
|
||||
return
|
||||
}
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||
if res.Truncated {
|
||||
head += " (не вся сеть)"
|
||||
}
|
||||
@@ -210,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
}
|
||||
}
|
||||
|
||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||
// 5+ устройств, and the teens are all the last form.
|
||||
func hostWord(n int) string {
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return "устройств"
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return "устройство"
|
||||
case 2, 3, 4:
|
||||
return "устройства"
|
||||
default:
|
||||
return "устройств"
|
||||
}
|
||||
}
|
||||
|
||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||
|
||||
@@ -74,18 +74,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := hostWord(n); got != want {
|
||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNetworkQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие устройства в сети?",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -185,7 +186,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
case dark > 0 && len(sensors) == 0:
|
||||
// Nothing is on and everything she can see is unreachable. "всё
|
||||
// выключено" would be a claim about the house she cannot make.
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, phraser.Devices(dark)), true
|
||||
default:
|
||||
parts = append(parts, "всё выключено")
|
||||
}
|
||||
@@ -193,7 +194,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
parts = append(parts, strings.Join(sensors, ", "))
|
||||
}
|
||||
if dark > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
|
||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark)))
|
||||
}
|
||||
return strings.Join(parts, "; ") + ".", true
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ var ackKeys = []string{
|
||||
|
||||
// ackFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var ackFloor = registerFloor(map[string]string{
|
||||
var ackFloor = map[string]string{
|
||||
AckFact: "записала факт.",
|
||||
AckFactKey: "отметила: {key}",
|
||||
AckFactValue: "отметила: {key} = {value}",
|
||||
@@ -90,7 +90,7 @@ var ackFloor = registerFloor(map[string]string{
|
||||
FailAck: "не получилось отметить.",
|
||||
FailSnooze: "не получилось отложить.",
|
||||
FailQuiet: "не получилось переключить тихий режим.",
|
||||
})
|
||||
}
|
||||
|
||||
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
|
||||
type Acks struct{ d *deck }
|
||||
@@ -119,7 +119,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
|
||||
// deck reads through a nil *Acks, which is the unloadable-file case.
|
||||
func (a *Acks) deck() *deck {
|
||||
if a == nil {
|
||||
return nil
|
||||
return floorDeck(ackFloor)
|
||||
}
|
||||
return a.d
|
||||
}
|
||||
|
||||
+73
-19
@@ -35,8 +35,8 @@ type deckFile struct {
|
||||
Entries map[string]deckEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// deck picks a line. Safe for concurrent use. A nil *deck answers from the
|
||||
// floor, which is what an unloadable file leaves behind.
|
||||
// deck picks a line. Safe for concurrent use. A deck with no entries answers
|
||||
// from the floor, which is what an unloadable file leaves behind (floorDeck).
|
||||
type deck struct {
|
||||
mu sync.Mutex
|
||||
rnd *rand.Rand
|
||||
@@ -90,7 +90,7 @@ func (d *deck) text(key string, vars map[string]string) string {
|
||||
tmpl := ""
|
||||
if d != nil {
|
||||
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
|
||||
tmpl = d.pick(key, e)
|
||||
tmpl = d.pick(key, fillable(e.Variants, vars))
|
||||
}
|
||||
}
|
||||
if tmpl == "" {
|
||||
@@ -129,12 +129,67 @@ func (d *deck) variants() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// fillable narrows variants to the ones this call can actually say, which is
|
||||
// the rule an optional placeholder needs: a caller with nothing to put in
|
||||
// {tail} must not be handed a variant that has one. Two passes, because both
|
||||
// halves matter. The first keeps only variants whose every placeholder has a
|
||||
// non-empty value, so an absent optional never reaches him as braces. The
|
||||
// second prefers, among those, the variants using the most of what the caller
|
||||
// supplied, so a caveat he was given is not dropped for a shorter wording.
|
||||
// Nothing fillable leaves the list alone, and the unfilled placeholder shows
|
||||
// up in the answer rather than turning it into silence.
|
||||
func fillable(variants []string, vars map[string]string) []string {
|
||||
if len(variants) < 2 {
|
||||
return variants
|
||||
}
|
||||
best, bestUsed := make([]string, 0, len(variants)), -1
|
||||
for _, v := range variants {
|
||||
used := 0
|
||||
ok := true
|
||||
for _, ph := range placeholders(v) {
|
||||
if vars[ph] == "" {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
used++
|
||||
}
|
||||
if !ok || used < bestUsed {
|
||||
continue
|
||||
}
|
||||
if used > bestUsed {
|
||||
best, bestUsed = best[:0], used
|
||||
}
|
||||
best = append(best, v)
|
||||
}
|
||||
if len(best) == 0 {
|
||||
return variants
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// placeholders lists the {name}s in tmpl, in order.
|
||||
func placeholders(tmpl string) []string {
|
||||
var out []string
|
||||
for {
|
||||
i := strings.IndexByte(tmpl, '{')
|
||||
if i < 0 {
|
||||
return out
|
||||
}
|
||||
j := strings.IndexByte(tmpl[i:], '}')
|
||||
if j < 0 {
|
||||
return out
|
||||
}
|
||||
out = append(out, tmpl[i+1:i+j])
|
||||
tmpl = tmpl[i+j+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// pick chooses at random, skipping whatever this entry said last time.
|
||||
func (d *deck) pick(key string, e deckEntry) string {
|
||||
func (d *deck) pick(key string, variants []string) string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
choices := e.Variants
|
||||
choices := variants
|
||||
if len(choices) > 1 {
|
||||
fresh := make([]string, 0, len(choices))
|
||||
for _, v := range choices {
|
||||
@@ -151,24 +206,23 @@ func (d *deck) pick(key string, e deckEntry) string {
|
||||
return got
|
||||
}
|
||||
|
||||
// floorOf reads the Go literal behind key, and works on a nil deck because that
|
||||
// is exactly the case it exists for. The per-family map is the source of truth.
|
||||
// floorOf reads the Go literal behind key. Every deck carries its own family's
|
||||
// map, including the floor-only deck an unloadable file leaves behind, so no
|
||||
// lookup ever crosses families. It used to go through one global map keyed by
|
||||
// bare entry name, which two families both calling an entry query_unknown
|
||||
// silently shared: whichever registered last answered for both (Vikunja #521).
|
||||
func floorOf(d *deck, key string) string {
|
||||
if d != nil && d.floor != nil {
|
||||
return d.floor[key]
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
return deckFloors[key]
|
||||
return d.floor[key]
|
||||
}
|
||||
|
||||
// deckFloors — every family's floor literals in one map, so a nil deck still
|
||||
// finds them. Families register at init; the keys are namespaced by family.
|
||||
var deckFloors = map[string]string{}
|
||||
|
||||
func registerFloor(floor map[string]string) map[string]string {
|
||||
for k, v := range floor {
|
||||
deckFloors[k] = v
|
||||
}
|
||||
return floor
|
||||
// floorDeck — the deck a family falls back to when its file will not load. It
|
||||
// has no entries, so every read drops through to the floor literals, and it is
|
||||
// a real *deck so no accessor has to know which case it is in.
|
||||
func floorDeck(floor map[string]string) *deck {
|
||||
return &deck{last: map[string]string{}, floor: floor}
|
||||
}
|
||||
|
||||
// fill substitutes {name} for each var. A placeholder with no value is left
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json and
|
||||
// ack_ru_v1.json on the persona checks the nudges already pass. These lines are
|
||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json, ack_ru_v1.json and
|
||||
// query_ru_v1.json on the persona checks the nudges already pass. These lines are
|
||||
// heard out loud and they live in a JSON file now, so a reworded variant that
|
||||
// says "рад" or "вы" would otherwise reach him with nothing in between.
|
||||
//
|
||||
@@ -20,22 +20,32 @@ func TestFallbackPersona(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
// No CheckHisGender. It reads a feminine verb near a second-person pronoun
|
||||
// as addressing him as a woman, which is right for a nudge and wrong here:
|
||||
// "не знаю — не нашла у тебя такой записи" is her own verb in her own
|
||||
// sentence. CheckFeminine still holds her side of the rule.
|
||||
persona := map[string]bool{
|
||||
CheckLang: true, CheckFeminine: true, CheckHisGender: true,
|
||||
CheckLang: true, CheckFeminine: true,
|
||||
CheckAddress: true, CheckCringe: true, CheckLength: true,
|
||||
}
|
||||
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
qry, err := phraser.LoadQueries(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
variants := append(fb.Variants(), ack.Variants()...)
|
||||
variants = append(variants, qry.Variants()...)
|
||||
if len(variants) == 0 {
|
||||
t.Fatal("no variants — the file loaded empty")
|
||||
}
|
||||
for _, v := range variants {
|
||||
// The placeholders stand for his own words and carry no persona.
|
||||
body := v
|
||||
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}"} {
|
||||
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
|
||||
"{location}", "{temp}", "{condition}", "{tail}"} {
|
||||
body = strings.ReplaceAll(body, ph, "вода")
|
||||
}
|
||||
for _, r := range RunChecks(Case{}, body, "neutral") {
|
||||
|
||||
@@ -39,12 +39,12 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
|
||||
|
||||
// hardFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var hardFloor = registerFloor(map[string]string{
|
||||
var hardFloor = map[string]string{
|
||||
fbChat: "даже не знаю, что сказать.",
|
||||
fbQueryUnknown: "не знаю.",
|
||||
fbQuerySources: "вот что я нашла: {sources}",
|
||||
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
|
||||
})
|
||||
}
|
||||
|
||||
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
|
||||
type Fallbacks struct{ d *deck }
|
||||
@@ -66,7 +66,7 @@ func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
|
||||
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
|
||||
func (f *Fallbacks) deck() *deck {
|
||||
if f == nil {
|
||||
return nil
|
||||
return floorDeck(hardFloor)
|
||||
}
|
||||
return f.d
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package phraser
|
||||
|
||||
// The counted noun, in the form the number in front of it demands.
|
||||
//
|
||||
// Russian inflects a noun after a numeral, and the form depends on the last two
|
||||
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
|
||||
// A line file cannot spell that out, so a count in a template splits into two
|
||||
// placeholders — the number, and {word} filled from here.
|
||||
//
|
||||
// The rule lived once as hostWord in cmd/mavend/netscan.go, which meant the
|
||||
// weather line said "градусов" for every temperature and was wrong for 1-4 and
|
||||
// for every number ending in 1-4. One helper, every count site (Vikunja #521).
|
||||
|
||||
import "math"
|
||||
|
||||
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
|
||||
// for 0, 5-20 and anything ending in those. A negative count reads its own
|
||||
// magnitude, since minus does not change the noun: -2 градуса.
|
||||
func CountWord(n int, one, few, many string) string {
|
||||
if n < 0 {
|
||||
n = -n
|
||||
}
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return many
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return few
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
|
||||
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
|
||||
// weather provider and counts by the whole degrees she is about to say, so the
|
||||
// noun agrees with the number in the same sentence rather than with the reading
|
||||
// behind it.
|
||||
func Degrees(temp float64) string {
|
||||
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
|
||||
}
|
||||
|
||||
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
|
||||
func Devices(n int) string {
|
||||
return CountWord(n, "устройство", "устройства", "устройств")
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package phraser
|
||||
|
||||
import "testing"
|
||||
|
||||
// The bug the helper exists for: the weather line said "градусов" for every
|
||||
// reading, which is wrong for 1-4 and for every number ending in 1-4.
|
||||
func TestDegreesAgreeWithTheReading(t *testing.T) {
|
||||
for temp, want := range map[float64]string{
|
||||
1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
|
||||
0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
|
||||
22: "градуса", 25: "градусов", 101: "градус",
|
||||
// Minus does not change the noun, and a reading rounds to the number
|
||||
// she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
|
||||
-1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
|
||||
} {
|
||||
if got := Degrees(temp); got != want {
|
||||
t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevicesAgreeWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := Devices(n); got != want {
|
||||
t.Errorf("Devices(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package phraser
|
||||
|
||||
// The query answers and gaps — what a query source says when it answers from
|
||||
// something other than the model, and what it says when it has nothing.
|
||||
//
|
||||
// Third family on the shared deck (deck.go), after the fallbacks and the
|
||||
// acknowledgements. They were literals spread across actions_query.go and
|
||||
// netscan.go, where the largest single site held two dozen of them.
|
||||
//
|
||||
// QueryUnknown is not the phraser's UnknownFallback, even though the two read
|
||||
// the same today. Here she looked and found nothing; there she failed to phrase
|
||||
// an answer she had. Two files, two entries, so rewording one leaves the other.
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed query_ru_v1.json
|
||||
var queryJSON []byte
|
||||
|
||||
// QuerySchemaVersion — this family's own version.
|
||||
const QuerySchemaVersion = 1
|
||||
|
||||
// The entry keys.
|
||||
const (
|
||||
QueryUnknown = "query_unknown"
|
||||
QueryOtherDay = "other_day"
|
||||
QueryPersonalNone = "personal_none"
|
||||
QueryFactWhen = "fact_when"
|
||||
QueryFactValue = "fact_value"
|
||||
QueryFound = "found"
|
||||
QueryPageText = "page_text"
|
||||
QueryPageBlocked = "page_blocked"
|
||||
QueryPageEmpty = "page_empty"
|
||||
QueryFeedsOff = "feeds_off"
|
||||
QueryFeedsNew = "feeds_new"
|
||||
QueryFeedsEmpty = "feeds_empty"
|
||||
QueryFeedsTopic = "feeds_empty_topic"
|
||||
QueryWeatherNow = "weather_now"
|
||||
QueryWeatherOff = "weather_off"
|
||||
QueryWeatherWhere = "weather_nolocation"
|
||||
QueryNetEmpty = "net_empty"
|
||||
|
||||
QueryFailPlan = "fail_plan"
|
||||
QueryFailNotes = "fail_notes"
|
||||
QueryFailFeeds = "fail_feeds"
|
||||
QueryFailCalendar = "fail_calendar"
|
||||
QueryFailWeather = "fail_weather"
|
||||
QueryFailAnswer = "fail_answer"
|
||||
QueryFailPage = "fail_page"
|
||||
QueryFailNetscan = "fail_netscan"
|
||||
)
|
||||
|
||||
var queryKeys = []string{
|
||||
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
|
||||
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
|
||||
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
|
||||
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty,
|
||||
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
|
||||
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
|
||||
}
|
||||
|
||||
// queryFloor — the literal each key falls back to when the file is unusable.
|
||||
// It started as the exact strings that lived in Go before this file existed and
|
||||
// now tracks the file's first variant instead, because a floor that keeps the
|
||||
// wording review threw out would say it back on the one turn nobody is watching.
|
||||
var queryFloor = map[string]string{
|
||||
QueryUnknown: "ничего не нашла.",
|
||||
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
|
||||
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
|
||||
QueryFactWhen: "записала это {when}",
|
||||
QueryFactValue: "у меня записано: {key} — {value}",
|
||||
QueryFound: "вот что я нашла: {text}",
|
||||
QueryPageText: "вот что на странице: {text}",
|
||||
QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.",
|
||||
QueryPageEmpty: "страница открылась, но читать там нечего.",
|
||||
QueryFeedsOff: "ленты не настроены.",
|
||||
QueryFeedsNew: "вот что нового: {items}",
|
||||
QueryFeedsEmpty: "в лентах пока ничего нового.",
|
||||
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
|
||||
QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.",
|
||||
QueryWeatherOff: "погода не настроена.",
|
||||
QueryWeatherWhere: "для какого города?",
|
||||
QueryNetEmpty: "в сети никого не нашла.",
|
||||
|
||||
QueryFailPlan: "не получилось собрать план.",
|
||||
QueryFailNotes: "не получилось посмотреть записи.",
|
||||
QueryFailFeeds: "не получилось посмотреть ленты.",
|
||||
QueryFailCalendar: "не получилось проверить календарь.",
|
||||
QueryFailWeather: "не получилось узнать погоду.",
|
||||
QueryFailAnswer: "не получилось найти ответ.",
|
||||
QueryFailPage: "не получилось прочитать страницу.",
|
||||
QueryFailNetscan: "не получилось просканировать сеть.",
|
||||
}
|
||||
|
||||
// Queries picks a hand-written Russian query line. Safe for concurrent use.
|
||||
type Queries struct{ d *deck }
|
||||
|
||||
// LoadQueries reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadQueries(src rand.Source) (*Queries, error) {
|
||||
d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The entries that exist to read something back. A variant without the
|
||||
// placeholder would answer the question by dropping the answer.
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"},
|
||||
{QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
|
||||
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
|
||||
{QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"},
|
||||
} {
|
||||
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Queries{d: d}, nil
|
||||
}
|
||||
|
||||
// deck reads through a nil *Queries, which is the unloadable-file case.
|
||||
func (q *Queries) deck() *deck {
|
||||
if q == nil {
|
||||
return floorDeck(queryFloor)
|
||||
}
|
||||
return q.d
|
||||
}
|
||||
|
||||
// Say returns one line for key, with the values filled into the frame.
|
||||
func (q *Queries) Say(key string, vars map[string]string) string {
|
||||
return q.deck().text(key, vars)
|
||||
}
|
||||
|
||||
// Variants returns every line the file can produce, for the persona scorer.
|
||||
func (q *Queries) Variants() []string { return q.deck().variants() }
|
||||
|
||||
var (
|
||||
queryOnce sync.Once
|
||||
queries *Queries
|
||||
)
|
||||
|
||||
// DefaultQueries returns the shared instance, loading it on first use. A broken
|
||||
// file logs once and leaves a nil *Queries, which still answers from queryFloor.
|
||||
func DefaultQueries() *Queries {
|
||||
queryOnce.Do(func() {
|
||||
q, err := LoadQueries(nil)
|
||||
if err != nil {
|
||||
log.Printf("phraser: query lines unavailable, using the built-in ones: %v", err)
|
||||
return
|
||||
}
|
||||
queries = q
|
||||
})
|
||||
return queries
|
||||
}
|
||||
|
||||
// Q — one query line, the way every caller says it.
|
||||
func Q(key string, vars map[string]string) string { return DefaultQueries().Say(key, vars) }
|
||||
|
||||
// IsQ reports whether text is a line key could have produced, for the tests.
|
||||
func IsQ(key string, vars map[string]string, text string) bool {
|
||||
return DefaultQueries().deck().matches(key, vars, text)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueriesLoad(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, k := range queryKeys {
|
||||
if got := q.Say(k, nil); got == "" {
|
||||
t.Errorf("%s says nothing", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bug: net_empty carried {tail} in every variant, and a scan that finished
|
||||
// the whole range has no caveat to put there. Whatever the file says, an answer
|
||||
// he can hear has to come out — never braces, never nothing.
|
||||
func TestNetEmptySaysSomethingWithNoTail(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, vars := range []map[string]string{nil, {"tail": ""}} {
|
||||
for i := 0; i < 20; i++ {
|
||||
got := q.Say(QueryNetEmpty, vars)
|
||||
if got == "" || strings.ContainsAny(got, "{}") {
|
||||
t.Fatalf("net_empty with vars %v said %q", vars, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The other half: a caveat he was given is not dropped for a shorter wording.
|
||||
func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
const tail = ", но успела посмотреть не всю сеть"
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) {
|
||||
t.Fatalf("net_empty dropped the tail: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// query_unknown means she looked and found nothing. The phraser's fallback
|
||||
// means she failed to phrase an answer she had. Two causes, two sentences, or
|
||||
// the distinction the two files exist for is unobservable from the outside.
|
||||
func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
f, err := LoadFallbacks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
failures := map[string]bool{}
|
||||
for _, v := range f.Variants() {
|
||||
failures[v] = true
|
||||
}
|
||||
for _, v := range q.d.file.Entries[QueryUnknown].Variants {
|
||||
if failures[v] {
|
||||
t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The weather line splits the count into a number and a noun, so a variant that
|
||||
// says the temperature without {word} is the hardcoded "градусов" coming back.
|
||||
func TestWeatherLineCountsWithTheHelper(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, v := range q.d.file.Entries[QueryWeatherNow].Variants {
|
||||
if strings.Contains(v, "градус") {
|
||||
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
|
||||
}
|
||||
}
|
||||
got := q.Say(QueryWeatherNow, map[string]string{
|
||||
"location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно",
|
||||
})
|
||||
if !strings.Contains(got, "1 градус,") {
|
||||
t.Errorf("weather_now said %q, want the singular noun", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No line spoken to him names a config key. She asks instead.
|
||||
func TestNoQueryLineRecitesAConfigPath(t *testing.T) {
|
||||
q, err := LoadQueries(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
for _, v := range q.Variants() {
|
||||
if strings.Contains(v, "voice.") || strings.Contains(v, "_location") {
|
||||
t.Errorf("variant %q says a config path out loud", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "russian query answers and gaps v1",
|
||||
"notes": [
|
||||
"What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||
"A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
|
||||
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.",
|
||||
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.",
|
||||
"A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.",
|
||||
"{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.",
|
||||
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
|
||||
],
|
||||
"entries": {
|
||||
"query_unknown": {
|
||||
"variants": ["ничего не нашла.", "искала — не нашла."]
|
||||
},
|
||||
"other_day": {
|
||||
"fixed": true,
|
||||
"variants": ["про другой день так не отвечу — спроси целиком."]
|
||||
},
|
||||
"personal_none": {
|
||||
"fixed": true,
|
||||
"variants": ["не знаю — не нашла у тебя такой записи."]
|
||||
},
|
||||
"fact_when": {
|
||||
"variants": ["записала это {when}"]
|
||||
},
|
||||
"fact_value": {
|
||||
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
|
||||
},
|
||||
"found": {
|
||||
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
|
||||
},
|
||||
"page_text": {
|
||||
"variants": ["вот что на странице: {text}", "на странице вот это: {text}"]
|
||||
},
|
||||
"page_blocked": {
|
||||
"fixed": true,
|
||||
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
|
||||
},
|
||||
"page_empty": {
|
||||
"variants": ["страница открылась, но читать там нечего."]
|
||||
},
|
||||
"feeds_off": {
|
||||
"variants": ["ленты не настроены."]
|
||||
},
|
||||
"feeds_new": {
|
||||
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
|
||||
},
|
||||
"feeds_empty": {
|
||||
"variants": ["в лентах пока ничего нового.", "в лентах тихо."]
|
||||
},
|
||||
"feeds_empty_topic": {
|
||||
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
|
||||
},
|
||||
"weather_now": {
|
||||
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
|
||||
},
|
||||
"weather_off": {
|
||||
"variants": ["погода не настроена."]
|
||||
},
|
||||
"weather_nolocation": {
|
||||
"fixed": true,
|
||||
"variants": ["для какого города?"]
|
||||
},
|
||||
"net_empty": {
|
||||
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
|
||||
},
|
||||
"fail_plan": {
|
||||
"variants": ["не получилось собрать план."]
|
||||
},
|
||||
"fail_notes": {
|
||||
"variants": ["не получилось посмотреть записи."]
|
||||
},
|
||||
"fail_feeds": {
|
||||
"variants": ["не получилось посмотреть ленты."]
|
||||
},
|
||||
"fail_calendar": {
|
||||
"variants": ["не получилось проверить календарь."]
|
||||
},
|
||||
"fail_weather": {
|
||||
"variants": ["не получилось узнать погоду."]
|
||||
},
|
||||
"fail_answer": {
|
||||
"variants": ["не получилось найти ответ."]
|
||||
},
|
||||
"fail_page": {
|
||||
"variants": ["не получилось прочитать страницу."]
|
||||
},
|
||||
"fail_netscan": {
|
||||
"variants": ["не получилось просканировать сеть."]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user