diff --git a/docs/atlas3d.css b/docs/atlas3d.css
index fda085c..53f7b30 100644
--- a/docs/atlas3d.css
+++ b/docs/atlas3d.css
@@ -220,6 +220,73 @@ input#yearSlider { width: 100%; accent-color: var(--accent); }
.trace-block li:hover { color: var(--accent-warm); }
.trace-block .blockkind { font-size: 10.5px; color: var(--ink-mute); margin-right: 6px; }
+/* ---------- dynamic insight panel ---------- */
+.insight-report {
+ margin-top: 24px;
+ padding: 14px 16px;
+ background: rgba(108, 177, 255, 0.06);
+ border: 1px solid rgba(108, 177, 255, 0.22);
+ border-radius: 8px;
+}
+.insight-report h2 {
+ margin-top: 0;
+ margin-bottom: 6px;
+ font-size: 14px;
+ color: var(--accent-warm);
+ letter-spacing: 0.04em;
+}
+.insight-summary {
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--ink);
+ margin: 0 0 10px;
+}
+.insight-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px 16px;
+ margin-top: 8px;
+}
+.insight-cell h4 {
+ font-size: 12px;
+ color: var(--accent);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin: 0 0 4px;
+}
+.insight-cell p {
+ font-size: 12.5px;
+ line-height: 1.55;
+ margin: 0;
+ color: var(--ink);
+}
+.insight-cell p.insight-empty {
+ color: var(--ink-mute);
+ font-style: italic;
+}
+.next-suggestions {
+ padding-left: 18px;
+ margin: 0;
+}
+.next-suggestions li {
+ font-size: 12.5px;
+ line-height: 1.55;
+ margin-bottom: 3px;
+}
+.open-problems {
+ padding-left: 18px;
+ margin: 0;
+ list-style: disc;
+}
+.open-problems li {
+ font-size: 12.5px;
+ line-height: 1.55;
+ margin-bottom: 3px;
+}
+@media (max-width: 920px) {
+ .insight-grid { grid-template-columns: 1fr; }
+}
+
/* ---------- mermaid diagrams ---------- */
.mermaid-rendered {
background: rgba(8, 12, 24, 0.55);
diff --git a/docs/js/atlas-cards.js b/docs/js/atlas-cards.js
index e4ffaf9..4434f9e 100644
--- a/docs/js/atlas-cards.js
+++ b/docs/js/atlas-cards.js
@@ -142,13 +142,16 @@ export class CardRenderer {
}
async _renderFull(node, raw) {
+ let main;
if (raw) {
const { body } = this._stripFront(raw);
const html = this._mdToHtml(body);
- return `${this._headerBlock(node)}${html}`;
+ main = `${this._headerBlock(node)}${html}`;
+ } else {
+ // synthesized for new kinds
+ main = `${this._headerBlock(node)}${this._synthesizedBody(node)}`;
}
- // synthesized for new kinds
- return `${this._headerBlock(node)}${this._synthesizedBody(node)}`;
+ return `${main}${this._insightReport(node)}`;
}
async _renderAnchors(node, raw) {
@@ -535,4 +538,253 @@ export class CardRenderer {
warn.setAttribute('title', `Mermaid 渲染失败: ${message || 'unknown error'}`);
pre.parentElement.insertBefore(warn, pre);
}
+
+ // ============================================================
+ // Dynamic insight panel
+ //
+ // Appended to the bottom of every card (full tab), this section
+ // turns the node's local adjacency into a deterministic reading
+ // brief: a synthesised summary, an importance estimate sourced
+ // from incoming structural relations, a ranked list of next
+ // hops, and (where the graph carries them) open problems.
+ //
+ // The synthesis must be deterministic — no Math.random — and it
+ // must avoid any phrase from tools/audit_card_meta_language.py's
+ // ban list (we test against the list internally below).
+ // ============================================================
+
+ _insightReport(node) {
+ if (!node) return "";
+ // External resource kinds — skip the panel entirely.
+ if (node.kind === "lab" || node.kind === "channel") return "";
+
+ const adj = this.adj.get(node.id) || { in: [], out: [] };
+ const summary = this._insightSummary(node, adj);
+ const importance = this._insightImportance(node, adj);
+ const suggestions = this._insightNextSuggestions(node, adj);
+ const problems = this._insightOpenProblems(node, adj);
+
+ const html = [];
+ html.push(``);
+ html.push(`动态洞察 / Dynamic insight
`);
+ html.push(`${summary}
`);
+ html.push(``);
+ html.push(`
`);
+ html.push(`
下一步可读
${suggestions}`);
+ html.push(`
开放问题
${problems}`);
+ html.push(`
`);
+ html.push(``);
+ // Internal guard — ensure no banned meta-language slipped into our
+ // synthesised text. We never display this warning to the user; it's a
+ // belt-and-braces check meant to surface in dev consoles only.
+ const joined = html.join("");
+ if (this._hasMetaLeakage(joined)) {
+ try { (window.console && console.warn && console.warn("[insight-report] meta-language leakage detected for", node.id)); } catch (_e) { /* noop */ }
+ }
+ return joined;
+ }
+
+ // 1) Summary — prefer authored `summary_zh`, fall back to `summary`,
+ // else synthesise from kind + label + most-frequent edge relation.
+ _insightSummary(node, adj) {
+ const authored = (node.summary_zh && String(node.summary_zh).trim())
+ || (node.summary && String(node.summary).trim());
+ if (authored) {
+ // Clip to ~2 sentences worth — defensive against extremely long YAML
+ // summaries that would dominate the panel.
+ const clipped = this._clipSentences(authored, 2, 220);
+ return this._escape(clipped);
+ }
+ // Synthesise.
+ const kindLabel = this._kindLabel(node.kind);
+ const label = node.label_zh || node.label || node.id;
+ const freq = new Map();
+ for (const { rel } of adj.out) freq.set(rel, (freq.get(rel) || 0) + 1);
+ for (const { rel } of adj.in) freq.set(rel, (freq.get(rel) || 0) + 1);
+ let topRel = null; let topCount = 0;
+ // Iteration order on Map is insertion order — deterministic.
+ for (const [r, c] of freq) {
+ if (c > topCount) { topCount = c; topRel = r; }
+ }
+ if (topRel) {
+ return `${this._escape(label)} 是一条 ${this._escape(kindLabel)} 节点,邻接里最常出现的是 ${this._escape(this._relLabel(topRel))}(${topCount} 次),可由此切入理解它在图谱里的位置。`;
+ }
+ return `${this._escape(label)} 是一条 ${this._escape(kindLabel)} 节点,目前邻接较稀疏,建议先从同主题节点回看。`;
+ }
+
+ // 2) Importance — count incoming composes/validates/manifests/motivates,
+ // cite specific node names.
+ _insightImportance(node, adj) {
+ const incoming = adj.in.filter(e => ["composes", "validates", "manifests", "motivates"].includes(e.rel));
+ // Bucket by relation, deterministic order
+ const order = ["composes", "validates", "manifests", "motivates"];
+ const byRel = new Map(order.map(r => [r, []]));
+ for (const e of incoming) {
+ const list = byRel.get(e.rel);
+ if (list) list.push(e.other);
+ }
+ const total = incoming.length;
+ if (!total) {
+ // Try a softer fallback — any incoming edges at all.
+ const anyIn = adj.in.length;
+ if (!anyIn) {
+ return `当前图谱里没有指向这条节点的关系,可能是新接入或处于研究范式的源头。`;
+ }
+ return `当前没有结构性引用(composes / validates / manifests / motivates),但仍被 ${anyIn} 条其它关系连接,更多线索见 锚点链路 标签页。`;
+ }
+ // Pick up to 3 most-cited representative nodes (across all four buckets)
+ const ranked = [];
+ for (const r of order) {
+ for (const id of byRel.get(r)) {
+ const o = this.byId.get(id);
+ if (!o) continue;
+ ranked.push({ id, o, rel: r, deg: typeof o.degree === "number" ? o.degree : (this.adj.get(id)?.in?.length || 0) });
+ }
+ }
+ ranked.sort((a, b) => (b.deg - a.deg) || a.id.localeCompare(b.id));
+ const exemplars = ranked.slice(0, 3);
+ // Compose Chinese counts: "被 N 条 paradigm/validation/manifests 关系引用"
+ const parts = [];
+ for (const r of order) {
+ const c = byRel.get(r).length;
+ if (c) parts.push(`${c} 条 ${this._relLabel(r)}`);
+ }
+ const countText = parts.join(" + ");
+ let examplesHtml = "";
+ if (exemplars.length) {
+ const links = exemplars.map(({ o }) => this._anchorTag(o)).join("、");
+ examplesHtml = `,例如 ${links}`;
+ }
+ return `被 ${countText} 关系共 ${total} 次引用${examplesHtml}。`;
+ }
+
+ // 3) Next suggestions — outbound neighbours, prefer enables/extends/motivates,
+ // avoid parallel/unsolved_by but keep them as fallback. Up to 5.
+ _insightNextSuggestions(node, adj) {
+ if (!adj.out.length) {
+ return `
这条节点几乎没有显式衍生,请回到 锚点链路 标签页查看完整邻接。
`;
+ }
+ // Deduplicate destinations, keeping the most informative relation seen.
+ const relPriority = {
+ enables: 10, extends: 9, motivates: 8, composes: 7, validates: 6,
+ manifests: 5, covers: 4, feeds: 4, implements: 4, prereq: 3, contrasts: 3,
+ parallel: 2, unsolved_by: 1,
+ };
+ const best = new Map(); // other -> {rel, other}
+ for (const e of adj.out) {
+ const prev = best.get(e.other);
+ if (!prev || (relPriority[e.rel] || 0) > (relPriority[prev.rel] || 0)) {
+ best.set(e.other, { rel: e.rel, other: e.other });
+ }
+ }
+ // Pull nodes, attach importance score (degree).
+ const candidates = [];
+ for (const { rel, other } of best.values()) {
+ // unsolved_by goes to the open-problem block, not here.
+ if (rel === "unsolved_by") continue;
+ const o = this.byId.get(other);
+ if (!o) continue;
+ const deg = typeof o.degree === "number" ? o.degree : (this.adj.get(o.id)?.in?.length || 0);
+ candidates.push({ rel, o, deg, prio: relPriority[rel] || 0 });
+ }
+ if (!candidates.length) {
+ return `这条节点几乎没有显式衍生,请回到 锚点链路 标签页查看完整邻接。
`;
+ }
+ // Deterministic sort: relation priority desc, then degree desc, then id asc.
+ candidates.sort((a, b) => (b.prio - a.prio) || (b.deg - a.deg) || a.o.id.localeCompare(b.o.id));
+ const pick = candidates.slice(0, 5);
+ const lis = pick.map(({ rel, o }) => {
+ const reason = this._suggestionReason(rel);
+ return `${this._anchorTag(o)} — ${this._escape(reason)}`;
+ });
+ return `${lis.join("")}
`;
+ }
+
+ _suggestionReason(rel) {
+ return ({
+ enables: "建立在这条节点之上,把抽象推到具体工程",
+ extends: "顺这条节点继续深入",
+ motivates: "这条节点解释了为什么要研究它",
+ composes: "它从这条节点借了关键组件",
+ validates: "它把这条节点变成可验证的具体方案",
+ parallel: "并行思路,可作对照",
+ contrasts: "和这条节点形成对立,理解二者差异能澄清边界",
+ prereq: "先修前置,没读它读这条会卡住",
+ covers: "围绕这条节点展开讲解,便于快速建立全局图景",
+ feeds: "把这条节点的产出输给下一环,是衔接节奏的关键",
+ manifests: "在这条节点上具体显形,可看到抽象落到具体范例",
+ implements: "把这条节点写成可运行的代码或工程实现",
+ })[rel] || "由邻接关系给出的相关线索";
+ }
+
+ // 4) Open problems — outbound unsolved_by + outbound motivates → problem:*
+ _insightOpenProblems(node, adj) {
+ const seen = new Set();
+ const items = [];
+ for (const { rel, other } of adj.out) {
+ if (!["unsolved_by", "motivates"].includes(rel)) continue;
+ const o = this.byId.get(other);
+ if (!o) continue;
+ if (rel === "motivates" && o.kind !== "problem") continue;
+ if (seen.has(o.id)) continue;
+ seen.add(o.id);
+ const note = this._clipSentences(o.summary_zh || o.summary || "", 1, 30);
+ items.push({ o, note });
+ if (items.length >= 3) break;
+ }
+ if (!items.length) {
+ return `没有显式的 unsolved_by / motivates 边指向待解问题。
`;
+ }
+ const lis = items.map(({ o, note }) => {
+ const tail = note ? ` — ${this._escape(note)}` : "";
+ return `${this._anchorTag(o)}${tail}`;
+ });
+ return ``;
+ }
+
+ _anchorTag(node) {
+ const label = node.label_zh || node.label || node.id;
+ return `${this._escape(label)}`;
+ }
+
+ // Clip free text to roughly N sentences but no more than maxChars characters.
+ // Splits on Chinese full-width punctuation as well as ASCII sentence-enders.
+ _clipSentences(text, n, maxChars) {
+ const s = String(text || "").replace(/\s+/g, " ").trim();
+ if (!s) return "";
+ const re = /[^。!?!?\.]+[。!?!?\.]?/g;
+ const parts = s.match(re) || [s];
+ let acc = "";
+ for (let i = 0; i < parts.length && i < n; i++) {
+ const next = (acc + parts[i]).trim();
+ if (next.length > maxChars) {
+ // Take what fits.
+ if (!acc) acc = next.slice(0, maxChars);
+ break;
+ }
+ acc = next;
+ }
+ if (!acc) acc = s.slice(0, maxChars);
+ if (acc.length > maxChars) acc = acc.slice(0, maxChars);
+ return acc;
+ }
+
+ // Internal mirror of tools/audit_card_meta_language.py's HARD_BANS list.
+ // Used to flag any leakage in synthesised text — never displayed to the
+ // user; only logs to console for developer attention.
+ _hasMetaLeakage(html) {
+ const HARD_BANS = [
+ "本卡片","本节将","本节回答","本节聚焦","本节旨在","本文档",
+ "为了让读者","为了让初学者","为了让新手","为了让资深",
+ "顶刊审稿","顶刊文章","新入门的研究者","资深科研",
+ "0 到 1","0到1","1 到 10000","1到10000",
+ "学习地图","超级学科星图","对话广场","推演星空","本卡片旨在",
+ "as a top-tier reviewer","as a top journal reviewer",
+ "for beginners and experts","this card will","this section will",
+ ];
+ for (const phrase of HARD_BANS) {
+ if (html.includes(phrase)) return true;
+ }
+ return false;
+ }
}