Files

109 lines
4.9 KiB
Go
Raw Permalink Normal View History

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