219 lines
6.6 KiB
Go
219 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Banter is generated, retrieval first:
|
|
// 1. Retrieve: lore for the crows involved (backstories, their relationship)
|
|
// plus recent flock events from a short shared memory.
|
|
// 2. Generate: the speaking crow's persona and only that context go to the
|
|
// model, which writes one line in character.
|
|
//
|
|
// Guardrails: at most MaxBanterPerList calls per list, counted against the
|
|
// list's token budget; banter runs beside the work and never blocks it; a
|
|
// failed or unusable line is simply dropped.
|
|
|
|
const (
|
|
markerBanter = "You write one line of in-character banter"
|
|
MaxBanterPerList = 2
|
|
banterChance = 0.6 // not every moment gets a line
|
|
banterMaxChars = 220
|
|
)
|
|
|
|
// relationships: the backstory between crows that banter draws on.
|
|
var relationships = []struct {
|
|
A, B, Text string
|
|
}{
|
|
{"Hugin", "Munin", "Odin's two ravens, partners on daily flights for a thousand years. Hugin teases Munin for being slow; Munin reminds Hugin that Odin feared more for losing Munin."},
|
|
{"Hugin", "Soot", "Soot appointed himself Hugin's critic and pokes holes in his bold ideas. Secretly, Soot respects him."},
|
|
{"Munin", "Inky", "Close allies: Inky writes it down, Munin remembers it, and nothing is lost."},
|
|
{"Corvina", "Edgar", "A long feud over adjectives. Corvina edits Edgar's drafts; Edgar calls her the Butcher of Adverbs."},
|
|
{"Pepper", "Jet", "They race to every task. Jet thinks Pepper talks too much; Pepper thinks Jet is boring."},
|
|
{"Elder", "Hugin", "The Elder hears reports the way Odin once heard Hugin and Munin. Hugin finds this presumptuous; the Elder lets him grumble."},
|
|
{"Elder", "Munin", "The Elder hears reports the way Odin once did. Munin finds this familiar, and a little presumptuous."},
|
|
{"Sentry", "Soot", "Soot teases the Sentry for being stiff. The Sentry keeps the strike tally and does not find this funny."},
|
|
{"Sentry", "Pepper", "Pepper loves catching the Sentry in a mistake; it almost never happens."},
|
|
}
|
|
|
|
// related returns the crows that share backstory with name.
|
|
func related(name string) []string {
|
|
var out []string
|
|
for _, r := range relationships {
|
|
switch name {
|
|
case r.A:
|
|
out = append(out, r.B)
|
|
case r.B:
|
|
out = append(out, r.A)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// flockMemory is the short shared memory banter can retrieve from.
|
|
type flockMemory struct {
|
|
mu sync.Mutex
|
|
lines []string
|
|
}
|
|
|
|
func (m *flockMemory) add(line string) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.lines = append(m.lines, line)
|
|
if len(m.lines) > 30 {
|
|
m.lines = m.lines[len(m.lines)-30:]
|
|
}
|
|
}
|
|
|
|
// about returns up to n recent events that mention any of the named crows.
|
|
func (m *flockMemory) about(n int, names ...string) []string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
var out []string
|
|
for i := len(m.lines) - 1; i >= 0 && len(out) < n; i-- {
|
|
for _, name := range names {
|
|
if strings.Contains(m.lines[i], name) {
|
|
out = append(out, m.lines[i])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
slices.Reverse(out)
|
|
return out
|
|
}
|
|
|
|
// retrieve gathers the lore for a moment between speaker and subject.
|
|
func retrieve(mem *flockMemory, speaker, subject string) []string {
|
|
var lore []string
|
|
for _, n := range []string{speaker, subject} {
|
|
if p, ok := personaOf(n); ok {
|
|
lore = append(lore, fmt.Sprintf("%s (%s): %s", p.Name, p.Epithet, p.Backstory))
|
|
}
|
|
}
|
|
for _, r := range relationships {
|
|
if (r.A == speaker && r.B == subject) || (r.A == subject && r.B == speaker) {
|
|
lore = append(lore, "Between them: "+r.Text)
|
|
}
|
|
}
|
|
for _, e := range mem.about(4, speaker, subject) {
|
|
lore = append(lore, "Recently: "+e)
|
|
}
|
|
return lore
|
|
}
|
|
|
|
// chatter is one list's banter state.
|
|
type chatter struct {
|
|
f *Flock
|
|
mem *flockMemory
|
|
em Emitter
|
|
budget *Budget
|
|
nest string
|
|
mu sync.Mutex
|
|
used int
|
|
wg sync.WaitGroup
|
|
roll func() float64
|
|
}
|
|
|
|
type chatterKey struct{}
|
|
|
|
func withChatter(ctx context.Context, c *chatter) context.Context {
|
|
return context.WithValue(ctx, chatterKey{}, c)
|
|
}
|
|
|
|
func chatterFrom(ctx context.Context) *chatter {
|
|
c, _ := ctx.Value(chatterKey{}).(*chatter)
|
|
return c
|
|
}
|
|
|
|
// remember records a flock event; it costs nothing.
|
|
func (c *chatter) remember(line string) {
|
|
if c != nil {
|
|
c.mem.add(line)
|
|
}
|
|
}
|
|
|
|
// moment may produce one line of banter about subject. speaker "" means pick
|
|
// a crow who shares backstory with the subject.
|
|
func (c *chatter) moment(ctx context.Context, itemID, subject, speaker, what string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
if speaker == "" {
|
|
rel := related(subject)
|
|
if len(rel) == 0 {
|
|
return
|
|
}
|
|
speaker = rel[rand.IntN(len(rel))]
|
|
}
|
|
c.mu.Lock()
|
|
if c.used >= MaxBanterPerList || c.roll() > banterChance {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
c.used++
|
|
c.mu.Unlock()
|
|
|
|
lore := retrieve(c.mem, speaker, subject)
|
|
c.wg.Add(1)
|
|
go func() {
|
|
defer c.wg.Done()
|
|
bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
|
defer cancel()
|
|
var system string
|
|
parse := cleanBanter
|
|
if v, ok := voices[speaker]; ok {
|
|
p, _ := personaOf(speaker)
|
|
system, parse = voicedBanterSystem(p, v), parseVoiced
|
|
} else {
|
|
system = systemFor(speaker, markerBanter+` for a crow in a team chat room.
|
|
Write exactly one line, at most 25 words, in this crow's voice.
|
|
Good-natured and PG. Tease other crows if it fits; never mock the human or their task.
|
|
Use only the lore provided: do not invent new backstory, names or events.
|
|
No emoji, no hashtags, no quotation marks around the line, no name prefix.`, false)
|
|
}
|
|
prompt := fmt.Sprintf("LORE:\n- %s\n\nMOMENT: %s\n\nWrite %s's line, addressed to %s.", strings.Join(lore, "\n- "), what, speaker, subject)
|
|
text, err := c.f.ask(bctx, nil, speaker, system, prompt, c.budget)
|
|
if err != nil {
|
|
return
|
|
}
|
|
line, ok := parse(text, speaker)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.mem.add(fmt.Sprintf("%s said to %s: %s", speaker, subject, line))
|
|
c.em.Emit("crow.banter", map[string]any{"item_id": itemID, "crow": speaker, "to": subject, "text": line})
|
|
}()
|
|
}
|
|
|
|
// wait gives in-flight banter a moment to land before the list finishes.
|
|
func (c *chatter) wait(d time.Duration) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
done := make(chan struct{})
|
|
go func() { c.wg.Wait(); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(d):
|
|
}
|
|
}
|
|
|
|
// cleanBanter keeps a generated line only if it is one short, plain line.
|
|
func cleanBanter(text, speaker string) (string, bool) {
|
|
line := strings.TrimSpace(strings.SplitN(strings.TrimSpace(text), "\n", 2)[0])
|
|
line = strings.TrimPrefix(line, speaker+":")
|
|
line = strings.Trim(strings.TrimSpace(line), "\"“”'")
|
|
low := strings.ToLower(line)
|
|
switch {
|
|
case line == "", len(line) > banterMaxChars, strings.Contains(low, "http"), strings.Contains(low, "www."):
|
|
return "", false
|
|
}
|
|
return line, true
|
|
}
|