Compare commits

...

4 Commits

Author SHA1 Message Date
zzh
46455b2ae9 Merge pull request '20261028' (#16) from test into master
All checks were successful
Build and Deploy / deploy (push) Successful in 2m0s
Reviewed-on: #16
2026-10-28 11:26:50 +08:00
zzh
ab37f59b1a feat: 品名可承运查询支持仅凭 cargoName 调用 oms.channel_guide
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
客户问能不能寄/走什么渠道时强制调工具;无产品代码则按对客说明检索可走渠道。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 11:17:35 +08:00
zzh
fc48b84f80 fix: AI 对话解析多模型 JSON 配置,取 defaultModelId
All checks were successful
Build and Deploy / deploy (push) Successful in 1m12s
兼容 apiSecret 纯模型名与 {defaultModelId,models} 结构,避免多模型配置后请求失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:46:36 +08:00
zzh
efbb18c7c3 feat: AI 提供商列表明文回传默认模型,并按类型回落模型名
All checks were successful
Build and Deploy / deploy (push) Successful in 1m17s
ApiSecret 实际承载文字主模型,列表不再脱敏;未配置时 Chat 按厂商默认模型回落。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 10:32:59 +08:00
6 changed files with 171 additions and 17 deletions

View File

@ -16,6 +16,9 @@ public class AiProviderOutput
public string? ApiKey { get; set; }
/// <summary>
/// 默认模型名(历史字段名 ApiSecret,实际不当密钥脱敏)。
/// </summary>
public string? ApiSecret { get; set; }
public bool IsDefault { get; set; }
@ -39,6 +42,9 @@ public class AiProviderSaveInput
public string? ApiKey { get; set; }
/// <summary>
/// 默认文字模型 ID;空表示保持原值。
/// </summary>
public string? ApiSecret { get; set; }
public bool IsDefault { get; set; }

View File

@ -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),

View File

@ -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<Oms.Domain.ChannelsAggregate.Entities.SalesProduct>();
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<object>(),
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)

View File

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

View File

@ -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】回复尽量短;一次只调必需工具。"

View File

@ -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];
}
/// <summary>
/// 未配置 ApiSecret(模型名)时按提供商类型回落默认模型。
/// </summary>
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
};
/// <summary>
/// 解析 ApiSecret:兼容纯模型名,或多模型 JSON
/// {"defaultModelId":"...","models":[{"modelId":"...","role":"文字"}]}。
/// </summary>
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