feat: establish v0.70 platform milestone
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-08-29 21:56:51 +08:00
parent d97bbf7cf5
commit e652516147
133 changed files with 12907 additions and 801 deletions
@@ -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)
+106 -13
View File
@@ -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},
+48 -2
View File
@@ -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)
}
}
}