ChatBIService.cs 44 KB

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