feat(workbench): integrate runtime data and map tooling
This commit is contained in:
@@ -80,15 +80,21 @@ describe("feature panel properties", () => {
|
||||
node_id: "J-1",
|
||||
link_id: null,
|
||||
api_query_id: "query-1",
|
||||
measurement_unit: "MPa",
|
||||
latest_value_with_unit: "0.31 MPa",
|
||||
latest_observed_at: "2026-09-17T02:29:00Z",
|
||||
transmission_mode: "non_realtime",
|
||||
transmission_frequency: "15min",
|
||||
reliability: "high"
|
||||
}).map(({ label, value }) => [label, value])).toEqual([
|
||||
["设备 ID", "S-1"],
|
||||
["设备类型", "压力监测"],
|
||||
["最新监测值", "0.31 MPa"],
|
||||
["监测时间", expect.any(String)],
|
||||
["关联节点", "J-1"],
|
||||
["关联连线", "暂无"],
|
||||
["采集接口", "query-1"],
|
||||
["计量单位", "MPa"],
|
||||
["传输方式", "非实时"],
|
||||
["传输频率", "15min"],
|
||||
["可靠性", "高"]
|
||||
@@ -111,7 +117,7 @@ describe("feature panel properties", () => {
|
||||
it.each([
|
||||
["valves", ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"]],
|
||||
["reservoirs", ["id", "head", "pattern_id"]],
|
||||
["scada", ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"]],
|
||||
["scada", ["device_id", "device_type", "latest_value_with_unit", "latest_observed_at", "node_id", "link_id", "api_query_id", "measurement_unit", "transmission_mode", "transmission_frequency", "reliability"]],
|
||||
["pumps", ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"]],
|
||||
["tanks", ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]]
|
||||
] as const)("localizes configured %s panel field labels", (layer, keys) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WaterNetworkSourceId } from "../map/sources";
|
||||
import { isSourceGisSourceId, type WaterNetworkSourceId } from "../map/sources";
|
||||
import { formatValue } from "./format-value";
|
||||
|
||||
export type LocalizedFeatureProperty = {
|
||||
@@ -110,9 +110,14 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
node_id: "关联节点",
|
||||
link_id: "关联连线",
|
||||
api_query_id: "采集接口",
|
||||
measurement_unit: "计量单位",
|
||||
transmission_mode: "传输方式",
|
||||
transmission_frequency: "传输频率",
|
||||
reliability: "可靠性",
|
||||
latest_value: "最新监测值",
|
||||
latest_value_with_unit: "最新监测值",
|
||||
latest_observed_at: "监测时间",
|
||||
latest_value_source: "数值来源",
|
||||
device_id: "设备 ID",
|
||||
device_external_id: "设备编号",
|
||||
device_type_id: "设备类型 ID",
|
||||
@@ -124,7 +129,17 @@ const PROPERTY_LABELS: Record<string, string> = {
|
||||
kind: "设备类型",
|
||||
location_source: "位置来源",
|
||||
site_external_id: "站点编号",
|
||||
synced_at: "同步时间"
|
||||
synced_at: "同步时间",
|
||||
source_fid: "源要素编号",
|
||||
source_object_id: "源对象编号",
|
||||
source_layer_name: "源图层",
|
||||
dataset_title: "数据集",
|
||||
source_date: "数据日期",
|
||||
monitoring_point_no: "监测点序号",
|
||||
selection_method: "选点依据",
|
||||
minimum_diameter: "关联管径下限",
|
||||
source_analysis: "来源分析",
|
||||
run_id: "分析运行编号"
|
||||
};
|
||||
|
||||
const PROPERTY_ORDER: Record<string, number> = {
|
||||
@@ -292,16 +307,47 @@ const PROPERTY_UNITS: Record<string, string> = {
|
||||
流量_m3_s: "m³/s"
|
||||
};
|
||||
|
||||
const FEATURE_PANEL_PROPERTY_KEYS: Record<WaterNetworkSourceId, readonly string[]> = {
|
||||
const FEATURE_PANEL_PROPERTY_KEYS: Partial<Record<WaterNetworkSourceId, readonly string[]>> = {
|
||||
pipes: ["id", "start_node_id", "end_node_id", "diameter", "length", "roughness", "status"],
|
||||
junctions: ["id", "elevation", "base_demand"],
|
||||
valves: ["id", "start_node_id", "end_node_id", "diameter", "valve_type", "setting", "minor_loss"],
|
||||
reservoirs: ["id", "head", "pattern_id"],
|
||||
scada: ["device_id", "device_type", "node_id", "link_id", "api_query_id", "transmission_mode", "transmission_frequency", "reliability"],
|
||||
scada: [
|
||||
"device_id",
|
||||
"device_type",
|
||||
"latest_value_with_unit",
|
||||
"latest_observed_at",
|
||||
"node_id",
|
||||
"link_id",
|
||||
"api_query_id",
|
||||
"measurement_unit",
|
||||
"transmission_mode",
|
||||
"transmission_frequency",
|
||||
"reliability"
|
||||
],
|
||||
pumps: ["id", "start_node_id", "end_node_id", "power", "head_curve_id", "speed", "pattern_id"],
|
||||
tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"]
|
||||
tanks: ["id", "elevation", "initial_level", "minimum_level", "maximum_level", "diameter", "minimum_volume", "volume_curve_id", "overflow"],
|
||||
water_quality_monitoring_points: [
|
||||
"id",
|
||||
"monitoring_point_no",
|
||||
"node_id",
|
||||
"selection_method",
|
||||
"minimum_diameter",
|
||||
"elevation",
|
||||
"source_analysis"
|
||||
]
|
||||
};
|
||||
|
||||
const SOURCE_GIS_PANEL_PROPERTY_KEYS = [
|
||||
"id",
|
||||
"source_layer_name",
|
||||
"source_date",
|
||||
"source_object_id",
|
||||
"diameter",
|
||||
"length",
|
||||
"dataset_title"
|
||||
] as const;
|
||||
|
||||
const FEATURE_PANEL_BADGE_KEYS: Partial<Record<WaterNetworkSourceId, string>> = {
|
||||
pipes: "status",
|
||||
valves: "setting"
|
||||
@@ -313,7 +359,7 @@ type FeatureInsightMetricDefinition = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsightMetricDefinition[]> = {
|
||||
const FEATURE_INSIGHT_METRICS: Partial<Record<WaterNetworkSourceId, readonly FeatureInsightMetricDefinition[]>> = {
|
||||
pipes: [
|
||||
{ label: "管径", key: "diameter" },
|
||||
{ label: "长度", key: "length" },
|
||||
@@ -339,10 +385,11 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
|
||||
{ label: "Y 坐标", key: "y" }
|
||||
],
|
||||
scada: [
|
||||
{ label: "类型", key: "device_type" },
|
||||
{ label: "最新监测值", key: "latest_value_with_unit" },
|
||||
{ label: "监测时间", key: "latest_observed_at" },
|
||||
{ label: "关联节点", key: "node_id" },
|
||||
{ label: "关联连线", key: "link_id" },
|
||||
{ label: "传输方式", key: "transmission_mode" }
|
||||
{ label: "类型", key: "device_type" }
|
||||
],
|
||||
pumps: [
|
||||
{ label: "功率", key: "power" },
|
||||
@@ -355,6 +402,12 @@ const FEATURE_INSIGHT_METRICS: Record<WaterNetworkSourceId, readonly FeatureInsi
|
||||
{ label: "初始水位", key: "initial_level" },
|
||||
{ label: "最低水位", key: "minimum_level" },
|
||||
{ label: "最高水位", key: "maximum_level" }
|
||||
],
|
||||
water_quality_monitoring_points: [
|
||||
{ label: "监测点序号", key: "monitoring_point_no" },
|
||||
{ label: "关联节点", key: "node_id" },
|
||||
{ label: "关联管径下限", key: "minimum_diameter" },
|
||||
{ label: "选点依据", value: "灵敏度分析" }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -371,7 +424,9 @@ export function getFeaturePanelProperties(
|
||||
properties: Record<string, unknown>,
|
||||
featureId?: string
|
||||
): LocalizedFeatureProperty[] {
|
||||
return FEATURE_PANEL_PROPERTY_KEYS[layer].map((key) => {
|
||||
const keys = FEATURE_PANEL_PROPERTY_KEYS[layer]
|
||||
?? (isSourceGisSourceId(layer) ? SOURCE_GIS_PANEL_PROPERTY_KEYS : ["id"]);
|
||||
return keys.map((key) => {
|
||||
const propertyValue = getPropertyValue(properties, key);
|
||||
const value = key === "id" || key === "scada_id" || key === "device_id" ? propertyValue ?? featureId : propertyValue;
|
||||
|
||||
@@ -397,7 +452,15 @@ export function getFeatureInsightMetrics(
|
||||
layer: WaterNetworkSourceId,
|
||||
properties: Record<string, unknown>
|
||||
): FeatureInsightMetric[] {
|
||||
return FEATURE_INSIGHT_METRICS[layer].map((metric) => ({
|
||||
const definitions = FEATURE_INSIGHT_METRICS[layer]
|
||||
?? (isSourceGisSourceId(layer)
|
||||
? [
|
||||
{ label: "源图层", key: "source_layer_name" },
|
||||
{ label: "源要素", key: "source_fid" },
|
||||
{ label: "数据日期", key: "source_date" }
|
||||
]
|
||||
: []);
|
||||
return definitions.map((metric) => ({
|
||||
label: metric.label,
|
||||
value: metric.key
|
||||
? formatFeaturePropertyValue(metric.key, getPropertyValue(properties, metric.key))
|
||||
@@ -448,6 +511,24 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
||||
return value ? "在线" : "离线";
|
||||
}
|
||||
|
||||
if (normalizedKey === "latest_observed_at" && typeof value === "string") {
|
||||
const timestamp = Date.parse(value);
|
||||
if (Number.isFinite(timestamp)) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedKey === "latest_value_source" && value === "monitored_value") {
|
||||
return "SCADA 原始监测值";
|
||||
}
|
||||
|
||||
if (normalizedKey === "location_source" && value === "density_cluster_demo") {
|
||||
return "密度聚类模拟位置";
|
||||
}
|
||||
@@ -456,6 +537,10 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
||||
return "空间均匀采样模拟位置";
|
||||
}
|
||||
|
||||
if (normalizedKey === "selection_method" && value === "sensitivity") {
|
||||
return "灵敏度分析";
|
||||
}
|
||||
|
||||
if (normalizedKey === "kind" && typeof value === "string") {
|
||||
return SCADA_KIND_LABELS[value] ?? value;
|
||||
}
|
||||
@@ -465,7 +550,7 @@ export function formatFeaturePropertyValue(key: string, value: unknown) {
|
||||
return `${formatValue(value)} ${unit}`;
|
||||
}
|
||||
|
||||
if ((normalizedKey === "diameter" || normalizedKey === "dn") && value !== null && value !== undefined && value !== "") {
|
||||
if (["diameter", "dn", "minimum_diameter"].includes(normalizedKey) && value !== null && value !== undefined && value !== "") {
|
||||
const diameterInMillimeters = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(diameterInMillimeters)
|
||||
? `${formatValue(diameterInMillimeters)} mm`
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GisAttributeGroup } from "../api/gis-properties-client";
|
||||
import { presentGisAttributeGroup } from "./gis-attribute-presentation";
|
||||
|
||||
describe("GIS attribute presentation", () => {
|
||||
it("prefers the actual DN100 inventory diameter and folds raw compatibility fields", () => {
|
||||
const presentation = presentGisAttributeGroup(createGroup({
|
||||
PIPE_D: 150,
|
||||
DIAMETER: 0,
|
||||
LENGTH: 91.85,
|
||||
MATERIAL: "球墨管",
|
||||
DEPTH_1: 0.8,
|
||||
SSHX: "奉城镇",
|
||||
ISABANDON: "正常",
|
||||
TEMP1: "0000/00/00",
|
||||
BZ: null,
|
||||
GJZ: 5752299
|
||||
}));
|
||||
|
||||
expect(presentation.datasetRole).toBe("扩展台账");
|
||||
expect(presentation.summary).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ label: "管径", value: "150 mm" }),
|
||||
expect.objectContaining({ label: "长度", value: "91.85 m" }),
|
||||
expect.objectContaining({ label: "材质", value: "球墨管" }),
|
||||
expect.objectContaining({ label: "埋深", value: "0.80 m" }),
|
||||
expect.objectContaining({ label: "所属区域", value: "奉城镇" }),
|
||||
expect.objectContaining({ label: "状态", value: "正常" })
|
||||
]));
|
||||
expect(presentation.summary.map((entry) => entry.value)).not.toContain("0 mm");
|
||||
expect(presentation.usefulCount).toBe(7);
|
||||
expect(presentation.rawCount).toBe(10);
|
||||
expect(presentation.rawEntries).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ label: "PIPE_D", value: "150 mm" }),
|
||||
expect.objectContaining({ label: "DIAMETER", value: "0 mm" })
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
function createGroup(attributes: Record<string, unknown>): GisAttributeGroup {
|
||||
return {
|
||||
dataset_key: "fx_pipe_inventory_20200811",
|
||||
dataset_title: "奉贤 DN100 及以上管网台账",
|
||||
source_date: "2020-08-11",
|
||||
layer_key: "fx_20200811_dn100_pipes",
|
||||
layer_name: "DN100 及以上管线台账",
|
||||
source_fid: 117,
|
||||
match_method: "geometry",
|
||||
match_distance_m: 0.12,
|
||||
attributes
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { GisAttributeGroup } from "../api/gis-properties-client";
|
||||
import {
|
||||
formatFeaturePropertyValue,
|
||||
getFeaturePropertyLabel,
|
||||
type LocalizedFeatureProperty
|
||||
} from "./feature-properties";
|
||||
|
||||
type SummaryFieldDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
keys: readonly string[];
|
||||
formatKey?: string;
|
||||
rejectZero?: boolean;
|
||||
};
|
||||
|
||||
export type GisAttributeSection = {
|
||||
id: string;
|
||||
label: string;
|
||||
entries: LocalizedFeatureProperty[];
|
||||
};
|
||||
|
||||
export type GisAttributePresentation = {
|
||||
datasetRole: string;
|
||||
description: string;
|
||||
usefulCount: number;
|
||||
rawCount: number;
|
||||
summary: LocalizedFeatureProperty[];
|
||||
sections: GisAttributeSection[];
|
||||
rawEntries: LocalizedFeatureProperty[];
|
||||
};
|
||||
|
||||
const PIPE_SUMMARY_FIELDS: readonly SummaryFieldDefinition[] = [
|
||||
{ id: "diameter", label: "管径", keys: ["PIPE_D", "管径", "直径", "DIAMETER"], formatKey: "diameter", rejectZero: true },
|
||||
{ id: "length", label: "长度", keys: ["LENGTH", "长度", "SHAPE_LEN", "SHAPE_LENG"], formatKey: "length", rejectZero: true },
|
||||
{ id: "material", label: "材质", keys: ["MATERIAL", "材质"] },
|
||||
{ id: "depth", label: "埋深", keys: ["DEPTH_1", "埋深", "DEPTH", "方顶深度"], formatKey: "max_depth", rejectZero: true },
|
||||
{ id: "location", label: "道路或位置", keys: ["ST_NAME", "所在路名", "地址", "地点", "地理位置"] },
|
||||
{ id: "district", label: "所属区域", keys: ["SSHX", "SSQX", "行政区", "所属区域"] },
|
||||
{ id: "operator", label: "管理单位", keys: ["SSZS", "COMP_NAME", "所属公司"] },
|
||||
{ id: "status", label: "状态", keys: ["ISABANDON", "状态", "ENABLED"] }
|
||||
];
|
||||
|
||||
const GENERIC_SUMMARY_FIELDS: readonly SummaryFieldDefinition[] = [
|
||||
{ id: "category", label: "设备类别", keys: ["设备类别", "SBLB_1", "SBLB"] },
|
||||
{ id: "diameter", label: "口径", keys: ["直径", "干管口径", "PIPE_D", "DIAMETER"], formatKey: "diameter", rejectZero: true },
|
||||
{ id: "location", label: "位置", keys: ["地址", "地点", "地理位置", "所在路名", "ST_NAME"] },
|
||||
{ id: "operator", label: "所属单位", keys: ["所属公司", "COMP_NAME", "SSZS"] },
|
||||
{ id: "status", label: "状态", keys: ["状态", "ISABANDON", "ENABLED"] },
|
||||
{ id: "installed", label: "安装日期", keys: ["安装日期", "INS_DATE", "INP_DATE", "CDATE"] }
|
||||
];
|
||||
|
||||
const ZERO_IS_MEANINGFUL = new Set(["enabled", "reliability"]);
|
||||
const RAW_FORMAT_KEYS: Record<string, string> = {
|
||||
pipe_d: "diameter",
|
||||
depth_1: "max_depth"
|
||||
};
|
||||
const RAW_ONLY_KEYS = new Set([
|
||||
"objectid",
|
||||
"shape_leng",
|
||||
"shape_len",
|
||||
"fnode",
|
||||
"tnode",
|
||||
"symbol",
|
||||
"angle",
|
||||
"angle_1",
|
||||
"wzgjz",
|
||||
"kzgjz",
|
||||
"gjz_1"
|
||||
]);
|
||||
|
||||
const SECTION_ORDER = ["identity", "location", "construction", "provenance", "other"] as const;
|
||||
const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], string> = {
|
||||
identity: "标识与状态",
|
||||
location: "位置与权属",
|
||||
construction: "建设与维护",
|
||||
provenance: "数据来源",
|
||||
other: "其他有效属性"
|
||||
};
|
||||
|
||||
export function presentGisAttributeGroup(
|
||||
group: GisAttributeGroup
|
||||
): GisAttributePresentation {
|
||||
const isPipe = group.layer_key === "fx_20200423_pipes"
|
||||
|| group.layer_key === "fx_20200811_dn100_pipes";
|
||||
const definitions = isPipe ? PIPE_SUMMARY_FIELDS : GENERIC_SUMMARY_FIELDS;
|
||||
const consumedKeys = new Set<string>();
|
||||
const summary = definitions.flatMap((definition) => {
|
||||
const match = findFirstUsefulAttribute(group.attributes, definition);
|
||||
if (!match) return [];
|
||||
consumedKeys.add(match.key);
|
||||
return [{
|
||||
key: definition.id,
|
||||
label: definition.label,
|
||||
value: formatFeaturePropertyValue(definition.formatKey ?? match.key, match.value)
|
||||
}];
|
||||
});
|
||||
const usefulEntries = Object.entries(group.attributes)
|
||||
.filter(([key, value]) => isUsefulGisValue(key, value));
|
||||
if (summary.length === 0) {
|
||||
usefulEntries.slice(0, 6).forEach(([key, value]) => {
|
||||
consumedKeys.add(key);
|
||||
summary.push(toLocalizedEntry(key, value));
|
||||
});
|
||||
}
|
||||
|
||||
const sectionEntries = new Map<string, LocalizedFeatureProperty[]>();
|
||||
usefulEntries.forEach(([key, value]) => {
|
||||
if (consumedKeys.has(key) || RAW_ONLY_KEYS.has(normalizeKey(key))) return;
|
||||
const sectionId = classifyField(key);
|
||||
const entries = sectionEntries.get(sectionId) ?? [];
|
||||
entries.push(toLocalizedEntry(key, value));
|
||||
sectionEntries.set(sectionId, entries);
|
||||
});
|
||||
|
||||
return {
|
||||
...getDatasetPresentation(group.layer_key),
|
||||
usefulCount: usefulEntries.length,
|
||||
rawCount: Object.keys(group.attributes).length,
|
||||
summary,
|
||||
sections: SECTION_ORDER.flatMap((id) => {
|
||||
const entries = sectionEntries.get(id);
|
||||
return entries?.length ? [{ id, label: SECTION_LABELS[id], entries }] : [];
|
||||
}),
|
||||
rawEntries: Object.entries(group.attributes).map(([key, value]) => ({
|
||||
key,
|
||||
label: key,
|
||||
value: formatFeaturePropertyValue(RAW_FORMAT_KEYS[normalizeKey(key)] ?? key, value)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function findFirstUsefulAttribute(
|
||||
attributes: Record<string, unknown>,
|
||||
definition: SummaryFieldDefinition
|
||||
) {
|
||||
for (const key of definition.keys) {
|
||||
if (!(key in attributes)) continue;
|
||||
const value = attributes[key];
|
||||
if (!isUsefulGisValue(key, value)) continue;
|
||||
if (definition.rejectZero && isZero(value)) continue;
|
||||
return { key, value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isUsefulGisValue(key: string, value: unknown) {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === "0000/00/00" || trimmed === "0000-00-00") return false;
|
||||
}
|
||||
if (isZero(value) && !ZERO_IS_MEANINGFUL.has(normalizeKey(key))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isZero(value: unknown) {
|
||||
return (typeof value === "number" && value === 0)
|
||||
|| (typeof value === "string" && /^0+(?:\.0+)?$/.test(value.trim()));
|
||||
}
|
||||
|
||||
function classifyField(key: string): (typeof SECTION_ORDER)[number] {
|
||||
const normalized = normalizeKey(key);
|
||||
if (/地址|地点|位置|路名|区域|公司|管理|所属|乡镇|行政/.test(key)
|
||||
|| ["st_name", "ssqx", "sszs", "sshx", "comp_name"].includes(normalized)) {
|
||||
return "location";
|
||||
}
|
||||
if (/日期|安装|工程|材质|口径|直径|长度|埋深|压力|接口|防腐|路面|厂家/.test(key)
|
||||
|| /^(pipe_d|material|length|depth|ins_date|inp_date|joint_|lining_|pavement|project)/.test(normalized)) {
|
||||
return "construction";
|
||||
}
|
||||
if (/来源|作者|更新|备注|图片|关键字|采集/.test(key)
|
||||
|| /^(source|author|update_|cdate|bz|gjz|picture)/.test(normalized)) {
|
||||
return "provenance";
|
||||
}
|
||||
if (/编号|编码|类别|类型|状态|功能|用途/.test(key)
|
||||
|| /^(id|pipe_id|prjid|sblb|pipetype|status|purpose|isabandon|enabled)/.test(normalized)) {
|
||||
return "identity";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getDatasetPresentation(layerKey: string) {
|
||||
if (layerKey === "fx_20200423_pipes") {
|
||||
return {
|
||||
datasetRole: "基础管网",
|
||||
description: "2020-04-23 全网除头桥基础快照,以拓扑、位置和基础业务字段为主。"
|
||||
};
|
||||
}
|
||||
if (layerKey === "fx_20200811_dn100_pipes") {
|
||||
return {
|
||||
datasetRole: "扩展台账",
|
||||
description: "2020-08-11 DN100 及以上专项台账,优先采用 PIPE_D,并补充材质、埋深和区域等字段。"
|
||||
};
|
||||
}
|
||||
return {
|
||||
datasetRole: "设施台账",
|
||||
description: "源 GIS 设施记录,仅展示有业务含义的字段摘要。"
|
||||
};
|
||||
}
|
||||
|
||||
function toLocalizedEntry(key: string, value: unknown): LocalizedFeatureProperty {
|
||||
return {
|
||||
key,
|
||||
label: getFeaturePropertyLabel(key),
|
||||
value: formatFeaturePropertyValue(key, value)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKey(key: string) {
|
||||
return key.trim().toLowerCase();
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export function createScheduledConditionAlerts(
|
||||
id: `${SCHEDULED_CONDITION_ALERT_ID_PREFIX}${condition.id}`,
|
||||
title: `待复核:${condition.title}`,
|
||||
description: condition.detail ?? condition.summary,
|
||||
severity: "critical",
|
||||
time: formatScheduledConditionTime(condition.scheduledAt)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ScheduledConditionRecord, WorkbenchAlert } from "../types";
|
||||
import {
|
||||
createAlertQueueConversationPrompt,
|
||||
createConditionConversationPrompt
|
||||
} from "./scheduled-condition-prompts";
|
||||
|
||||
const mockCondition: ScheduledConditionRecord = {
|
||||
id: "mock-running",
|
||||
kind: "condition",
|
||||
taskId: "scada-diagnosis",
|
||||
sessionId: "scheduled-condition-scada-diagnosis-test",
|
||||
scheduledAt: "2026-09-17T10:00:00+08:00",
|
||||
title: "SCADA 数据诊断",
|
||||
summary: "演示摘要",
|
||||
detail: "演示详情",
|
||||
status: "running",
|
||||
riskLevel: "attention",
|
||||
updatedAt: Date.parse("2026-09-17T10:01:00+08:00")
|
||||
};
|
||||
|
||||
const backendCondition: ScheduledConditionRecord = {
|
||||
id: "backend-run",
|
||||
kind: "condition",
|
||||
taskId: "backend-schedule",
|
||||
dataSource: "scheduled_tasks",
|
||||
sessionId: "scheduled-condition-schedule-run-test",
|
||||
scheduledAt: "2026-09-17T10:00:00+08:00",
|
||||
title: "演示 后端调查",
|
||||
summary: "后端摘要",
|
||||
detail: "后端明确记录的详情",
|
||||
evidence: ["后端证据 A"],
|
||||
status: "running",
|
||||
riskLevel: "attention",
|
||||
updatedAt: Date.parse("2026-09-17T10:01:00+08:00"),
|
||||
analysisInsight: {
|
||||
summary: "不应发送的前端推断",
|
||||
notes: [],
|
||||
solutionOptions: [
|
||||
{
|
||||
id: "fake-option",
|
||||
title: "虚构方案",
|
||||
scenario: "虚构场景",
|
||||
action: "虚构动作",
|
||||
tradeoff: "虚构代价"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
describe("scheduled condition prompts", () => {
|
||||
it("keeps mock workflows and labels them as demonstration data", () => {
|
||||
const prompt = createConditionConversationPrompt(mockCondition, "mock");
|
||||
|
||||
expect(prompt).toContain("来源:明确标注的演示数据");
|
||||
expect(prompt).toContain("任务工作流:SCADA 诊断流程");
|
||||
expect(prompt).toContain("以上内容为演示数据");
|
||||
});
|
||||
|
||||
it("sends only persisted backend facts without inferred workflow steps", () => {
|
||||
const prompt = createConditionConversationPrompt(backendCondition, "scheduled_tasks");
|
||||
|
||||
expect(prompt).toContain("来源:后端持久化运行态");
|
||||
expect(prompt).toContain("后端明确记录的详情");
|
||||
expect(prompt).toContain("后端证据 A");
|
||||
expect(prompt).toContain("不要补造执行步骤、进度或阈值");
|
||||
expect(prompt).not.toContain("任务工作流");
|
||||
expect(prompt).not.toContain("SCADA 诊断流程");
|
||||
});
|
||||
|
||||
it("does not include frontend solution inference in a backend alert summary", () => {
|
||||
const alerts: WorkbenchAlert[] = [
|
||||
{
|
||||
id: "scheduled-condition-alert-backend-run",
|
||||
conditionId: "backend-run",
|
||||
title: "后端提醒",
|
||||
description: "后端异常记录",
|
||||
time: "10:01"
|
||||
}
|
||||
];
|
||||
const prompt = createAlertQueueConversationPrompt({
|
||||
alerts,
|
||||
conditions: [backendCondition],
|
||||
dataSource: "backend"
|
||||
});
|
||||
|
||||
expect(prompt).toContain("数据来源:后端持久化运行态");
|
||||
expect(prompt).toContain("后端证据 A");
|
||||
expect(prompt).not.toContain("虚构方案");
|
||||
expect(prompt).not.toContain("虚构动作");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { getRunningWorkflowDefinition } from "../data/running-workflows";
|
||||
import type { ScheduledConditionItem, ScheduledConditionItemStatus, WorkbenchAlert } from "../types";
|
||||
import type {
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionItemStatus,
|
||||
WorkbenchAlert,
|
||||
WorkbenchAlertDataSource,
|
||||
WorkbenchRuntimeDataSource
|
||||
} from "../types";
|
||||
|
||||
const SCHEDULED_CONDITION_ALERT_ID_PREFIX = "scheduled-condition-alert-";
|
||||
|
||||
@@ -12,14 +18,21 @@ const statusLabels: Record<ScheduledConditionItemStatus, string> = {
|
||||
pending: "预约"
|
||||
};
|
||||
|
||||
export function createConditionConversationPrompt(condition: ScheduledConditionItem) {
|
||||
export function createConditionConversationPrompt(
|
||||
condition: ScheduledConditionItem,
|
||||
dataSource: WorkbenchRuntimeDataSource = "mock"
|
||||
) {
|
||||
const effectiveDataSource = condition.kind === "condition"
|
||||
? condition.dataSource ?? dataSource
|
||||
: dataSource;
|
||||
const backendCondition = effectiveDataSource !== "mock";
|
||||
const workflow =
|
||||
condition.kind === "condition"
|
||||
condition.kind === "condition" && !backendCondition
|
||||
? getRunningWorkflowDefinition(condition)
|
||||
: null;
|
||||
const evidence = condition.evidence?.map((item, index) => `${index + 1}. ${item}`).join("\n") || "无";
|
||||
const kpis =
|
||||
condition.kind === "condition" && condition.kpis?.length
|
||||
!backendCondition && condition.kind === "condition" && condition.kpis?.length
|
||||
? condition.kpis
|
||||
.map((kpi, index) => {
|
||||
const value = `${kpi.value}${kpi.unit ?? ""}`;
|
||||
@@ -29,7 +42,7 @@ export function createConditionConversationPrompt(condition: ScheduledConditionI
|
||||
.join("\n")
|
||||
: "无";
|
||||
const report =
|
||||
condition.kind === "condition" && condition.report
|
||||
!backendCondition && condition.kind === "condition" && condition.report
|
||||
? [
|
||||
condition.report.conclusion,
|
||||
...condition.report.sections.map(
|
||||
@@ -43,6 +56,7 @@ export function createConditionConversationPrompt(condition: ScheduledConditionI
|
||||
"",
|
||||
`时间:${formatTime(condition.scheduledAt)}`,
|
||||
"类型:历史工况",
|
||||
`来源:${backendCondition ? "后端持久化运行态" : "明确标注的演示数据"}`,
|
||||
workflow ? `任务工作流:${workflow.title}` : null,
|
||||
workflow ? `工作流步骤:${workflow.steps.map((step, index) => `${index + 1}. ${step.title}`).join(";")}` : null,
|
||||
`标题:${condition.title}`,
|
||||
@@ -56,6 +70,9 @@ export function createConditionConversationPrompt(condition: ScheduledConditionI
|
||||
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
|
||||
condition.sessionId ? `sessionId:${condition.sessionId}` : null,
|
||||
"",
|
||||
backendCondition
|
||||
? "仅将上述状态、详情、证据和已有建议视为后端事实;不要补造执行步骤、进度或阈值,新增判断必须明确标注为分析建议。"
|
||||
: "以上内容为演示数据,可用于测试对话流程,不代表真实运行记录。",
|
||||
"请先说明当前判断,再列出需要人工确认的事项和建议动作。"
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -64,11 +81,14 @@ export function createConditionConversationPrompt(condition: ScheduledConditionI
|
||||
|
||||
export function createAlertQueueConversationPrompt({
|
||||
alerts,
|
||||
conditions
|
||||
conditions,
|
||||
dataSource = "mock"
|
||||
}: {
|
||||
alerts: WorkbenchAlert[];
|
||||
conditions: ScheduledConditionItem[];
|
||||
dataSource?: WorkbenchAlertDataSource;
|
||||
}) {
|
||||
const backendAlerts = dataSource !== "mock";
|
||||
const alertSummaries = alerts.map((alert, index) => {
|
||||
const condition = findConditionForAlert(alert, conditions);
|
||||
const baseSummary = [
|
||||
@@ -90,7 +110,10 @@ export function createAlertQueueConversationPrompt({
|
||||
condition.detail ? `工况详情:${condition.detail}` : null,
|
||||
condition.evidence?.length ? `关键证据:\n${condition.evidence.map((item, evidenceIndex) => `${evidenceIndex + 1}. ${item}`).join("\n")}` : null,
|
||||
condition.recommendation ? `已有建议:${condition.recommendation}` : null,
|
||||
condition.kind === "condition" && condition.analysisInsight?.solutionOptions?.length
|
||||
(condition.kind !== "condition"
|
||||
|| (condition.dataSource ?? (backendAlerts ? "backend" : "mock")) === "mock")
|
||||
&& condition.kind === "condition"
|
||||
&& condition.analysisInsight?.solutionOptions?.length
|
||||
? `已有处置方案:\n${condition.analysisInsight.solutionOptions.map((option, optionIndex) => `${optionIndex + 1}. ${option.title}:${option.action};代价:${option.tradeoff}`).join("\n")}`
|
||||
: null
|
||||
]
|
||||
@@ -101,6 +124,11 @@ export function createAlertQueueConversationPrompt({
|
||||
return [
|
||||
"请作为供水管网调度 Agent,汇总当前待复核工况队列。",
|
||||
"",
|
||||
`数据来源:${backendAlerts ? "后端持久化运行态" : "明确标注的演示数据"}`,
|
||||
backendAlerts
|
||||
? "仅将后端记录的状态、详情、证据和已有建议视为事实;不要补造执行步骤、进度或阈值,新增判断必须明确标注为分析建议。"
|
||||
: "这些内容为演示数据,用于测试汇总流程。",
|
||||
"",
|
||||
"目标:",
|
||||
"1. 汇总这一组待复核工况之间的关联关系和处理顺序。",
|
||||
"2. 列出支撑判断的关键证据和不确定性。",
|
||||
@@ -117,6 +145,9 @@ export function createAlertQueueConversationPrompt({
|
||||
}
|
||||
|
||||
function findConditionForAlert(alert: WorkbenchAlert, conditions: ScheduledConditionItem[]) {
|
||||
if (alert.conditionId) {
|
||||
return conditions.find((condition) => condition.id === alert.conditionId) ?? null;
|
||||
}
|
||||
if (!alert.id.startsWith(SCHEDULED_CONDITION_ALERT_ID_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user