ChatBIService.cs 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037
  1. using System.Text;
  2. using System.Text.Json;
  3. using System.Text.RegularExpressions;
  4. using Admin.NET.Plugin.AiDOP.Entity;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure;
  6. namespace Admin.NET.Plugin.AiDOP.ChatBI;
  7. public sealed class ChatBIService : ITransient
  8. {
  9. private static readonly string[] ValueTables =
  10. {
  11. "ado_s9_kpi_value_l1_day",
  12. "ado_s9_kpi_value_l2_day",
  13. "ado_s9_kpi_value_l3_day",
  14. "ado_s9_kpi_value_l4_day"
  15. };
  16. private readonly ISqlSugarClient _db;
  17. private readonly DeepSeekChatClient _deepSeek;
  18. public ChatBIService(ISqlSugarClient db, DeepSeekChatClient deepSeek)
  19. {
  20. _db = db;
  21. _deepSeek = deepSeek;
  22. }
  23. public async Task<ChatBIAnswerOutput> AskAsync(ChatBIAskInput input, long tenantId, CancellationToken cancellationToken = default)
  24. {
  25. var question = string.IsNullOrWhiteSpace(input.Question) ? "当前最需要关注的问题是什么?" : input.Question.Trim();
  26. var moduleCode = NormalizeModuleCode(input.ModuleCode);
  27. var llmClass = await TryClassifyIntentWithLlmAsync(question, moduleCode, cancellationToken);
  28. var usedLlmClass = llmClass != null;
  29. var intent = usedLlmClass
  30. ? MapLlmTypeToIntent(llmClass.Type, question)
  31. : ClassifyIntent(question, moduleCode);
  32. if (IsOutOfScopeDetailQuestion(question))
  33. intent = "data_query";
  34. var (dateStart, dateEnd) = ParseDateFilters(input.Filters);
  35. var allMetrics = await LoadMetricCardsAsync(
  36. tenantId, input.FactoryId <= 0 ? 1 : input.FactoryId, moduleCode, dateStart, dateEnd);
  37. var excludedModules = ParseExcludedModules(question);
  38. var includedModules = ParseIncludedModules(question, excludedModules);
  39. var matchText = AppendMetricHint(question, llmClass?.MetricHint);
  40. var hasNamedMetric = HasExplicitMetricMention(allMetrics, matchText);
  41. if (intent == "data_query")
  42. {
  43. // 来自哪些订单 / 怎么算 / 取值:仍属数据查询,不改成诊断模板。
  44. }
  45. else if (!usedLlmClass && hasNamedMetric && !IsDiagnosticQuestion(question) && intent is not "best_metric")
  46. intent = IsLookupQuestion(question) ? "list_metrics" : "metric_status";
  47. else if (intent == "unrecognized" && includedModules.Count > 0)
  48. intent = "list_metrics";
  49. else if (usedLlmClass && intent == "unrecognized" && hasNamedMetric && !IsDiagnosticQuestion(question))
  50. intent = "data_query";
  51. var requestedLevel = ParseRequestedLevel(question)
  52. ?? (intent is "list_metrics" or "best_metric" && !hasNamedMetric ? 1 : null);
  53. var scopedMetrics = ApplyQuestionScope(allMetrics, includedModules, excludedModules, requestedLevel);
  54. var hasExplicitMetric = HasExplicitMetricMention(scopedMetrics, matchText);
  55. ChatBIMetricCard? focus;
  56. List<ChatBIMetricCard> metrics;
  57. if (intent == "unrecognized")
  58. {
  59. focus = null;
  60. metrics = new List<ChatBIMetricCard>();
  61. }
  62. else if (intent == "data_query")
  63. {
  64. var canLookup = hasNamedMetric || IsLookupQuestion(question) || requestedLevel != null;
  65. metrics = canLookup
  66. ? SelectLookupMetrics(scopedMetrics, matchText, hasNamedMetric)
  67. : new List<ChatBIMetricCard>();
  68. focus = metrics.FirstOrDefault();
  69. }
  70. else if (intent == "list_metrics")
  71. {
  72. focus = null;
  73. metrics = SelectLookupMetrics(scopedMetrics, matchText, hasNamedMetric);
  74. if (metrics.Count == 1) focus = metrics[0];
  75. }
  76. else if (intent == "best_metric")
  77. {
  78. metrics = SelectBestMetrics(scopedMetrics);
  79. focus = metrics.FirstOrDefault();
  80. }
  81. else
  82. {
  83. focus = ResolveFocusMetric(scopedMetrics, matchText, intent);
  84. metrics = BuildContextMetrics(scopedMetrics, focus, intent, moduleCode, hasExplicitMetric);
  85. }
  86. var contextTitle = moduleCode == null ? "九宫格全局运营看板" : $"{moduleCode} {ResolveModuleName(moduleCode)}";
  87. var fallback = BuildDeterministicAnswer(question, contextTitle, intent, focus, metrics, excludedModules, requestedLevel, moduleCode);
  88. var llmAnswer = intent == "unrecognized"
  89. ? null
  90. : await TryBuildDeepSeekAnswerAsync(question, contextTitle, input.Filters, fallback, metrics, cancellationToken);
  91. if (!string.IsNullOrWhiteSpace(llmAnswer))
  92. {
  93. fallback.Source = "deepseek";
  94. fallback.IsFallback = false;
  95. fallback.AnswerText = llmAnswer;
  96. fallback.Summary = FirstSentence(llmAnswer);
  97. }
  98. fallback.ContextTitle = contextTitle;
  99. fallback.Actions = BuildActions(moduleCode, focus ?? metrics.FirstOrDefault(), intent);
  100. fallback.Suggestions = BuildSuggestions(moduleCode, intent, focus ?? metrics.FirstOrDefault());
  101. return fallback;
  102. }
  103. private async Task<List<ChatBIMetricCard>> LoadMetricCardsAsync(
  104. long tenantId, long factoryId, string? moduleCode, DateTime? dateStart, DateTime? dateEnd)
  105. {
  106. var kpiQuery = _db.Queryable<AdoSmartOpsKpiMaster>()
  107. .Where(x => x.TenantId == tenantId && x.IsEnabled);
  108. if (!string.IsNullOrWhiteSpace(moduleCode))
  109. kpiQuery = kpiQuery.Where(x => x.ModuleCode == moduleCode);
  110. var kpis = await kpiQuery
  111. .OrderBy(x => x.MetricLevel)
  112. .OrderBy(x => x.SortNo)
  113. .ToListAsync();
  114. var values = await LoadCurrentValuesAsync(tenantId, factoryId, moduleCode, dateStart, dateEnd);
  115. var cards = kpis
  116. .Select(kpi =>
  117. {
  118. values.TryGetValue(kpi.MetricCode, out var value);
  119. var current = value?.MetricValue;
  120. var target = value?.TargetValue;
  121. var status = AidopS4KpiMerge.AchievementLevel(
  122. current,
  123. target,
  124. kpi.Direction ?? "higher_is_better",
  125. kpi.YellowThreshold,
  126. kpi.RedThreshold);
  127. var gap = AidopS4KpiMerge.GapValue(current, target);
  128. return new ChatBIMetricCard
  129. {
  130. Id = kpi.Id,
  131. ParentId = kpi.ParentId,
  132. ModuleCode = kpi.ModuleCode,
  133. MetricCode = kpi.MetricCode,
  134. MetricName = kpi.MetricName,
  135. MetricLevel = kpi.MetricLevel,
  136. CurrentValue = current,
  137. ResultStatus = value?.ResultStatus,
  138. ResultReason = value?.ResultReason,
  139. TargetValue = target,
  140. Unit = kpi.Unit ?? "",
  141. StatusColor = status,
  142. GapLabel = FormatGapLabel(gap, kpi.Unit),
  143. Department = kpi.Department ?? "",
  144. TrendFlag = value?.TrendFlag ?? "",
  145. Direction = kpi.Direction ?? "higher_is_better"
  146. };
  147. })
  148. .Where(x => x.CurrentValue != null || x.TargetValue != null || x.ResultStatus is "NO_DATA" or "SOURCE_UNSUPPORTED")
  149. .ToList();
  150. return cards;
  151. }
  152. private async Task<Dictionary<string, MetricValueRow>> LoadCurrentValuesAsync(
  153. long tenantId, long factoryId, string? moduleCode, DateTime? dateStart, DateTime? dateEnd)
  154. {
  155. var result = new Dictionary<string, MetricValueRow>(StringComparer.OrdinalIgnoreCase);
  156. for (var i = 0; i < ValueTables.Length; i++)
  157. {
  158. var table = ValueTables[i];
  159. try
  160. {
  161. var sql = string.IsNullOrWhiteSpace(moduleCode)
  162. ? $"""
  163. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue,
  164. v.status_color AS StatusColor, v.trend_flag AS TrendFlag,
  165. v.result_status AS ResultStatus, v.result_reason AS ResultReason
  166. FROM {table} v
  167. WHERE v.tenant_id=@tenantId AND v.factory_id=@factoryId AND v.is_deleted=0
  168. AND v.biz_date=(
  169. SELECT MAX(v2.biz_date) FROM {table} v2
  170. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  171. AND v2.metric_code=v.metric_code AND v2.is_deleted=0
  172. AND (@ds IS NULL OR v2.biz_date >= @ds)
  173. AND (@de IS NULL OR v2.biz_date <= @de)
  174. )
  175. """
  176. : $"""
  177. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue,
  178. v.status_color AS StatusColor, v.trend_flag AS TrendFlag,
  179. v.result_status AS ResultStatus, v.result_reason AS ResultReason
  180. FROM {table} v
  181. WHERE v.tenant_id=@tenantId AND v.factory_id=@factoryId AND v.module_code=@moduleCode AND v.is_deleted=0
  182. AND v.biz_date=(
  183. SELECT MAX(v2.biz_date) FROM {table} v2
  184. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  185. AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0
  186. AND (@ds IS NULL OR v2.biz_date >= @ds)
  187. AND (@de IS NULL OR v2.biz_date <= @de)
  188. )
  189. """;
  190. var rows = await _db.Ado.SqlQueryAsync<MetricValueRow>(sql, new
  191. {
  192. tenantId,
  193. factoryId,
  194. moduleCode,
  195. ds = dateStart,
  196. de = dateEnd
  197. });
  198. foreach (var row in rows.Where(x => !string.IsNullOrWhiteSpace(x.MetricCode)))
  199. {
  200. row.Level = i + 1;
  201. result[row.MetricCode!] = row;
  202. }
  203. }
  204. catch
  205. {
  206. // Demo 读取允许某一层指标值表暂缺,避免 ChatBI 整体不可用。
  207. }
  208. }
  209. return result;
  210. }
  211. private async Task<string?> TryBuildDeepSeekAnswerAsync(
  212. string question,
  213. string contextTitle,
  214. Dictionary<string, string>? filters,
  215. ChatBIAnswerOutput deterministic,
  216. List<ChatBIMetricCard> metrics,
  217. CancellationToken cancellationToken)
  218. {
  219. if (metrics.Count == 0) return null;
  220. var systemPrompt = SystemPromptFor(deterministic.Intent);
  221. var userPrompt = new StringBuilder()
  222. .AppendLine($"入口:{contextTitle}")
  223. .AppendLine($"用户问题:{question}")
  224. .AppendLine($"识别意图:{deterministic.Intent}")
  225. .AppendLine($"焦点指标:{deterministic.FocusMetricCode}")
  226. .AppendLine($"筛选条件:{FormatFilters(filters)}")
  227. .AppendLine("确定性回答结构:");
  228. foreach (var section in deterministic.Sections)
  229. userPrompt.AppendLine($"- {section.Title}:{section.Content}");
  230. userPrompt
  231. .AppendLine("KPI 聚合摘要:");
  232. foreach (var m in metrics.Take(10))
  233. {
  234. userPrompt.AppendLine(
  235. $"- {m.ModuleCode} {m.MetricName}({m.MetricCode}, L{m.MetricLevel}):当前 {FormatResult(m)},目标 {FormatValue(m.TargetValue, m.Unit)},状态 {StatusText(m.StatusColor)},期量差 {m.GapLabel},责任 {FallbackText(m.Department, "未配置")}");
  236. }
  237. return await _deepSeek.CompleteAsync(systemPrompt, userPrompt.ToString(), cancellationToken);
  238. }
  239. private static ChatBIAnswerOutput BuildDeterministicAnswer(
  240. string question,
  241. string contextTitle,
  242. string intent,
  243. ChatBIMetricCard? focus,
  244. List<ChatBIMetricCard> metrics,
  245. HashSet<string>? excludedModules = null,
  246. int? requestedLevel = null,
  247. string? moduleCode = null)
  248. {
  249. var sections = intent switch
  250. {
  251. "unrecognized" => BuildUnrecognizedSections(question, contextTitle, moduleCode),
  252. "data_query" => BuildDataQuerySections(question, metrics),
  253. "list_metrics" => BuildListSections(question, contextTitle, metrics, excludedModules, requestedLevel),
  254. "best_metric" => BuildRankSections(question, contextTitle, metrics),
  255. "metric_status" => BuildStatusSections(focus),
  256. _ => BuildSections(question, contextTitle, intent, focus, metrics),
  257. };
  258. var answer = sections.Count == 0
  259. ? $"已收到问题“{question}”。当前 {contextTitle} 暂未读取到可用于分析的 KPI 聚合值,请先确认指标日值表是否有数据。"
  260. : string.Join("\n", sections.Select(x => $"{x.Title}:{x.Content}"));
  261. return new ChatBIAnswerOutput
  262. {
  263. Source = "local",
  264. IsFallback = true,
  265. Intent = intent,
  266. FocusMetricCode = focus?.MetricCode ?? "",
  267. Summary = intent == "unrecognized"
  268. ? "请改问当前看板的 KPI"
  269. : focus == null
  270. ? "暂未读取到 KPI 聚合数据"
  271. : intent == "best_metric" ? $"当前最好:{focus.MetricName}" : $"重点关注:{focus.MetricName}",
  272. AnswerText = answer,
  273. Sections = sections,
  274. Metrics = metrics
  275. };
  276. }
  277. private static List<ChatBIAction> BuildActions(string? moduleCode, ChatBIMetricCard? focus, string intent)
  278. {
  279. var primaryModule = moduleCode ?? focus?.ModuleCode ?? "S1";
  280. var metricCode = focus?.MetricLevel == 1 ? focus.MetricCode : null;
  281. var actions = new List<ChatBIAction>
  282. {
  283. new() { Label = $"查看 {primaryModule} 看板", Url = $"/aidop/smart-ops/{primaryModule.ToLowerInvariant()}" },
  284. };
  285. if (intent is not ("best_metric" or "list_metrics" or "data_query" or "unrecognized"))
  286. {
  287. actions.Add(new ChatBIAction
  288. {
  289. Label = "打开智慧诊断",
  290. Url = string.IsNullOrWhiteSpace(metricCode)
  291. ? $"/aidop/smart-diagnosis?module={primaryModule}"
  292. : $"/aidop/smart-diagnosis?module={primaryModule}&metricCode={metricCode}"
  293. });
  294. }
  295. return actions;
  296. }
  297. private static List<string> BuildSuggestions(string? moduleCode, string intent, ChatBIMetricCard? focus)
  298. {
  299. if (intent == "unrecognized")
  300. return AllowedQuestionExamples(moduleCode);
  301. if (moduleCode == null)
  302. {
  303. return intent == "best_metric"
  304. ? new List<string> { "当前全局最严重的问题是什么?", "哪些模块出现红灯指标?", "物料交货满足率什么情况" }
  305. : new List<string>
  306. {
  307. "当前全局最严重的问题是什么?",
  308. "哪些模块出现红灯指标?",
  309. focus == null ? "S1 产销协同有什么风险?" : $"{focus.ModuleCode} 的 {focus.MetricName} 为什么异常?"
  310. };
  311. }
  312. return new List<string>
  313. {
  314. $"{moduleCode} 当前最严重的指标是什么?",
  315. focus == null ? "订单评审周期为什么红了?" : $"{focus.MetricName} 为什么异常?",
  316. intent == "improvement_plan" ? "如何验证改善是否有效?" : "下一步应该创建什么改善计划?"
  317. };
  318. }
  319. internal static string ClassifyIntent(string question, string? moduleCode)
  320. {
  321. if (IsOutOfScopeDetailQuestion(question)) return "data_query";
  322. if (IsBestQuestion(question)) return "best_metric";
  323. if (IsBottleneckQuestion(question)) return "global_bottleneck";
  324. if (IsDiagnosticQuestion(question))
  325. return MapDiagnoseIntent(question);
  326. if (IsLookupQuestion(question)
  327. || Regex.IsMatch(question, @"L\s*[1-4].*(指标|值|多少)", RegexOptions.IgnoreCase)
  328. || Regex.IsMatch(question, @"(指标|值|多少).*L\s*[1-4]", RegexOptions.IgnoreCase))
  329. return "list_metrics";
  330. return "unrecognized";
  331. }
  332. internal static string MapLlmTypeToIntent(string type, string question) => type switch
  333. {
  334. "data_query" => "data_query",
  335. "compare" => IsBottleneckQuestion(question) && !IsBestQuestion(question)
  336. ? "global_bottleneck"
  337. : IsBestQuestion(question) ? "best_metric" : "list_metrics",
  338. "locate" => "global_bottleneck",
  339. "diagnose" => MapDiagnoseIntent(question),
  340. "unrecognized" => "unrecognized",
  341. _ => "unrecognized"
  342. };
  343. internal static ChatBILlmClass? ParseLlmClassification(string? raw)
  344. {
  345. if (string.IsNullOrWhiteSpace(raw)) return null;
  346. var text = raw.Trim();
  347. var start = text.IndexOf('{');
  348. var end = text.LastIndexOf('}');
  349. if (start < 0 || end <= start) return null;
  350. text = text[start..(end + 1)];
  351. try
  352. {
  353. using var doc = JsonDocument.Parse(text);
  354. var type = doc.RootElement.TryGetProperty("type", out var typeEl)
  355. ? typeEl.GetString()?.Trim().ToLowerInvariant()
  356. : null;
  357. if (type is not ("data_query" or "compare" or "locate" or "diagnose" or "unrecognized"))
  358. return null;
  359. var hint = doc.RootElement.TryGetProperty("metricHint", out var hintEl)
  360. ? hintEl.GetString()
  361. : null;
  362. return new ChatBILlmClass(type, string.IsNullOrWhiteSpace(hint) ? null : hint.Trim());
  363. }
  364. catch (JsonException)
  365. {
  366. return null;
  367. }
  368. }
  369. private async Task<ChatBILlmClass?> TryClassifyIntentWithLlmAsync(
  370. string question, string? moduleCode, CancellationToken cancellationToken)
  371. {
  372. try
  373. {
  374. var scope = moduleCode == null ? "九宫格全局运营看板" : $"{moduleCode} 模块看板";
  375. var raw = await _deepSeek.CompleteAsync(
  376. ClassifySystemPrompt,
  377. $"入口:{scope}\n用户问题:{question}",
  378. cancellationToken,
  379. maxTokens: 80);
  380. return ParseLlmClassification(raw);
  381. }
  382. catch
  383. {
  384. return null;
  385. }
  386. }
  387. private static string MapDiagnoseIntent(string question)
  388. {
  389. if (ContainsAny(question, "改善计划", "改善措施", "怎么改善", "如何改善", "下一步")) return "improvement_plan";
  390. if (ContainsAny(question, "趋势", "变化", "近", "最近", "连续")) return "trend_summary";
  391. return "root_cause";
  392. }
  393. private static string AppendMetricHint(string question, string? metricHint) =>
  394. string.IsNullOrWhiteSpace(metricHint) ? question : $"{question} {metricHint}";
  395. private const string ClassifySystemPrompt = """
  396. 你是 MDIOP ChatBI 的问题分类器。只判断用户问题属于哪一类,不要回答业务内容,不要给数值。
  397. 只输出一行 JSON,不要 Markdown,不要解释。格式:
  398. {"type":"data_query|compare|locate|diagnose|unrecognized","metricHint":""}
  399. type 含义:
  400. - data_query:数据查询。问指标是多少、什么情况、当前值、怎么算、计算口径、来自哪些订单/单据/明细。
  401. - compare:指标好坏对比。问谁最好、哪些绿灯、最差、好坏对比。
  402. - locate:问题定位。问最严重、瓶颈、哪些模块红灯、当前最需要关注。
  403. - diagnose:问题原因和如何改善。问为什么、原因、怎么改善、改善计划、趋势恶化。
  404. - unrecognized:无法归入以上四类,或与看板 KPI 无关。
  405. metricHint:若问题里提到了指标名,填该名称;否则空字符串。
  406. """;
  407. internal static bool IsBestQuestion(string question) =>
  408. ContainsAny(question, "最好", "最优秀", "表现最好", "做得最好", "做的最好", "哪些绿灯", "哪个绿灯");
  409. internal static bool IsBottleneckQuestion(string question) =>
  410. ContainsAny(question, "最严重", "最差", "最需要关注", "需要关注", "瓶颈", "风险", "红灯", "哪些模块出现");
  411. internal static bool IsOutOfScopeDetailQuestion(string question) =>
  412. ContainsAny(question, "来自哪些", "哪些订单", "哪些单据", "哪几笔", "哪张订单",
  413. "怎么算", "如何计算", "如何算出", "计算出来", "计算口径", "明细", "原始数据", "原始明细");
  414. private static List<string> AllowedQuestionExamples(string? moduleCode) =>
  415. moduleCode == null
  416. ? new List<string>
  417. {
  418. "当前全局最严重的问题是什么?",
  419. "整体做得最好的是哪个?",
  420. "物料交货满足率什么情况",
  421. }
  422. : new List<string>
  423. {
  424. $"{moduleCode} 当前最严重的指标是什么?",
  425. $"{moduleCode} 哪些指标是绿灯?",
  426. $"{moduleCode} L1 指标分别是多少?",
  427. };
  428. private static List<ChatBIAnswerSection> BuildUnrecognizedSections(
  429. string question,
  430. string contextTitle,
  431. string? moduleCode)
  432. {
  433. var scope = moduleCode == null
  434. ? "当前九宫格上的 L1 运营指标(S1–S7 / S9),以及你在看板上选的日期等筛选条件"
  435. : $"{moduleCode} {ResolveModuleName(moduleCode)} 上的 L1–L4 指标,以及当前筛选条件";
  436. return new List<ChatBIAnswerSection>
  437. {
  438. new()
  439. {
  440. Title = "无法识别",
  441. Tone = "warning",
  442. Content = $"「{question}」不在 ChatBI 可回答范围内。这里只根据{scope}做指标问答,不查原始明细,也不生成 SQL。"
  443. },
  444. new()
  445. {
  446. Title = "可以这样问",
  447. Tone = "info",
  448. Content = "可问四类问题:数据查询 / 指标好坏对比 / 问题定位 / 问题原因和如何改善。请用看板上的指标名,例如物料交货满足率。"
  449. }
  450. };
  451. }
  452. private static bool IsLookupQuestion(string question) =>
  453. ContainsAny(question, "是多少", "当前值", "现值", "指标值", "查一下", "查询", "取值", "读数",
  454. "有哪些", "列出", "分别是", "以外", "之外", "除外", "除了");
  455. private static bool IsDiagnosticQuestion(string question) =>
  456. ContainsAny(question, "为什么", "原因", "异常", "改善", "措施", "下一步", "趋势", "怎么处理", "怎么办", "没达标", "未达标");
  457. private static List<ChatBIAnswerSection> BuildDataQuerySections(string question, List<ChatBIMetricCard> metrics)
  458. {
  459. var sections = new List<ChatBIAnswerSection>();
  460. if (metrics.Count > 0)
  461. {
  462. var lines = metrics.Select(x =>
  463. $"{x.ModuleCode} {x.MetricName}:当前 {FormatValue(x.CurrentValue, x.Unit)},目标 {FormatValue(x.TargetValue, x.Unit)},{StatusText(x.StatusColor)}");
  464. sections.Add(new ChatBIAnswerSection
  465. {
  466. Title = "查询结果",
  467. Tone = "info",
  468. Content = string.Join(";", lines)
  469. });
  470. }
  471. sections.Add(new ChatBIAnswerSection
  472. {
  473. Title = "数据说明",
  474. Tone = "warning",
  475. Content = IsOutOfScopeDetailQuestion(question)
  476. ? "这属于数据查询。当前只能给出看板已算出的 KPI 聚合值,不能列出构成该指标的订单或原始明细,也不生成 SQL。若要看单据,请到对应模块详情页。"
  477. : "这属于数据查询,以上为当前看板聚合值。"
  478. });
  479. return sections;
  480. }
  481. private static string SystemPromptFor(string intent) => intent switch
  482. {
  483. "data_query" => """
  484. 你是 MDIOP 制造运营 ChatBI 助手。用户在做数据查询(取值、来源、怎么算、哪些订单)。
  485. 先如实给出 KPI 聚合摘要中的当前值、目标、状态。
  486. 若用户问来自哪些订单、单据或明细:明确说当前只能查看板聚合结果,不能列出订单,也不生成 SQL。
  487. 禁止改写成红灯诊断或改善计划。不得编造摘要里没有的数值。
  488. 总字数控制在 200 字以内。
  489. """,
  490. "list_metrics" => """
  491. 你是 MDIOP 制造运营 ChatBI 助手。用户要的是指标取值清单,不是诊断报告。
  492. 只根据「KPI 聚合摘要」如实列出模块、指标名、当前值、目标、状态。
  493. 不得改写成红灯瓶颈分析,不得编造摘要里没有的数值。若摘要为空,明确说所选条件下没有日值。
  494. 总字数控制在 280 字以内。
  495. """,
  496. "best_metric" => """
  497. 你是 MDIOP 制造运营 ChatBI 助手。用户在问谁做得最好、哪项表现最好。
  498. 直接点名最好的 1 个指标及其模块、当前值、目标、状态;最多再列 1-2 个对照。
  499. 禁止套用「结论 / 证据 / 可能原因 / 下一步」。
  500. 禁止把红灯或最差指标说成最好。只能用摘要里的绿灯或相对最好的项。
  501. 总字数控制在 180 字以内。
  502. """,
  503. "metric_status" => """
  504. 你是 MDIOP 制造运营 ChatBI 助手。用户在问某个指标现在怎么样。
  505. 只回答该指标的当前值、目标、状态。不要写可能原因和下一步,除非用户明确问原因。
  506. 不得把其它模块红灯拉进来。不得编造摘要里没有的数值。
  507. 总字数控制在 160 字以内。
  508. """,
  509. _ => """
  510. 你是 MDIOP 制造运营 ChatBI 助手。后端已经完成意图识别、指标选择和事实校验。
  511. 先直接回答用户问题。仅当用户在问原因、异常或改善时,再补充可能原因 / 下一步。
  512. 不得新增未给出的原因、单据、负责人或数值。
  513. 如果用户问题已经指定某个指标,只能围绕焦点指标和它的真实下级指标回答,不得把其它模块、其它 L1 红灯写成原因。
  514. 总字数控制在 220 字以内。
  515. """
  516. };
  517. internal static List<ChatBIMetricCard> SelectBestMetrics(List<ChatBIMetricCard> scoped)
  518. {
  519. return scoped
  520. .OrderBy(x => StatusRank(x.StatusColor) == 0 ? 9 : StatusRank(x.StatusColor))
  521. .ThenByDescending(AchievementScore)
  522. .ThenBy(x => x.MetricLevel)
  523. .ThenBy(x => x.MetricCode)
  524. .Take(4)
  525. .ToList();
  526. }
  527. internal static decimal AchievementScore(ChatBIMetricCard metric)
  528. {
  529. if (metric.CurrentValue is not { } current || metric.TargetValue is not { } target || target == 0)
  530. return 0;
  531. return AidopS4KpiMerge.IsLowerBetter(metric.Direction)
  532. ? (current == 0 ? 99 : target / current)
  533. : current / target;
  534. }
  535. private static List<ChatBIAnswerSection> BuildRankSections(
  536. string question,
  537. string contextTitle,
  538. List<ChatBIMetricCard> metrics)
  539. {
  540. if (metrics.Count == 0)
  541. {
  542. return new List<ChatBIAnswerSection>
  543. {
  544. new()
  545. {
  546. Title = "直接回答",
  547. Tone = "info",
  548. Content = $"{contextTitle} 当前没有可比较的日值,无法判断谁做得最好。"
  549. }
  550. };
  551. }
  552. var best = metrics[0];
  553. var hasGreen = StatusRank(best.StatusColor) == 1;
  554. var lead = hasGreen
  555. ? $"针对「{question}」,当前整体做得最好的是 {best.ModuleCode} {best.MetricName},绿灯,当前 {FormatValue(best.CurrentValue, best.Unit)},目标 {FormatValue(best.TargetValue, best.Unit)}。"
  556. : $"针对「{question}」,当前没有绿灯。相对最好的是 {best.ModuleCode} {best.MetricName},{StatusText(best.StatusColor)},当前 {FormatValue(best.CurrentValue, best.Unit)},目标 {FormatValue(best.TargetValue, best.Unit)}。";
  557. var sections = new List<ChatBIAnswerSection>
  558. {
  559. new() { Title = "直接回答", Tone = hasGreen ? "success" : "warning", Content = lead }
  560. };
  561. var others = metrics.Skip(1).Take(2).ToList();
  562. if (others.Count > 0)
  563. {
  564. sections.Add(new ChatBIAnswerSection
  565. {
  566. Title = "对照",
  567. Tone = "info",
  568. Content = "其次较好的还有:" + string.Join(";", others.Select(x =>
  569. $"{x.ModuleCode} {x.MetricName} {StatusText(x.StatusColor)},当前 {FormatValue(x.CurrentValue, x.Unit)} / 目标 {FormatValue(x.TargetValue, x.Unit)}"))
  570. });
  571. }
  572. return sections;
  573. }
  574. private static List<ChatBIAnswerSection> BuildStatusSections(ChatBIMetricCard? focus)
  575. {
  576. if (focus == null) return new List<ChatBIAnswerSection>();
  577. return new List<ChatBIAnswerSection>
  578. {
  579. new()
  580. {
  581. Title = "指标状态",
  582. Tone = StatusRank(focus.StatusColor) >= 3 ? "danger" : StatusRank(focus.StatusColor) == 2 ? "warning" : "success",
  583. Content = $"{focus.ModuleCode} {focus.MetricName} 当前 {FormatValue(focus.CurrentValue, focus.Unit)},目标 {FormatValue(focus.TargetValue, focus.Unit)},状态 {StatusText(focus.StatusColor)},期量差 {FallbackText(focus.GapLabel, "-")}。"
  584. }
  585. };
  586. }
  587. private static List<ChatBIMetricCard> SelectLookupMetrics(List<ChatBIMetricCard> scoped, string question, bool hasNamedMetric)
  588. {
  589. if (hasNamedMetric)
  590. {
  591. var named = scoped
  592. .Where(x => MetricMatchScore(question, x) > 0)
  593. .OrderByDescending(x => MetricMatchScore(question, x))
  594. .ThenBy(x => x.MetricLevel)
  595. .Take(8)
  596. .ToList();
  597. if (named.Count > 0) return named;
  598. }
  599. return scoped.OrderBy(x => x.ModuleCode).ThenBy(x => x.MetricLevel).ThenBy(x => x.MetricCode).Take(30).ToList();
  600. }
  601. private static ChatBIMetricCard? ResolveFocusMetric(List<ChatBIMetricCard> metrics, string question, string intent)
  602. {
  603. var exact = metrics
  604. .Select(x => new { Metric = x, Score = MetricMatchScore(question, x) })
  605. .Where(x => x.Score > 0)
  606. .OrderByDescending(x => x.Score)
  607. .ThenByDescending(x => StatusRank(x.Metric.StatusColor))
  608. .ThenBy(x => x.Metric.MetricLevel)
  609. .FirstOrDefault();
  610. if (exact != null) return exact.Metric;
  611. var preferredLevels = intent == "global_bottleneck" ? new[] { 1, 2, 3, 4 } : new[] { 1, 2, 3, 4 };
  612. return metrics
  613. .OrderByDescending(x => StatusRank(x.StatusColor))
  614. .ThenBy(x => Array.IndexOf(preferredLevels, x.MetricLevel))
  615. .ThenBy(x => x.MetricCode)
  616. .FirstOrDefault();
  617. }
  618. private static List<ChatBIMetricCard> BuildContextMetrics(
  619. List<ChatBIMetricCard> allMetrics,
  620. ChatBIMetricCard? focus,
  621. string intent,
  622. string? moduleCode,
  623. bool hasExplicitMetric)
  624. {
  625. if (focus == null)
  626. return allMetrics.OrderByDescending(x => StatusRank(x.StatusColor)).ThenBy(x => x.MetricLevel).Take(moduleCode == null ? 9 : 10).ToList();
  627. var sameModule = allMetrics.Where(x => string.Equals(x.ModuleCode, focus.ModuleCode, StringComparison.OrdinalIgnoreCase)).ToList();
  628. var context = new List<ChatBIMetricCard> { focus };
  629. if (hasExplicitMetric)
  630. {
  631. context.AddRange(sameModule
  632. .Where(x => x.MetricCode != focus.MetricCode && IsDescendantOf(x, focus, sameModule))
  633. .OrderByDescending(x => StatusRank(x.StatusColor))
  634. .ThenBy(x => x.MetricLevel)
  635. .ThenBy(x => x.MetricCode)
  636. .Take(9));
  637. return context
  638. .GroupBy(x => x.MetricCode, StringComparer.OrdinalIgnoreCase)
  639. .Select(x => x.First())
  640. .Take(10)
  641. .ToList();
  642. }
  643. if (intent is "root_cause" or "improvement_plan")
  644. {
  645. context.AddRange(sameModule
  646. .Where(x => x.MetricCode != focus.MetricCode && x.MetricLevel >= focus.MetricLevel)
  647. .OrderByDescending(x => StatusRank(x.StatusColor))
  648. .ThenBy(x => x.MetricLevel)
  649. .Take(7));
  650. }
  651. else if (moduleCode == null)
  652. {
  653. context.AddRange(allMetrics
  654. .Where(x => x.MetricCode != focus.MetricCode && x.MetricLevel == 1)
  655. .OrderByDescending(x => StatusRank(x.StatusColor))
  656. .ThenBy(x => x.ModuleCode)
  657. .Take(8));
  658. }
  659. else
  660. {
  661. context.AddRange(sameModule
  662. .Where(x => x.MetricCode != focus.MetricCode)
  663. .OrderByDescending(x => StatusRank(x.StatusColor))
  664. .ThenBy(x => x.MetricLevel)
  665. .Take(7));
  666. }
  667. return context
  668. .GroupBy(x => x.MetricCode, StringComparer.OrdinalIgnoreCase)
  669. .Select(x => x.First())
  670. .Take(10)
  671. .ToList();
  672. }
  673. private static List<ChatBIAnswerSection> BuildListSections(
  674. string question,
  675. string contextTitle,
  676. List<ChatBIMetricCard> metrics,
  677. HashSet<string>? excludedModules,
  678. int? requestedLevel)
  679. {
  680. var levelText = requestedLevel == null ? "L1" : $"L{requestedLevel}";
  681. var excludeText = excludedModules is { Count: > 0 }
  682. ? $"已排除 {string.Join("、", excludedModules.OrderBy(x => x))}。"
  683. : "";
  684. if (metrics.Count == 0)
  685. {
  686. return new List<ChatBIAnswerSection>
  687. {
  688. new()
  689. {
  690. Title = "取值结果",
  691. Tone = "info",
  692. Content = $"{contextTitle} 在当前问题与筛选条件下没有可展示的 {levelText} 日值。{excludeText}请改到有数据的日期区间后再问。"
  693. }
  694. };
  695. }
  696. if (metrics.Count == 1)
  697. {
  698. var one = metrics[0];
  699. return new List<ChatBIAnswerSection>
  700. {
  701. new()
  702. {
  703. Title = "取值结果",
  704. Tone = "info",
  705. Content = $"{one.ModuleCode} {one.MetricName} 当前 {FormatValue(one.CurrentValue, one.Unit)},目标 {FormatValue(one.TargetValue, one.Unit)},状态 {StatusText(one.StatusColor)}。"
  706. }
  707. };
  708. }
  709. var lines = metrics.Select(x =>
  710. $"{x.ModuleCode} {x.MetricName}:当前 {FormatValue(x.CurrentValue, x.Unit)},目标 {FormatValue(x.TargetValue, x.Unit)},{StatusText(x.StatusColor)}");
  711. return new List<ChatBIAnswerSection>
  712. {
  713. new()
  714. {
  715. Title = "取值结果",
  716. Tone = "info",
  717. Content = $"针对「{question}」列出 {metrics.Count} 个{levelText}指标。{excludeText}"
  718. },
  719. new()
  720. {
  721. Title = $"{levelText} 指标",
  722. Tone = "info",
  723. Content = string.Join(";", lines)
  724. }
  725. };
  726. }
  727. private static List<ChatBIMetricCard> ApplyQuestionScope(
  728. List<ChatBIMetricCard> metrics,
  729. HashSet<string> includedModules,
  730. HashSet<string> excludedModules,
  731. int? requestedLevel)
  732. {
  733. return metrics
  734. .Where(x => requestedLevel == null || x.MetricLevel == requestedLevel)
  735. .Where(x => excludedModules.Count == 0 || !excludedModules.Contains(x.ModuleCode))
  736. .Where(x => includedModules.Count == 0 || includedModules.Contains(x.ModuleCode))
  737. .ToList();
  738. }
  739. private static HashSet<string> ParseExcludedModules(string question)
  740. {
  741. var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  742. foreach (Match match in Regex.Matches(question, @"(?:除了|除開)?\s*S\s*([1-9])\s*(?:楼|樓|模块|模組)?\s*(?:以外|之外|除外)"))
  743. set.Add("S" + match.Groups[1].Value);
  744. foreach (Match match in Regex.Matches(question, @"除了\s*S\s*([1-9])"))
  745. set.Add("S" + match.Groups[1].Value);
  746. return set;
  747. }
  748. private static HashSet<string> ParseIncludedModules(string question, HashSet<string> excluded)
  749. {
  750. var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  751. if (excluded.Count > 0) return set;
  752. foreach (Match match in Regex.Matches(question, @"S\s*([1-9])"))
  753. set.Add("S" + match.Groups[1].Value);
  754. return set;
  755. }
  756. private static int? ParseRequestedLevel(string question)
  757. {
  758. var match = Regex.Match(question, @"L\s*([1-4])", RegexOptions.IgnoreCase);
  759. return match.Success && int.TryParse(match.Groups[1].Value, out var level) ? level : null;
  760. }
  761. private static (DateTime? DateStart, DateTime? DateEnd) ParseDateFilters(Dictionary<string, string>? filters)
  762. {
  763. DateTime? start = null;
  764. DateTime? end = null;
  765. if (filters != null)
  766. {
  767. if (filters.TryGetValue("dateStart", out var rawStart) && DateTime.TryParse(rawStart, out var parsedStart))
  768. start = parsedStart.Date;
  769. if (filters.TryGetValue("dateEnd", out var rawEnd) && DateTime.TryParse(rawEnd, out var parsedEnd))
  770. end = parsedEnd.Date;
  771. }
  772. return (start, end);
  773. }
  774. private static List<ChatBIAnswerSection> BuildSections(
  775. string question,
  776. string contextTitle,
  777. string intent,
  778. ChatBIMetricCard? focus,
  779. List<ChatBIMetricCard> metrics)
  780. {
  781. if (focus == null) return new List<ChatBIAnswerSection>();
  782. var sameModule = metrics
  783. .Where(x => string.Equals(x.ModuleCode, focus.ModuleCode, StringComparison.OrdinalIgnoreCase))
  784. .ToList();
  785. var relatedRisks = metrics
  786. .Where(x => x.MetricCode != focus.MetricCode && StatusRank(x.StatusColor) >= 2)
  787. .Where(x => string.Equals(x.ModuleCode, focus.ModuleCode, StringComparison.OrdinalIgnoreCase)
  788. && (x.MetricLevel > focus.MetricLevel || IsDescendantOf(x, focus, sameModule)))
  789. .OrderByDescending(x => StatusRank(x.StatusColor))
  790. .ThenBy(x => x.MetricLevel)
  791. .Take(3)
  792. .ToList();
  793. var riskText = relatedRisks.Count == 0
  794. ? $"未发现 {focus.MetricName} 的红黄下级指标,需进入智慧诊断继续看明细证据,不能用其它模块红灯当原因。"
  795. : "其下级红黄指标包括:" + string.Join("、", relatedRisks.Select(x => $"{x.MetricName}({StatusText(x.StatusColor)})")) + "。";
  796. var nextAction = intent switch
  797. {
  798. "improvement_plan" => $"建议围绕 {focus.MetricName} 建立改善计划,目标值先按 {FormatValue(focus.TargetValue, focus.Unit)} 对齐,并把红黄下层指标作为行动项来源。",
  799. "root_cause" => $"建议打开智慧诊断,从 {focus.MetricName} 下钻到 L2/L3/L4,确认责任部门、卡点和单据后再创建改善计划。",
  800. "trend_summary" => $"建议结合近 7 到 14 天趋势复核 {focus.MetricName} 是否连续恶化,再决定是否升级为改善任务。",
  801. _ => $"建议优先跟进 {focus.MetricName},若持续红黄则进入智慧诊断并创建改善任务。"
  802. };
  803. return new List<ChatBIAnswerSection>
  804. {
  805. new()
  806. {
  807. Title = "结论",
  808. Tone = StatusRank(focus.StatusColor) >= 3 ? "danger" : StatusRank(focus.StatusColor) == 2 ? "warning" : "info",
  809. Content = $"{contextTitle} 当前焦点是 {focus.MetricName},状态为{StatusText(focus.StatusColor)}。"
  810. },
  811. new()
  812. {
  813. Title = "证据",
  814. Tone = "info",
  815. Content = $"{focus.MetricName} 当前 {FormatValue(focus.CurrentValue, focus.Unit)},目标 {FormatValue(focus.TargetValue, focus.Unit)},期量差 {FallbackText(focus.GapLabel, "-")}。"
  816. },
  817. new()
  818. {
  819. Title = "可能原因",
  820. Tone = relatedRisks.Count > 0 ? "warning" : "info",
  821. Content = riskText
  822. },
  823. new()
  824. {
  825. Title = "下一步",
  826. Tone = "success",
  827. Content = nextAction
  828. }
  829. };
  830. }
  831. private static string? NormalizeModuleCode(string? moduleCode)
  832. {
  833. var mc = (moduleCode ?? "").Trim().ToUpperInvariant();
  834. return mc is "S1" or "S2" or "S3" or "S4" or "S5" or "S6" or "S7" or "S9" ? mc : null;
  835. }
  836. private static bool QuestionMatchesMetric(string question, ChatBIMetricCard metric)
  837. {
  838. if (string.IsNullOrWhiteSpace(question)) return false;
  839. if (question.Contains(metric.MetricName, StringComparison.OrdinalIgnoreCase)) return true;
  840. if (question.Contains(metric.MetricCode, StringComparison.OrdinalIgnoreCase)) return true;
  841. var core = NormalizeMetricLabel(metric.MetricName);
  842. return core.Length >= 4 && question.Contains(core, StringComparison.OrdinalIgnoreCase);
  843. }
  844. internal static string NormalizeMetricLabel(string? raw)
  845. {
  846. var s = (raw ?? "").Trim();
  847. s = Regex.Replace(s, @"[((][^))]*[))]", string.Empty);
  848. return s.Trim();
  849. }
  850. private static bool HasExplicitMetricMention(List<ChatBIMetricCard> metrics, string question)
  851. {
  852. return metrics.Any(metric => QuestionMatchesMetric(question, metric));
  853. }
  854. private static bool IsDescendantOf(ChatBIMetricCard candidate, ChatBIMetricCard ancestor, List<ChatBIMetricCard> sameModule)
  855. {
  856. var byId = sameModule.Where(x => x.Id > 0).ToDictionary(x => x.Id);
  857. var parentId = candidate.ParentId;
  858. while (parentId != null)
  859. {
  860. if (parentId.Value == ancestor.Id) return true;
  861. if (!byId.TryGetValue(parentId.Value, out var parent)) return false;
  862. parentId = parent.ParentId;
  863. }
  864. return false;
  865. }
  866. private static int MetricMatchScore(string question, ChatBIMetricCard metric)
  867. {
  868. var score = 0;
  869. if (QuestionMatchesMetric(question, metric)) score += 100;
  870. foreach (var token in SplitMetricName(metric.MetricName))
  871. {
  872. if (token.Length >= 2 && question.Contains(token, StringComparison.OrdinalIgnoreCase)) score += 10;
  873. }
  874. return score;
  875. }
  876. private static IEnumerable<string> SplitMetricName(string metricName)
  877. {
  878. var separators = new[] { ' ', '/', '-', '_', '(', ')', '(', ')', ':', ':', '、' };
  879. return (metricName ?? "").Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
  880. }
  881. private static int StatusRank(string? status) => (status ?? "").ToLowerInvariant() switch
  882. {
  883. "red" => 3,
  884. "yellow" => 2,
  885. "green" => 1,
  886. _ => 0
  887. };
  888. private static string StatusText(string? status) => (status ?? "").ToLowerInvariant() switch
  889. {
  890. "red" => "红灯",
  891. "yellow" => "黄灯",
  892. "green" => "绿灯",
  893. _ => "未知"
  894. };
  895. private static string ResolveModuleName(string moduleCode) => moduleCode.ToUpperInvariant() switch
  896. {
  897. "S1" => "产销协同动态详情看板",
  898. "S2" => "制造协同动态详情看板",
  899. "S3" => "供应协同动态详情看板",
  900. "S4" => "采购执行动态详情看板",
  901. "S5" => "物料仓储动态详情看板",
  902. "S6" => "生产执行动态详情看板",
  903. "S7" => "成品仓储动态详情看板",
  904. "S9" => "运营指标动态详情看板",
  905. _ => "动态详情看板"
  906. };
  907. private static bool ContainsAny(string text, params string[] needles)
  908. {
  909. return needles.Any(x => text.Contains(x, StringComparison.OrdinalIgnoreCase));
  910. }
  911. private static string FormatGapLabel(decimal? gap, string? unit)
  912. {
  913. if (gap == null) return "";
  914. var rounded = decimal.Round(gap.Value, 2);
  915. return $"{rounded:0.##}{unit ?? ""}";
  916. }
  917. private static string FormatResult(ChatBIMetricCard metric)
  918. {
  919. if (string.Equals(metric.ResultStatus, "SOURCE_UNSUPPORTED", StringComparison.OrdinalIgnoreCase))
  920. return string.IsNullOrWhiteSpace(metric.ResultReason) ? "数据源不支持" : $"数据源不支持({metric.ResultReason})";
  921. if (string.Equals(metric.ResultStatus, "NO_DATA", StringComparison.OrdinalIgnoreCase))
  922. return "无业务数据";
  923. return FormatValue(metric.CurrentValue, metric.Unit);
  924. }
  925. private static string FormatValue(decimal? value, string? unit)
  926. {
  927. if (value == null) return "-";
  928. return $"{decimal.Round(value.Value, 2):0.##}{unit ?? ""}";
  929. }
  930. private static string FormatFilters(Dictionary<string, string>? filters)
  931. {
  932. if (filters == null || filters.Count == 0) return "未填写条件(展示全部)";
  933. return string.Join(";", filters.Where(x => !string.IsNullOrWhiteSpace(x.Value)).Select(x => $"{x.Key}={x.Value}"));
  934. }
  935. private static string FallbackText(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value;
  936. private static string FirstSentence(string text)
  937. {
  938. var trimmed = text.Trim();
  939. var idx = trimmed.IndexOfAny(new[] { '。', '!', '?', '\n' });
  940. return idx > 0 ? trimmed[..Math.Min(idx + 1, trimmed.Length)] : trimmed;
  941. }
  942. private sealed class MetricValueRow
  943. {
  944. public int Level { get; set; }
  945. public string? MetricCode { get; set; }
  946. public decimal? MetricValue { get; set; }
  947. public decimal? TargetValue { get; set; }
  948. public string? StatusColor { get; set; }
  949. public string? TrendFlag { get; set; }
  950. public string? ResultStatus { get; set; }
  951. public string? ResultReason { get; set; }
  952. }
  953. }