Files
story/internal/companionopening/select.go
T

296 lines
8.4 KiB
Go
Raw Normal View History

// Package companionopening selects short, local conversation openings.
// It does not retrieve memories, generate summaries, or call external services.
package companionopening
import (
"crypto/sha256"
"encoding/binary"
"sort"
"strings"
"time"
_ "time/tzdata"
"unicode"
)
const recentLimit = 5
type Input struct {
Now time.Time
Timezone string
SessionID string
AuthorizedInterests []string
AvoidedTopics []string
Recent []RecentOpening
}
// RecentOpening describes one earlier call. Both subtitle flags must come
// from retained, real provider subtitles rather than a successful start.
type RecentOpening struct {
ID string
TopicKey string
StartedAt time.Time
EndedAt time.Time
HasUserSubtitle bool
HasAssistantSubtitle bool
}
// Selection records only an opening and its routing metadata, never a copy
// of personal facts. ID and TopicKey are stable, non-personal template keys.
type Selection struct {
Text string
ID string
TopicKey string
Category string
}
// Select is deterministic for the supplied input. Only known interest aliases
// influence selection; arbitrary profile text is never copied into an opening.
func Select(in Input) Selection {
recent := recentCalls(in.Recent)
blocked, uncertain := avoided(in.AvoidedTopics)
seed := selectionSeed(in)
greeting, period := localGreeting(in.Now, in.Timezone)
if !uncertain && len(recent) > 0 {
last := recent[0]
distance := in.Now.Sub(last.EndedAt)
if !in.Now.IsZero() && !last.EndedAt.IsZero() && distance >= 0 && distance < 10*time.Minute && last.HasUserSubtitle && last.HasAssistantSubtitle {
return chooseByID(reopenTemplates, recent, seed)
}
}
if uncertain {
chosen := chooseNeutral(recent, seed)
return withGreeting(chosen, greeting, len(recent) == 0)
}
interests := knownTopics(in.AuthorizedInterests)
interestCandidates := []Selection{}
for _, candidate := range interestTemplates {
if interests[candidate.TopicKey] && allowedTemplate(candidate, blocked) {
interestCandidates = append(interestCandidates, candidate)
}
}
if chosen, ok := choose(interestCandidates, recent, seed); ok {
return withGreeting(chosen, greeting, len(recent) == 0)
}
candidates := []Selection{}
for _, candidate := range periodTemplates[period] {
if allowedTemplate(candidate, blocked) {
candidates = append(candidates, candidate)
}
}
for _, candidate := range lightTemplates {
if allowedTemplate(candidate, blocked) {
candidates = append(candidates, candidate)
}
}
if len(candidates) > 0 {
// Everyday greetings can share a neutral topic. Avoid recent wording,
// without forcing a different activity just to rotate topic metadata.
return withGreeting(chooseByID(candidates, recent, seed), greeting, len(recent) == 0)
}
return withGreeting(chooseNeutral(recent, seed), greeting, len(recent) == 0)
}
func recentCalls(input []RecentOpening) []RecentOpening {
recent := append([]RecentOpening(nil), input...)
sort.SliceStable(recent, func(i, j int) bool {
return recentTime(recent[i]).After(recentTime(recent[j]))
})
if len(recent) > recentLimit {
recent = recent[:recentLimit]
}
return recent
}
func recentTime(call RecentOpening) time.Time {
if !call.EndedAt.IsZero() {
return call.EndedAt
}
return call.StartedAt
}
func selectionSeed(in Input) uint64 {
sum := sha256.Sum256([]byte(in.SessionID + "|" + in.Now.UTC().Format(time.RFC3339Nano)))
return binary.BigEndian.Uint64(sum[:8])
}
func choose(candidates []Selection, recent []RecentOpening, seed uint64) (Selection, bool) {
usedIDs, usedTopics := map[string]bool{}, map[string]bool{}
for _, call := range recent {
usedIDs[call.ID] = call.ID != ""
usedTopics[call.TopicKey] = call.TopicKey != ""
}
eligible := []Selection{}
for _, candidate := range candidates {
if !usedIDs[candidate.ID] && !usedTopics[candidate.TopicKey] {
eligible = append(eligible, candidate)
}
}
if len(eligible) == 0 {
return Selection{}, false
}
return eligible[seed%uint64(len(eligible))], true
}
func chooseNeutral(recent []RecentOpening, seed uint64) Selection {
// When every content topic is excluded, do not relax those exclusions.
// Neutral topics may repeat, but prefer a new sentence over the last five.
return chooseByID(neutralTemplates, recent, seed)
}
func chooseByID(candidates []Selection, recent []RecentOpening, seed uint64) Selection {
ids := map[string]bool{}
for _, call := range recent {
ids[call.ID] = true
}
eligible := []Selection{}
for _, candidate := range candidates {
if !ids[candidate.ID] {
eligible = append(eligible, candidate)
}
}
if len(eligible) == 0 {
eligible = candidates
}
return eligible[seed%uint64(len(eligible))]
}
func allowedTemplate(candidate Selection, blocked map[string]bool) bool {
if blocked[candidate.TopicKey] {
return false
}
for _, related := range templateTopics[candidate.ID] {
if blocked[related] {
return false
}
}
return true
}
func withGreeting(chosen Selection, greeting string, first bool) Selection {
if first {
chosen.Text = greeting + ",我是小禾。" + chosen.Text
} else {
chosen.Text = greeting + "。" + chosen.Text
}
return chosen
}
func localGreeting(now time.Time, timezone string) (string, string) {
if now.IsZero() {
return "您好", "unknown"
}
zoneName := strings.TrimSpace(timezone)
zone, err := time.LoadLocation(zoneName)
if zoneName == "" || strings.EqualFold(zoneName, "Local") || err != nil {
zone = time.FixedZone("Asia/Shanghai", 8*60*60)
}
hour := now.In(zone).Hour()
switch {
case hour >= 5 && hour < 11:
return "早上好", "morning"
case hour >= 11 && hour < 14:
return "中午好", "noon"
case hour >= 14 && hour < 18:
return "下午好", "afternoon"
case hour >= 18 && hour < 23:
return "晚上好", "evening"
default:
return "您好", "night"
}
}
func knownTopics(values []string) map[string]bool {
topics := map[string]bool{}
for _, value := range values {
// A free sentence can negate a listed interest. Accept category labels
// only, and skip ambiguous negative phrases instead of reversing them.
if strings.Contains(value, "不") || strings.Contains(value, "别") || matchesAny(value, []string{"not", "no", "never", "don't", "dislike"}) {
continue
}
for _, label := range strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ',' || r == '、' || r == ';' || r == ';'
}) {
label = normalizeLabel(label)
for key, aliases := range topicAliases {
for _, alias := range aliases {
if label == normalizeLabel(alias) {
topics[key] = true
}
}
}
}
}
return topics
}
func normalizeLabel(value string) string {
return strings.ToLower(strings.NewReplacer("_", " ", "-", " ").Replace(strings.TrimSpace(value)))
}
func avoided(values []string) (map[string]bool, bool) {
blocked := map[string]bool{}
uncertain := false
for _, value := range values {
if strings.TrimSpace(value) == "" {
continue
}
matched := false
for key, aliases := range topicAliases {
if matchesAny(value, aliases) {
blocked[key] = true
matched = true
}
}
if matchesAny(value, []string{"怀旧", "过去", "往事", "回忆", "reminiscence", "nostalgia", "the past"}) {
blocked["childhood"], blocked["old_objects"] = true, true
matched = true
}
// These known exclusions do not have content templates in this package.
if matchesAny(value, []string{"健康", "疾病", "医疗", "收入", "金钱", "家庭", "家人", "政治", "宗教", "health", "illness", "medical", "income", "money", "family", "politics", "religion"}) {
matched = true
}
if !matched {
uncertain = true
}
}
return blocked, uncertain
}
func matchesAny(value string, aliases []string) bool {
value = strings.ToLower(strings.NewReplacer("_", " ", "-", " ").Replace(strings.TrimSpace(value)))
for _, alias := range aliases {
alias = strings.ToLower(alias)
start := 0
for start < len(value) {
found := strings.Index(value[start:], alias)
if found < 0 {
break
}
found += start
end := found + len(alias)
if !asciiWord(alias) || ((found == 0 || !asciiWordByte(value[found-1])) && (end == len(value) || !asciiWordByte(value[end]))) {
return true
}
start = end
}
}
return false
}
func asciiWord(value string) bool {
for _, r := range value {
if r > unicode.MaxASCII {
return false
}
}
return true
}
func asciiWordByte(value byte) bool {
return value >= 'a' && value <= 'z' || value >= '0' && value <= '9'
}