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": ["не получилось просканировать сеть.", "сеть не просканировалась."] + } + } +}