Files
story/internal/elderagent/service.go
T

312 lines
14 KiB
Go
Raw Normal View History

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