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()