package callmemory import ( "context" "encoding/json" "errors" "strings" "time" "data-terminal-cloud/internal/companionopening" "github.com/jackc/pgx/v5" ) // Only bounded template keys are retained for opening rotation, without facts. // These describe what was selected, not what the elder heard or discussed. type directOpeningMetadata struct { Version string `json:"version"` ID string `json:"id"` TopicKey string `json:"topic_key"` Category string `json:"category"` } func (s *Service) selectDirectOpening(ctx context.Context, tx pgx.Tx, subject, bot, sid, device string, generation, epoch int64, memory bool) (string, *directOpeningMetadata, error) { var welcome, timezone string var profileRaw, preferencesRaw []byte err := tx.QueryRow(ctx, `SELECT COALESCE(a.agent_config->>'WelcomeMessage',''),p.timezone,p.profile_data,p.conversation_preferences FROM subject_voice_agent a JOIN subject_profile p ON p.id=a.subject_id WHERE a.subject_id=$1::uuid AND a.bot_id=$2 AND a.status='READY'`, subject, bot).Scan(&welcome, &timezone, &profileRaw, &preferencesRaw) if errors.Is(err, pgx.ErrNoRows) { return "", nil, &Error{409, "VOICE_AGENT_NOT_READY", "该老人的智能体尚未创建成功,请重试创建", "NONE"} } if err != nil { return "", nil, err } welcome = strings.TrimSpace(welcome) if welcome == "" { welcome = "你好,今天想聊些什么?" } // Onboarding belongs to this binding, independently of personalized-memory // permission, privacy epochs, and the last five rotation entries. Existing // bindings without this guide receive it once on rollout. A normal, fully // stopped call completes the guide session; this is not a playback receipt. if s.direct.PrepareWithWelcome != nil { intro := companionopening.Introduction() var established bool err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session c WHERE c.device_id=$1::uuid AND c.subject_id=$2::uuid AND c.binding_generation=$3 AND c.budget_snapshot->>'protocol'='DIRECT-CHAT-1' AND c.budget_snapshot->'opening'->>'id'=$4 AND c.call_state='ENDED' AND c.ended_at IS NOT NULL AND c.local_audio_off AND c.sdk_stopped)`, device, subject, generation, intro.ID).Scan(&established) if err != nil { return "", nil, err } if !established { return intro.Text, openingMetadata(intro), nil } } var preferences struct { DynamicOpening bool `json:"dynamic_opening"` Preferred []string `json:"preferred_topics"` Avoided []string `json:"avoided_topics"` } // Malformed or unsupported preferences keep the original welcome. The pilot // also requires an updater that accepts the same text given to the device. if json.Unmarshal(preferencesRaw, &preferences) != nil || !preferences.DynamicOpening || s.direct.PrepareWithWelcome == nil { return welcome, nil, nil } input := companionopening.Input{Now: s.now(), Timezone: timezone, SessionID: sid, AvoidedTopics: preferences.Avoided} transcripts := false if memory { transcripts, err = transcriptPermission(ctx, tx, subject) if err != nil { return "", nil, err } } if memory { var profile struct { Interests []string `json:"interests"` } if json.Unmarshal(profileRaw, &profile) == nil { input.AuthorizedInterests = append(profile.Interests, preferences.Preferred...) } } // History is scoped to this binding and current privacy epoch. Transcript content is // never loaded: only the existence of both sides of a real conversation. rows, err := tx.Query(ctx, `SELECT COALESCE(c.budget_snapshot->'opening'->>'id',''),COALESCE(c.budget_snapshot->'opening'->>'topic_key',''),c.created_at,c.ended_at, $3 AND EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker='USER' AND u.text_content NOT IN ('','[deleted]')), $3 AND EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker='ASSISTANT' AND u.text_content NOT IN ('','[deleted]')) FROM call_session c WHERE c.subject_id=$1::uuid AND c.privacy_epoch=$2 AND c.device_id=$4::uuid AND c.binding_generation=$5 AND c.budget_snapshot->>'protocol'='DIRECT-CHAT-1' ORDER BY c.created_at DESC,c.id DESC LIMIT 5`, subject, epoch, transcripts, device, generation) if err != nil { return "", nil, err } defer rows.Close() for rows.Next() { var previous companionopening.RecentOpening var ended *time.Time if err = rows.Scan(&previous.ID, &previous.TopicKey, &previous.StartedAt, &ended, &previous.HasUserSubtitle, &previous.HasAssistantSubtitle); err != nil { return "", nil, err } if ended != nil { previous.EndedAt = *ended } input.Recent = append(input.Recent, previous) } if err = rows.Err(); err != nil { return "", nil, err } selected := companionopening.Select(input) return selected.Text, openingMetadata(selected), nil } func openingMetadata(selected companionopening.Selection) *directOpeningMetadata { return &directOpeningMetadata{Version: "companion-opening-v2", ID: selected.ID, TopicKey: selected.TopicKey, Category: selected.Category} }