Files
ai-agent/internal/ai/application/runtime/package_reply_guard.go
T
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

226 lines
6.9 KiB
Go

package runtime
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
type verifiedAddonOption struct {
Sequence int
Name string
Price string
CanPurchase bool
Recommended bool
UnavailableReason string
ReasonCode string
}
func cloneVerifiedToolResults(input map[string]string) map[string]string {
if len(input) == 0 {
return make(map[string]string)
}
result := make(map[string]string, len(input))
for code, value := range input {
result[code] = value
}
return result
}
const (
cardPackageCatalogToolCode = "business/card_package_catalog"
devicePackageCatalogToolCode = "business/device_package_catalog"
)
type verifiedPackageCatalogDecision struct {
RequiresAddon bool
AddonOptions []verifiedAddonOption
}
// enforceVerifiedPackageReply is the final business safety boundary for
// package recommendations. Model instructions remain useful for presentation,
// but a probabilistic reply must never override the verified eligibility
// result returned by the host system.
//
// Only the two package-catalog tools are authoritative here. Diagnosis and
// unrelated tool payloads may contain similarly named fields, so recursively
// searching every tool result would let stale or unrelated facts replace a
// valid answer. Once the current-turn catalog says add-ons are mandatory, the
// final answer is rendered deterministically instead of trying to recognise a
// contradictory Chinese sentence after the fact.
func enforceVerifiedPackageReply(reply string, toolResults map[string]string) string {
decision, ok := resolveVerifiedPackageCatalogDecision(toolResults)
if !ok || !decision.RequiresAddon {
return reply
}
return buildVerifiedAddonReply(decision.AddonOptions)
}
func resolveVerifiedPackageCatalogDecision(toolResults map[string]string) (verifiedPackageCatalogDecision, bool) {
var decision verifiedPackageCatalogDecision
foundCatalog := false
allRecognizedOptionsAreAddon := true
recognizedOptionCount := 0
for _, code := range []string{cardPackageCatalogToolCode, devicePackageCatalogToolCode} {
raw, exists := toolResults[code]
if !exists || strings.TrimSpace(raw) == "" {
continue
}
var value any
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
if decoder.Decode(&value) != nil {
continue
}
foundCatalog = true
collectPackageCatalogDecision(value, &decision, &recognizedOptionCount, &allRecognizedOptionsAreAddon)
}
if !foundCatalog || recognizedOptionCount == 0 {
return verifiedPackageCatalogDecision{}, false
}
decision.RequiresAddon = decision.RequiresAddon || allRecognizedOptionsAreAddon
sort.SliceStable(decision.AddonOptions, func(i, j int) bool {
if decision.AddonOptions[i].Recommended != decision.AddonOptions[j].Recommended {
return decision.AddonOptions[i].Recommended
}
if decision.AddonOptions[i].CanPurchase != decision.AddonOptions[j].CanPurchase {
return decision.AddonOptions[i].CanPurchase
}
return decision.AddonOptions[i].Sequence < decision.AddonOptions[j].Sequence
})
return decision, true
}
func collectPackageCatalogDecision(
value any,
decision *verifiedPackageCatalogDecision,
recognizedOptionCount *int,
allRecognizedOptionsAreAddon *bool,
) {
switch typed := value.(type) {
case []any:
for _, item := range typed {
collectPackageCatalogDecision(item, decision, recognizedOptionCount, allRecognizedOptionsAreAddon)
}
case map[string]any:
if rawPackageType, exists := typed["package_type"]; exists {
packageType := strings.ToLower(strings.TrimSpace(anyString(rawPackageType)))
if packageType == "basic" || packageType == "addon" {
*recognizedOptionCount++
if packageType != "addon" {
*allRecognizedOptionsAreAddon = false
return
}
option := verifiedAddonOption{
Sequence: anyInt(typed["sequence"]),
Name: strings.TrimSpace(anyString(firstValue(typed, "name", "package_name", "title"))),
Price: strings.TrimSpace(anyString(firstValue(typed, "price", "amount"))),
CanPurchase: anyBool(typed["can_purchase"]),
Recommended: anyBool(typed["recommended"]),
UnavailableReason: strings.TrimSpace(anyString(typed["unavailable_reason"])),
ReasonCode: strings.TrimSpace(anyString(typed["reason_code"])),
}
decision.AddonOptions = append(decision.AddonOptions, option)
if option.Recommended {
decision.RequiresAddon = true
}
return
}
}
for _, item := range typed {
collectPackageCatalogDecision(item, decision, recognizedOptionCount, allRecognizedOptionsAreAddon)
}
}
}
func buildVerifiedAddonReply(options []verifiedAddonOption) string {
lines := []string{
"当前主套餐仍在有效期内,但本周期流量已经用完。",
"待生效的基础套餐不会补充当前周期流量;当前只能购买加油包,不能再购买基础套餐来恢复本周期上网。",
}
purchasable := make([]verifiedAddonOption, 0, len(options))
for _, option := range options {
if option.CanPurchase {
purchasable = append(purchasable, option)
}
}
if len(purchasable) > 0 {
lines = append(lines, "", "当前可购买的加油包:")
for i, option := range purchasable {
sequence := option.Sequence
if sequence <= 0 {
sequence = i + 1
}
label := strings.TrimSpace(option.Name)
if label == "" {
label = "加油包"
}
if option.Price != "" {
label += " - ¥" + option.Price
}
lines = append(lines, fmt.Sprintf("%d. %s", sequence, label))
}
lines = append(lines, "", "请回复加油包序号,我再为您进入下单确认。")
return strings.Join(lines, "\n")
}
if hasInsufficientBalanceAddon(options) {
lines = append(lines, "", "已查到加油包,但当前余额不足。请先充值余额,充值后再购买加油包。")
return strings.Join(lines, "\n")
}
lines = append(lines, "", "当前暂未查到可购买的加油包,请稍后重新查询,或回复“人工客服”继续处理。")
return strings.Join(lines, "\n")
}
func hasInsufficientBalanceAddon(options []verifiedAddonOption) bool {
for _, option := range options {
if strings.EqualFold(option.ReasonCode, "insufficient_balance") || strings.Contains(option.UnavailableReason, "余额不足") {
return true
}
}
return false
}
func firstValue(item map[string]any, keys ...string) any {
for _, key := range keys {
if value, exists := item[key]; exists {
return value
}
}
return nil
}
func anyString(value any) string {
switch typed := value.(type) {
case string:
return typed
case json.Number:
return typed.String()
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case nil:
return ""
default:
return fmt.Sprint(typed)
}
}
func anyInt(value any) int {
parsed, _ := strconv.Atoi(anyString(value))
return parsed
}
func anyBool(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case string:
parsed, _ := strconv.ParseBool(typed)
return parsed
default:
return false
}
}