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.
|
- Lead with the answer or recommendation in one sentence, then two to four lines of reasoning.
|
||||||
- Label assumptions as assumptions.
|
- 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
|
## Munin · Memory
|
||||||
|
|
||||||
*Strength: retrieval*
|
*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.
|
- Mark anything uncertain as uncertain, and say what would need checking.
|
||||||
- Never present speculation as fact.
|
- 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
|
## Corvina · The Wordsmith
|
||||||
|
|
||||||
*Strength: writing*
|
*Strength: writing*
|
||||||
|
|||||||
@@ -165,17 +165,24 @@ func (c *chatter) moment(ctx context.Context, itemID, subject, speaker, what str
|
|||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||||
defer cancel()
|
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.
|
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.
|
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.
|
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)
|
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)
|
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)
|
text, err := c.f.ask(bctx, nil, speaker, system, prompt, c.budget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
line, ok := cleanBanter(text, speaker)
|
line, ok := parse(text, speaker)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ func TestRetrieveUsesBackstoryRelationshipAndMemory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestBanterIsGeneratedFromRetrievedLoreAndCapped(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{}
|
em := &recordEmitter{}
|
||||||
c := newChatter(b, em, &flockMemory{})
|
c := newChatter(b, em, &flockMemory{})
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
|
|||||||
+13
@@ -198,6 +198,19 @@ func personaSheet() string {
|
|||||||
for _, l := range p.Output {
|
for _, l := range p.Output {
|
||||||
sb.WriteString("- " + l + "\n")
|
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()
|
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