28c0ff73bd
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.
266 lines
8.1 KiB
Go
266 lines
8.1 KiB
Go
package say
|
|
|
|
// Package say holds the mechanics every family of hand-written Russian lines
|
|
// shares. The families themselves live next to the code that speaks them.
|
|
//
|
|
// 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"
|
|
)
|
|
|
|
// Entry — one line she can say, in as many wordings as the file gives.
|
|
type Entry 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 file struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Name string `json:"name"`
|
|
Notes []string `json:"notes"`
|
|
Entries map[string]Entry `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 file
|
|
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 Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) {
|
|
var f file
|
|
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
|
|
}
|
|
|
|
// VariantsOf returns the wordings the file gives for one key, for a test that
|
|
// has something to say about every one of them.
|
|
func (d *Deck) VariantsOf(key string) []string {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
return d.file.Entries[key].Variants
|
|
}
|
|
|
|
// UnfixedSingles lists the keys with exactly one variant that are not marked
|
|
// fixed. Nothing breaks on one — the picker has nothing to pick either way — but
|
|
// the flag is what a reader goes by, and parallel entries disagreeing about it
|
|
// is how a family stops being readable. Load already rejects the other half of
|
|
// the rule, fixed with more than one variant, so this is the pair to it.
|
|
func (d *Deck) UnfixedSingles() []string {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, k := range d.keys {
|
|
if e := d.file.Entries[k]; len(e.Variants) == 1 && !e.Fixed {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
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
|
|
}
|