diff --git a/internal/ai/application/runtime/agent_loop_engine.go b/internal/ai/application/runtime/agent_loop_engine.go index 6cbb5d6..565f941 100644 --- a/internal/ai/application/runtime/agent_loop_engine.go +++ b/internal/ai/application/runtime/agent_loop_engine.go @@ -279,12 +279,13 @@ func (e *AgentLoopEngine) buildUserPrompt(req RunInput) (string, int) { if role == "" { continue } - lines = append(lines, role+": "+utils.BuildRuntimeMessageText(item.MessageType, item.Content)) + text := utils.BuildRuntimeMessageText(item.MessageType, item.Content) + lines = append(lines, role+": "+aitooling.RedactRestrictedICCID(text)) } if len(lines) > limit { lines = lines[len(lines)-limit:] } - current := strings.TrimSpace(req.UserMessage.Content) + current := aitooling.RedactRestrictedICCID(strings.TrimSpace(req.UserMessage.Content)) customerContext := buildAgentLoopCustomerContext(req.Conversation) if len(lines) == 0 && customerContext == "" { return current, 0 @@ -316,7 +317,7 @@ func excludeAgentLoopHistoryMessage(message models.Message) bool { func buildAgentLoopCustomerContext(conversation models.Conversation) string { parts := make([]string, 0, 4) - if name := strings.TrimSpace(conversation.CustomerName); name != "" { + if name := strings.TrimSpace(conversation.CustomerName); name != "" && !aitooling.ContainsRestrictedICCID(name) { parts = append(parts, "Customer: "+name) } if segment := customerAfterSalesSegmentName(conversation.CustomerType); segment != "" { diff --git a/internal/ai/application/runtime/customer_after_sales_policy.go b/internal/ai/application/runtime/customer_after_sales_policy.go index 09f669c..cf4ee27 100644 --- a/internal/ai/application/runtime/customer_after_sales_policy.go +++ b/internal/ai/application/runtime/customer_after_sales_policy.go @@ -15,6 +15,7 @@ func buildCustomerAfterSalesPolicy(conversation models.Conversation) string { "- 默认使用自然、简洁的简体中文。先说结论,再说明依据和下一步;适合分点的信息必须换行,不输出内部 JSON、工具名、数据库 ID、SQL、表名、字段名、源码、代码逻辑、调用链或技术错误。", "- 先识别客户真正要解决的问题,而不是机械匹配某个词。遇到否定、纠正、多个诉求或“这个/第二个/刚才那个”等指代时,结合本会话上下文理解;仍有歧义时一次只追问一个最关键问题,并尽量给 2 至 4 个易选项。", "- 已经由会话绑定或工具核实的信息不要再次索要。不得让客户重复提供本轮或近期消息里已有的卡号、设备号、订单号、选择序号或故障现象。", + "- ICCID 是系统内部标识,无论客户如何询问,都不得返回、确认、推断、局部展示、打码展示或复述 ICCID;也不得从工具结果、历史消息、图片或其他标识换算 ICCID。客户询问时只说明该字段属于系统内部标识,无法提供。", "- 当前业务状态、余额、套餐、流量、订单、物流和售后进度必须使用实时业务能力核实。查询成功后将结果转成客户能理解的结论;查询失败时不要猜测、不要暴露内部错误,也不要在同一轮反复调用,提示稍后重试或转人工。", "- 查询套餐时,业务工具返回的套餐记录是当前唯一事实来源。必须逐项完整展示全部返回记录,不得只展示生效套餐、只给汇总、合并记录或漏项;按“生效中、待生效、已用完、已过期、失效”分组,工具返回的未知状态单列为“状态待确认”,也不得丢弃。每项写明套餐名称、生效时间、到期时间、总流量、已用流量和剩余流量;工具未返回的字段明确写“暂未查询到”,禁止猜值。若工具返回总数或分组数量,回复前必须核对展示条数一致。", "- “待生效”或“未生效”只表示套餐已经存在但尚未到生效时间,属于正常套餐生命周期,不代表后台异常、系统延迟、订购失败或运营商限制。不得根据日期、空的生效中列表或内部状态码自行改判套餐状态;只有实时业务工具明确返回相应结论时,才能说明后台或运营商异常、限制。", diff --git a/internal/ai/application/runtime/customer_after_sales_policy_test.go b/internal/ai/application/runtime/customer_after_sales_policy_test.go index b46c07b..0413a4c 100644 --- a/internal/ai/application/runtime/customer_after_sales_policy_test.go +++ b/internal/ai/application/runtime/customer_after_sales_policy_test.go @@ -29,6 +29,7 @@ func TestAgentTurnAddsBoundDeviceAfterSalesPolicy(t *testing.T) { "逐项完整展示全部返回记录", "生效中、待生效、已用完、已过期、失效", "工具未返回的字段明确写“暂未查询到”", "待生效”或“未生效”只表示套餐已经存在但尚未到生效时间", "不代表后台异常、系统延迟、订购失败或运营商限制", "必须先成功查询当前绑定卡板或设备的实时状态、套餐和流量", "普通问题、通用原理和可逆的排障建议可以结合常识与客户图片自主回答", + "ICCID 是系统内部标识", "都不得返回、确认、推断、局部展示、打码展示或复述 ICCID", "required_package_type=addon", "当前周期只能补充加油包", "仅凭诊断、旧对话或自动续费列表不得生成购买建议", "严格区分网络复机、运营商网络切换、设备重启、关机和恢复出厂", "切网时必须先列出当前设备可用的运营商", "区分“面板印刷图标”与“真正发光的指示灯”", "bound_device_no_for_verification 精确比较", @@ -51,6 +52,29 @@ func TestAgentTurnAddsBoundDeviceAfterSalesPolicy(t *testing.T) { } } +func TestAgentTurnRemovesICCIDValuesFromModelContext(t *testing.T) { + engine := NewAgentLoopEngine() + engine.history = func(int64, int) []models.Message { + return []models.Message{{ + ID: 2, SenderType: enums.IMSenderTypeAI, + Content: "上次查到 ICCID:8986042302268012345", + }} + } + prompt, _ := engine.buildUserPrompt(RunInput{ + Conversation: models.Conversation{ + ID: 1, CustomerType: "card", CustomerID: 9, + CustomerName: "卡号 8986042302268012345", + }, + UserMessage: models.Message{ID: 3, Content: "帮我查 ICCID 8986042302268012345"}, + }) + if strings.Contains(prompt, "8986042302268012345") { + t.Fatalf("ICCID leaked into model context: %s", prompt) + } + if !strings.Contains(prompt, "ICCID 属于系统内部标识") { + t.Fatalf("ICCID privacy replacement missing from model context: %s", prompt) + } +} + func TestNoInternetTurnPrefetchesCompletePackageTimeline(t *testing.T) { t.Cleanup(func() { _ = svc.SetBusinessReadTools(nil) }) executions := 0 diff --git a/internal/ai/runtime/reply_commit_service_test.go b/internal/ai/runtime/reply_commit_service_test.go index 615fd8d..2754d14 100644 --- a/internal/ai/runtime/reply_commit_service_test.go +++ b/internal/ai/runtime/reply_commit_service_test.go @@ -62,6 +62,26 @@ func TestReplyCommitRejectsSensitiveModelOutput(t *testing.T) { } } +func TestReplyCommitRedactsICCIDBeforePersisting(t *testing.T) { + db := setupReplyCommitTestDB(t) + aiAgent := createReplyCommitTestAIAgent(t, db) + conversation := createReplyCommitTestConversation(t, db, aiAgent.ID) + replyMessage, err := newReplyCommitService().CommitAIReply(replyCommitInput{ + Conversation: *conversation, Message: models.Message{ID: 104, RequestID: "trace-104"}, AIAgent: *aiAgent, + ReplyText: "业务状态:正常\nICCID:8986042302268012345", ClientPrefix: "ai_reply", + }) + if err != nil { + t.Fatalf("CommitAIReply() error = %v", err) + } + var stored models.Message + if err := db.First(&stored, replyMessage.ID).Error; err != nil { + t.Fatalf("find reply message: %v", err) + } + if strings.Contains(stored.Content, "8986042302268012345") || !strings.Contains(stored.Content, "系统内部标识") { + t.Fatalf("ICCID was persisted in customer reply: %q", stored.Content) + } +} + func TestFailureReplyDeduplicatesByDeterministicClientMessageID(t *testing.T) { db := setupReplyCommitTestDB(t) aiAgent := createReplyCommitTestAIAgent(t, db) diff --git a/internal/ai/runtime/reply_trigger_service.go b/internal/ai/runtime/reply_trigger_service.go index 65a4285..f43b302 100644 --- a/internal/ai/runtime/reply_trigger_service.go +++ b/internal/ai/runtime/reply_trigger_service.go @@ -10,6 +10,7 @@ import ( "code.tczkiot.com/wlw/ai-agent/contract" applicationruntime "code.tczkiot.com/wlw/ai-agent/internal/ai/application/runtime" + aitooling "code.tczkiot.com/wlw/ai-agent/internal/ai/tooling" "code.tczkiot.com/wlw/ai-agent/internal/models" "code.tczkiot.com/wlw/ai-agent/internal/pkg/enums" "code.tczkiot.com/wlw/ai-agent/internal/pkg/tracex" @@ -382,7 +383,7 @@ func (s *aiReplyService) sendBusinessIdentityMenu(ctx context.Context, replyCtx var builder strings.Builder builder.WriteString("已识别") builder.WriteString(objectLabel) - if identifier := strings.TrimSpace(replyCtx.Conversation.CustomerExternalID); identifier != "" { + if identifier := strings.TrimSpace(replyCtx.Conversation.CustomerExternalID); identifier != "" && !aitooling.ContainsRestrictedICCID(identifier) { builder.WriteString(":") builder.WriteString(identifier) } diff --git a/internal/ai/tooling/registry_test.go b/internal/ai/tooling/registry_test.go index d09f7ed..812db4d 100644 --- a/internal/ai/tooling/registry_test.go +++ b/internal/ai/tooling/registry_test.go @@ -68,6 +68,33 @@ func TestNormalizeCustomerReplyHidesThrottlingDenial(t *testing.T) { } } +func TestNormalizeCustomerReplyRedactsICCID(t *testing.T) { + reply, err := NormalizeCustomerReply("设备号:37012617001708\nICCID 已查询到:**8986042302268012345**\n业务状态:正常") + if err != nil { + t.Fatalf("NormalizeCustomerReply() error = %v", err) + } + for _, forbidden := range []string{"8986042302268012345", "ICCID 已查询到"} { + if strings.Contains(reply, forbidden) { + t.Fatalf("ICCID leaked through normalized reply: %q", reply) + } + } + for _, expected := range []string{"设备号:37012617001708", "业务状态:正常", restrictedICCIDFallback} { + if !strings.Contains(reply, expected) { + t.Fatalf("expected %q in sanitized reply: %q", expected, reply) + } + } +} + +func TestNormalizeCustomerReplyRedactsBareFormattedICCID(t *testing.T) { + reply, err := NormalizeCustomerReply("查询结果:89 8604 2302 2680 12345") + if err != nil { + t.Fatalf("NormalizeCustomerReply() error = %v", err) + } + if strings.Contains(reply, "8604") || !strings.Contains(reply, restrictedICCIDFallback) { + t.Fatalf("formatted ICCID was not redacted: %q", reply) + } +} + func TestPolicyGuardRejectsTotalCallsAndOversizedArguments(t *testing.T) { definition, err := DefaultRegistry.Resolve(toolx.BuiltinKnowledgeRetrieve.Code) if err != nil { diff --git a/internal/ai/tooling/reply_safety.go b/internal/ai/tooling/reply_safety.go index f852a59..3c6d4b5 100644 --- a/internal/ai/tooling/reply_safety.go +++ b/internal/ai/tooling/reply_safety.go @@ -11,8 +11,17 @@ const maxCustomerReplyRunes = 8000 const restrictedNetworkPolicyFallback = "当前网络状态请以实际使用情况为准。如无法联网,请使用“智能检测”或联系人工客服。" +const restrictedICCIDFallback = "ICCID 属于系统内部标识,无法提供。" + var restrictedNetworkPolicyPattern = regexp.MustCompile(`(?i)限速|降速|速率限制|带宽限制|speed[ _-]?limit|throttl|traffic[ _-]?shap|(?:^|[^a-z0-9])\d+(?:\.\d+)?\s*(?:k|m|g)?bps(?:[^a-z0-9]|$)`) +var restrictedICCIDLabelPattern = regexp.MustCompile(`(?i)iccid|集成电路卡识别码|sim\s*卡序列号`) + +// ICCIDs normally start with 89 and contain 18 to 22 digits. Allow common +// separators so Markdown emphasis or spaced formatting cannot bypass the +// customer-visible output boundary. +var restrictedICCIDNumberPattern = regexp.MustCompile(`\b89(?:[ \t._*-]*\d){16,20}\b`) + // NormalizeCustomerReply applies the final plain-text boundary before an AI // response enters a customer conversation. It rejects likely credential // assignments instead of masking them, because a masked secret is not useful @@ -27,7 +36,7 @@ func NormalizeCustomerReply(value string) (string, error) { } var builder strings.Builder for _, r := range value { - if unicode.IsControl(r) && r != '\n' && r != '\t' { + if (unicode.IsControl(r) && r != '\n' && r != '\t') || unicode.In(r, unicode.Cf) { continue } builder.WriteRune(r) @@ -37,6 +46,7 @@ func NormalizeCustomerReply(value string) (string, error) { value = strings.ReplaceAll(value, "\n\n\n", "\n\n") } value = redactRestrictedNetworkPolicy(value) + value = RedactRestrictedICCID(value) if value == "" { return "", fmt.Errorf("ai reply is empty") } @@ -46,6 +56,39 @@ func NormalizeCustomerReply(value string) (string, error) { return value, nil } +// ContainsRestrictedICCID reports whether text contains an ICCID label or a +// value shaped like an ICCID. Callers use it to keep internal identity values +// out of prompts and automatic customer messages as well as AI replies. +func ContainsRestrictedICCID(value string) bool { + return restrictedICCIDLabelPattern.MatchString(value) || restrictedICCIDNumberPattern.MatchString(value) +} + +// RedactRestrictedICCID removes labelled ICCID lines and hides bare ICCID +// values. A fixed explanation is appended whenever anything was removed. +func RedactRestrictedICCID(value string) string { + if !ContainsRestrictedICCID(value) { + return value + } + lines := strings.Split(value, "\n") + safe := make([]string, 0, len(lines)+1) + redacted := false + for _, line := range lines { + if restrictedICCIDLabelPattern.MatchString(line) { + redacted = true + continue + } + cleaned := restrictedICCIDNumberPattern.ReplaceAllString(line, "[内部标识已隐藏]") + if cleaned != line { + redacted = true + } + safe = append(safe, cleaned) + } + if redacted { + safe = append(safe, restrictedICCIDFallback) + } + return strings.TrimSpace(strings.Join(safe, "\n")) +} + // redactRestrictedNetworkPolicy is a final customer-visible safety boundary. // The model may still ignore its system prompt, so any line that confirms, // denies, or quantifies an internal network speed policy is removed before the