109 lines
4.9 KiB
Go
109 lines
4.9 KiB
Go
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}
|
||
|
|
}
|