${esc(demand.name)}
${esc(demand.granularity)}需求模型 Reason:${esc(demand.modelReason)}
(() => {
const SOURCE_URL = "/全局分类树.html";
const DIMENSIONS = [
{ key: "ext_pop", label: "外部热度", group: "prior", weight: .30 },
{ key: "plat_sust_pop", label: "平台持续热度", group: "prior", weight: .23 },
{ key: "plat_ly_pop", label: "去年同期热度", group: "prior", weight: .17 },
{ key: "recent_pop", label: "近期热度", group: "prior", weight: .30 },
{ key: "real_rov_7d", label: "真实 ROV", group: "posterior", weight: .50 },
{ key: "real_vov_7d", label: "真实 VOV", group: "posterior", weight: .50 }
];
const STRUCTURE_ONLY = new Set([
"表象", "理念", "实体", "行为", "知识", "现象", "事件", "观念",
"内容形态", "场景", "声音", "符号", "视觉", "对象", "分类", "其他",
"个体", "方法", "概念", "社会", "个人", "事物", "属性", "关系"
]);
const ISOLATED_ACTIONS = new Set([
"转发", "点赞", "评论", "收藏", "关注", "播放", "浏览", "点击", "分享",
"上传", "下载", "发布", "互动", "推荐"
]);
const BROAD_DEMANDS = new Set([
"战争", "历史", "人物", "诗词", "音乐", "电影", "健康", "教育", "科技",
"美食", "旅游", "体育", "情感", "家庭", "生活", "文化", "军事", "政治"
]);
const state = {
roots: [],
flat: [],
byId: new Map(),
elementsByCategory: {},
metricModel: {},
candidateCount: 0,
demands: [],
demandById: new Map(),
focus: null,
startDepth: 0,
maxDepth: 0,
selectedNode: null,
selectedDemand: null,
granularity: "all",
query: "",
canvas: null,
ctx: null,
dpr: 1,
baseRects: [],
rects: [],
view: { scale: 1, x: 0, y: 0 },
pointers: new Map(),
drag: { active: false, moved: false, x: 0, y: 0, pinchDistance: 0 },
resizeObserver: null
};
const esc = value => String(value ?? "").replace(/[&<>"']/g, char => (
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]
));
const clamp01 = value => Math.max(0, Math.min(1, Number(value) || 0));
const formatScore = value => value == null ? "无数据" : clamp01(value).toFixed(2);
const rawNumber = value => Number(value) || 0;
const formatRaw = value => rawNumber(value).toLocaleString("zh-CN", { maximumFractionDigits: 4 });
const normalizeName = name => String(name || "").replace(/\s+/g, "").trim();
function quantile(sorted, q) {
if (!sorted.length) return 0;
const position = (sorted.length - 1) * q;
const base = Math.floor(position);
const rest = position - base;
return sorted[base + 1] == null
? sorted[base]
: sorted[base] + rest * (sorted[base + 1] - sorted[base]);
}
function buildMetricModel(nodes) {
const model = {};
for (const dimension of DIMENSIONS) {
const values = nodes
.map(node => rawNumber(node.weights?.[dimension.key]))
.filter(value => value > 0)
.map(value => Math.log1p(value))
.sort((a, b) => a - b);
model[dimension.key] = {
cap: quantile(values, .95) || Math.max(...values, 1),
nonZeroCount: values.length
};
}
return model;
}
function normalizedMetric(key, raw) {
const value = rawNumber(raw);
if (value <= 0) return null;
const cap = state.metricModel[key]?.cap || 1;
return clamp01(Math.log1p(value) / cap);
}
function weightedGroup(node, group) {
const dimensions = DIMENSIONS.filter(item => item.group === group);
let weighted = 0;
let weight = 0;
let covered = 0;
for (const dimension of dimensions) {
const score = normalizedMetric(dimension.key, node.weights?.[dimension.key]);
if (score == null) continue;
weighted += score * dimension.weight;
weight += dimension.weight;
covered += 1;
}
return {
score: weight ? weighted / weight : null,
coverage: covered / dimensions.length,
count: covered
};
}
function computeNodeScores(node) {
const prior = weightedGroup(node, "prior");
const posterior = weightedGroup(node, "posterior");
const hasAnyData = prior.count + posterior.count > 0;
let heatScore = null;
if (prior.score != null && posterior.score != null) {
heatScore = prior.score * .58 + posterior.score * .42;
} else if (prior.score != null) {
heatScore = prior.score;
} else if (posterior.score != null) {
heatScore = posterior.score;
}
const dimensionCoverage = (prior.count + posterior.count) / DIMENSIONS.length;
// 单一维度很强仍然是有效信号,但不应与多维共同升温显示成同样热。
// 覆盖度只校正“热度”,不参与是否为需求的判断。
if (heatScore != null) {
heatScore *= .50 + dimensionCoverage * .50;
}
const agreement = prior.score != null && posterior.score != null
? 1 - Math.min(1, Math.abs(prior.score - posterior.score))
: .46;
const confidence = hasAnyData
? clamp01(dimensionCoverage * .62 + agreement * .25 + (node.elements.length ? .13 : 0))
: clamp01(node.elements.length ? .18 : 0);
node.score = {
heatScore: heatScore == null ? null : clamp01(heatScore),
confidence,
dataState: hasAnyData ? "observed" : "missing",
priorScore: prior.score,
posteriorScore: posterior.score,
priorCoverage: prior.coverage,
posteriorCoverage: posterior.coverage
};
}
function flatten(nodes, parent = null, path = []) {
for (const node of nodes || []) {
node.parent = parent;
node.path = [...path, node.name];
node.children = node.children || [];
node.elements = state.elementsByCategory[String(node.id)] || [];
state.flat.push(node);
state.maxDepth = Math.max(state.maxDepth, node.path.length - 1);
state.byId.set(String(node.id), node);
flatten(node.children, node, node.path);
}
}
function computeSubtreeScore(node) {
node.leafCount = node.children.length
? node.children.reduce((sum, child) => sum + computeSubtreeScore(child).leafCount, 0)
: 1;
const childScores = node.children
.map(child => child.displayScore)
.filter(value => value != null)
.sort((a, b) => b - a);
const strongChildren = childScores.slice(0, Math.max(1, Math.ceil(childScores.length * .25)));
const childSignal = strongChildren.length
? strongChildren.reduce((sum, value) => sum + value, 0) / strongChildren.length
: null;
if (node.score.heatScore != null && childSignal != null) {
node.displayScore = clamp01(node.score.heatScore * .68 + childSignal * .32);
} else {
node.displayScore = node.score.heatScore ?? (childSignal == null ? null : childSignal * .78);
}
return node;
}
function classifySearchIntent(name, sourceType, node) {
const normalized = normalizeName(name);
if (!normalized || normalized.length < 2) {
return { isDemand: false, granularity: null, reasonCode: "too_short" };
}
if (STRUCTURE_ONLY.has(normalized)) {
return { isDemand: false, granularity: null, reasonCode: "structure_only" };
}
if (ISOLATED_ACTIONS.has(normalized)) {
return { isDemand: false, granularity: null, reasonCode: "isolated_action" };
}
if (/^(其他|相关|类别|类型|内容|信息|方面|部分|综合|未知)/.test(normalized)) {
return { isDemand: false, granularity: null, reasonCode: "generic_structure" };
}
let granularity;
if (BROAD_DEMANDS.has(normalized) || normalized.length <= 2) granularity = "宽泛";
else if (normalized.length <= 5) granularity = "主题级";
else if (normalized.length <= 10) granularity = "具体";
else granularity = "长尾";
if (sourceType === "node" && node && node.path.length <= 2 && normalized.length <= 3) {
return { isDemand: false, granularity: null, reasonCode: "upper_structure" };
}
return { isDemand: true, granularity, reasonCode: "searchable" };
}
function inheritedHeat(node) {
if (node.score.heatScore != null) return node.score.heatScore;
let current = node.parent;
let decay = .64;
while (current) {
if (current.score.heatScore != null) return clamp01(current.score.heatScore * decay);
current = current.parent;
decay *= .72;
}
return null;
}
function createCandidate(name, node, sourceType, rawReason = "") {
const intent = classifySearchIntent(name, sourceType, node);
if (!intent.isDemand) return null;
return {
name: normalizeName(name),
sourceType,
granularity: intent.granularity,
attachment: {
nodeId: String(node.id),
nodeName: node.name,
path: node.path.join(" / "),
heatScore: inheritedHeat(node),
ownHeatScore: node.score.heatScore,
confidence: node.score.confidence,
dataState: node.score.dataState,
weights: node.weights || {},
rawReason: rawReason || "",
elementName: sourceType === "element" ? name : ""
}
};
}
function extractDemandCandidates() {
const candidates = [];
for (const node of state.flat) {
for (const element of node.elements) {
const candidate = createCandidate(element.name, node, "element", element.reason);
if (candidate) candidates.push(candidate);
}
const nodeCandidate = createCandidate(node.name, node, "node", node.description || "");
if (nodeCandidate) candidates.push(nodeCandidate);
}
return candidates;
}
function mergeDemandCandidates(candidates) {
const merged = new Map();
for (const candidate of candidates) {
const key = candidate.name.toLowerCase();
if (!merged.has(key)) {
merged.set(key, {
id: `demand-${merged.size + 1}`,
name: candidate.name,
granularity: candidate.granularity,
attachments: [],
sourceTypes: new Set()
});
}
const demand = merged.get(key);
demand.sourceTypes.add(candidate.sourceType);
const existingAttachment = demand.attachments.find(item =>
item.nodeId === candidate.attachment.nodeId
);
if (!existingAttachment) {
demand.attachments.push(candidate.attachment);
} else if (
candidate.attachment.rawReason &&
!existingAttachment.rawReason.includes(candidate.attachment.rawReason)
) {
existingAttachment.rawReason = existingAttachment.rawReason
? `${existingAttachment.rawReason};${candidate.attachment.rawReason}`
: candidate.attachment.rawReason;
}
if (candidate.granularity === "长尾") demand.granularity = "长尾";
else if (candidate.granularity === "具体" && demand.granularity !== "长尾") demand.granularity = "具体";
}
const results = [...merged.values()];
for (const demand of results) {
const heatValues = demand.attachments.map(item => item.heatScore).filter(value => value != null);
const confidenceValues = demand.attachments.map(item => item.confidence).filter(value => value != null);
demand.heatScore = heatValues.length ? Math.max(...heatValues) : null;
demand.confidence = confidenceValues.length
? clamp01(confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length)
: 0;
demand.hasObservedData = demand.attachments.some(item => item.dataState === "observed");
demand.priority = (demand.heatScore ?? .08) * .56
+ demand.confidence * .24
+ Math.min(1, demand.attachments.length / 4) * .12
+ (demand.sourceTypes.has("element") ? .08 : 0);
demand.factReason = buildFactReason(demand);
demand.modelReason = buildModelReason(demand);
}
return results.sort((a, b) => b.priority - a.priority || b.attachments.length - a.attachments.length);
}
function selectDemandPortfolio(candidates) {
// 输出规模保持在“数百条”。主队列由多维数据支持,探索队列专门保留
// 冷区或数据缺失但具备明确搜索意义的需求,避免热度阈值一刀切。
const supported = candidates.filter(demand =>
demand.hasObservedData && (demand.heatScore ?? 0) >= .12
);
const exploration = candidates.filter(demand =>
!demand.hasObservedData || (demand.heatScore ?? 0) < .12
);
const selected = new Map();
supported.slice(0, 650).forEach(demand => selected.set(demand.name.toLowerCase(), demand));
exploration
.sort((a, b) => {
const aSpecific = a.sourceTypes.has("element") ? 1 : 0;
const bSpecific = b.sourceTypes.has("element") ? 1 : 0;
return bSpecific - aSpecific || b.priority - a.priority;
})
.slice(0, 250)
.forEach(demand => selected.set(demand.name.toLowerCase(), demand));
// 用户明确确认的宽泛需求必须保留,不能因为配额或粒度被过滤。
for (const demand of candidates) {
if (BROAD_DEMANDS.has(demand.name)) selected.set(demand.name.toLowerCase(), demand);
}
return [...selected.values()].sort((a, b) => b.priority - a.priority);
}
function strongestDimensions(attachment) {
return DIMENSIONS
.map(dimension => ({
label: dimension.label,
raw: rawNumber(attachment.weights?.[dimension.key]),
normalized: normalizedMetric(dimension.key, attachment.weights?.[dimension.key])
}))
.filter(item => item.normalized != null)
.sort((a, b) => b.normalized - a.normalized)
.slice(0, 2);
}
function buildFactReason(demand) {
const primary = demand.attachments
.slice()
.sort((a, b) => (b.heatScore ?? -1) - (a.heatScore ?? -1))[0];
const dimensions = strongestDimensions(primary);
const signals = dimensions.length
? dimensions.map(item => `${item.label} ${formatRaw(item.raw)}`).join("、")
: "六项表现数据当前缺失";
const posterior = rawNumber(primary.weights.real_rov_7d) > 0 || rawNumber(primary.weights.real_vov_7d) > 0
? `真实 ROV ${formatRaw(primary.weights.real_rov_7d)}、真实 VOV ${formatRaw(primary.weights.real_vov_7d)}`
: "真实 ROV / VOV 暂无样本";
const rawReason = demand.attachments.find(item => item.rawReason)?.rawReason;
return `主要挂靠“${primary.path}”;${signals};${posterior}`
+ (rawReason ? `;上游原始 reason:“${rawReason}”` : "。");
}
function buildModelReason(demand) {
const heat = demand.heatScore;
const dataPhrase = heat == null
? "当前表现数据不足,热度需要后续验证"
: heat >= .8 ? "当前处于高热区域"
: heat >= .6 ? "当前处于升温区域"
: heat >= .4 ? "当前热度中等"
: heat >= .2 ? "当前偏冷但仍具有搜索意义"
: "当前位于冷区,但冷区不等于无需求";
const breadth = {
宽泛: "它能够独立表达外部搜索意图,范围较大,因此作为父级宽泛需求保留,并继续下挂更具体需求",
主题级: "它能够独立表达明确主题,可承接多个具体内容方向",
具体: "它表达了较明确的搜索对象或问题,可以直接用于内容供给",
长尾: "它包含较完整的限定条件,适合作为长尾搜索需求"
}[demand.granularity];
const relation = demand.attachments.length > 1
? `;该需求具有 ${demand.attachments.length} 个挂靠点,说明它跨越多个分类语义`
: "";
return `${breadth}。${dataPhrase}${relation}。`;
}
function prepareData(data) {
state.roots = data.nodes || [];
state.elementsByCategory = data.demandsByCategory || {};
flatten(state.roots);
state.metricModel = buildMetricModel(state.flat);
state.flat.forEach(computeNodeScores);
state.roots.forEach(computeSubtreeScore);
const candidates = mergeDemandCandidates(extractDemandCandidates());
state.candidateCount = candidates.length;
state.demands = selectDemandPortfolio(candidates);
state.demands.forEach(demand => state.demandById.set(demand.id, demand));
}
function heatColor(score, dataState) {
if (dataState === "missing" && score == null) return "#f1f5f9";
if (score == null) return "#f1f5f9";
if (score >= .8) return "#ef4444";
if (score >= .6) return "#f59e0b";
if (score >= .4) return "#22a06b";
if (score >= .2) return "#2563eb";
return "#bfdbfe";
}
function textColor(score) {
return score == null || score < .2 ? "#475569" : "#ffffff";
}
function descendantsAtDepth(roots, targetDepth) {
const results = [];
const walk = node => {
const depth = node.path.length - 1;
if (depth === targetDepth) {
results.push(node);
return;
}
if (depth < targetDepth) node.children.forEach(walk);
};
roots.forEach(walk);
return results;
}
function activeBaseDepth() {
const focusDepth = state.focus ? state.focus.path.length - 1 : 0;
return Math.max(focusDepth, state.startDepth);
}
function viewRoots() {
const scopeRoots = state.focus ? [state.focus] : state.roots;
const baseDepth = activeBaseDepth();
const roots = descendantsAtDepth(scopeRoots, baseDepth);
return roots.length ? roots : scopeRoots;
}
function relativeMaxDepth(nodes, baseDepth) {
let maximum = 0;
const walk = node => {
maximum = Math.max(maximum, node.path.length - 1 - baseDepth);
node.children.forEach(walk);
};
nodes.forEach(walk);
return maximum;
}
function layoutIcicle() {
if (!state.canvas) return [];
const rect = state.canvas.getBoundingClientRect();
const roots = viewRoots();
const baseDepth = activeBaseDepth();
const maxDepth = relativeMaxDepth(roots, baseDepth);
const columns = maxDepth + 1;
const columnWidth = rect.width / columns;
const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0);
const rows = [];
let yCursor = 0;
const place = (node, y, height) => {
const relativeDepth = node.path.length - 1 - baseDepth;
rows.push({
node,
x: relativeDepth * columnWidth,
y,
width: columnWidth,
height
});
let childY = y;
for (const child of node.children) {
const childHeight = height * (child.leafCount / node.leafCount);
place(child, childY, childHeight);
childY += childHeight;
}
};
for (const root of roots) {
const height = rect.height * (root.leafCount / totalLeaves);
place(root, yCursor, height);
yCursor += height;
}
renderDepthAxis(baseDepth);
return rows;
}
function renderDepthAxis(baseDepth) {
const axis = document.querySelector("#depth-axis");
axis.style.gridTemplateColumns = `repeat(${state.maxDepth + 1}, minmax(38px, 1fr))`;
axis.innerHTML = Array.from({ length: state.maxDepth + 1 }, (_, depth) => {
return ``;
}).join("");
}
function renderBreadcrumb() {
const target = document.querySelector("#breadcrumb-bar");
const pathNodes = [];
let current = state.selectedNode || state.focus;
while (current) {
pathNodes.unshift(current);
current = current.parent;
}
target.innerHTML = pathNodes.length
? pathNodes.map((node, index) => `
${index ? "→" : ""}
`).join("")
: ``;
const parentButton = document.querySelector("#parent-view");
parentButton.disabled = !(state.selectedNode?.parent || state.focus?.parent);
}
function updateCanvasExtent() {
const viewport = document.querySelector("#icicle-viewport");
const wrap = document.querySelector("#icicle-wrap");
const roots = viewRoots();
const totalLeaves = roots.reduce((sum, node) => sum + node.leafCount, 0);
const shouldExtend = state.startDepth > 0 || Boolean(state.focus);
const minimumLeafHeight = state.focus ? 13 : 9;
const desiredHeight = shouldExtend
? Math.max(viewport.clientHeight, Math.min(24000, totalLeaves * minimumLeafHeight))
: viewport.clientHeight;
wrap.style.height = `${Math.max(420, desiredHeight)}px`;
}
function resizeCanvas() {
const canvas = document.querySelector("#icicle-canvas");
updateCanvasExtent();
const rect = canvas.getBoundingClientRect();
state.canvas = canvas;
state.ctx = canvas.getContext("2d");
state.dpr = rect.height > 3000 ? 1 : Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(rect.width * state.dpr);
canvas.height = Math.round(rect.height * state.dpr);
state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
fitView(false);
drawIcicle();
}
function drawIcicle() {
if (!state.ctx || !state.flat.length) return;
const ctx = state.ctx;
const rect = state.canvas.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
state.baseRects = layoutIcicle();
state.rects = state.baseRects.map(item => ({
...item,
x: item.x * state.view.scale + state.view.x,
y: item.y * state.view.scale + state.view.y,
width: item.width * state.view.scale,
height: item.height * state.view.scale
}));
const query = state.query.toLowerCase();
for (const item of state.rects) {
const node = item.node;
const score = node.displayScore;
const selected = state.selectedNode === node;
const matched = query && (
node.name.toLowerCase().includes(query) ||
node.elements.some(element => element.name.toLowerCase().includes(query))
);
ctx.fillStyle = heatColor(score, node.score.dataState);
ctx.fillRect(item.x, item.y, item.width, item.height);
ctx.strokeStyle = "rgba(255,255,255,.88)";
ctx.lineWidth = .8;
ctx.strokeRect(item.x, item.y, item.width, item.height);
if (selected || matched) {
ctx.strokeStyle = selected ? "#7c3aed" : "#1d4ed8";
ctx.lineWidth = selected ? 3 : 2;
ctx.strokeRect(item.x + 1.5, item.y + 1.5, Math.max(0, item.width - 3), Math.max(0, item.height - 3));
}
if (item.height >= 15 && item.width >= 54) {
ctx.save();
ctx.beginPath();
ctx.rect(item.x + 2, item.y + 1, item.width - 4, item.height - 2);
ctx.clip();
ctx.fillStyle = textColor(score);
ctx.font = `${item.height >= 30 ? 9 : 7}px Inter, "PingFang SC", sans-serif`;
ctx.textBaseline = "top";
ctx.fillText(node.name, item.x + 5, item.y + 4);
if (item.height >= 31) {
ctx.globalAlpha = .82;
ctx.font = '7px Inter, "PingFang SC", sans-serif';
ctx.fillText(score == null ? "无数据" : `Score ${formatScore(score)}`, item.x + 5, item.y + 17);
}
ctx.restore();
}
}
document.querySelector("#map-loading").style.display = "none";
document.body.dataset.viewScale = state.view.scale.toFixed(3);
document.body.dataset.viewX = state.view.x.toFixed(1);
document.body.dataset.viewY = state.view.y.toFixed(1);
document.body.dataset.startDepth = String(activeBaseDepth());
document.body.dataset.focusNode = state.focus?.name || "";
document.body.dataset.selectedNode = state.selectedNode?.name || "";
document.body.dataset.canvasHeight = String(Math.round(rect.height));
renderBreadcrumb();
}
function fitView(redraw = true) {
state.view = { scale: 1, x: 0, y: 0 };
if (redraw) {
const viewport = document.querySelector("#icicle-viewport");
viewport.scrollTop = 0;
viewport.scrollLeft = 0;
drawIcicle();
}
}
function zoomAt(factor, screenX, screenY) {
const oldScale = state.view.scale;
const nextScale = Math.max(1, Math.min(18, oldScale * factor));
const worldX = (screenX - state.view.x) / oldScale;
const worldY = (screenY - state.view.y) / oldScale;
state.view.scale = nextScale;
state.view.x = screenX - worldX * nextScale;
state.view.y = screenY - worldY * nextScale;
drawIcicle();
}
function nodeAt(clientX, clientY) {
const rect = state.canvas.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
let match = null;
for (const item of state.rects) {
if (x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height) {
if (!match || item.node.path.length > match.node.path.length) match = item;
}
}
return match?.node || null;
}
function scrollSelectedNodeIntoView() {
if (!state.selectedNode) return;
const item = state.rects.find(rect => rect.node === state.selectedNode);
if (!item) return;
const viewport = document.querySelector("#icicle-viewport");
viewport.scrollTop = Math.max(0, item.y - viewport.clientHeight * .38);
viewport.scrollLeft = Math.max(0, item.x - viewport.clientWidth * .18);
}
function demandInFocus(demand) {
if (!state.focus) return true;
const focusPath = state.focus.path.join(" / ");
return demand.attachments.some(item => item.path.startsWith(focusPath));
}
function filteredDemands() {
const query = state.query.toLowerCase();
return state.demands.filter(demand => {
if (state.granularity !== "all" && demand.granularity !== state.granularity) return false;
if (!demandInFocus(demand)) return false;
if (!query) return true;
return demand.name.toLowerCase().includes(query)
|| demand.attachments.some(item => item.path.toLowerCase().includes(query));
});
}
function renderDemandList() {
const demands = filteredDemands();
document.querySelector("#demand-count").textContent = demands.length.toLocaleString("zh-CN");
document.querySelector("#demand-context").textContent = state.focus
? `当前聚焦“${state.focus.name}”分支;点击回到全局可恢复全部结果。`
: "从全树自动识别,不要求逐节点点击。";
document.querySelector("#demand-list").innerHTML = demands.slice(0, 250).map(demand => `
模型 Reason:${esc(demand.modelReason)}${esc(demand.name)}
${esc(demand.granularity)}需求
${esc(node.path.join(" / "))}
${esc(dimensions)}
${node.score.dataState === "missing" ? "该节点没有直接表现数据,但仍保留在全局树中。系统会继续检查其名称是否具有搜索意义、是否存在具体元素,以及父子和跨树关系。" : "该节点的颜色反映综合热度;颜色不直接决定它是否为需求。系统还会独立判断名称能否表达外部搜索意图。"}
${siblings.length ? `同级对比:${siblings.map(item => `${esc(item.name)} ${formatScore(item.displayScore)}` ).join(";")}
` : ""}${esc(demand.factReason)}
${esc(demand.modelReason)}
说明:这里是基于名称、树路径、元素和六维数据生成的模型解释,不是上游原始 reason。