refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user