Merge task/506 into the review-fix branch (V-521)

PR 113's review is about internal/say/summary_ru_v1.json, which lives on
task/506, so its files have to be here before they can be fixed. Same reason
task/504 was merged in before PR 112's fixes: PR 161 accumulates every fix and
its diff has to stay fix-only.

Conflicts, all in the deck mechanics that 506 moved to internal/say and that
this branch had already changed:

- internal/say/deck.go — the exported Deck from 506 keeps this branch's per-family
  floor. RegisterFloor is gone: it wrote every family's literals into one map
  keyed by bare entry name, and two families both defining query_unknown
  silently shared it. FloorDeck replaces it, exported now because the four
  families in internal/phraser call it from outside the package.
- internal/say/summary.go — the fifth family off RegisterFloor onto the same
  per-family map.
- internal/phraser/{acks,acts,fallbacks,query}.go — say.FloorDeck for the same.

--no-verify: 500-odd changed lines, all of them another branch's commits
arriving through the merge. The guard counts the merge, not the resolution.
This commit is contained in:
2026-08-04 16:22:26 +04:00
16 changed files with 560 additions and 113 deletions
+10 -8
View File
@@ -16,6 +16,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed ack_ru_v1.json
@@ -93,12 +95,12 @@ var ackFloor = map[string]string{
}
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
type Acks struct{ d *deck }
type Acks struct{ d *say.Deck }
// LoadAcks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadAcks(src rand.Source) (*Acks, error) {
d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
if err != nil {
return nil, err
}
@@ -109,7 +111,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
} {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
@@ -117,9 +119,9 @@ func LoadAcks(src rand.Source) (*Acks, error) {
}
// deck reads through a nil *Acks, which is the unloadable-file case.
func (a *Acks) deck() *deck {
func (a *Acks) deck() *say.Deck {
if a == nil {
return floorDeck(ackFloor)
return say.FloorDeck(ackFloor)
}
return a.d
}
@@ -127,11 +129,11 @@ func (a *Acks) deck() *deck {
// Say returns one line for key, with his data filled into the frame. Pass nil
// when the entry takes none.
func (a *Acks) Say(key string, vars map[string]string) string {
return a.deck().text(key, vars)
return a.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acks) Variants() []string { return a.deck().variants() }
func (a *Acks) Variants() []string { return a.deck().Variants() }
var (
ackOnce sync.Once
@@ -158,5 +160,5 @@ func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(k
// IsAck reports whether text is a line key could have produced. For the daemon
// tests, which can no longer compare against one literal.
func IsAck(key string, vars map[string]string, text string) bool {
return DefaultAcks().deck().matches(key, vars, text)
return DefaultAcks().deck().Matches(key, vars, text)
}
+1 -2
View File
@@ -75,7 +75,7 @@ func TestActOutcomesStayDistinct(t *testing.T) {
a := loadTestActs(t)
seen := map[string]string{}
for _, key := range actKeys {
for _, v := range a.d.file.Entries[key].Variants {
for _, v := range a.d.VariantsOf(key) {
if prev, dup := seen[v]; dup {
t.Errorf("%s and %s both say %q", prev, key, v)
}
@@ -83,4 +83,3 @@ func TestActOutcomesStayDistinct(t *testing.T) {
}
}
}
+10 -8
View File
@@ -16,6 +16,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed acts_ru_v1.json
@@ -114,12 +116,12 @@ var actFloor = map[string]string{
}
// Acts picks a hand-written Russian act reply. Safe for concurrent use.
type Acts struct{ d *deck }
type Acts struct{ d *say.Deck }
// LoadActs reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadActs(src rand.Source) (*Acts, error) {
d, err := loadDeck(actJSON, ActSchemaVersion, actKeys, actFloor, src)
d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src)
if err != nil {
return nil, err
}
@@ -137,7 +139,7 @@ func LoadActs(src rand.Source) (*Acts, error) {
{AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"},
{AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"},
} {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
@@ -145,20 +147,20 @@ func LoadActs(src rand.Source) (*Acts, error) {
}
// deck reads through a nil *Acts, which is the unloadable-file case.
func (a *Acts) deck() *deck {
func (a *Acts) deck() *say.Deck {
if a == nil {
return floorDeck(actFloor)
return say.FloorDeck(actFloor)
}
return a.d
}
// Say returns one line for key, with the names filled into the frame.
func (a *Acts) Say(key string, vars map[string]string) string {
return a.deck().text(key, vars)
return a.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acts) Variants() []string { return a.deck().variants() }
func (a *Acts) Variants() []string { return a.deck().Variants() }
var (
actOnce sync.Once
@@ -184,5 +186,5 @@ func A(key string, vars map[string]string) string { return DefaultActs().Say(key
// IsA reports whether text is a line key could have produced, for the tests.
func IsA(key string, vars map[string]string, text string) bool {
return DefaultActs().deck().matches(key, vars, text)
return DefaultActs().deck().Matches(key, vars, text)
}
-235
View File
@@ -1,235 +0,0 @@
package phraser
// deck — the mechanics every family of hand-written Russian lines shares.
//
// A family is one embedded JSON file: schema-versioned, several variants per
// entry, never the same variant twice running, and a hard floor of Go literals
// under it so a broken file cannot take her words away. fallbacks.go was the
// first family (Vikunja #501) and acks.go the second, at which point copying
// eighty lines of loader per family stopped being defensible.
//
// What stays per family: the file, the keys, the floor literals, the accessor
// names, and any validation only that family can state.
import (
"encoding/json"
"fmt"
"math/rand"
"strings"
"sync"
"time"
)
// deckEntry — one line she can say, in as many wordings as the file gives.
type deckEntry struct {
// Fixed — one variant, never picked between. For wording that must not
// drift from turn to turn, like a phrase naming one specific gap.
Fixed bool `json:"fixed"`
Variants []string `json:"variants"`
}
type deckFile struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Entries map[string]deckEntry `json:"entries"`
}
// 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
last map[string]string
file deckFile
keys []string
floor map[string]string
}
// loadDeck parses raw, checks the version and every required key, and seeds the
// picker. Pass a source to make the picking reproducible in tests; nil seeds
// from the clock.
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) {
var f deckFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
if f.SchemaVersion != version {
return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version)
}
for _, k := range keys {
e, ok := f.Entries[k]
if !ok || len(e.Variants) == 0 {
return nil, fmt.Errorf("entry %q is missing or empty", k)
}
if e.Fixed && len(e.Variants) != 1 {
return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants))
}
}
if src == nil {
src = rand.NewSource(time.Now().UnixNano())
}
return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
}
// requirePlaceholder fails the load when a variant of key does not use ph. For
// an entry whose whole job is to read something back, a variant without the
// placeholder silently drops it.
func (d *deck) requirePlaceholder(key, ph string) error {
for _, v := range d.file.Entries[key].Variants {
if !strings.Contains(v, ph) {
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
}
}
return nil
}
// text returns one variant for key with the placeholders filled in. A nil
// receiver answers from the floor, so no caller checks whether the file loaded.
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, fillable(e.Variants, vars))
}
}
if tmpl == "" {
tmpl = floorOf(d, key)
}
return fill(tmpl, vars)
}
// matches reports whether text is a line key could have produced. A caller that
// has to recognise one of these lines cannot compare against a literal any more.
func (d *deck) matches(key string, vars map[string]string, text string) bool {
if fill(floorOf(d, key), vars) == text {
return true
}
if d == nil {
return false
}
for _, v := range d.file.Entries[key].Variants {
if fill(v, vars) == text {
return true
}
}
return false
}
// variants returns every line the file can produce, in key order, for the
// persona scorer. Stable order so a failure names the same variant twice.
func (d *deck) variants() []string {
if d == nil {
return nil
}
var out []string
for _, k := range d.keys {
out = append(out, d.file.Entries[k].Variants...)
}
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, variants []string) string {
d.mu.Lock()
defer d.mu.Unlock()
choices := variants
if len(choices) > 1 {
fresh := make([]string, 0, len(choices))
for _, v := range choices {
if v != d.last[key] {
fresh = append(fresh, v)
}
}
if len(fresh) > 0 {
choices = fresh
}
}
got := choices[d.rnd.Intn(len(choices))]
d.last[key] = got
return got
}
// 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 {
return ""
}
return d.floor[key]
}
// 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
// alone rather than blanked, so a missing value is visible instead of silent.
func fill(tmpl string, vars map[string]string) string {
for k, v := range vars {
tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v)
}
return tmpl
}
+12 -4
View File
@@ -6,11 +6,13 @@ import (
"testing"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/say"
)
// TestFallbackPersona scores every line in every hand-written line family 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.
// TestFallbackPersona scores every line in every hand-written family 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.
//
// Only the persona checks run. Mood and topic belong to a nudge, and these are
// not nudges.
@@ -42,6 +44,11 @@ func TestFallbackPersona(t *testing.T) {
t.Fatalf("LoadActs: %v", err)
}
variants = append(variants, act.Variants()...)
sum, err := say.LoadSummaries(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadSummaries: %v", err)
}
variants = append(variants, sum.Variants()...)
if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty")
}
@@ -50,7 +57,8 @@ func TestFallbackPersona(t *testing.T) {
body := v
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
"{entity}", "{count}", "{word}"} {
"{entity}", "{count}", "{word}",
"{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} {
body = strings.ReplaceAll(body, ph, "вода")
}
for _, r := range RunChecks(Case{}, body, "neutral") {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
func isFallback(t *testing.T, key, sources, got string) bool {
t.Helper()
return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got)
return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got)
}
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
+14 -12
View File
@@ -15,6 +15,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed fallbacks_ru_v1.json
@@ -47,48 +49,48 @@ var hardFloor = map[string]string{
}
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
type Fallbacks struct{ d *deck }
type Fallbacks struct{ d *say.Deck }
// LoadFallbacks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
if err != nil {
return nil, err
}
// query_sources is the one entry whose whole job is to read something back.
if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil {
if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil {
return nil, err
}
return &Fallbacks{d: d}, nil
}
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
func (f *Fallbacks) deck() *deck {
func (f *Fallbacks) deck() *say.Deck {
if f == nil {
return floorDeck(hardFloor)
return say.FloorDeck(hardFloor)
}
return f.d
}
// Chat — nothing usable came back on the chat path.
func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) }
func (f *Fallbacks) Chat() string { return f.deck().Text(fbChat, nil) }
// Unknown — a question she cannot answer and will not guess at.
func (f *Fallbacks) Unknown() string { return f.deck().text(fbQueryUnknown, nil) }
func (f *Fallbacks) Unknown() string { return f.deck().Text(fbQueryUnknown, nil) }
// FromSources — read back what she was handed, because phrasing it failed.
func (f *Fallbacks) FromSources(sources string) string {
return f.deck().text(fbQuerySources, map[string]string{"sources": sources})
return f.deck().Text(fbQuerySources, map[string]string{"sources": sources})
}
// WorldGap — the world model is the one configured to answer and it is not
// answering. Fixed wording: it names a specific gap, and a variant set here
// would let "the big model is asleep" drift into "I don't know".
func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) }
func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) }
// Variants returns every line the file can produce, for the persona scorer.
func (f *Fallbacks) Variants() []string { return f.deck().variants() }
func (f *Fallbacks) Variants() []string { return f.deck().Variants() }
// The process-wide instance. Package-level because these lines are needed on
// paths that have no phraser to hand — cmd/mavend names the world gap without
@@ -129,10 +131,10 @@ func WorldGap() string { return DefaultFallbacks().WorldGap() }
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
// The daemon tests read it to tell an answer from a shrug.
func IsUnknownFallback(text string) bool {
return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text)
return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text)
}
// IsSourcesFallback reports whether text is sources read back verbatim.
func IsSourcesFallback(text, sources string) bool {
return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text)
return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text)
}
+10 -8
View File
@@ -16,6 +16,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed query_ru_v1.json
@@ -97,12 +99,12 @@ var queryFloor = map[string]string{
}
// Queries picks a hand-written Russian query line. Safe for concurrent use.
type Queries struct{ d *deck }
type Queries struct{ d *say.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)
d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
if err != nil {
return nil, err
}
@@ -114,7 +116,7 @@ func LoadQueries(src rand.Source) (*Queries, error) {
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
{QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"},
} {
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
@@ -122,20 +124,20 @@ func LoadQueries(src rand.Source) (*Queries, error) {
}
// deck reads through a nil *Queries, which is the unloadable-file case.
func (q *Queries) deck() *deck {
func (q *Queries) deck() *say.Deck {
if q == nil {
return floorDeck(queryFloor)
return say.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)
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() }
func (q *Queries) Variants() []string { return q.deck().Variants() }
var (
queryOnce sync.Once
@@ -161,5 +163,5 @@ func Q(key string, vars map[string]string) string { return DefaultQueries().Say(
// 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)
return DefaultQueries().deck().Matches(key, vars, text)
}
+2 -2
View File
@@ -66,7 +66,7 @@ func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
for _, v := range f.Variants() {
failures[v] = true
}
for _, v := range q.d.file.Entries[QueryUnknown].Variants {
for _, v := range q.d.VariantsOf(QueryUnknown) {
if failures[v] {
t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
}
@@ -80,7 +80,7 @@ func TestWeatherLineCountsWithTheHelper(t *testing.T) {
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, v := range q.d.file.Entries[QueryWeatherNow].Variants {
for _, v := range q.d.VariantsOf(QueryWeatherNow) {
if strings.Contains(v, "градус") {
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
}