From ae8d38fc3106e6564ef91e3813e5321be316f706 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:32:15 +0400 Subject: [PATCH] phraser: put the query answers and gaps in a versioned json (V-503) What a query source says when it answers from something other than the model, and what it says when it has nothing. Two dozen of them lived in actions_query.go alone. Every gap keeps its own entry. "The feeds are not configured", "the search failed" and "I do not know" are different truths, and one variant set would let them answer for each other. The personal boundary and the refusal to re-ask a question for another day are fixed: both are load-bearing wording. query_unknown is not the phraser fallback that reads the same. Here she looked and found nothing; there she failed to phrase an answer she had. --- internal/phraser/query.go | 162 ++++++++++++++++++++++++++++++ internal/phraser/query_ru_v1.json | 93 +++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 internal/phraser/query.go create mode 100644 internal/phraser/query_ru_v1.json diff --git a/internal/phraser/query.go b/internal/phraser/query.go new file mode 100644 index 0000000..638eefd --- /dev/null +++ b/internal/phraser/query.go @@ -0,0 +1,162 @@ +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. +// These are the exact strings that lived in Go before this file existed. +var queryFloor = registerFloor(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} градусов, {condition}.", + QueryWeatherOff: "погода не настроена.", + QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", + QueryNetEmpty: "в сети никого не нашла{tail}.", + + 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, "{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 nil + } + 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) +} diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json new file mode 100644 index 0000000..249294e --- /dev/null +++ b/internal/phraser/query_ru_v1.json @@ -0,0 +1,93 @@ +{ + "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.", + "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.", + "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}", "записала это {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} градусов, {condition}.", "{location}: {temp} градусов, {condition}."] + }, + "weather_off": { + "variants": ["погода не настроена."] + }, + "weather_nolocation": { + "fixed": true, + "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] + }, + "net_empty": { + "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{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": ["не получилось просканировать сеть.", "сеть не просканировалась."] + } + } +}