Files
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

296 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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'
}