296 lines
8.4 KiB
Go
296 lines
8.4 KiB
Go
// 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'
|
|||
|
|
}
|