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

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

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

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

118 lines
10 KiB
Go

package runtime
import (
"context"
"encoding/json"
"strings"
"time"
aitooling "code.tczkiot.com/wlw/ai-agent/internal/ai/tooling"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
svc "code.tczkiot.com/wlw/ai-agent/internal/services"
)
type agentLoopTurn struct {
RetrieverCount int
RetrieveErr error
ResponsePolicy agentLoopResponsePolicy
SystemPrompt string
UserPrompt string
HistoryCount int
AllowedTools []string
ToolPolicy agentLoopToolPolicy
PrefetchedToolCalls []svc.AgentLoopToolCallInput
PrefetchedToolResults map[string]string
VerifiedToolResults map[string]string
}
func (e *AgentLoopEngine) prepareTurn(ctx context.Context, req RunInput, _ *svc.AgentRevisionSnapshot) agentLoopTurn {
knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
responsePolicy := evaluateAgentLoopResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
systemPrompt := buildAgentLoopSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
systemPrompt += buildCustomerAfterSalesPolicy(req.Conversation)
userPrompt, historyCount := e.buildUserPrompt(req)
var memories []svc.BusinessToolMemory
if e.history != nil && e.businessMemory != nil && req.Conversation.ID > 0 {
memories = e.businessMemory(req.Conversation.ID, 4)
}
if len(memories) > 0 {
lines := make([]string, 0, len(memories))
for _, memory := range memories {
lines = append(lines, "- "+memory.ToolCode+": "+memory.Result)
}
userPrompt += "\n\nRecent verified business tool memory from this same conversation:\n" + strings.Join(lines, "\n")
}
if knowledgeContext != "" {
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
}
allowedTools := agentLoopSafeBuiltinCodes()
catalog := []string{
"- " + toolx.BuiltinConversationContext.Code + " | 读取当前会话和客户上下文",
"- " + toolx.BuiltinKnowledgeRetrieve.Code + " | 按需再次检索已绑定知识库",
"- " + toolx.GraphTriageServiceRequest.Code + " | 分析服务请求并生成处置建议",
"- " + toolx.GraphAnalyzeConversation.Code + " | 分析会话意图和风险信号",
}
var prefetchedToolCalls []svc.AgentLoopToolCallInput
prefetchedToolResults := make(map[string]string)
verifiedToolResults := make(map[string]string)
for _, tool := range svc.BusinessReadToolService.ListForCustomerType(req.Conversation.CustomerType) {
allowedTools = append(allowedTools, tool.Code)
catalog = append(catalog, "- "+tool.Code+" | "+tool.Description)
if tool.MatchIntent == nil || !tool.MatchIntent(req.UserMessage.Content) {
continue
}
startedAt := time.Now()
result, err := svc.BusinessReadToolService.Execute(ctx, tool, businessReadContext(ctx, req.Conversation, ""), map[string]any{})
record := svc.AgentLoopToolCallInput{
ToolCode: tool.Code, RiskLevel: aitooling.RiskLevelRead, Status: "completed",
ArgumentsPreview: "{}", DurationMS: int(time.Since(startedAt).Milliseconds()),
}
if err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
prefetchedToolCalls = append(prefetchedToolCalls, record)
userPrompt += "\n\nRequired fresh business data is unavailable for the current message (" + tool.Code + "). Do not answer from stale chat text, do not retry the same failed lookup in this turn, and never expose its internal error. Briefly explain that the live query is temporarily unavailable and offer retry or human support."
continue
}
encoded, err := json.Marshal(result)
if err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
prefetchedToolCalls = append(prefetchedToolCalls, record)
continue
}
record.ResultPreview = aitooling.SanitizePreview(string(encoded))
prefetchedToolResults[agentLoopToolResultCacheKey(tool.Code, map[string]any{})] = string(encoded)
verifiedToolResults[tool.Code] = string(encoded)
prefetchedToolCalls = append(prefetchedToolCalls, record)
userPrompt += "\n\nFresh required business data for the current message (use this instead of stale chat text):\n- " + tool.Code + ": " + string(encoded)
}
for _, tool := range svc.BusinessActionToolService.ListForCustomerType(req.Conversation.CustomerType) {
allowedTools = append(allowedTools, tool.Code)
catalog = append(catalog, "- "+tool.Code+" | "+tool.Description+"(写操作,必须经用户明确确认)")
}
systemPrompt += "\n\nAvailable capabilities:\n" + strings.Join(catalog, "\n")
systemPrompt += "\n\nUse tool_search with an exact capability code only when needed. Never invent a capability code. For card status, network connectivity, packages, data usage, remaining data, expiration, or other host business facts, call the matching business capability before answering and treat its result as the only current source of truth. Never guess host business data. For any requested human handoff, call conversation_decision. When the customer explicitly asks for human support, set action=handoff, handoff_initiator=customer, and handoff_confirmed=true. Never claim a handoff, assignment, or queue entry succeeded in reply text."
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\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."
return agentLoopTurn{
RetrieverCount: retrieverCount,
RetrieveErr: retrieveErr,
ResponsePolicy: responsePolicy,
SystemPrompt: systemPrompt,
UserPrompt: userPrompt,
HistoryCount: historyCount,
AllowedTools: allowedTools,
ToolPolicy: parseAgentLoopToolPolicy(req.AIAgent.ToolPolicy),
PrefetchedToolCalls: prefetchedToolCalls,
PrefetchedToolResults: prefetchedToolResults,
VerifiedToolResults: verifiedToolResults,
}
}