// 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' }