All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
客户问能不能寄/走什么渠道时强制调工具;无产品代码则按对客说明检索可走渠道。 Co-authored-by: Cursor <cursoragent@cursor.com>
331 lines
14 KiB
C#
331 lines
14 KiB
C#
using System.Diagnostics;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Nodes;
|
||
using Fake.Application;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Oms.Application.Dtos.Mcp;
|
||
using Oms.Application.Dtos.Shared;
|
||
using Oms.Application.Mcp;
|
||
using Oms.Application.Shared;
|
||
using Oms.Domain.AiAggregate.Repositories;
|
||
using Oms.Domain.CustomersAggregate.Repositories;
|
||
using Oms.Domain.McpAggregate.Entities;
|
||
using Oms.Domain.McpAggregate.Repositories;
|
||
|
||
namespace Oms.Application.Services.Mcp;
|
||
|
||
[ApiExplorerSettings(GroupName = "OMS-Mcp")]
|
||
public class McpToolService : ApplicationService
|
||
{
|
||
private IMcpToolRepository McpToolRepository => LazyServiceProvider.GetRequiredService<IMcpToolRepository>();
|
||
private IMcpToolExecutor ToolExecutor => LazyServiceProvider.GetRequiredService<IMcpToolExecutor>();
|
||
private IAiProviderRepository AiProviderRepository => LazyServiceProvider.GetRequiredService<IAiProviderRepository>();
|
||
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
|
||
private AiLlmClient LlmClient => LazyServiceProvider.GetRequiredService<AiLlmClient>();
|
||
|
||
/// <summary>列出 MCP 工具(内置目录 + 库表覆盖)</summary>
|
||
[HttpPost("/mcp/tool/list")]
|
||
public async Task<OmsItemsResult<McpToolOutput>> ListAsync()
|
||
{
|
||
var stored = await McpToolRepository.GetListAsync();
|
||
var byKey = stored.ToDictionary(x => x.ToolKey, StringComparer.OrdinalIgnoreCase);
|
||
var items = McpToolCatalog.All.Select(def =>
|
||
{
|
||
byKey.TryGetValue(def.ToolKey, out var row);
|
||
return new McpToolOutput
|
||
{
|
||
Id = row?.Id,
|
||
ToolKey = def.ToolKey,
|
||
DisplayName = string.IsNullOrWhiteSpace(row?.DisplayName) ? def.DisplayName : row!.DisplayName,
|
||
Description = string.IsNullOrWhiteSpace(row?.Description) ? def.Description : row!.Description,
|
||
Enabled = row?.Enabled ?? true,
|
||
SortOrder = row?.SortOrder ?? def.DefaultSortOrder,
|
||
InputSchemaJson = def.InputSchemaJson.Trim(),
|
||
Builtin = true
|
||
};
|
||
})
|
||
.OrderBy(x => x.SortOrder)
|
||
.ThenBy(x => x.ToolKey)
|
||
.ToList();
|
||
|
||
return new OmsItemsResult<McpToolOutput> { Items = items };
|
||
}
|
||
|
||
/// <summary>保存工具启用状态与描述</summary>
|
||
[HttpPost("/mcp/tool/save")]
|
||
public async Task<McpToolOutput> SaveAsync(McpToolSaveInput input)
|
||
{
|
||
var def = McpToolCatalog.Find(input.ToolKey)
|
||
?? throw new BusinessException($"未知工具:{input.ToolKey}");
|
||
|
||
var entity = await McpToolRepository.GetByToolKeyAsync(def.ToolKey);
|
||
if (entity == null)
|
||
{
|
||
entity = new McpTool { ToolKey = def.ToolKey };
|
||
await McpToolRepository.InsertAsync(entity);
|
||
}
|
||
|
||
entity.DisplayName = string.IsNullOrWhiteSpace(input.DisplayName) ? def.DisplayName : input.DisplayName.Trim();
|
||
entity.Description = string.IsNullOrWhiteSpace(input.Description) ? def.Description : input.Description.Trim();
|
||
entity.Enabled = input.Enabled;
|
||
entity.SortOrder = input.SortOrder;
|
||
await McpToolRepository.UpdateAsync(entity);
|
||
|
||
return new McpToolOutput
|
||
{
|
||
Id = entity.Id,
|
||
ToolKey = entity.ToolKey,
|
||
DisplayName = entity.DisplayName,
|
||
Description = entity.Description,
|
||
Enabled = entity.Enabled,
|
||
SortOrder = entity.SortOrder,
|
||
InputSchemaJson = def.InputSchemaJson.Trim(),
|
||
Builtin = true
|
||
};
|
||
}
|
||
|
||
/// <summary>手动试跑工具</summary>
|
||
[HttpPost("/mcp/tool/call")]
|
||
public async Task<McpToolCallOutput> CallAsync(McpToolCallInput input)
|
||
{
|
||
await EnsureToolEnabledAsync(input.ToolKey);
|
||
var sw = Stopwatch.StartNew();
|
||
var (ok, content) = await ToolExecutor.InvokeAsync(input.ToolKey, input.ArgumentsJson ?? "{}");
|
||
return new McpToolCallOutput
|
||
{
|
||
ToolKey = input.ToolKey,
|
||
Success = ok,
|
||
Content = content,
|
||
ElapsedMs = sw.ElapsedMilliseconds
|
||
};
|
||
}
|
||
|
||
/// <summary>AI Agent 对话(可自动调用已启用 MCP 工具;带防滥用限流)</summary>
|
||
[HttpPost("/mcp/agent/chat")]
|
||
[HttpPost("/ai/agent/chat")] // 兼容旧路径;生产 OMS 反代请走 /mcp/*
|
||
public async Task<AiAgentChatOutput> AgentChatAsync(AiAgentChatInput input)
|
||
{
|
||
var customerId = await OmsSessionHelper.ResolveXhsCustomerIdAsync(CustomerRepository, CurrentUser);
|
||
var mapId = OmsSessionHelper.TryGetCustomerMapId(CurrentUser);
|
||
var principal = mapId is > 0
|
||
? $"u{mapId.Value}"
|
||
: (customerId > 0 ? $"c{customerId}" : $"a{CurrentUser.GetId<int>()}");
|
||
|
||
AiAgentAbuseGuard.EnsureAllowed(principal);
|
||
|
||
var provider = await ResolveProviderAsync(customerId, input.ProviderKey.Trim());
|
||
|
||
var enabledTools = await ResolveEnabledToolsAsync(input.ToolKeys);
|
||
var llmTools = enabledTools.Select(t =>
|
||
{
|
||
JsonNode parameters;
|
||
try
|
||
{
|
||
parameters = JsonNode.Parse(t.InputSchemaJson) ?? new JsonObject();
|
||
}
|
||
catch
|
||
{
|
||
parameters = new JsonObject { ["type"] = "object" };
|
||
}
|
||
|
||
return new LlmToolDefinition
|
||
{
|
||
Name = t.ToolKey,
|
||
Description = $"{t.DisplayName}:{t.Description}",
|
||
Parameters = parameters
|
||
};
|
||
}).ToList();
|
||
|
||
var messages = new List<LlmChatMessage>
|
||
{
|
||
new()
|
||
{
|
||
Role = "system",
|
||
Content =
|
||
"你是 OMS 客户侧物流助手。可用工具:" +
|
||
"oms.price_check(查价格)、oms.channel_guide(查对客说明/品名可否承运)、oms.track_query(查轨迹)。" +
|
||
"\n【强制规则】" +
|
||
"\n1) 查价:必填仅「目的国家 + 实重kg」。邮编/尺寸/货物属性/运输方式为选填。" +
|
||
"不要索要起运地、发货城市、仓库(用户提到深圳等可忽略或作备注)。" +
|
||
"国家可用中文,调工具时转二字码(美国→US、英国→GB)。" +
|
||
"用户已给目的国+重量时,必须立刻调用 oms.price_check,用一两句中文总结结果。" +
|
||
"\n2) 对客说明/可承运/走什么渠道:用户问某品名能不能寄、能不能走、走什么渠道时,必须立刻调用 oms.channel_guide。" +
|
||
"参数示例:{\"productCode\":\"\",\"productName\":\"\",\"cargoName\":\"lipstick\"}。" +
|
||
"cargoName 必填(从用户话里提取品名);productCode/productName 仅在客户已点名渠道时填写,否则留空由工具按品名检索。" +
|
||
"有结果后用一两句中文说明可走/不可走的渠道;未命中时如实说,不要编造。" +
|
||
"\n3) 查轨迹:消息里已有运单号/跟踪号/客户单号(如 IDE 开头)时,立刻调用 oms.track_query,禁止再向用户要单号。" +
|
||
"\n4) 禁止空回复;信息不足时用一两句中文追问缺项;不要复述 JSON 或大段工具原文。" +
|
||
"\n【省 token】回复尽量短;一次只调必需工具。"
|
||
}
|
||
};
|
||
|
||
foreach (var m in TrimHistory(input.Messages))
|
||
{
|
||
messages.Add(new LlmChatMessage
|
||
{
|
||
Role = string.IsNullOrWhiteSpace(m.Role) ? "user" : m.Role.Trim(),
|
||
Content = AiAgentAbuseGuard.Truncate(m.Content, AiAgentAbuseGuard.MaxMessageChars)
|
||
});
|
||
}
|
||
|
||
var traces = new List<AiAgentToolTraceOutput>();
|
||
var maxRounds = Math.Clamp(
|
||
input.MaxToolRounds <= 0 ? AiAgentAbuseGuard.MaxToolRoundsHardCap : input.MaxToolRounds,
|
||
1,
|
||
AiAgentAbuseGuard.MaxToolRoundsHardCap);
|
||
var toolInvocations = 0;
|
||
|
||
for (var round = 0; round < maxRounds; round++)
|
||
{
|
||
AiAgentAbuseGuard.ConsumeLlmCall(principal);
|
||
var result = await LlmClient.ChatCompletionDetailedAsync(
|
||
provider,
|
||
messages,
|
||
llmTools.Count > 0 ? llmTools : null,
|
||
input.Model);
|
||
|
||
if (result.ToolCalls.Count == 0)
|
||
{
|
||
var text = result.Content?.Trim() ?? string.Empty;
|
||
if (string.IsNullOrWhiteSpace(text))
|
||
{
|
||
text = "我这边没有生成有效回复。请换个说法再试,或直接发:目的国+重量查价,或运单号查轨迹。";
|
||
}
|
||
|
||
return new AiAgentChatOutput
|
||
{
|
||
Content = text,
|
||
ToolTraces = traces
|
||
};
|
||
}
|
||
|
||
var calls = result.ToolCalls.Take(AiAgentAbuseGuard.MaxToolCallsPerRound).ToList();
|
||
messages.Add(new LlmChatMessage
|
||
{
|
||
Role = "assistant",
|
||
Content = AiAgentAbuseGuard.Truncate(result.Content, 400),
|
||
ToolCalls = calls
|
||
});
|
||
|
||
foreach (var call in calls)
|
||
{
|
||
if (toolInvocations >= AiAgentAbuseGuard.MaxToolInvocationsPerRequest)
|
||
{
|
||
messages.Add(new LlmChatMessage
|
||
{
|
||
Role = "tool",
|
||
ToolCallId = call.Id,
|
||
Name = call.FunctionName,
|
||
Content = "本轮工具调用次数已达上限,请根据已有结果直接回答用户。"
|
||
});
|
||
continue;
|
||
}
|
||
|
||
toolInvocations++;
|
||
var enabled = enabledTools.Any(t =>
|
||
string.Equals(t.ToolKey, call.FunctionName, StringComparison.OrdinalIgnoreCase));
|
||
string toolContent;
|
||
var success = false;
|
||
if (!enabled)
|
||
{
|
||
toolContent = $"工具未启用或不存在:{call.FunctionName}";
|
||
}
|
||
else
|
||
{
|
||
(success, toolContent) = await ToolExecutor.InvokeAsync(
|
||
call.FunctionName,
|
||
call.ArgumentsJson,
|
||
customerId);
|
||
}
|
||
|
||
toolContent = AiAgentAbuseGuard.Truncate(toolContent, AiAgentAbuseGuard.MaxToolResultChars);
|
||
traces.Add(new AiAgentToolTraceOutput
|
||
{
|
||
ToolKey = call.FunctionName,
|
||
ArgumentsJson = AiAgentAbuseGuard.Truncate(call.ArgumentsJson, 400),
|
||
ResultPreview = toolContent.Length > 500 ? toolContent[..500] + "…" : toolContent,
|
||
Success = success
|
||
});
|
||
|
||
messages.Add(new LlmChatMessage
|
||
{
|
||
Role = "tool",
|
||
ToolCallId = call.Id,
|
||
Name = call.FunctionName,
|
||
Content = toolContent
|
||
});
|
||
}
|
||
}
|
||
|
||
AiAgentAbuseGuard.ConsumeLlmCall(principal);
|
||
var final = await LlmClient.ChatCompletionDetailedAsync(provider, messages, tools: null, input.Model);
|
||
return new AiAgentChatOutput
|
||
{
|
||
Content = final.Content?.Trim()
|
||
?? "已查询相关信息,请根据页面提示补充后重试,或联系客服。",
|
||
ToolTraces = traces
|
||
};
|
||
}
|
||
|
||
private static List<AiAgentChatMessageInput> TrimHistory(List<AiAgentChatMessageInput> raw)
|
||
{
|
||
var cleaned = (raw ?? [])
|
||
.Where(m => m != null && !string.IsNullOrWhiteSpace(m.Content))
|
||
.Select(m => new AiAgentChatMessageInput
|
||
{
|
||
Role = string.IsNullOrWhiteSpace(m.Role) ? "user" : m.Role.Trim().ToLowerInvariant(),
|
||
Content = m.Content ?? ""
|
||
})
|
||
.Where(m => m.Role is "user" or "assistant" or "system")
|
||
.ToList();
|
||
|
||
if (cleaned.Count <= AiAgentAbuseGuard.MaxHistoryMessages)
|
||
{
|
||
return cleaned;
|
||
}
|
||
|
||
return cleaned.Skip(cleaned.Count - AiAgentAbuseGuard.MaxHistoryMessages).ToList();
|
||
}
|
||
|
||
private async Task EnsureToolEnabledAsync(string toolKey)
|
||
{
|
||
var def = McpToolCatalog.Find(toolKey)
|
||
?? throw new BusinessException($"未知工具:{toolKey}");
|
||
var row = await McpToolRepository.GetByToolKeyAsync(def.ToolKey);
|
||
if (row is { Enabled: false })
|
||
{
|
||
throw new BusinessException($"工具已禁用:{def.ToolKey}");
|
||
}
|
||
}
|
||
|
||
private async Task<List<McpToolOutput>> ResolveEnabledToolsAsync(List<string>? toolKeys)
|
||
{
|
||
var all = (await ListAsync()).Items;
|
||
var enabled = all.Where(x => x.Enabled).ToList();
|
||
if (toolKeys is { Count: > 0 })
|
||
{
|
||
var set = new HashSet<string>(toolKeys, StringComparer.OrdinalIgnoreCase);
|
||
enabled = enabled.Where(x => set.Contains(x.ToolKey)).ToList();
|
||
}
|
||
return enabled;
|
||
}
|
||
|
||
private async Task<Oms.Domain.AiAggregate.Entities.AiProvider> ResolveProviderAsync(
|
||
int customerId,
|
||
string providerKey)
|
||
{
|
||
var provider = await AiProviderRepository.GetByProviderKeyAsync(customerId, providerKey);
|
||
if (provider == null || !provider.Enabled)
|
||
{
|
||
provider = await AiProviderRepository.GetByProviderKeyAsync(0, providerKey);
|
||
}
|
||
|
||
if (provider == null || !provider.Enabled)
|
||
{
|
||
throw new BusinessException($"AI 提供商 {providerKey} 未配置或未启用");
|
||
}
|
||
|
||
return provider;
|
||
}
|
||
}
|