diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js
index 0f07ed9..4baeab5 100644
--- a/xiaobai-datahub/admin/app.js
+++ b/xiaobai-datahub/admin/app.js
@@ -162,6 +162,30 @@ function hm(iso) {
if (idx < 0 || s.length < idx + 6) return s.slice(0, 5) || '—';
return s.slice(idx + 1, idx + 6);
}
+/* 大白话错误摘要:主表只放一行短句,完整原文放 title 悬停提示。
+ 不猜业务结论,只做确定性文案映射 + 定长截断。 */
+function plainError(raw, max = 18) {
+ const s = String(raw || '');
+ if (!s) return '';
+ const rules = [
+ [/UNIQUE constraint failed/i, '重复数据写入冲突'],
+ [/circuit open|breaker/i, '熔断保护中'],
+ [/timeout|timed out|ETIMEDOUT/i, '请求超时'],
+ [/row ratio/i, '行数校验未达标'],
+ [/field gate/i, '字段完整率未达标'],
+ [/empty official batch|no_rows_returned|empty_or_incomplete/i, '上游返回空数据'],
+ [/release group not switched/i, '发布组未切换'],
+ [/missing_fields/i, '返回缺字段'],
+ [/data_age_exceeds/i, '数据过期超阈值'],
+ [/blocked|captcha|安全验证|访问异常/i, '被上游拦截'],
+ ];
+ for (const [re, text] of rules) if (re.test(s)) return text;
+ return s.length > max ? s.slice(0, max) + '…' : s;
+}
+/* 单行截断单元格:class ellip(styles.css 生产补充段),title 放全文 */
+function ellipHtml(text, color, fontSize, title) {
+ return `${esc(text)}`;
+}
const p2 = (x) => String(x).padStart(2, '0');
/* ================= sparkline(真实历史:来自 provider_call_log) ================= */
@@ -377,7 +401,7 @@ function heroHtml(vm) {
`` +
`
` +
kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') +
- kpiHtml('今日调用成功率', vm.successRate == null ? '—' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) +
+ kpiHtml('今日调用成功率', vm.successRate == null ? '—' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) +
kpiHtml('主站回退页面', `${vm.fallbackPages.length} 页`, vm.fallbackPages.length ? esc(vm.fallbackPages[0]) : '实时层主源正常', vm.fallbackPages.length ? 'amb' : 'txt') +
kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') +
`
`;
@@ -389,7 +413,7 @@ function datasetStripHtml(vm) {
let note = '';
if (d.time) note += `${esc(d.time)} · `;
if (d.rows) note += `${Number(d.rows).toLocaleString('en-US')} 行`;
- if (d.err) { const brief = String(d.err).length > 22 ? String(d.err).slice(0, 22) + '…' : d.err; note += `${esc(brief)}`; }
+ if (d.err) note += `${esc(plainError(d.err, 16))}`;
if (!d.time && !d.rows && !d.err) note = '今日尚未发布';
return `` +
`
${esc(d.name)}${pillHtml(d.bucket)}
` +
@@ -403,11 +427,11 @@ function observatoryHtml(vm) {
const right = `
${Object.keys(legendByProv).map((p) => `${ledHtml('ok')}${legendByProv[p]}`).join('')}`;
const rows = vm.observers.length ? vm.observers.map((o) => `
` +
`| ${esc(o.name)} | ` +
- `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'} | ` +
+ `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'} | ` +
`${ageHtml(o.lastOk)} | ` +
`${latHtml(o.lat, o.bucket !== 'ok')} | ` +
`${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)} | ` +
- `${pillHtml(o.bucket)}${o.note ? `${esc(o.note)}` : ''} | ` +
+ `${pillHtml(o.bucket)}${o.note ? ellipHtml(plainError(o.note, 20), '', '10px', o.note) : ''} | ` +
`
`).join('') : `
| 尚无真实观测记录(等待首次探测/调用) |
`;
const body = `
| 观察项 | 当前来源 | 缓存年龄 | 延迟 | 趋势 | 状态 · 备注 |
${rows}
`;
return panelHtml('实时观察层', right, body, false);
@@ -418,7 +442,7 @@ function siteImpactHtml(vm) {
const rows = bad.length ? bad.slice(0, 5).map((s) => `
` +
`| ${esc(s.page)} | ` +
`${pillHtml(s.bucket)} | ` +
- `${s.note ? esc(s.note) : '—'} | ` +
+ `${s.note ? ellipHtml(plainError(s.note, 22), '#54637e', '11px', s.note) : '—'} | ` +
` | ` +
`
`).join('') : `
| 全部页面正常 | ${pillHtml('ok')} | — | |
`;
const footPages = vm.siteImpact.map((s) => ({ name: s.short, bucket: s.bucket }));
@@ -479,7 +503,7 @@ function incidentsHtml(vm) {
return `
${tag}` +
`
` +
`
${esc(it.title)}
` +
- `
${meta}
` +
+ `
${meta}
` +
`
` +
`
` +
`
`;
@@ -589,7 +613,7 @@ function sourceCardHtml(card) {
`
接口 ${card.usedCount}/${card.totalCount} 在用` +
`
|最后探测 ${probeCell}` +
`
|延迟 ${latHtml(card.latency)}` +
- (card.error ? `
|最近错误 ${esc(card.error)}` : (card.bucket !== 'off' ? `
|无最近错误` : '')) +
+ (card.error ? `
|最近错误 ${ellipHtml(plainError(card.error, 26), '#f87171', '11px', card.error)}` : (card.bucket !== 'off' ? `
|无最近错误` : '')) +
`
`;
if (ui.open && card.bucket !== 'off') {
h += `` + card.groups.map((g) => `
` +
@@ -600,7 +624,7 @@ function sourceCardHtml(card) {
`
${esc(it.use)} | ` +
`${esc(it.ok)} | ` +
`${latHtml(it.lat)} | ` +
- `${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? `${esc(it.err)}` : ''} | ` +
+ `${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? ellipHtml(plainError(it.err, 16), '#f87171', '10px', it.err) : ''} | ` +
``).join('') +
``).join('') + `
`;
}
@@ -765,7 +789,7 @@ function lineageGraphHtml(vm) {
const p = gpos['s:' + s.id]; if (!p) return;
h += `` +
`` +
- `` +
+ `` +
`${esc(s.name)}`;
});
vm.datasets.forEach((d) => {
@@ -838,7 +862,7 @@ function renderLinRows(vm) {
`${esc(r.freq)} | ` +
`${r.ok ? esc(hm(r.ok)) : '—'} | ` +
`${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'} | ` +
- `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(r.note)}` : ''} | ` +
+ `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? ellipHtml(plainError(r.note, 20), '#54637e', '10px', r.note) : ''} | ` +
``).join('') || `| 无匹配记录 · 调整筛选条件 |
`;
if (bodyN) bodyN.innerHTML = rows.map((r) => `` +
`| ${esc(r.page)}${esc(r.item)} | ` +
@@ -846,7 +870,7 @@ function renderLinRows(vm) {
`${esc(r.via)} | ` +
`${esc(r.freq)}${esc(r.role)} | ` +
`${r.ok ? esc(hm(r.ok)) : '—'}${r.lat != null ? r.lat + 'ms' : '—'} | ` +
- `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(r.note)}` : ''} | ` +
+ `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(plainError(r.note, 20))}` : ''} | ` +
`
`).join('') || `| 无匹配记录 · 调整筛选条件 |
`;
}
function lineageHtml() {
@@ -1212,6 +1236,16 @@ function paintSparks() {
}
const prevLat = new Map();
function flashChangedLatencies() {
+ document.querySelectorAll('[data-rate]').forEach((el) => {
+ const v = el.textContent;
+ const prev = prevLat.get(el);
+ if (prev !== undefined && prev !== v) {
+ el.classList.remove('flash');
+ void el.offsetWidth;
+ el.classList.add('flash');
+ }
+ prevLat.set(el, v);
+ });
document.querySelectorAll('[data-lat]').forEach((el) => {
const jid = el.closest('[data-jid]') ? el.closest('[data-jid]').dataset.jid : (el.closest('tr') ? 'row:' + [...el.closest('tr').children].indexOf(el) : '');
const v = el.dataset.lat;
@@ -1369,7 +1403,7 @@ async function startApp() {
renderPage(pageFromHash());
updateTape(); // 预装最近真实事件并开始滚动
updateClock();
- setInterval(() => { updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏
+ setInterval(() => { if (document.hidden) return; updateClock(); updateDynamics(); }, 200); // 打样 tick 节奏;页面隐藏时不做任何工作
await Poller.start();
}
diff --git a/xiaobai-datahub/admin/styles.css b/xiaobai-datahub/admin/styles.css
index 15bba78..0718cf4 100644
--- a/xiaobai-datahub/admin/styles.css
+++ b/xiaobai-datahub/admin/styles.css
@@ -375,6 +375,18 @@ body {
0% { transform: scale(.4); opacity: .8; }
80%, 100% { transform: scale(3.2); opacity: 0; }
}
+
+/* ---------- HEL-529 第二轮返工生产补充(布局鲁棒 + 错峰编排) ----------
+ 1) .ellip:表格/卡片里的长错误与备注统一单行截断,完整原文走 title;
+ 2) spark-end / LED 相位错峰:同一动画不同 delay,避免所有元素同频同步。
+ 均复用既有 token 与动效曲线,不新增缓动、不改打样规则。 ---------- */
+.ellip { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; max-width: 100%; display: inline-block; vertical-align: bottom; }
+.ellip:empty { display: none; }
+table.dtable tbody tr:nth-child(2n) .spark-end { animation-delay: -.7s; }
+table.dtable tbody tr:nth-child(3n) .spark-end { animation-delay: -.35s; }
+.ds-grid .dsc:nth-child(2n) .spark-end { animation-delay: -.5s; }
+.ds-grid .dsc:nth-child(3n) .spark-end { animation-delay: -.9s; }
+.ds-grid .dsc:nth-child(5n) .spark-end { animation-delay: -.2s; }
.lin-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid #1a2540; }
.lin-sel, .lin-q { background: #0c1220; border: 1px solid #243152; border-radius: 4px; font-size: 11px; color: #8b9bb4; padding: 6px 8px; outline: none; font-family: inherit; }
.lin-q { color: #d7e1f0; padding: 6px 10px; width: 220px; }
diff --git a/xiaobai-datahub/datahub/lineage.py b/xiaobai-datahub/datahub/lineage.py
index 669eb78..38aea0f 100644
--- a/xiaobai-datahub/datahub/lineage.py
+++ b/xiaobai-datahub/datahub/lineage.py
@@ -184,9 +184,31 @@ DATASETS: list[dict[str, Any]] = [
"tier": "licensed",
"update_freq": "按需调用",
"v1_endpoint": "/v1/query (api_name=ifind_wencai)",
- "primary_source": "ifind:smart_stock_picking",
+ "primary_source": "ifind:wencai",
"backup_source": None,
- "known_consumers": ["问师(自然语言选股,需 iFinD 凭证)"],
+ # Real call site: backend/features/pools/service.py:82 (wencai(query,"stock"))
+ # feeds 股票池's ifind_event_enrichment_v1 (涨停/炸板/跌停原因、首末
+ # 涨停时间、开板次数). 问师 does NOT call wencai anywhere — its main
+ # body is LLM + 主站市场快照 (mentor/service.py builds context from
+ # dashboard/popularity snapshots only).
+ "known_consumers": ["股票池(涨停/炸板/跌停事件补充 enrichment,需 iFinD 凭证)"],
+ },
+ {
+ "dataset": "ifind_history",
+ "tier": "licensed",
+ "update_freq": "问师问询时按需 · 45 日回看",
+ "v1_endpoint": "/v1/query (api_name=ifind_history)",
+ "primary_source": "ifind:history",
+ "backup_source": None,
+ # Real call site: backend/features/mentor/service.py:433-457
+ # (_mentor_market_matrix → ifind.history(close/volume/amount, 45 日回看)).
+ # Only the trend/macro thinking-model profiles use it, and only when
+ # iFinD is configured — it fails open to [] otherwise. 问师其余子能力
+ # (本体问答/低吸/人气上下文等) 不依赖 iFinD.
+ "known_consumers": [
+ "问师·趋势思维模型(指数动量矩阵,可选)",
+ "问师·宏观思维模型(宽基指数与核心ETF矩阵,可选)",
+ ],
},
]
diff --git a/xiaobai-datahub/tests/test_hel529_rework.py b/xiaobai-datahub/tests/test_hel529_rework.py
index 4109328..f20b52f 100644
--- a/xiaobai-datahub/tests/test_hel529_rework.py
+++ b/xiaobai-datahub/tests/test_hel529_rework.py
@@ -209,5 +209,52 @@ class SourceCatalogJoinTests(_Base):
self.assertTrue(item.get("update_freq"), f"missing update_freq for {item['dataset']}")
+class LineageMentorIfindTests(_Base):
+ """问师 → iFinD 血缘修正(第二轮返工):不按名称猜关系,按真实调用代码。"""
+
+ REPO = Path(__file__).resolve().parents[2]
+
+ def test_no_mentor_dependency_on_ifind_wencai(self) -> None:
+ items = self.hub.admin.lineage("20240902")["items"]
+ wencai = [i for i in items if i["dataset"] == "ifind_wencai"]
+ self.assertEqual(len(wencai), 1)
+ consumers = wencai[0]["known_consumers"]
+ for consumer in consumers:
+ self.assertNotIn("问师", consumer, f"wencai consumer must not be 问师: {consumer}")
+ all_consumers = " ".join(c for i in items for c in i["known_consumers"])
+ self.assertNotIn("问师(自然语言选股", all_consumers)
+
+ def test_wencai_real_consumer_is_pools_enrichment_with_code_evidence(self) -> None:
+ items = self.hub.admin.lineage("20240902")["items"]
+ wencai = [i for i in items if i["dataset"] == "ifind_wencai"][0]
+ self.assertTrue(any("股票池" in c for c in wencai["known_consumers"]))
+ # Real call evidence in the main-site source tree:
+ pools_src = (self.REPO / "backend" / "features" / "pools" / "service.py").read_text(encoding="utf-8")
+ self.assertIn("ifind.wencai(", pools_src)
+ self.assertIn("ifind_event_enrichment_v1", pools_src)
+ # And 问师 itself never calls wencai:
+ mentor_src = (self.REPO / "backend" / "features" / "mentor" / "service.py").read_text(encoding="utf-8")
+ self.assertNotIn(".wencai(", mentor_src)
+
+ def test_mentor_optional_ifind_history_subcapabilities(self) -> None:
+ items = self.hub.admin.lineage("20240902")["items"]
+ history = [i for i in items if i["dataset"] == "ifind_history"]
+ self.assertEqual(len(history), 1)
+ consumers = history[0]["known_consumers"]
+ self.assertTrue(any("趋势思维模型" in c for c in consumers))
+ self.assertTrue(any("宏观思维模型" in c for c in consumers))
+ # every consumer must be a 问师 sub-capability, not the whole board
+ for consumer in consumers:
+ self.assertIn("·", consumer, f"not a sub-capability mapping: {consumer}")
+ # Real call evidence: mentor builds market matrices via ifind.history
+ mentor_src = (self.REPO / "backend" / "features" / "mentor" / "service.py").read_text(encoding="utf-8")
+ self.assertIn("ifind.history(", mentor_src)
+ self.assertIn("_mentor_market_matrix", mentor_src)
+ self.assertIn("MENTOR_INDEX_UNIVERSE", mentor_src)
+ self.assertIn("MENTOR_ETF_UNIVERSE", mentor_src)
+ # Optional dependency: fails open when ifind is not configured
+ self.assertIn("if not ifind or not ifind.configured", mentor_src)
+
+
if __name__ == "__main__":
unittest.main()