admin/src/Oms.Application/Services/Mcp/McpToolService.cs
zzh ab37f59b1a
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
feat: 品名可承运查询支持仅凭 cargoName 调用 oms.channel_guide
客户问能不能寄/走什么渠道时强制调工具;无产品代码则按对客说明检索可走渠道。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 11:17:35 +08:00

331 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}