Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/atlas3d.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
258 changes: 255 additions & 3 deletions docs/js/atlas-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(`<section class="insight-report" data-node-id="${this._escape(node.id)}">`);
html.push(`<h2>动态洞察 / Dynamic insight</h2>`);
html.push(`<p class="insight-summary">${summary}</p>`);
html.push(`<div class="insight-grid">`);
html.push(`<div class="insight-cell"><h4>这条节点为什么重要</h4><p>${importance}</p></div>`);
html.push(`<div class="insight-cell"><h4>下一步可读</h4>${suggestions}</div>`);
html.push(`<div class="insight-cell"><h4>开放问题</h4>${problems}</div>`);
html.push(`</div>`);
html.push(`</section>`);
// 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 `<p class="insight-empty">这条节点几乎没有显式衍生,请回到 锚点链路 标签页查看完整邻接。</p>`;
}
// 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 `<p class="insight-empty">这条节点几乎没有显式衍生,请回到 锚点链路 标签页查看完整邻接。</p>`;
}
// 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 `<li>${this._anchorTag(o)} — ${this._escape(reason)}</li>`;
});
return `<ol class="next-suggestions">${lis.join("")}</ol>`;
}

_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 `<p class="insight-empty">没有显式的 unsolved_by / motivates 边指向待解问题。</p>`;
}
const lis = items.map(({ o, note }) => {
const tail = note ? ` — ${this._escape(note)}` : "";
return `<li>${this._anchorTag(o)}${tail}</li>`;
});
return `<ul class="open-problems">${lis.join("")}</ul>`;
}

_anchorTag(node) {
const label = node.label_zh || node.label || node.id;
return `<a class="anchor-link" data-jump="${this._escape(node.id)}" href="#">${this._escape(label)}</a>`;
}

// 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;
}
}
Loading