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:
@@ -0,0 +1,265 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user