This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"data-terminal-cloud/internal/billing"
|
||||
"data-terminal-cloud/internal/conversation"
|
||||
"data-terminal-cloud/internal/memorytool"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
"data-terminal-cloud/internal/ratelimit"
|
||||
"data-terminal-cloud/internal/voiceprovider"
|
||||
@@ -40,6 +41,19 @@ type StartProcessor struct {
|
||||
startLimiter providerStartRateLimiter
|
||||
maxActiveTasks int
|
||||
now func() time.Time
|
||||
memoryTool *memorytool.TokenSigner
|
||||
memoryToolURL string
|
||||
memoryToolTTL time.Duration
|
||||
}
|
||||
|
||||
func (p *StartProcessor) ConfigureMemoryTool(signer *memorytool.TokenSigner, endpoint string, ttl time.Duration) error {
|
||||
if signer == nil || endpoint == "" || ttl <= 0 {
|
||||
return errors.New("memory tool signer, endpoint and positive token TTL are required")
|
||||
}
|
||||
p.memoryTool = signer
|
||||
p.memoryToolURL = endpoint
|
||||
p.memoryToolTTL = ttl
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigureStartProtection enables the provider-wide RPS bucket and durable
|
||||
@@ -105,6 +119,13 @@ func (p *StartProcessor) Handle(ctx context.Context, event outbox.Event) error {
|
||||
if taskStatus != "STARTING" && taskStatus != "UNKNOWN" {
|
||||
return fmt.Errorf("provider task status %s cannot be started", taskStatus)
|
||||
}
|
||||
if request.EngineMode == "s2s_hybrid" && p.memoryTool != nil {
|
||||
request.MemoryToolURL = p.memoryToolURL
|
||||
request.MemoryToolToken, err = p.memoryTool.Issue(request.ConversationID, request.TaskRef, p.now().UTC().Add(p.memoryToolTTL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("issue memory tool token: %w", err)
|
||||
}
|
||||
}
|
||||
allowed, err := p.health.AllowStart(ctx, tenantID, providerName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read provider health: %w", err)
|
||||
|
||||
@@ -38,6 +38,12 @@ var (
|
||||
|
||||
const contextPreparationFailureCode = "context_preparation_failed"
|
||||
|
||||
const (
|
||||
contextSummaryLimit = 1000
|
||||
recentSummaryLimit = 3
|
||||
recentSummaryContentLimit = 240
|
||||
)
|
||||
|
||||
type AllocationOptions struct {
|
||||
Now time.Time
|
||||
AllocationExpires time.Time
|
||||
@@ -367,19 +373,24 @@ func (r *Repository) Create(
|
||||
consentSnapshotID, memoryVersionsJSON, promptBundleVersion, engineMode,
|
||||
privacyFromConsent(effectiveConsents), options)
|
||||
}
|
||||
contextSummary := buildContextSummary(subjectName, profileContext)
|
||||
if len(memoryContents) > 0 {
|
||||
contextSummary += ";已确认记忆:" + strings.Join(memoryContents, ";")
|
||||
var recentSummaries []string
|
||||
if consentAllowsConversationContinuity(effectiveConsents) {
|
||||
recentSummaries, err = selectRecentConversationSummaries(ctx, tx, identity.TenantID, subjectID, recentSummaryLimit)
|
||||
if err != nil {
|
||||
return CreateResponse{}, "", fmt.Errorf("retrieve recent conversation summaries: %w", err)
|
||||
}
|
||||
}
|
||||
contextSummary := buildContextSummary(subjectName, profileContext, recentSummaries, memoryContents)
|
||||
contextValue := struct {
|
||||
SubjectID string `json:"subject_id"`
|
||||
PreferredName string `json:"preferred_name"`
|
||||
ProfileVersion int64 `json:"profile_version"`
|
||||
RequestedGoal string `json:"requested_goal"`
|
||||
ProfileContext authorizedSubjectContext `json:"profile_context"`
|
||||
RecentSummaries []string `json:"recent_summaries"`
|
||||
MemoryContents []string `json:"memory_contents"`
|
||||
RetrievalPolicy string `json:"retrieval_policy"`
|
||||
}{subjectPublicID, subjectName, profileVersion, input.RequestedGoal, profileContext, memoryContents, retrievalPolicy(options.MemoryPolicy)}
|
||||
}{subjectPublicID, subjectName, profileVersion, input.RequestedGoal, profileContext, recentSummaries, memoryContents, retrievalPolicy(options.MemoryPolicy)}
|
||||
contextJSON, _ := json.Marshal(contextValue)
|
||||
contextDigest := sha256.Sum256(contextJSON)
|
||||
contextSnapshotID := uuid.NewString()
|
||||
@@ -664,6 +675,7 @@ type authorizedSubjectContext struct {
|
||||
ConversationGoal string `json:"conversation_goal,omitempty"`
|
||||
Pace string `json:"pace,omitempty"`
|
||||
PreferredTopics []string `json:"preferred_topics,omitempty"`
|
||||
AvoidedTopics []string `json:"avoided_topics,omitempty"`
|
||||
}
|
||||
|
||||
func authorizedProfileContext(profileDataJSON, preferencesJSON json.RawMessage) (authorizedSubjectContext, error) {
|
||||
@@ -694,6 +706,10 @@ func authorizedProfileContext(profileDataJSON, preferencesJSON json.RawMessage)
|
||||
if err != nil {
|
||||
return authorizedSubjectContext{}, err
|
||||
}
|
||||
avoidedTopics, err := strictContextStringList(preferences, "avoided_topics", 8, 50)
|
||||
if err != nil {
|
||||
return authorizedSubjectContext{}, err
|
||||
}
|
||||
if pace != "slow" && pace != "normal" && pace != "lively" {
|
||||
pace = ""
|
||||
}
|
||||
@@ -703,30 +719,107 @@ func authorizedProfileContext(profileDataJSON, preferencesJSON json.RawMessage)
|
||||
ConversationGoal: goal,
|
||||
Pace: pace,
|
||||
PreferredTopics: preferredTopics,
|
||||
AvoidedTopics: avoidedTopics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildContextSummary(preferredName string, profile authorizedSubjectContext) string {
|
||||
parts := []string{"称呼:" + truncateContextText(preferredName, 120)}
|
||||
if profile.Background != "" {
|
||||
parts = append(parts, "背景资料:"+profile.Background)
|
||||
func buildContextSummary(preferredName string, profile authorizedSubjectContext, recentSummaries, memoryContents []string) string {
|
||||
parts := make([]string, 0, 10)
|
||||
if len(profile.AvoidedTopics) > 0 {
|
||||
parts = append(parts, "交流边界:不要主动提及“"+strings.Join(truncateContextItems(profile.AvoidedTopics, 24), "、")+"”;若长者主动提及,先简短共情,再自然转向;紧急安全响应不受此限制")
|
||||
}
|
||||
if len(profile.Interests) > 0 {
|
||||
parts = append(parts, "兴趣:"+strings.Join(profile.Interests, "、"))
|
||||
parts = append(parts, "称呼:"+truncateContextField(preferredName, 60))
|
||||
for index, summary := range recentSummaries {
|
||||
parts = append(parts, fmt.Sprintf("最近第%d次通话摘要:%s", index+1, truncateContextField(summary, 140)))
|
||||
}
|
||||
if len(memoryContents) > 0 {
|
||||
parts = append(parts, "已人工确认的长期记忆:"+strings.Join(truncateContextItems(memoryContents, 30), ";"))
|
||||
}
|
||||
goal := profile.ConversationGoal
|
||||
if goal == "" {
|
||||
goal = "进行一次自然、尊重的陪伴交流"
|
||||
}
|
||||
parts = append(parts, "本次交流目标:"+goal)
|
||||
parts = append(parts, "本次交流目标:"+truncateContextField(goal, 80))
|
||||
if profile.Pace != "" {
|
||||
paceLabels := map[string]string{"slow": "放慢语速并留出回应时间", "normal": "自然语速", "lively": "轻快但保持清晰"}
|
||||
parts = append(parts, "交流节奏:"+paceLabels[profile.Pace])
|
||||
}
|
||||
if len(profile.PreferredTopics) > 0 {
|
||||
parts = append(parts, "优先话题:"+strings.Join(profile.PreferredTopics, "、"))
|
||||
parts = append(parts, "优先话题:"+strings.Join(truncateContextItems(profile.PreferredTopics, 15), "、"))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
if profile.Background != "" {
|
||||
parts = append(parts, "背景资料:"+truncateContextField(profile.Background, 80))
|
||||
}
|
||||
if len(profile.Interests) > 0 {
|
||||
parts = append(parts, "兴趣:"+strings.Join(truncateContextItems(profile.Interests, 15), "、"))
|
||||
}
|
||||
result := strings.Join(parts, ";")
|
||||
return truncateContextField(result, contextSummaryLimit)
|
||||
}
|
||||
|
||||
func truncateContextItems(values []string, itemLimit int) []string {
|
||||
items := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value = truncateContextField(value, itemLimit); value != "" {
|
||||
items = append(items, value)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func truncateContextField(value string, limit int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) <= limit {
|
||||
return string(runes)
|
||||
}
|
||||
if limit <= 1 {
|
||||
return "…"
|
||||
}
|
||||
return string(runes[:limit-1]) + "…"
|
||||
}
|
||||
|
||||
func consentAllowsConversationContinuity(consents json.RawMessage) bool {
|
||||
values := map[string]bool{}
|
||||
if json.Unmarshal(consents, &values) != nil {
|
||||
return false
|
||||
}
|
||||
return values["personalized_memory"] && values["story_extraction"]
|
||||
}
|
||||
|
||||
func selectRecentConversationSummaries(ctx context.Context, tx pgx.Tx, tenantID, subjectID string, limit int) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
WITH latest_summary AS (
|
||||
SELECT DISTINCT ON (summary.conversation_id)
|
||||
summary.conversation_id, summary.content, conversation.ended_at, conversation.created_at
|
||||
FROM conversation_summary summary
|
||||
JOIN conversation ON conversation.id=summary.conversation_id
|
||||
AND conversation.tenant_id=summary.tenant_id
|
||||
WHERE summary.tenant_id=$1::uuid AND summary.subject_id=$2::uuid
|
||||
AND summary.summary_type='conversation_summary' AND summary.status='READY'
|
||||
AND conversation.status='COMPLETED'
|
||||
ORDER BY summary.conversation_id, summary.generation_version DESC, summary.created_at DESC
|
||||
)
|
||||
SELECT content FROM latest_summary
|
||||
ORDER BY COALESCE(ended_at,created_at) DESC, conversation_id DESC
|
||||
LIMIT $3`, tenantID, subjectID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var content string
|
||||
if err := rows.Scan(&content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if content = truncateContextText(content, recentSummaryContentLimit); content != "" {
|
||||
result = append(result, content)
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func strictContextString(values map[string]any, key string, limit int) (string, error) {
|
||||
|
||||
@@ -39,6 +39,9 @@ const (
|
||||
testModelConfigPublicID = "90000000-0000-0000-0000-000000000713"
|
||||
testRoutingConfigID = "90000000-0000-0000-0000-000000000704"
|
||||
testRoutingConfigPublicID = "90000000-0000-0000-0000-000000000714"
|
||||
testConsentSnapshotID = "90000000-0000-0000-0000-000000000801"
|
||||
testPriorContextID = "90000000-0000-0000-0000-000000000802"
|
||||
testPriorConversationID = "90000000-0000-0000-0000-000000000803"
|
||||
testBundleConfigKey = "orchestration-test-agent-bundle"
|
||||
testBundleCreateKey = "orchestration-agent-bundle-create"
|
||||
testBundleEvaluationKey = "orchestration-agent-bundle-evaluation"
|
||||
@@ -134,14 +137,22 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
|
||||
{`INSERT INTO subject_profile (id, public_id, tenant_id, household_id, preferred_name, profile_data, conversation_preferences, status)
|
||||
VALUES ($1::uuid, '90000000-0000-0000-0000-000000000411', '10000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000201', '服务测试长者',
|
||||
'{"background":"年轻时从事铁路工作","interests":["老照片","家乡变化"],"internal_note":"不得进入语音上下文"}',
|
||||
'{"conversation_goal":"聊聊第一次参加工作的经历","pace":"slow","preferred_topics":["工作经历"]}', 'active')`, []any{testSubjectID}},
|
||||
'{"conversation_goal":"聊聊第一次参加工作的经历","pace":"slow","preferred_topics":["工作经历"],"avoided_topics":["收入"]}', 'active')`, []any{testSubjectID}},
|
||||
{`INSERT INTO device (id, public_id, tenant_id, serial_digest, model, status, credential_ref)
|
||||
VALUES ($1::uuid, $2::uuid, '10000000-0000-0000-0000-000000000001', repeat('9',64), 'service-test', 'registered', 'test://service')`, []any{testDeviceID, testDevicePublicID}},
|
||||
{`INSERT INTO device_binding (tenant_id, device_id, household_id, subject_id, reason)
|
||||
VALUES ('10000000-0000-0000-0000-000000000001', $1::uuid, '10000000-0000-0000-0000-000000000201', $2::uuid, 'integration test')`, []any{testDeviceID, testSubjectID}},
|
||||
{`INSERT INTO device_twin (device_id, tenant_id) VALUES ($1::uuid, '10000000-0000-0000-0000-000000000001')`, []any{testDeviceID}},
|
||||
{`INSERT INTO consent_snapshot (tenant_id, subject_id, snapshot_version, effective_consents)
|
||||
VALUES ('10000000-0000-0000-0000-000000000001', $1::uuid, 1, '{"voice_chat":true,"transcript":true,"audio_recording":false}'::jsonb)`, []any{testSubjectID}},
|
||||
{`INSERT INTO consent_snapshot (id,tenant_id,subject_id,snapshot_version,effective_consents)
|
||||
VALUES ($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,1,'{"voice_chat":true,"transcript":true,"audio_recording":false,"story_extraction":true,"personalized_memory":true}'::jsonb)`, []any{testConsentSnapshotID, testSubjectID}},
|
||||
{`INSERT INTO context_snapshot (id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary)
|
||||
VALUES ($1::uuid,'10000000-0000-0000-0000-000000000001',$2::uuid,1,$3::uuid,'[]'::jsonb,'prior-test','cascade',repeat('b',64),'此前通话上下文')`, []any{testPriorContextID, testSubjectID, testConsentSnapshotID}},
|
||||
{`INSERT INTO conversation (id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,created_at,updated_at)
|
||||
VALUES ($1::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201',$2::uuid,$3::uuid,$4::uuid,$5::uuid,'prior-context-task','cascade','transcript_only','COMPLETED','2026-08-23T10:30:00Z','2026-08-23T10:00:00Z','2026-08-23T10:20:00Z','2026-08-23T10:00:00Z','2026-08-23T10:20:00Z')`, []any{testPriorConversationID, testSubjectID, testDeviceID, testConsentSnapshotID, testPriorContextID}},
|
||||
{`INSERT INTO conversation_turn (tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
|
||||
VALUES ('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'prior-subject-1',1,'subject','2026-08-23T10:01:00Z','2026-08-23T10:01:05Z','我注册了一个个体工商户。','provider')`, []any{testSubjectID, testPriorConversationID}},
|
||||
{`INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,status,created_at)
|
||||
VALUES ('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'conversation_summary','上次聊到刚注册了一个个体工商户,准备开始新的工作。','["prior-subject-1"]'::jsonb,'mock-content-v1','prompt-v1',1,'READY','2026-08-23T10:21:00Z')`, []any{testSubjectID, testPriorConversationID}},
|
||||
{`INSERT INTO consent_record (tenant_id,subject_id,consent_type,policy_version,notice_digest,actor_user_id)
|
||||
VALUES ('10000000-0000-0000-0000-000000000001',$1::uuid,'personalized_memory','orchestration-service-test',repeat('a',64),'10000000-0000-0000-0000-000000000101')`, []any{testSubjectID}},
|
||||
{`INSERT INTO runtime_config_version (
|
||||
@@ -200,8 +211,8 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configAdmin := platformidentity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
configApprover := platformidentity.Principal{Issuer: configAdmin.Issuer, Subject: "00000000-0000-0000-0000-000000000102"}
|
||||
configAdmin := platformidentity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
|
||||
configApprover := platformidentity.Principal{Issuer: configAdmin.Issuer, Subject: "00000000-0000-0000-0000-000000000105"}
|
||||
bundle, created, err := configService.Create(ctx, configAdmin, testBundleCreateKey, "orchestration-agent-bundle-create", opsconfig.CreateInput{
|
||||
TenantID: "10000000-0000-0000-0000-000000000010", ConfigKey: testBundleConfigKey,
|
||||
DisplayName: "编排测试智能体包", ConfigKind: "AGENT_BUNDLE", ChangeSummary: "验证运营发布的自定义版本族进入通话运行时",
|
||||
@@ -268,7 +279,7 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
|
||||
if frozenBundleVersion != "cfg:"+bundle.ID || first.Session.EngineMode != "cascade" {
|
||||
t.Fatalf("frozen bundle=%q engine=%q", frozenBundleVersion, first.Session.EngineMode)
|
||||
}
|
||||
for _, expected := range []string{"服务测试长者", "年轻时从事铁路工作", "老照片", "聊聊第一次参加工作的经历", "放慢语速", "工作经历"} {
|
||||
for _, expected := range []string{"服务测试长者", "年轻时从事铁路工作", "老照片", "聊聊第一次参加工作的经历", "放慢语速", "工作经历", "交流边界", "收入", "最近第1次通话摘要", "个体工商户"} {
|
||||
if !strings.Contains(frozenContext, expected) {
|
||||
t.Fatalf("frozen context %q does not contain %q", frozenContext, expected)
|
||||
}
|
||||
@@ -303,7 +314,7 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
|
||||
if err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM idempotency_record WHERE actor_key=$1 AND response_body::text LIKE '%dev1.%')`, "device:"+testDevicePublicID).Scan(&tokenPersisted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conversations != 1 || histories != 2 || audits != 1 || events != 1 || tokenPersisted {
|
||||
if conversations != 2 || histories != 2 || audits != 1 || events != 1 || tokenPersisted {
|
||||
t.Fatalf("conversation=%d history=%d audit=%d event=%d tokenPersisted=%v", conversations, histories, audits, events, tokenPersisted)
|
||||
}
|
||||
activateInput := ActivateInput{
|
||||
@@ -863,6 +874,7 @@ func cleanupOrchestrationFixture(t *testing.T, ctx context.Context, pool *pgxpoo
|
||||
{`DELETE FROM runtime_config_version WHERE id=$1::uuid`, testVoiceConfigID},
|
||||
{`DELETE FROM runtime_config_version WHERE id=$1::uuid`, testModelConfigID},
|
||||
{`DELETE FROM runtime_config_version WHERE id=$1::uuid`, testRoutingConfigID},
|
||||
{`DELETE FROM conversation_summary WHERE conversation_id IN (SELECT id FROM conversation WHERE device_id=$1::uuid)`, testDeviceID},
|
||||
{`DELETE FROM conversation_turn WHERE conversation_id IN (SELECT id FROM conversation WHERE device_id=$1::uuid)`, testDeviceID},
|
||||
{`DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM conversation WHERE device_id=$1::uuid)`, testDeviceID},
|
||||
{`DELETE FROM conversation_state_history WHERE conversation_id IN (SELECT id FROM conversation WHERE device_id=$1::uuid)`, testDeviceID},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -52,9 +54,9 @@ func TestConstructorAndBoundaryValidation(t *testing.T) {
|
||||
func TestAuthorizedProfileContextRejectsMalformedWhitelistedFields(t *testing.T) {
|
||||
valid, err := authorizedProfileContext(
|
||||
[]byte(`{"background":"退休教师","interests":["戏曲","散步"],"internal_note":{"ignored":true}}`),
|
||||
[]byte(`{"conversation_goal":"轻松交流","pace":"slow","preferred_topics":["家乡"]}`),
|
||||
[]byte(`{"conversation_goal":"轻松交流","pace":"slow","preferred_topics":["家乡"],"avoided_topics":["收入","家庭矛盾"]}`),
|
||||
)
|
||||
if err != nil || valid.Background != "退休教师" || len(valid.Interests) != 2 || valid.Pace != "slow" {
|
||||
if err != nil || valid.Background != "退休教师" || len(valid.Interests) != 2 || valid.Pace != "slow" || len(valid.AvoidedTopics) != 2 {
|
||||
t.Fatalf("valid context=%+v err=%v", valid, err)
|
||||
}
|
||||
for _, malformed := range []struct {
|
||||
@@ -63,9 +65,53 @@ func TestAuthorizedProfileContextRejectsMalformedWhitelistedFields(t *testing.T)
|
||||
{`{"background":42}`, `{}`},
|
||||
{`{"interests":["戏曲",42]}`, `{}`},
|
||||
{`{}`, `{"preferred_topics":"家乡"}`},
|
||||
{`{}`, `{"avoided_topics":"收入"}`},
|
||||
} {
|
||||
if _, err := authorizedProfileContext([]byte(malformed.profile), []byte(malformed.preferences)); err == nil {
|
||||
t.Fatalf("malformed whitelisted context was accepted: profile=%s preferences=%s", malformed.profile, malformed.preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildContextSummaryIncludesContinuityAndBoundariesWithinSnapshotLimit(t *testing.T) {
|
||||
profile := authorizedSubjectContext{
|
||||
Background: "退休教师", Interests: []string{"戏曲"}, ConversationGoal: "自然陪伴", Pace: "slow",
|
||||
PreferredTopics: []string{"家乡"}, AvoidedTopics: []string{"收入", "家庭矛盾"},
|
||||
}
|
||||
result := buildContextSummary("王阿姨", profile,
|
||||
[]string{"上次聊到刚刚注册了个体工商户,准备开始新的工作。"},
|
||||
[]string{"喜欢在周末听越剧。"})
|
||||
for _, expected := range []string{"交流边界", "收入", "王阿姨", "最近第1次通话摘要", "注册了个体工商户", "已人工确认的长期记忆", "越剧"} {
|
||||
if !strings.Contains(result, expected) {
|
||||
t.Fatalf("context %q does not contain %q", result, expected)
|
||||
}
|
||||
}
|
||||
oversized := buildContextSummary(strings.Repeat("长", 120), authorizedSubjectContext{
|
||||
Background: strings.Repeat("背", 500), AvoidedTopics: []string{strings.Repeat("禁", 50), "家庭矛盾"},
|
||||
PreferredTopics: []string{strings.Repeat("优", 50)}, Interests: []string{strings.Repeat("兴", 50)},
|
||||
}, []string{strings.Repeat("摘", 500), "第二次摘要", "第三次摘要"}, []string{strings.Repeat("忆", 500), "第二条记忆", "第三条记忆", "第四条记忆", "第五条记忆"})
|
||||
if len([]rune(oversized)) > contextSummaryLimit {
|
||||
t.Fatalf("context length=%d exceeds snapshot budget", len([]rune(oversized)))
|
||||
}
|
||||
for _, expected := range []string{"交流边界", "家庭矛盾", "最近第3次通话摘要", "第三次摘要", "已人工确认的长期记忆", "第五条记忆"} {
|
||||
if !strings.Contains(oversized, expected) {
|
||||
t.Fatalf("oversized context %q lost priority field %q", oversized, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationContinuityRequiresBothExplicitConsents(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value json.RawMessage
|
||||
want bool
|
||||
}{
|
||||
{json.RawMessage(`{"personalized_memory":true,"story_extraction":true}`), true},
|
||||
{json.RawMessage(`{"personalized_memory":true}`), false},
|
||||
{json.RawMessage(`{"story_extraction":true}`), false},
|
||||
{json.RawMessage(`not-json`), false},
|
||||
} {
|
||||
if got := consentAllowsConversationContinuity(test.value); got != test.want {
|
||||
t.Fatalf("consent %s allowed=%v want=%v", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user