feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
// 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'
|
||||
}
|
||||
Reference in New Issue
Block a user