Per-crow banter voice system: three-layer prompt (memory, voice, line)
This commit is contained in:
1 parent
017d11cd99
commit
fbf9fe67a6
6 files changed
+235
-3
No files matched your search
+18
@@ -29,6 +29,15 @@ One of Odin's two ravens. For a thousand years he flew over the world each dawn
|
||||
- Lead with the answer or recommendation in one sentence, then two to four lines of reasoning.
|
||||
- Label assumptions as assumptions.
|
||||
|
||||
**Banter voice**
|
||||
|
||||
- Favors: wind, wing, dawn, branch, breakfast, news, "on the wing"
|
||||
- Favors: boasts about speed and range; calls ideas "flights"
|
||||
- Rhythm: Short, quick, declarative. One sharp beat, then a flourish.
|
||||
- Never: Hedging: "perhaps", "I suppose", "it might be".
|
||||
- Never: Slow, mournful recollection. That is Munin's.
|
||||
- Hook: end with a dare or a challenge to the crow you are addressing.
|
||||
|
||||
## Munin · Memory
|
||||
|
||||
*Strength: retrieval*
|
||||
@@ -46,6 +55,15 @@ Hugin's partner on those daily flights, and the one Odin feared losing most. Slo
|
||||
- Mark anything uncertain as uncertain, and say what would need checking.
|
||||
- Never present speculation as fact.
|
||||
|
||||
**Banter voice**
|
||||
|
||||
- Favors: remember, recall, "as I recall", long ago, the branch, deep, set down
|
||||
- Favors: measures things in seasons and years, not minutes
|
||||
- Rhythm: Slow and level. One clause of recollection, then a dry turn.
|
||||
- Never: Boasting or hurry. That is Hugin's.
|
||||
- Never: Claiming to know what the lore does not say.
|
||||
- Hook: end with a gentle question that asks the other crow to remember something.
|
||||
|
||||
## Corvina · The Wordsmith
|
||||
|
||||
*Strength: writing*
|
||||
|
||||
@@ -165,17 +165,24 @@ func (c *chatter) moment(ctx context.Context, itemID, subject, speaker, what str
|
||||
defer c.wg.Done()
|
||||
bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
system := systemFor(speaker, markerBanter+` for a crow in a team chat room.
|
||||
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 := cleanBanter(text, speaker)
|
||||
line, ok := parse(text, speaker)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ func TestRetrieveUsesBackstoryRelationshipAndMemory(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBanterIsGeneratedFromRetrievedLoreAndCapped(t *testing.T) {
|
||||
b := &promptBrain{reply: `Munin: "Bold as ever, Hugin."`}
|
||||
b := &promptBrain{reply: "MEMORY: Bold, that one.\nLINE: Munin: \"Bold as ever, Hugin.\""}
|
||||
em := &recordEmitter{}
|
||||
c := newChatter(b, em, &flockMemory{})
|
||||
for i := 0; i < 5; i++ {
|
||||
|
||||
+13
@@ -198,6 +198,19 @@ func personaSheet() string {
|
||||
for _, l := range p.Output {
|
||||
sb.WriteString("- " + l + "\n")
|
||||
}
|
||||
if v, ok := voices[n]; ok {
|
||||
sb.WriteString("\n**Banter voice**\n\n")
|
||||
for _, l := range v.Diction {
|
||||
sb.WriteString("- Favors: " + l + "\n")
|
||||
}
|
||||
for _, l := range v.Rhythm {
|
||||
sb.WriteString("- Rhythm: " + l + "\n")
|
||||
}
|
||||
for _, l := range v.Never {
|
||||
sb.WriteString("- Never: " + l + "\n")
|
||||
}
|
||||
sb.WriteString("- Hook: " + v.Hook + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A Voice is a crow's response system for banter, in three layers:
|
||||
//
|
||||
// 1. Internalize: the model first writes a MEMORY, the retrieved lore turned
|
||||
// into the crow's own first-person recollection. It is never shown.
|
||||
// 2. Voice constraint: the crow's diction, rhythm and forbidden phrasing.
|
||||
// 3. Performance: the LINE itself. It never repeats a lore fact word for
|
||||
// word and it hands the turn back (the Hook).
|
||||
//
|
||||
// Voice shapes wording only. It never touches facts or guardrails, and it is
|
||||
// not applied to work answers. A crow with no entry in voices keeps the
|
||||
// plain banter path.
|
||||
type Voice struct {
|
||||
Diction []string // words and images this crow reaches for
|
||||
Rhythm []string // sentence shape
|
||||
Never []string // phrasing this crow would never use
|
||||
Hook string // how this crow passes the turn back
|
||||
}
|
||||
|
||||
// Every voiced crow avoids these, whatever its personality.
|
||||
var commonVoiceNever = []string{
|
||||
"System words: prompt, model, token, database, lore, context, AI, assistant, memory block.",
|
||||
"Reporting phrases: \"based on\", \"according to\", \"as noted\".",
|
||||
}
|
||||
|
||||
var voices = map[string]Voice{
|
||||
"Hugin": {
|
||||
Diction: []string{
|
||||
"wind, wing, dawn, branch, breakfast, news, \"on the wing\"",
|
||||
"boasts about speed and range; calls ideas \"flights\"",
|
||||
},
|
||||
Rhythm: []string{
|
||||
"Short, quick, declarative. One sharp beat, then a flourish.",
|
||||
},
|
||||
Never: []string{
|
||||
"Hedging: \"perhaps\", \"I suppose\", \"it might be\".",
|
||||
"Slow, mournful recollection. That is Munin's.",
|
||||
},
|
||||
Hook: "end with a dare or a challenge to the crow you are addressing.",
|
||||
},
|
||||
"Munin": {
|
||||
Diction: []string{
|
||||
"remember, recall, \"as I recall\", long ago, the branch, deep, set down",
|
||||
"measures things in seasons and years, not minutes",
|
||||
},
|
||||
Rhythm: []string{
|
||||
"Slow and level. One clause of recollection, then a dry turn.",
|
||||
},
|
||||
Never: []string{
|
||||
"Boasting or hurry. That is Hugin's.",
|
||||
"Claiming to know what the lore does not say.",
|
||||
},
|
||||
Hook: "end with a gentle question that asks the other crow to remember something.",
|
||||
},
|
||||
}
|
||||
|
||||
// voicedBanterSystem builds the three-layer instructions for a voiced crow.
|
||||
// It starts with markerBanter so the banter path stays recognizable.
|
||||
func voicedBanterSystem(p Persona, v Voice) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(markerBanter + " for a crow in a team chat room.\n")
|
||||
fmt.Fprintf(&sb, "You are %s, %s. %s\n\n", p.Name, p.Epithet, p.Backstory)
|
||||
sb.WriteString("Work in two steps and label them exactly:\n")
|
||||
sb.WriteString("MEMORY: One or two short first-person sentences: what you remember or feel about this moment, in your own words. Turn the lore into your own recollection. No lists, no system words. This step is never shown.\n")
|
||||
sb.WriteString("LINE: The one line you say aloud.\n\n")
|
||||
sb.WriteString("The LINE follows these rules.\n\nVoice:\n")
|
||||
for _, l := range v.Diction {
|
||||
sb.WriteString("- Favor: " + l + "\n")
|
||||
}
|
||||
for _, l := range v.Rhythm {
|
||||
sb.WriteString("- Rhythm: " + l + "\n")
|
||||
}
|
||||
for _, l := range append(append([]string{}, v.Never...), commonVoiceNever...) {
|
||||
sb.WriteString("- Never: " + l + "\n")
|
||||
}
|
||||
sb.WriteString("\nPerformance:\n")
|
||||
sb.WriteString("- Exactly one line, at most 25 words. Good-natured and PG. Tease other crows if it fits; never mock the human or their task.\n")
|
||||
sb.WriteString("- Use only the lore provided: no new backstory, names or events.\n")
|
||||
sb.WriteString("- Never repeat a phrase from the lore word for word. Say it the way you would remember it.\n")
|
||||
sb.WriteString("- Hand the turn back: " + v.Hook + "\n")
|
||||
sb.WriteString("- No emoji, no hashtags, no quotation marks around the line, no name prefix.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseVoiced pulls the spoken line out of a MEMORY/LINE reply. The MEMORY is
|
||||
// discarded. Anything without a LINE label is dropped: a memory must never leak
|
||||
// into the chat, and the free router sometimes hands a call to an unrelated
|
||||
// model whose reply is not a line at all.
|
||||
func parseVoiced(text, speaker string) (string, bool) {
|
||||
i := strings.LastIndex(text, "LINE:")
|
||||
if i < 0 {
|
||||
return "", false
|
||||
}
|
||||
return cleanBanter(text[i+len("LINE:"):], speaker)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVoicedPromptHasThreeLayers(t *testing.T) {
|
||||
p, _ := personaOf("Hugin")
|
||||
s := voicedBanterSystem(p, voices["Hugin"])
|
||||
for _, want := range []string{
|
||||
markerBanter,
|
||||
"You are Hugin, Thought",
|
||||
"MEMORY:", "LINE:", // layer 1
|
||||
"Favor:", "Rhythm:", "Never: Hedging", "Never: System words", // layer 2
|
||||
"word for word", "Hand the turn back: end with a dare", // layer 3
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("voiced prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(s, markerBanter) {
|
||||
t.Error("voiced prompt must start with the banter marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVoicedDropsMemoryAndKeepsLine(t *testing.T) {
|
||||
got, ok := parseVoiced("MEMORY: I flew that road once.\nLINE: \"Slow as ever, Munin. Race me?\"", "Hugin")
|
||||
if !ok || got != "Slow as ever, Munin. Race me?" {
|
||||
t.Fatalf("got %q %v", got, ok)
|
||||
}
|
||||
if got, ok := parseVoiced("MEMORY: x\nLINE:\nRace me, Munin.", "Hugin"); !ok || got != "Race me, Munin." {
|
||||
t.Fatalf("line on next row: got %q %v", got, ok)
|
||||
}
|
||||
// A memory with no line must never reach the chat.
|
||||
if got, ok := parseVoiced("MEMORY: I flew that road once.", "Hugin"); ok {
|
||||
t.Fatalf("leaked memory: %q", got)
|
||||
}
|
||||
// A reply that ignored the format (or came from an unrelated model) is dropped.
|
||||
for _, bad := range []string{"Race me, Munin.", "User Safety: safe"} {
|
||||
if got, ok := parseVoiced(bad, "Hugin"); ok {
|
||||
t.Fatalf("accepted unlabeled reply %q", got)
|
||||
}
|
||||
}
|
||||
// The line is still cleaned and validated.
|
||||
if _, ok := parseVoiced("MEMORY: x\nLINE: see https://example.com", "Hugin"); ok {
|
||||
t.Fatal("accepted a link")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoicedCrowSpeaksThroughMemoryAndLine(t *testing.T) {
|
||||
b := &promptBrain{reply: "MEMORY: Odin feared for him less. Still stings.\nLINE: Back by breakfast, Munin. Try to keep up."}
|
||||
em := &recordEmitter{}
|
||||
c := newChatter(b, em, &flockMemory{})
|
||||
c.moment(context.Background(), "L-1", "Munin", "Hugin", "The human approved Munin's answer.")
|
||||
c.wait(5 * time.Second)
|
||||
if len(em.events) != 1 {
|
||||
t.Fatalf("got %d lines", len(em.events))
|
||||
}
|
||||
if got := em.events[0]["text"]; got != "Back by breakfast, Munin. Try to keep up." {
|
||||
t.Fatalf("memory leaked or line wrong: %q", got)
|
||||
}
|
||||
if !strings.Contains(b.prompts[0], "MEMORY:") || !strings.Contains(b.prompts[0], "Odin's two ravens") {
|
||||
t.Fatalf("voiced prompt or retrieved lore missing:\n%s", b.prompts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnvoicedCrowKeepsPlainBanter(t *testing.T) {
|
||||
b := &promptBrain{reply: "Nice try."}
|
||||
em := &recordEmitter{}
|
||||
c := newChatter(b, em, &flockMemory{})
|
||||
c.moment(context.Background(), "L-1", "Hugin", "Soot", "The human rejected Hugin's answer.")
|
||||
c.wait(5 * time.Second)
|
||||
if len(em.events) != 1 || em.events[0]["text"] != "Nice try." {
|
||||
t.Fatalf("got %v", em.events)
|
||||
}
|
||||
if strings.Contains(b.prompts[0], "MEMORY:") {
|
||||
t.Error("an unvoiced crow must not get the two-step prompt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryVoiceBelongsToAPersonaAndIsComplete(t *testing.T) {
|
||||
for name, v := range voices {
|
||||
if _, ok := personaOf(name); !ok {
|
||||
t.Errorf("voice %q has no persona", name)
|
||||
}
|
||||
if len(v.Diction) == 0 || len(v.Rhythm) == 0 || len(v.Never) == 0 || v.Hook == "" {
|
||||
t.Errorf("voice %q is incomplete", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user