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,108 @@
|
||||
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}
|
||||
}
|
||||
Reference in New Issue
Block a user