diff --git a/src/Oms.Application/Dtos/Ai/AiDtos.cs b/src/Oms.Application/Dtos/Ai/AiDtos.cs index 7cb0f98..b217d5d 100644 --- a/src/Oms.Application/Dtos/Ai/AiDtos.cs +++ b/src/Oms.Application/Dtos/Ai/AiDtos.cs @@ -16,6 +16,9 @@ public class AiProviderOutput public string? ApiKey { get; set; } + /// + /// 默认模型名(历史字段名 ApiSecret,实际不当密钥脱敏)。 + /// public string? ApiSecret { get; set; } public bool IsDefault { get; set; } @@ -39,6 +42,9 @@ public class AiProviderSaveInput public string? ApiKey { get; set; } + /// + /// 默认文字模型 ID;空表示保持原值。 + /// public string? ApiSecret { get; set; } public bool IsDefault { get; set; } diff --git a/src/Oms.Application/Mcp/McpToolCatalog.cs b/src/Oms.Application/Mcp/McpToolCatalog.cs index 4ab874f..1b76866 100644 --- a/src/Oms.Application/Mcp/McpToolCatalog.cs +++ b/src/Oms.Application/Mcp/McpToolCatalog.cs @@ -40,20 +40,16 @@ public static class McpToolCatalog new( ChannelGuide, "查渠道对客说明", - "按销售产品代码或名称查询对客说明,判断客户品名是否可承运(禁运/可寄)。", + "客户问某品名能不能寄、走什么渠道时调用。必填 cargoName;productCode/productName 可选(已知渠道时传入)。仅品名时按对客说明检索可走渠道。", """ { "type": "object", "properties": { - "productCode": { "type": "string", "description": "销售产品代码,优先使用" }, - "productName": { "type": "string", "description": "销售产品名称关键词(无代码时用)" }, - "cargoName": { "type": "string", "description": "客户品名,用于对照说明判断可否承运" } + "productCode": { "type": "string", "description": "销售产品代码,可选;客户已指定渠道时优先" }, + "productName": { "type": "string", "description": "销售产品/渠道名称关键词,可选" }, + "cargoName": { "type": "string", "description": "客户品名,如 lipstick、化妆品;必填" } }, - "required": ["cargoName"], - "anyOf": [ - { "required": ["productCode"] }, - { "required": ["productName"] } - ] + "required": ["cargoName"] } """, 20), diff --git a/src/Oms.Application/Mcp/McpToolExecutor.cs b/src/Oms.Application/Mcp/McpToolExecutor.cs index c9fb27e..1c075b8 100644 --- a/src/Oms.Application/Mcp/McpToolExecutor.cs +++ b/src/Oms.Application/Mcp/McpToolExecutor.cs @@ -153,6 +153,13 @@ public class McpToolExecutor( var productName = GetString(root, "productName", "ProductName"); var cargoName = GetString(root, "cargoName", "CargoName"); + if (string.IsNullOrWhiteSpace(cargoName) + && string.IsNullOrWhiteSpace(productCode) + && string.IsNullOrWhiteSpace(productName)) + { + return (false, "请至少提供 cargoName(客户品名),或 productCode / productName"); + } + var products = new List(); if (!string.IsNullOrWhiteSpace(productCode)) { @@ -166,14 +173,51 @@ public class McpToolExecutor( var list = await salesProductRepository.GetListAsync( x => x.IsActive == EnableStatus.Enabled && (x.ProductName.Contains(keyword) || x.ProductCode.Contains(keyword))); - products.AddRange(list.Take(5)); + products.AddRange(list.Take(8)); + } + + // 仅品名:在已启用且配置了对客说明的产品中检索可走渠道 + if (products.Count == 0 && !string.IsNullOrWhiteSpace(cargoName)) + { + var keyword = cargoName.Trim(); + var withGuide = await salesProductRepository.GetListAsync( + x => x.IsActive == EnableStatus.Enabled + && x.CustomerGuideMarkdown != null + && x.CustomerGuideMarkdown != ""); + + static bool ContainsIgnoreCase(string? haystack, string needle) => + !string.IsNullOrEmpty(haystack) + && haystack.Contains(needle, StringComparison.OrdinalIgnoreCase); + + var matched = withGuide + .Where(p => + ContainsIgnoreCase(p.CustomerGuideMarkdown, keyword) + || ContainsIgnoreCase(p.ProductName, keyword) + || ContainsIgnoreCase(p.ProductCode, keyword)) + .Take(10) + .ToList(); + + if (matched.Count > 0) + { + products.AddRange(matched); + } + else + { + return (true, JsonSerializer.Serialize(new + { + items = Array.Empty(), + cargoName = keyword, + hint = "未在已配置对客说明的渠道中命中该品名。请换关键词,或补充销售产品代码/名称后再查;不要擅自判定可寄。" + }, JsonOptions)); + } } if (products.Count == 0) { - return (false, "未找到销售产品,请提供 productCode 或 productName"); + return (false, "未找到销售产品,请提供 productCode、productName 或 cargoName"); } + var cargo = string.IsNullOrWhiteSpace(cargoName) ? null : cargoName.Trim(); var items = products.Select(p => { var guide = p.CustomerGuideMarkdown?.Trim() ?? string.Empty; @@ -184,14 +228,19 @@ public class McpToolExecutor( productName = p.ProductName, hasGuide = !string.IsNullOrWhiteSpace(guide), customerGuideMarkdown = string.IsNullOrWhiteSpace(guide) ? null : guide, - cargoNameToCheck = string.IsNullOrWhiteSpace(cargoName) ? null : cargoName.Trim(), + cargoNameToCheck = cargo, hint = string.IsNullOrWhiteSpace(guide) ? "该产品尚未配置对客说明,无法据此判断品名可否承运。" - : "请根据对客说明判断 cargoName 是否可寄;说明未提及的品名不要擅自判定可寄。" + : "请根据对客说明判断 cargoName 是否可寄、是否适合该渠道;说明未提及的品名不要擅自判定可寄。" }; }).ToList(); - return (true, JsonSerializer.Serialize(new { items }, JsonOptions)); + return (true, JsonSerializer.Serialize(new + { + items, + cargoName = cargo, + hint = "用一两句中文告诉客户:哪些渠道对客说明支持该品名、哪些不支持;可列出产品代码/名称。" + }, JsonOptions)); } private async Task<(bool, string)> TrackQueryAsync(string argumentsJson, CancellationToken cancellationToken) diff --git a/src/Oms.Application/Services/Ai/AiService.cs b/src/Oms.Application/Services/Ai/AiService.cs index cc9ecbd..d8f804a 100644 --- a/src/Oms.Application/Services/Ai/AiService.cs +++ b/src/Oms.Application/Services/Ai/AiService.cs @@ -127,7 +127,8 @@ public class AiService : ApplicationService DisplayName = entity.DisplayName, BaseUrl = entity.BaseUrl, ApiKey = maskSecret ? MaskSecret(entity.ApiKeyEnc) : entity.ApiKeyEnc, - ApiSecret = maskSecret ? MaskSecret(entity.ApiSecretEnc) : entity.ApiSecretEnc, + // ApiSecret 实际承载默认模型名,列表需明文回传以便卡片展示/编辑 + ApiSecret = entity.ApiSecretEnc, IsDefault = entity.IsDefault, Enabled = entity.Enabled }; diff --git a/src/Oms.Application/Services/Mcp/McpToolService.cs b/src/Oms.Application/Services/Mcp/McpToolService.cs index 2985f1c..c8fab9a 100644 --- a/src/Oms.Application/Services/Mcp/McpToolService.cs +++ b/src/Oms.Application/Services/Mcp/McpToolService.cs @@ -149,7 +149,10 @@ public class McpToolService : ApplicationService "不要索要起运地、发货城市、仓库(用户提到深圳等可忽略或作备注)。" + "国家可用中文,调工具时转二字码(美国→US、英国→GB)。" + "用户已给目的国+重量时,必须立刻调用 oms.price_check,用一两句中文总结结果。" + - "\n2) 对客说明/可承运:必填销售产品代码或名称 + 客户品名;缺一再追问。" + + "\n2) 对客说明/可承运/走什么渠道:用户问某品名能不能寄、能不能走、走什么渠道时,必须立刻调用 oms.channel_guide。" + + "参数示例:{\"productCode\":\"\",\"productName\":\"\",\"cargoName\":\"lipstick\"}。" + + "cargoName 必填(从用户话里提取品名);productCode/productName 仅在客户已点名渠道时填写,否则留空由工具按品名检索。" + + "有结果后用一两句中文说明可走/不可走的渠道;未命中时如实说,不要编造。" + "\n3) 查轨迹:消息里已有运单号/跟踪号/客户单号(如 IDE 开头)时,立刻调用 oms.track_query,禁止再向用户要单号。" + "\n4) 禁止空回复;信息不足时用一两句中文追问缺项;不要复述 JSON 或大段工具原文。" + "\n【省 token】回复尽量短;一次只调必需工具。" diff --git a/src/Oms.Application/Shared/AiLlmClient.cs b/src/Oms.Application/Shared/AiLlmClient.cs index 6f9f825..eb263af 100644 --- a/src/Oms.Application/Shared/AiLlmClient.cs +++ b/src/Oms.Application/Shared/AiLlmClient.cs @@ -80,7 +80,15 @@ public class AiLlmClient throw new BusinessException($"请先配置 {provider.DisplayName} 的 API Key"); } - var model = modelOverride ?? provider.ApiSecretEnc; + var model = modelOverride; + if (string.IsNullOrWhiteSpace(model)) + { + model = ResolveConfiguredModelId(provider.ApiSecretEnc); + } + if (string.IsNullOrWhiteSpace(model)) + { + model = DefaultModelForProviderType(provider.ProviderType); + } if (string.IsNullOrWhiteSpace(model)) { throw new BusinessException($"请先配置 {provider.DisplayName} 的模型名(ApiSecret 字段)或在请求中传入 model"); @@ -256,6 +264,97 @@ public class AiLlmClient return body.Length <= 300 ? body : body[..300]; } + + /// + /// 未配置 ApiSecret(模型名)时按提供商类型回落默认模型。 + /// + private static string? DefaultModelForProviderType(string? providerType) => + (providerType ?? string.Empty).Trim().ToLowerInvariant() switch + { + "chatgpt" => "gpt-4o-mini", + "doubao" => "doubao-1-5-pro-32k-250115", + "deepseek" => "deepseek-chat", + "zhipu" => "glm-5.1", + _ => null + }; + + /// + /// 解析 ApiSecret:兼容纯模型名,或多模型 JSON + /// {"defaultModelId":"...","models":[{"modelId":"...","role":"文字"}]}。 + /// + private static string? ResolveConfiguredModelId(string? apiSecret) + { + var secret = (apiSecret ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(secret)) + { + return null; + } + + if (!secret.StartsWith('{')) + { + return secret; + } + + try + { + using var doc = JsonDocument.Parse(secret); + var root = doc.RootElement; + if (root.TryGetProperty("defaultModelId", out var defaultModel) && + defaultModel.ValueKind == JsonValueKind.String) + { + var id = defaultModel.GetString()?.Trim(); + if (!string.IsNullOrWhiteSpace(id)) + { + return id; + } + } + + if (root.TryGetProperty("models", out var models) && + models.ValueKind == JsonValueKind.Array) + { + foreach (var item in models.EnumerateArray()) + { + if (!item.TryGetProperty("modelId", out var modelIdEl) || + modelIdEl.ValueKind != JsonValueKind.String) + { + continue; + } + + var modelId = modelIdEl.GetString()?.Trim(); + if (string.IsNullOrWhiteSpace(modelId)) + { + continue; + } + + if (item.TryGetProperty("role", out var roleEl) && + roleEl.ValueKind == JsonValueKind.String && + string.Equals(roleEl.GetString(), "文字", StringComparison.Ordinal)) + { + return modelId; + } + } + + foreach (var item in models.EnumerateArray()) + { + if (item.TryGetProperty("modelId", out var modelIdEl) && + modelIdEl.ValueKind == JsonValueKind.String) + { + var modelId = modelIdEl.GetString()?.Trim(); + if (!string.IsNullOrWhiteSpace(modelId)) + { + return modelId; + } + } + } + } + } + catch (JsonException) + { + return secret; + } + + return null; + } } public static class AiLlmServiceExtensions