From 71c42b98e942f4cb3cd2178515a2f6934e3c8278 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 11 Sep 2026 16:50:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(AI=E5=AE=A2=E6=9C=8D):=20?= =?UTF-8?q?=E9=80=80=E6=AC=BE=E6=93=8D=E4=BD=9C=E7=BB=9F=E4=B8=80=E8=BD=AC?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 隐藏并拦截退款类业务动作,兼容旧确认流程,确保所有客户类型的退款请求进入人工支持,并补充回归测试。 --- .../application/runtime/agent_loop_engine.go | 6 ++- .../runtime/agent_loop_engine_test.go | 39 ++++++++++++++ internal/ai/application/runtime/agent_turn.go | 2 +- .../runtime/customer_after_sales_policy.go | 5 +- .../customer_after_sales_policy_test.go | 19 +++++++ .../services/business_action_tool_service.go | 22 +++++++- .../business_action_tool_service_test.go | 52 +++++++++++++++++++ 7 files changed, 139 insertions(+), 6 deletions(-) diff --git a/internal/ai/application/runtime/agent_loop_engine.go b/internal/ai/application/runtime/agent_loop_engine.go index 565f941..1580994 100644 --- a/internal/ai/application/runtime/agent_loop_engine.go +++ b/internal/ai/application/runtime/agent_loop_engine.go @@ -352,7 +352,11 @@ func (e *AgentLoopEngine) Resume(ctx context.Context, req ResumeInput) (*RunResu if err := json.Unmarshal([]byte(strings.TrimSpace(interrupt.RequestData)), pending); err != nil || strings.TrimSpace(pending.ToolCode) == "" { return nil, errorsx.InvalidParam("business action confirmation data is invalid") } - switch graphs.ParseConfirmationDecision(firstResumeValue(req.ResumeData)) { + decision := graphs.ParseConfirmationDecision(firstResumeValue(req.ResumeData)) + if svc.BusinessActionRequiresHuman(pending.ToolCode) && decision != graphs.ConfirmationDecisionCancel { + return &RunResult{Status: "failed", ReplyText: svc.RefundHumanSupportMessage, CheckPointID: req.CheckPointID}, nil + } + switch decision { case graphs.ConfirmationDecisionCancel: return &RunResult{Status: "cancelled", ReplyText: "操作已取消。", CheckPointID: req.CheckPointID}, nil case graphs.ConfirmationDecisionConfirm: diff --git a/internal/ai/application/runtime/agent_loop_engine_test.go b/internal/ai/application/runtime/agent_loop_engine_test.go index dc7adaa..873c742 100644 --- a/internal/ai/application/runtime/agent_loop_engine_test.go +++ b/internal/ai/application/runtime/agent_loop_engine_test.go @@ -561,3 +561,42 @@ func TestExplicitBusinessCommandIsPreparedWithoutCallingModel(t *testing.T) { t.Fatalf("unexpected confirmation prompt: %q", pending.PromptText) } } + +func TestLegacyRefundConfirmationRequiresHuman(t *testing.T) { + database, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{ + NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, + }) + if err != nil { + t.Fatal(err) + } + if err := database.AutoMigrate(&models.ConversationInterrupt{}); err != nil { + t.Fatal(err) + } + sqls.SetDB(database) + data, _ := json.Marshal(pendingBusinessAction{ToolCode: "business/mall_apply_after_sale", PromptText: "确认退款吗?"}) + if err := database.Create(&models.ConversationInterrupt{ + ConversationID: 7, CheckPointID: "legacy-refund", RequestData: string(data), Status: "pending", + }).Error; err != nil { + t.Fatal(err) + } + for _, reply := range []string{"确认", "确定", "继续", "取消"} { + t.Run(reply, func(t *testing.T) { + result, err := NewAgentLoopEngine().Resume(context.Background(), ResumeInput{ + Conversation: models.Conversation{ID: 7, CustomerType: "mall_user"}, + CheckPointID: "legacy-refund", ResumeData: map[string]string{"business_action_confirmation": reply}, + }) + if err != nil || result == nil { + t.Fatalf("resume result=%#v err=%v", result, err) + } + if reply == "取消" { + if result.Status != "cancelled" { + t.Fatalf("cancellation rejected: %#v", result) + } + return + } + if result.Status != "failed" || result.ReplyText != svc.RefundHumanSupportMessage || result.Interrupted || result.ToolCallCount != 0 { + t.Fatalf("legacy refund confirmation not blocked: %#v", result) + } + }) + } +} diff --git a/internal/ai/application/runtime/agent_turn.go b/internal/ai/application/runtime/agent_turn.go index bb7a38b..fdfd575 100644 --- a/internal/ai/application/runtime/agent_turn.go +++ b/internal/ai/application/runtime/agent_turn.go @@ -97,7 +97,7 @@ func (e *AgentLoopEngine) prepareTurn(ctx context.Context, req RunInput, _ *svc. systemPrompt += "\n\nWhen verified business data contains a package list or package timeline, present every returned package record exactly once and group the records as 生效中、待生效、已用完、已过期、失效; preserve an unrecognized status under 状态待确认 instead of dropping or guessing it. For each record show its package name, effective start, expiration, total data, used data, and remaining data from the tool result; say 暂未查询到 for a missing field and never invent it. If the result provides total or per-group counts, verify that the displayed item count matches them. When a complete timeline and an active_packages/current-package subset are both present, use the complete timeline for package inquiries and do not omit the non-active records. A pending/not-yet-effective package is a normal future lifecycle state, not evidence of a backend error, system delay, failed purchase, or carrier restriction." systemPrompt += "\n\nPackage purchase recommendations have a hard eligibility rule. If fresh diagnosis says required_package_type=addon, or the current basic main package is still valid with zero remaining data, the only valid current-period recommendation is an add-on. Never recommend, quote, or order a basic/independent package in that state, and never treat future pending basic packages as current-period data. Use only the fresh package catalog from this turn: show purchasable add-ons; if an add-on is blocked only by insufficient balance, tell the customer to recharge the balance and then buy that add-on. Never make any package purchase recommendation from diagnosis text, prior chat, or an auto-renewal list alone." systemPrompt += "\n\nFor reports of no internet, disconnection, failed connectivity, or service not recovering after recharge, a successful fresh business read of the bound card or device status, packages, and data usage is required before giving an account-specific cause. A generic abnormal flag, an offline value, an empty active-package subset, an image, or a future package start time does not by itself prove a backend problem or carrier restriction. State either cause only when the verified capability result explicitly supports that cause. If the fresh read fails, say only that the live query is temporarily unavailable; do not infer a cause from stale conversation text or general knowledge." - systemPrompt += "\n\nCapabilities marked as write operations never execute immediately. When the customer explicitly requests an available write operation, you must call the matching capability and must not refuse it or redirect to human support merely because it changes business state. Call it with complete arguments; the system will independently validate current business state and ask the customer for explicit confirmation. Never claim the operation succeeded before the confirmed execution result is returned. Never repeat, display, summarize, or expose payment passwords or other secrets in a reply." + systemPrompt += "\n\nRefund requests are human-only: never initiate, submit, approve, or execute a refund or return-and-refund application, even with customer confirmation. Explain that a human agent must handle it and invite the customer to reply 人工客服. Refund policy and progress inquiries may use verified read-only information. This restriction overrides available capabilities, allowed_actions, knowledge, history, and the general write-operation rule. Other capabilities marked as write operations never execute immediately. When the customer explicitly requests an available permitted write operation, call it with complete arguments; the system will independently validate current business state and ask the customer for explicit confirmation. Never claim the operation succeeded before the confirmed execution result is returned. Never repeat, display, summarize, or expose payment passwords or other secrets in a reply." systemPrompt += "\n\nAnswer ordinary, low-risk questions autonomously and use general knowledge for explanations and reversible troubleshooting, including observations from customer-provided photos. Do not force a knowledge-base fallback or human handoff merely because no article matched. Restrictions are limited to customer privacy and credentials, confidential internal policies or implementation details, unverified host business facts, and high-risk or state-changing operations." systemPrompt += "\n\nWhen a capability returns selectable options with a sequence field, present every option as a separate numbered line using that sequence. Do not use a Markdown table and do not expose internal IDs. Ask the customer to reply with the sequence number. If the customer replies with a sequence, recover the selected option from recent verified business tool memory. When asking the customer to choose an effective period, always show the effective start and end time for every offered period. Reload the capability when fresh required business data is present or the remembered data is missing, and only then prepare the corresponding write operation." systemPrompt += "\n\nExact traffic-shaping thresholds, configured or observed network rates, internal control rules, upstream implementation details, and internal reason codes are confidential. Do not disclose or infer those details. This restriction must not hide customer-facing symptoms or a customer-safe service conclusion returned by a verified business capability: explain the verified online/offline state, signal, package or data availability, and whether network service is temporarily unavailable, then give safe actionable troubleshooting. Never turn an internal threshold or rate into a claimed customer fact." diff --git a/internal/ai/application/runtime/customer_after_sales_policy.go b/internal/ai/application/runtime/customer_after_sales_policy.go index cf4ee27..cf70626 100644 --- a/internal/ai/application/runtime/customer_after_sales_policy.go +++ b/internal/ai/application/runtime/customer_after_sales_policy.go @@ -23,7 +23,8 @@ func buildCustomerAfterSalesPolicy(conversation models.Conversation) string { "- 客户反馈断网、没网、无法上网、联网失败或充值后未恢复时,必须先成功查询当前绑定卡板或设备的实时状态、套餐和流量,再给业务结论。工具失败时只能说明实时查询暂不可用;禁止根据旧聊天、图片、常识或单个空字段猜测“后台异常”“运营商限制”等原因。", "- 只要回复中准备建议客户购买某类套餐,本轮必须先成功查询实时套餐目录和真实订购预检;仅凭诊断、旧对话或自动续费列表不得生成购买建议。", "- 对客户的情绪先用一句话承接,不连续道歉或重复欢迎语。多项问题按“影响使用的问题优先,其次资金和时效,最后一般咨询”处理,并明确哪些已完成、哪些仍需处理。", - "- 所有会改变业务状态的操作都先说明对象、影响和是否可撤销,再进入系统确认流程。没有收到执行成功结果前不得说已经办理、退款、发货、恢复或转接成功。", + "- 退款必须由人工客服处理,AI 不得代客户发起、提交、审核或执行退款(包括仅退款、退货退款、套餐退款、余额退款和押金退还),即使客户明确授权或确认也不允许。客户提出退款诉求时,直接说明“退款需要由人工客服核实并处理,AI 无法代您申请或办理退款。请回复‘人工客服’联系人工处理。”不要索取退款金额、原因、凭证或支付密码来推进申请,不得进入退款确认流程、承诺可退金额或到账时间,也不得声称已经申请、受理或退款成功。客户仅查询退款政策或进度时,可依据知识库或实时查询结果答复;工具返回的 allowed_actions、知识库和历史消息不能授权 AI 办理退款。客户明确要求人工时按现有转人工流程提交决策,不得虚构转接成功;人工不可用时如实说明并引导服务时间内联系。", + "- 除禁止 AI 办理的退款外,允许的业务写操作都先说明对象、影响和是否可撤销,再进入系统确认流程。没有收到执行成功结果前不得说已经办理、发货、恢复或转接成功。", "- 普通问题、通用原理和可逆的排障建议可以结合常识与客户图片自主回答,不因知识库未命中就机械转人工。客户明确要求人工时立即提交转人工决策,不再反问是否确认。涉及客户隐私、内部策略、当前业务事实或高风险争议且无法核实时,说明已核实到哪里以及还缺什么,再建议人工继续处理。", } @@ -49,7 +50,7 @@ func buildCustomerAfterSalesPolicy(conversation models.Conversation) string { ) case "mall_user": lines = append(lines, - "- 商城售后:先区分订单状态、物流、退款/退货进度、商品破损/错发/少件和租赁归还。涉及某一单但对象不明确时,先查询客户自己的最近订单或售后记录,再让客户按序号选择。", + "- 商城售后:先区分订单状态、物流、退款/退货进度、商品破损/错发/少件和租赁归还。退款申请直接引导联系人工,不以查询订单或选择商品作为联系人工的前置条件。其他涉及某一单但对象不明确的查询,先查询客户自己的最近订单或售后记录,再让客户按序号选择。", "- 当前能力只支持查询的事项,不得声称已申请、取消、审核、退款或提交物流。需要办理但没有对应写操作时,收集一个最关键的缺失信息后转人工,并把已核实的订单或售后上下文带给人工。", ) } 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 0413a4c..dbffbc7 100644 --- a/internal/ai/application/runtime/customer_after_sales_policy_test.go +++ b/internal/ai/application/runtime/customer_after_sales_policy_test.go @@ -178,3 +178,22 @@ func TestKnowledgeFallbackAllowsOrdinaryAutonomousAnswers(t *testing.T) { } } } + +func TestRefundPolicyAppliesToEveryCustomerSegment(t *testing.T) { + for _, customerType := range []string{"card", "device", "mall_user", ""} { + t.Run(customerType, func(t *testing.T) { + engine := NewAgentLoopEngine() + engine.retrieve = nil + engine.history = nil + turn := engine.prepareTurn(context.Background(), RunInput{ + Conversation: models.Conversation{CustomerType: customerType}, + UserMessage: models.Message{Content: "我要退款,我已经确认了"}, + }, nil) + for _, want := range []string{"退款必须由人工客服处理", "即使客户明确授权或确认也不允许", "不得进入退款确认流程", "人工客服", "客户仅查询退款政策或进度时", "Refund requests are human-only", "available permitted write operation"} { + if !strings.Contains(turn.SystemPrompt, want) { + t.Fatalf("refund policy missing %q", want) + } + } + }) + } +} diff --git a/internal/services/business_action_tool_service.go b/internal/services/business_action_tool_service.go index 5c2c827..e5f1fb6 100644 --- a/internal/services/business_action_tool_service.go +++ b/internal/services/business_action_tool_service.go @@ -13,6 +13,15 @@ import ( var BusinessActionToolService = &businessActionToolService{} +const RefundHumanSupportMessage = "退款需要由人工客服核实并处理,AI 无法代您申请或办理退款。请回复“人工客服”联系人工处理。" + +// BusinessActionRequiresHuman also covers the legacy mall application tool, +// whose refund_only and return_refund actions do not mention refund in its code. +func BusinessActionRequiresHuman(code string) bool { + code = strings.ToLower(strings.TrimSpace(code)) + return code == "business/mall_apply_after_sale" || strings.Contains(code, "refund") +} + type businessActionToolService struct { mu sync.RWMutex tools map[string]contract.BusinessActionTool @@ -55,7 +64,7 @@ func (s *businessActionToolService) ListForCustomerType(customerType string) []c defer s.mu.RUnlock() ret := make([]contract.BusinessActionTool, 0, len(s.tools)) for _, tool := range s.tools { - if businessActionToolSupportsCustomerType(tool, customerType) { + if !BusinessActionRequiresHuman(tool.Code) && businessActionToolSupportsCustomerType(tool, customerType) { ret = append(ret, tool) } } @@ -67,7 +76,7 @@ func (s *businessActionToolService) ResolveForCustomerType(code, customerType st s.mu.RLock() defer s.mu.RUnlock() tool, ok := s.tools[strings.TrimSpace(code)] - if !ok || !businessActionToolSupportsCustomerType(tool, customerType) { + if !ok || BusinessActionRequiresHuman(tool.Code) || !businessActionToolSupportsCustomerType(tool, customerType) { return contract.BusinessActionTool{}, false } return tool, true @@ -77,14 +86,23 @@ func (s *businessActionToolService) Resolve(code string) (contract.BusinessActio s.mu.RLock() defer s.mu.RUnlock() tool, ok := s.tools[strings.TrimSpace(code)] + if ok && BusinessActionRequiresHuman(tool.Code) { + return contract.BusinessActionTool{}, false + } return tool, ok } func (s *businessActionToolService) Preview(ctx context.Context, tool contract.BusinessActionTool, businessContext contract.BusinessReadContext, arguments map[string]any) (string, error) { + if BusinessActionRequiresHuman(tool.Code) { + return "", contract.NewBusinessActionError(RefundHumanSupportMessage, nil) + } return tool.Preview(ctx, businessContext, arguments) } func (s *businessActionToolService) Execute(ctx context.Context, conversationID, aiAgentID int64, idempotencyKey string, tool contract.BusinessActionTool, businessContext contract.BusinessReadContext, arguments map[string]any) (*contract.BusinessActionResult, bool, error) { + if BusinessActionRequiresHuman(tool.Code) { + return nil, false, contract.NewBusinessActionError(RefundHumanSupportMessage, nil) + } businessContext.CheckPointID = strings.TrimSpace(idempotencyKey) if tool.AuthorizeConfirmation != nil { if err := tool.AuthorizeConfirmation(ctx, businessContext, arguments, businessContext.CheckPointID); err != nil { diff --git a/internal/services/business_action_tool_service_test.go b/internal/services/business_action_tool_service_test.go index 393e3f9..bf372bc 100644 --- a/internal/services/business_action_tool_service_test.go +++ b/internal/services/business_action_tool_service_test.go @@ -240,3 +240,55 @@ func TestBusinessActionToolRetriesExplicitPreSideEffectFailure(t *testing.T) { t.Fatalf("retry result=%#v reused=%t executions=%d err=%v", result, reused, executions, err) } } + +func TestRefundActionsAreHiddenAndBlockedBeforeHostCallbacks(t *testing.T) { + t.Cleanup(func() { _ = SetBusinessActionTools(nil) }) + for _, code := range []string{"business/mall_apply_after_sale", "business/card_package_refund", "business/device_balance_refund", "business/mall_deposit_refund"} { + t.Run(code, func(t *testing.T) { + tool := contract.BusinessActionTool{ + Code: code, Description: "refund", + Preview: func(context.Context, contract.BusinessReadContext, map[string]any) (string, error) { + t.Fatal("refund preview must not reach the host") + return "", nil + }, + AuthorizeConfirmation: func(context.Context, contract.BusinessReadContext, map[string]any, string) error { + t.Fatal("refund must be blocked before authorizing or claiming an invocation") + return nil + }, + Execute: func(context.Context, contract.BusinessReadContext, map[string]any) (*contract.BusinessActionResult, error) { + t.Fatal("refund must not execute, even with an existing confirmation") + return nil, nil + }, + } + if err := SetBusinessActionTools([]contract.BusinessActionTool{tool}); err != nil { + t.Fatal(err) + } + for _, customerType := range []string{"card", "device", "mall_user", ""} { + if got := BusinessActionToolService.ListForCustomerType(customerType); len(got) != 0 { + t.Fatalf("refund exposed in catalog: %#v", got) + } + if _, ok := BusinessActionToolService.ResolveForCustomerType(code, customerType); ok { + t.Fatal("refund resolved for customer") + } + } + if _, ok := BusinessActionToolService.Resolve(code); ok { + t.Fatal("refund resolved for tool definitions") + } + _, err := BusinessActionToolService.Preview(context.Background(), tool, contract.BusinessReadContext{}, nil) + assertRefundHumanSupportError(t, err) + result, reused, err := BusinessActionToolService.Execute(context.Background(), 1, 2, "legacy-refund-confirmation", tool, contract.BusinessReadContext{}, nil) + assertRefundHumanSupportError(t, err) + if result != nil || reused { + t.Fatalf("refund returned an execution result: %#v reused=%v", result, reused) + } + }) + } +} + +func assertRefundHumanSupportError(t *testing.T, err error) { + t.Helper() + var publicErr *contract.BusinessActionError + if !errors.As(err, &publicErr) || publicErr.Message != RefundHumanSupportMessage { + t.Fatalf("expected human support guidance, got %v", err) + } +}