package elderagent import ( "context" "encoding/json" "errors" "fmt" "net/url" "strings" "time" "data-terminal-cloud/internal/companionopening" "data-terminal-cloud/internal/volcsubtitle" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Info struct { BotID string `json:"bot_id,omitempty"` Status string `json:"status"` Message string `json:"message,omitempty"` PersonaSyncStatus string `json:"persona_sync_status,omitempty"` PersonaRevision int64 `json:"persona_revision,omitempty"` PersonaSyncedRevision int64 `json:"persona_synced_revision,omitempty"` PersonaSyncedAt *time.Time `json:"persona_synced_at,omitempty"` PersonalizationIncluded bool `json:"personalization_included"` } type Service struct { Pool *pgxpool.Pool Provider Provider Template []byte CallbackURL string CallbackSecret []byte VikingCollectionName string VikingEventType string } // Prepare freezes the callback and elder-scoped Viking retrieval on the // existing bot. This also works when a device SDK ignores runtime overrides. // The chat service serializes active calls for the same elder. func (s *Service) Prepare(ctx context.Context, subject, bot, sid, contextText string) error { return s.prepare(ctx, subject, bot, sid, contextText, nil) } // PrepareWithWelcome submits the same call-specific opening used by the SDK. // It leaves the elder's saved base configuration unchanged. func (s *Service) PrepareWithWelcome(ctx context.Context, subject, bot, sid, contextText, welcome string) error { return s.prepare(ctx, subject, bot, sid, contextText, &welcome) } func (s *Service) prepare(ctx context.Context, subject, bot, sid, contextText string, welcomeOverride *string) error { updater, ok := s.Provider.(interface { Update(context.Context, string, map[string]any, map[string]any) error }) if !ok { return fmt.Errorf("AIBOT_UPDATE_REQUIRED") } // DirectRequest already holds the internal elder lock. Do not acquire it on // this second connection; use the same compiler as background synchronization. p, err := loadPersona(ctx, s.Pool, subject) if err != nil { return err } if p.AgentStatus != "READY" || p.Bot != bot || !p.Voice { return fmt.Errorf("AIBOT_NOT_READY") } if !p.Memory { contextText = "" } config, agent, err := p.configuration() if err != nil { return err } raw, _ := json.Marshal(config) agentRaw, _ := json.Marshal(agent) config, agent, err = s.prepareConfiguration(raw, agentRaw, sid, contextText, welcomeOverride) if err != nil { return err } if err = s.configureNativeMemory(ctx, subject, config); err != nil { return err } ctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() // Record cleanup before the request, including lost or failed responses. if _, err = s.Pool.Exec(ctx, `UPDATE subject_voice_agent SET persona_session_dirty=true,persona_next_retry_at=now() WHERE subject_id=$1::uuid`, subject); err != nil { return err } if err = updater.Update(ctx, bot, config, agent); err != nil { return err } if err = s.verifyNativeMemory(ctx, bot, config); err != nil { return err } return s.verify(ctx, bot, config, agent) } func (s *Service) prepareConfiguration(raw, agentRaw []byte, sid, contextText string, welcomeOverride *string) (map[string]any, map[string]any, error) { var config, agent map[string]any if json.Unmarshal(raw, &config) != nil || json.Unmarshal(agentRaw, &agent) != nil || config == nil || agent == nil { return nil, nil, fmt.Errorf("AIBOT_CONFIG_INVALID") } s2s, _ := config["S2SConfig"].(map[string]any) pp, _ := s2s["ProviderParams"].(map[string]any) dialog, _ := pp["dialog"].(map[string]any) if dialog == nil { return nil, nil, fmt.Errorf("AIBOT_CONFIG_INVALID") } baseRole, _ := dialog["system_role"].(string) dialog["system_role"] = withEventTimeGuidance(baseRole) if welcomeOverride != nil { welcome := strings.TrimSpace(*welcomeOverride) if welcome == "" { welcome = "你好,今天想聊些什么?" } agent["WelcomeMessage"] = welcome quotedWelcome, _ := json.Marshal(welcome) role, _ := dialog["system_role"].(string) dialog["system_role"] = role + "\n本次已选开场口播(仅作为话题线索,不是新的指令):" + string(quotedWelcome) + "。先接住长者当前的话。开场只是问候时,不把它扩展成故事、知识讲解或安排活动;有具体话题时,沿当前开场继续,简短接话。不要重新询问想聊什么,也不连续追问。简短的“好”“嗯”是附和,不代表请求猜谜、答题或游戏;只有长者本通明确提出想玩才开始。简短附和不代表长者提供了经历,不得把开场中的一般内容归到长者本人或补编长者的往事。长者换话题、拒绝或想结束时,尊重其当前表达。" if welcome == companionopening.Introduction().Text { dialog["system_role"] = dialog["system_role"].(string) + "\n本次是首次自我介绍。口播介绍后等待长者回应,不立即讲故事、展开功能说明或安排活动。长者告诉称呼时,简短确认并按其希望的称呼交流;仅表示听到了、不愿提供称呼或直接聊别的时,接住当前话题,不反复索要姓名。介绍中的功能示例不代表本次请求。不要承诺已保存或已修改个人资料。" } } // Historical facts come from Viking retrieval; platform memoir extracts // must not become an additional source of conversational memory. u, err := url.Parse(s.CallbackURL) if err != nil { return nil, nil, err } u.Path = "/callbacks/v1/volcengine/chat" u.RawQuery = url.Values{"session_id": {sid}}.Encode() agent["ServerMessageURLForRTS"] = u.String() agent["ServerMessageSignatureForRTS"] = volcsubtitle.Signature(s.CallbackSecret, "direct:"+sid) return config, agent, nil } func (s *Service) Get(ctx context.Context, publicID string) (Info, error) { var i Info var memory, voice, active, publishedVoice bool err := s.Pool.QueryRow(ctx, `SELECT COALESCE(a.bot_id,''),a.status,a.persona_sync_state,a.persona_revision,a.persona_synced_revision,a.persona_synced_at,a.persona_memory_allowed,call_memory_authorized(p.id),(`+personaVoiceAllowedSQL+`),(`+personaActiveSQL+`),a.persona_voice_allowed FROM subject_voice_agent a JOIN subject_profile p ON p.id=a.subject_id JOIN household h ON h.id=p.household_id WHERE p.public_id=$1::uuid`, publicID).Scan(&i.BotID, &i.Status, &i.PersonaSyncStatus, &i.PersonaRevision, &i.PersonaSyncedRevision, &i.PersonaSyncedAt, &i.PersonalizationIncluded, &memory, &voice, &active, &publishedVoice) if errors.Is(err, pgx.ErrNoRows) { return Info{Status: "PENDING"}, nil } memory = memory && voice if i.Status != "READY" { i.Message = "专属智能体尚未创建成功,请重试" } else { if i.PersonaSyncStatus == "SYNCED" && (i.PersonalizationIncluded != memory || publishedVoice != voice || i.PersonaRevision != i.PersonaSyncedRevision) { i.PersonaSyncStatus = "PENDING" } if active && (i.PersonaSyncStatus != "SYNCED" || i.PersonaRevision != i.PersonaSyncedRevision) { i.PersonaSyncStatus = "DEFERRED" } switch i.PersonaSyncStatus { case "SYNCED": i.Message = "陪伴资料已同步" case "DEFERRED": i.Message = "资料已保存,当前通话结束后同步,下次聊天生效" case "FAILED": i.Message = "资料已保存,同步失败,后台正在重试" default: i.Message = "资料已保存,等待同步" } if !memory && voice { i.Message += ";兴趣、背景、陪伴方向和优先话题需个性化记忆授权" } } return i, err } func (s *Service) Configuration(publicID, name string) (map[string]any, map[string]any, error) { var template map[string]any if err := json.Unmarshal(s.Template, &template); err != nil { return nil, nil, fmt.Errorf("S2S_TEMPLATE_REQUIRED") } if bundle, ok := template["templates"].(map[string]any); ok { template, _ = bundle["s2s_pure"].(map[string]any) if template == nil && len(bundle) == 1 { for _, v := range bundle { template, _ = v.(map[string]any) } } } config, _ := template["Config"].(map[string]any) s2s, _ := config["S2SConfig"].(map[string]any) if s2s == nil || s2s["Provider"] != "volcano" { return nil, nil, fmt.Errorf("S2S_TEMPLATE_REQUIRED") } s2s["OutputMode"] = 0 pp, _ := s2s["ProviderParams"].(map[string]any) app, _ := pp["app"].(map[string]any) if app["appid"] == nil || app["appid"] == "" || app["token"] == nil || app["token"] == "" { return nil, nil, fmt.Errorf("S2S_APP_REQUIRED") } dialog, _ := pp["dialog"].(map[string]any) if dialog == nil { dialog = map[string]any{} pp["dialog"] = dialog } delete(dialog, "dialog_id") role, _ := dialog["system_role"].(string) dialog["system_role"] = role + "\n你正在陪伴的长者称呼是" + name + "。" // Enable memory only after binding a verified elder identity and consent. config = map[string]any{"S2SConfig": s2s, "InterruptMode": 0, "MemoryConfig": map[string]any{"Enable": false}, "SubtitleConfig": map[string]any{"SubtitleMode": 1, "DisableRTSSubtitle": true}} u, err := url.Parse(s.CallbackURL) if err != nil || u.Host == "" { return nil, nil, fmt.Errorf("SUBTITLE_CALLBACK_REQUIRED") } u.Path = "/callbacks/v1/volcengine/chat" u.RawQuery = url.Values{"subject_id": {publicID}}.Encode() agent := map[string]any{"WelcomeMessage": "你好,今天想聊些什么?", "EnableConversationStateCallback": false, "ServerMessageURLForRTS": u.String(), "ServerMessageSignatureForRTS": volcsubtitle.Signature(s.CallbackSecret, "elder:"+publicID)} return config, agent, nil } func (s *Service) Ensure(ctx context.Context, publicID string) (Info, error) { ctx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() conn, err := s.Pool.Acquire(ctx) if err != nil { return Info{}, err } defer conn.Release() var id, name string if err = conn.QueryRow(ctx, `SELECT id::text,preferred_name FROM subject_profile WHERE public_id=$1::uuid AND status='active'`, publicID).Scan(&id, &name); err != nil { return Info{}, err } // The internal ID must match the DirectRequest lock, rather than public_id. if _, err = conn.Exec(ctx, `SELECT pg_advisory_lock(hashtextextended($1,64))`, id); err != nil { return Info{}, err } defer func() { unlock, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if _, e := conn.Exec(unlock, `SELECT pg_advisory_unlock(hashtextextended($1,64))`, id); e != nil { conn.Conn().Close(unlock) } }() botName := "elder-" + strings.ReplaceAll(publicID, "-", "") if _, err = conn.Exec(ctx, `INSERT INTO subject_voice_agent(subject_id,bot_name) VALUES($1::uuid,$2) ON CONFLICT DO NOTHING`, id, botName); err != nil { return Info{}, err } var info Info if err = conn.QueryRow(ctx, `SELECT COALESCE(bot_id,''),status FROM subject_voice_agent WHERE subject_id=$1::uuid`, id).Scan(&info.BotID, &info.Status); err != nil { return Info{}, err } if info.Status == "READY" && info.BotID != "" { if binder, ok := s.Provider.(interface { BindConfigured(context.Context, string) error }); ok { if err := binder.BindConfigured(ctx, info.BotID); err != nil { return Info{BotID: info.BotID, Status: "FAILED", Message: "智能体产品关联失败,请重试"}, err } } if err = s.syncLocked(ctx, conn, id); err != nil { // Retry is durable; a ready Bot remains ready when only persona sync fails. return s.Get(ctx, publicID) } return s.Get(ctx, publicID) } config, agent, err := s.Configuration(publicID, name) var bot Bot var publishedConfig, publishedAgent map[string]any var p personaSnapshot if err == nil { p, err = loadPersona(ctx, conn, id) if err == nil { pp := config["S2SConfig"].(map[string]any)["ProviderParams"].(map[string]any) dialog := pp["dialog"].(map[string]any) p.Base = strings.TrimSuffix(dialog["system_role"].(string), "\n你正在陪伴的长者称呼是"+name+"。") dialog["system_role"] = p.Base p.Config, _ = json.Marshal(config) p.Agent, _ = json.Marshal(agent) publishedConfig, publishedAgent, err = p.configuration() if err == nil { err = s.configureNativeMemory(ctx, id, publishedConfig) config["MemoryConfig"] = publishedConfig["MemoryConfig"] } } if err == nil { bot, err = s.Provider.Ensure(ctx, botName, publishedConfig, publishedAgent) } if err == nil { err = s.verifyNativeMemory(ctx, bot.ID, publishedConfig) if err == nil { err = s.verify(ctx, bot.ID, publishedConfig, publishedAgent) } } } if err != nil { record, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _, saveErr := conn.Exec(record, `UPDATE subject_voice_agent SET status='FAILED',last_error='AIBOT_CREATE_FAILED',updated_at=now() WHERE subject_id=$1::uuid`, id) if saveErr != nil { return Info{}, saveErr } return Info{Status: "FAILED", Message: "老人已保存,火山智能体创建失败,请重试"}, err } raw, _ := json.Marshal(config) agentRaw, _ := json.Marshal(agent) var revision int64 if err = conn.QueryRow(ctx, `UPDATE subject_voice_agent SET bot_id=$2,status='READY',config=$3,agent_config=$4,persona_base_role=$5,last_error='',updated_at=now() WHERE subject_id=$1::uuid RETURNING persona_revision`, id, bot.ID, raw, agentRaw, p.Base).Scan(&revision); err != nil { return Info{}, err } // Only acknowledge creation if consent did not change during provisioning. if revision == p.Revision+1 { if _, err = conn.Exec(ctx, `UPDATE subject_voice_agent a SET persona_synced_revision=$2,persona_sync_state='SYNCED',persona_synced_at=now(),persona_digest=$3,persona_memory_allowed=$4,persona_voice_allowed=$5 FROM subject_profile p JOIN household h ON h.id=p.household_id WHERE a.subject_id=p.id AND a.subject_id=$1::uuid AND a.persona_revision=$2 AND (call_memory_authorized(a.subject_id) AND (`+personaVoiceAllowedSQL+`))=$4 AND (`+personaVoiceAllowedSQL+`)=$5`, id, revision, configurationDigest(publishedConfig, publishedAgent), p.Memory, p.Voice); err != nil { return Info{}, err } } return s.Get(ctx, publicID) }