dsh-plugin-show-me-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The panel's module-level store (docs/05 §2).
3
+ *
4
+ * State lives here rather than in component state because the overlay entry can
5
+ * be remounted at any time: facts must belong to whoever can also close them. The
6
+ * store is a plain observable with a tiny subscription API, which makes it
7
+ * testable without React.
8
+ *
9
+ * @module client/store
10
+ */
11
+
12
+ /** The initial state. A single object keeps snapshots comparable by identity. */
13
+ export const INITIAL_STATE = {
14
+ open: false,
15
+ tab: 'today',
16
+ range: '1Y',
17
+ groups: ['US', 'CN', 'GLOBAL', 'CUSTOM'],
18
+ status: 'idle',
19
+ lastUpdatedAt: undefined,
20
+ error: undefined,
21
+ overview: undefined,
22
+ detail: undefined,
23
+ detailLoading: false,
24
+ // Bar size for OHLC sources: 'day' | 'week' | 'month'.
25
+ barSize: 'day',
26
+ ai: { mode: 'deterministic', text: '', streaming: false, result: undefined, error: undefined },
27
+ discussing: false,
28
+ discussingId: undefined,
29
+ discussError: undefined,
30
+ discussSession: undefined,
31
+ discussAnswer: undefined,
32
+ discussPending: false,
33
+ /** The question this discussion's turn asked, and whether the panel asked it. */
34
+ discussQuestion: undefined,
35
+ discussAutoQuestion: false,
36
+ /** Multi-select: indicator ids picked for one combined analysis. */
37
+ selected: [],
38
+ selectionLoading: false,
39
+ watchlist: [],
40
+ // Filled from '/settings' + '/health' when the settings tab opens; the panel
41
+ // shows a loading line until then rather than invented defaults.
42
+ settings: { sources: [] },
43
+ health: undefined,
44
+ /** Connection-test state for the settings screen. */
45
+ testing: false,
46
+ testingId: undefined,
47
+ testResult: undefined,
48
+ unreachable: false,
49
+ }
50
+
51
+ /**
52
+ * Create a store.
53
+ *
54
+ * @param {object} [initial] - initial state overrides.
55
+ * @returns {object} store handle.
56
+ */
57
+ export function createStore(initial = {}) {
58
+ let state = { ...INITIAL_STATE, ...initial }
59
+ /** @type {Set<(state: object, prev: object) => void>} */
60
+ const listeners = new Set()
61
+
62
+ /**
63
+ * Subscribe to state changes.
64
+ *
65
+ * @param {(state: object, prev: object) => void} listener - listener.
66
+ * @returns {() => void} unsubscribe.
67
+ */
68
+ function subscribe(listener) {
69
+ listeners.add(listener)
70
+ return () => listeners.delete(listener)
71
+ }
72
+
73
+ /**
74
+ * Read the current state (stable identity between changes).
75
+ *
76
+ * @returns {object} state.
77
+ */
78
+ function getState() {
79
+ return state
80
+ }
81
+
82
+ /**
83
+ * Apply a patch (object or updater) and notify listeners.
84
+ *
85
+ * @param {object|((state: object) => object)} patch - patch.
86
+ * @returns {object} the new state.
87
+ */
88
+ function setState(patch) {
89
+ const prev = state
90
+ const next = typeof patch === 'function' ? patch(state) : patch
91
+ if (next === undefined || next === null) return state
92
+ // A no-op patch must not notify: components would re-render for nothing.
93
+ const changed = Object.keys(next).some((key) => next[key] !== state[key])
94
+ if (!changed) return state
95
+ state = { ...state, ...next }
96
+ for (const listener of listeners) listener(state, prev)
97
+ return state
98
+ }
99
+
100
+ return { getState, setState, subscribe, reset: () => setState({ ...INITIAL_STATE }) }
101
+ }
102
+
103
+ /**
104
+ * Merge a fresh overview payload into the state.
105
+ *
106
+ * @param {object} state - current state.
107
+ * @param {object} payload - '/overview' response.
108
+ * @returns {object} state patch.
109
+ */
110
+ export function applyOverview(state, payload) {
111
+ return {
112
+ status: 'ready',
113
+ overview: payload,
114
+ lastUpdatedAt: payload.generatedAt,
115
+ error: undefined,
116
+ unreachable: false,
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Merge a fresh detail payload into the state.
122
+ *
123
+ * @param {object} state - current state.
124
+ * @param {object} payload - '/series' response.
125
+ * @returns {object} state patch.
126
+ */
127
+ export function applyDetail(state, payload) {
128
+ return { detail: payload, detailLoading: false, error: undefined }
129
+ }
130
+
131
+ /**
132
+ * Forget everything that belonged to the *previous* indicator's answer.
133
+ *
134
+ * The AI block, the question box and a discussion are all about one indicator.
135
+ * Keeping them when the reader selects another card showed one indicator's
136
+ * answer (and its "open in a new session" note) under a different indicator's
137
+ * numbers, which reads as the panel answering the wrong question.
138
+ *
139
+ * @param {object} state - current state.
140
+ * @param {string|undefined} indicatorId - the indicator now selected.
141
+ * @returns {object} state patch.
142
+ */
143
+ export function applyIndicatorChange(state, indicatorId) {
144
+ if (state.detail?.indicatorId === indicatorId) return {}
145
+ return {
146
+ indicatorId,
147
+ ai: { ...INITIAL_STATE.ai },
148
+ discussing: false,
149
+ discussingId: undefined,
150
+ discussError: undefined,
151
+ discussSession: undefined,
152
+ discussAnswer: undefined,
153
+ discussPending: false,
154
+ discussQuestion: undefined,
155
+ discussAutoQuestion: false,
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Merge an SSE text chunk into the streaming answer.
161
+ *
162
+ * @param {object} state - current state.
163
+ * @param {string} chunk - text chunk.
164
+ * @returns {object} state patch.
165
+ */
166
+ export function applyAiText(state, chunk) {
167
+ return { ai: { ...state.ai, streaming: true, text: `${state.ai.text}${chunk}` } }
168
+ }
169
+
170
+ /**
171
+ * Merge a finished AI result.
172
+ *
173
+ * @param {object} state - current state.
174
+ * @param {object} result - validated 'AiResult'.
175
+ * @returns {object} state patch.
176
+ */
177
+ export function applyAiResult(state, result) {
178
+ return {
179
+ ai: {
180
+ ...state.ai,
181
+ streaming: false,
182
+ result,
183
+ text: result?.markdown ?? state.ai.text,
184
+ mode: result?.mode ?? state.ai.mode,
185
+ error: undefined,
186
+ },
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Record a failed request.
192
+ *
193
+ * @param {object} state - current state.
194
+ * @param {object} error - structured error.
195
+ * @param {{ unreachable?: boolean }} [options] - options.
196
+ * @returns {object} state patch.
197
+ */
198
+ export function applyError(state, error, { unreachable = false } = {}) {
199
+ return {
200
+ status: unreachable ? 'unreachable' : 'error',
201
+ error,
202
+ unreachable,
203
+ detailLoading: false,
204
+ // Bar size for OHLC sources: 'day' | 'week' | 'month'.
205
+ barSize: 'day',
206
+ ai: { ...state.ai, streaming: false, error: undefined },
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Whether the panel should show its "some sources failed" banner.
212
+ *
213
+ * @param {object} state - state.
214
+ * @returns {boolean} banner visibility.
215
+ */
216
+ export function shouldShowDegradedBanner(state) {
217
+ if (state.overview === undefined) return false
218
+ return state.overview.degraded === true || (state.overview.errors ?? []).length > 0
219
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Prompt construction — four pure functions (docs/06 §2).
3
+ *
4
+ * The rules the model is given are deliberately strict because the panel must
5
+ * never present an unsourced number: only '<data>' values may be cited, every
6
+ * conclusion carries '[id@date, value]', and insufficient data is a valid answer.
7
+ *
8
+ * @module core/ai/prompts
9
+ */
10
+
11
+ /** Default output budget for every prompt. */
12
+ export const MAX_CHARS = 1200
13
+
14
+ /** Default section budget. */
15
+ export const MAX_SECTIONS = 6
16
+
17
+ /** The shared system prompt (docs/06 §2.1). */
18
+ export const SYSTEM_PROMPT = `你是宏观数据分析助手。你的唯一数据来源是下面 <data> 块中提供的观测值。
19
+
20
+ 硬性规则:
21
+ 1. 只能引用 <data> 中出现过的指标 ID、日期与数值。禁止引入 <data> 之外的数据、事件或新闻。
22
+ 2. 每个结论句后面用 [指标ID@日期, 值] 的形式标注依据。
23
+ 3. 如果 <data> 不足以回答问题,直接说明"数据不足"并列出缺少什么,不要推测。
24
+ 4. 区分事实与解释:先用一句话陈述数据事实,再给出可能解释,并明确标注为"可能"。
25
+ 5. 不做点位预测,不给买卖建议,不使用"必然/一定/肯定"等绝对措辞。
26
+ 6. 单位与口径必须与 <data> 一致(% 就是 %,不要写成小数)。
27
+ 7. 输出中文,Markdown,最多 {maxSections} 个要点,总长不超过 {maxChars} 字。`
28
+
29
+ /**
30
+ * Render the system prompt with its budgets filled in.
31
+ *
32
+ * @param {{ maxChars?: number, maxSections?: number }} [options] - budgets.
33
+ * @returns {string} system prompt.
34
+ */
35
+ export function systemPrompt({ maxChars = MAX_CHARS, maxSections = MAX_SECTIONS } = {}) {
36
+ return SYSTEM_PROMPT.replace('{maxSections}', String(maxSections)).replace('{maxChars}', String(maxChars))
37
+ }
38
+
39
+ /**
40
+ * Wrap the digest in its '<data>' block. An empty digest says so explicitly, so
41
+ * the model cannot read an empty block as "no constraints".
42
+ *
43
+ * @param {string} digest - digest text.
44
+ * @returns {string} data block.
45
+ */
46
+ export function dataBlock(digest) {
47
+ const body = typeof digest === 'string' && digest.trim() !== '' ? digest.trim() : '(无数据:本次没有任何可用的指标观测)'
48
+ return `<data>\n${body}\n</data>`
49
+ }
50
+
51
+ /**
52
+ * The scenario prompts, as pure functions of structured input.
53
+ *
54
+ * @param {object} input - scenario input.
55
+ * @param {string} [input.digest] - '<data>' content.
56
+ * @param {string} [input.indicatorId] - indicator for 'explain'.
57
+ * @param {string} [input.label] - indicator label for 'explain'.
58
+ * @param {{ from: string, to: string }} [input.range] - range.
59
+ * @param {string} [input.question] - user question for 'answer'.
60
+ * @param {string} [input.scope] - scope label for 'overview'.
61
+ * @param {string} [input.request] - natural-language request for 'propose'.
62
+ * @param {string[]} [input.violations] - validator feedback for a retry.
63
+ * @param {string[]} [input.supportedAdapters] - adapters 'propose' may use.
64
+ * @returns {{ system: string, user: string }} messages.
65
+ */
66
+ export function buildExplainPrompt({ digest, indicatorId, label, range, violations }) {
67
+ const lines = []
68
+ lines.push(dataBlock(digest))
69
+ lines.push('')
70
+ lines.push(`任务:解析指标 ${indicatorId ?? '(未指定)'}${label === undefined ? '' : `(${label})`}`)
71
+ if (range !== undefined) lines.push(`时间范围:${range.from} .. ${range.to}`)
72
+ lines.push('输出 5 段:① 这个指标是什么(口径、单位、频率)② 本期读数意味着什么 ③ 与历史对比(分位/极值/趋势)④ 可能影响的资产或政策(标注"可能")⑤ 反向风险。')
73
+ lines.push(...retryLines(violations))
74
+ return { system: systemPrompt(), user: lines.join('\n') }
75
+ }
76
+
77
+ /**
78
+ * @param {object} input - scenario input.
79
+ * @returns {{ system: string, user: string }} messages.
80
+ */
81
+ export function buildSummaryPrompt({ digest, range, violations }) {
82
+ const lines = []
83
+ lines.push(dataBlock(digest))
84
+ lines.push('')
85
+ lines.push('任务:给出一段「时段总结」。')
86
+ if (range !== undefined) lines.push(`时间范围:${range.from} .. ${range.to}`)
87
+ lines.push('输出 4 段,每段 1–3 条:① 关键变化 ② 相互印证 ③ 背离与矛盾 ④ 下一个该盯的数据。')
88
+ lines.push(...retryLines(violations))
89
+ return { system: systemPrompt(), user: lines.join('\n') }
90
+ }
91
+
92
+ /**
93
+ * The whole-panel analysis prompt: "what is going on", not "what does this mean".
94
+ *
95
+ * It names the scope and the counts so the model can say "本组 12 个指标中 3 个
96
+ * 触发规则" instead of guessing at coverage, and it asks for cross-indicator
97
+ * structure — corroboration, divergence, and what to watch — because a summary
98
+ * that only restates each series adds nothing to the panel the reader already sees.
99
+ *
100
+ * @param {object} input - scenario input.
101
+ * @param {string} input.digest - '<data>' content.
102
+ * @param {string} [input.scope] - human scope label ('美国', '全部指标', …).
103
+ * @param {{ from: string, to: string }} [input.range] - range.
104
+ * @param {number} [input.indicatorCount] - observables in scope.
105
+ * @param {number} [input.noteworthyCount] - attention rules triggered in scope.
106
+ * @param {string[]} [input.violations] - feedback from the previous attempt.
107
+ * @returns {{ system: string, user: string }} messages.
108
+ */
109
+ export function buildOverviewPrompt({ digest, scope, range, indicatorCount, noteworthyCount, violations }) {
110
+ const lines = []
111
+ lines.push(dataBlock(digest))
112
+ lines.push('')
113
+ lines.push(`任务:给出「${scope ?? '整体'}」的总体分析。`)
114
+ if (range !== undefined) lines.push(`时间范围:${range.from} .. ${range.to}`)
115
+ if (Number.isFinite(indicatorCount)) lines.push(`本范围指标数:${indicatorCount}`)
116
+ if (Number.isFinite(noteworthyCount)) lines.push(`其中触发关注规则:${noteworthyCount}`)
117
+ lines.push('输出 5 段,每段 1–3 条,全部基于 <data>:')
118
+ lines.push('① 总体态势:这段区间内哪些指标在动、方向是否一致,先用事实句概括。')
119
+ lines.push('② 关键变化:列出变化幅度最大或触发规则的指标,每条带 [指标ID@日期, 值]。')
120
+ lines.push('③ 相互印证:哪些指标互相支持同一结论。')
121
+ lines.push('④ 背离与矛盾:哪些指标方向相反或口径不一致,需要同时看。')
122
+ lines.push('⑤ 下一个该盯的数据:指出本范围里最重要的待更新项及其滞后天数。')
123
+ lines.push('不要逐个复述指标定义;读者已经能看到面板上的单项数据。')
124
+ lines.push(...retryLines(violations))
125
+ return { system: systemPrompt(), user: lines.join('\n') }
126
+ }
127
+
128
+ /**
129
+ * @param {object} input - scenario input.
130
+ * @returns {{ system: string, user: string }} messages.
131
+ */
132
+ export function buildAnswerPrompt({ digest, question, range, violations }) {
133
+ const lines = []
134
+ lines.push(dataBlock(digest))
135
+ lines.push('')
136
+ lines.push(`用户问题:${typeof question === 'string' && question.trim() !== '' ? question.trim() : '(未提供问题)'}`)
137
+ if (range !== undefined) lines.push(`可用时间范围:${range.from} .. ${range.to}`)
138
+ lines.push('输出结构:结论文(≤3 句)→ 依据列表(每条带 [指标ID@日期, 值])→ 数据边界说明(哪些问题数据无法回答)。')
139
+ lines.push('只回答被问到的内容;问题超出 <data> 覆盖范围时,直接回答"数据不足"并说明缺什么。')
140
+ lines.push(...retryLines(violations))
141
+ return { system: systemPrompt(), user: lines.join('\n') }
142
+ }
143
+
144
+ /**
145
+ * 'propose' is a structured task: JSON out, no free text (docs/06 §4).
146
+ *
147
+ * @param {object} input - scenario input.
148
+ * @returns {{ system: string, user: string }} messages.
149
+ */
150
+ export function buildProposePrompt({ request, supportedAdapters = [], knownIds = [], violations }) {
151
+ const lines = []
152
+ lines.push('任务:把用户的自然语言需求解析成指标定义候选,只输出 JSON。')
153
+ lines.push(`用户需求:${typeof request === 'string' && request.trim() !== '' ? request.trim() : '(空)'}`)
154
+ lines.push('')
155
+ lines.push('只能使用下列已注册数据源适配器,且 seriesRef 必须是你确知存在的标识:')
156
+ for (const adapter of supportedAdapters) lines.push(`- ${adapter}`)
157
+ lines.push('')
158
+ if (knownIds.length > 0) lines.push(`已存在(会判为冲突):${knownIds.slice(0, 40).join(', ')}`)
159
+ lines.push('')
160
+ lines.push('输出 JSON:{ "candidates": [ { "id", "label": {"zh","en"}, "unit", "freq", "group", "importance", "source": {"adapter","seriesRef","params"}, "display": {"transform","decimals","polarity"}, "confidence", "rationale" } ], "unsupported": [ { "request", "reason", "alternatives": [] } ] }')
161
+ lines.push('规则:不确定的 seriesRef 一律放进 unsupported 并说明原因,绝对不要猜一个看起来合理的 ID。')
162
+ lines.push(...retryLines(violations))
163
+ return { system: systemPrompt({ maxSections: 1, maxChars: 4000 }), user: lines.join('\n') }
164
+ }
165
+
166
+ /**
167
+ * Feedback appended when the validator rejected a previous answer.
168
+ *
169
+ * @param {string[]|undefined} violations - violations from 'app/ai-validate.js'.
170
+ * @returns {string[]} lines (empty when there were none).
171
+ */
172
+ function retryLines(violations) {
173
+ if (!Array.isArray(violations) || violations.length === 0) return []
174
+ return [
175
+ '',
176
+ '上一次输出未通过溯源校验,请重写。问题清单:',
177
+ ...violations.slice(0, 10).map((entry) => `- ${entry}`),
178
+ '要求:只保留能在 <data> 中找到依据的结论,无法溯源的数字与断言一律删除。',
179
+ ]
180
+ }
181
+
182
+ /**
183
+ * The question a discussion asks when the reader clicks the button without
184
+ * typing one.
185
+ *
186
+ * A discussion opened with *only* context never runs a turn: the message waits in
187
+ * the session inbox for a turn that nothing starts, the session opens empty, and
188
+ * the panel — polling for an answer that cannot exist — reports "会话已结束但没有
189
+ * 文本回答(可能是模型未配置或请求失败)". The reader clicked a button labelled
190
+ * 「讨论」, so the honest behaviour is to ask something; the panel shows which
191
+ * question it asked, and the reader can continue in the session from there.
192
+ *
193
+ * @param {{ subject?: 'indicator'|'group'|'noteworthy', label?: string }} [options] - subject.
194
+ * @returns {string} the opening question.
195
+ */
196
+ export function buildDefaultQuestion({ subject = 'indicator', label } = {}) {
197
+ const name = typeof label === 'string' && label.trim() !== '' ? label.trim() : '这些数据'
198
+ if (subject === 'group') {
199
+ return `请基于上下文中的面板数据回答:${name}目前整体处于什么状态?最值得关注的变化有哪些?它们之间如何相互印证或背离?接下来该盯哪些数据?`
200
+ }
201
+ if (subject === 'noteworthy') {
202
+ return `这条「值得关注」为什么会被触发?请基于上下文中的面板数据说明 ${name} 当前的含义,以及接下来该盯什么。`
203
+ }
204
+ return `请基于上下文中的面板数据回答:${name}当前处于什么位置?最近的变化说明了什么?接下来该盯哪些相关数据?`
205
+ }
206
+
207
+ /** The four scenario builders, keyed by operation. */
208
+ export const PROMPT_BUILDERS = {
209
+ explain: buildExplainPrompt,
210
+ summarize: buildSummaryPrompt,
211
+ answer: buildAnswerPrompt,
212
+ propose: buildProposePrompt,
213
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Axis label geometry (docs/05 §4.1).
3
+ *
4
+ * @module core/chart/axis
5
+ */
6
+ import { box } from './line.js'
7
+ import { linearScale, niceTicks } from './scale.js'
8
+
9
+ /**
10
+ * Build y-axis tick labels.
11
+ *
12
+ * @param {{ min: number, max: number }} yDomain - y domain.
13
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, ticks?: number, decimals?: number, suffix?: string }} geometry - chart geometry.
14
+ * @returns {Array<{ y: number, value: number, label: string }>} tick entries.
15
+ */
16
+ export function buildYAxis(yDomain, geometry) {
17
+ const { innerH, padY } = box({ width: geometry.width ?? 1, height: geometry.height, padding: geometry.padding, paddingX: geometry.paddingX, paddingY: geometry.paddingY })
18
+ const decimals = geometry.decimals ?? 2
19
+ const suffix = geometry.suffix ?? ''
20
+ // No explicit count → derive "nice" 1/2/5×10ⁿ ticks from the domain; an
21
+ // explicit count → evenly spaced ticks inside the given domain.
22
+ const values = geometry.ticks === undefined
23
+ ? niceTicks(yDomain.min, yDomain.max, 5).ticks.filter((t) => t >= yDomain.min && t <= yDomain.max)
24
+ : buildEvenTicks(yDomain, geometry.ticks)
25
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
26
+ return values.map((value) => ({
27
+ y: Number(y(value).toFixed(2)),
28
+ value,
29
+ label: `${Number(value.toFixed(decimals))}${suffix}`,
30
+ }))
31
+ }
32
+
33
+ /**
34
+ * Evenly spaced ticks across a domain (used when the caller fixes the count).
35
+ *
36
+ * @param {{ min: number, max: number }} domain - domain.
37
+ * @param {number} count - tick count.
38
+ * @returns {number[]} tick values.
39
+ */
40
+ function buildEvenTicks(domain, count) {
41
+ if (count <= 1) return [domain.min]
42
+ const step = (domain.max - domain.min) / (count - 1)
43
+ return Array.from({ length: count }, (_, i) => Number((domain.min + step * i).toFixed(12)))
44
+ }
45
+
46
+ /**
47
+ * Build x-axis tick labels from date strings.
48
+ *
49
+ * @param {string[]} dates - ascending 'YYYY-MM-DD' values.
50
+ * @param {{ width: number, padding?: number, paddingX?: number, count?: number }} geometry - chart geometry.
51
+ * @returns {Array<{ x: number, label: string, t: string }>} tick entries.
52
+ */
53
+ export function buildXAxis(dates, geometry) {
54
+ if (!Array.isArray(dates) || dates.length === 0) return []
55
+ const { innerW, padX } = box({ width: geometry.width, height: 1, padding: geometry.padding, paddingX: geometry.paddingX })
56
+ const wanted = Math.min(geometry.count ?? 4, dates.length)
57
+ const step = dates.length <= wanted ? 1 : (dates.length - 1) / (wanted - 1)
58
+ const picked = []
59
+ for (let i = 0; i < wanted; i += 1) picked.push(Math.round(i * step))
60
+ const unique = [...new Set(picked)].filter((i) => i >= 0 && i < dates.length)
61
+ const span = Math.max(1, dates.length - 1)
62
+ return unique.map((index) => ({
63
+ x: Number((padX + (index / span) * innerW).toFixed(2)),
64
+ label: formatAxisDate(dates[index]),
65
+ t: dates[index],
66
+ }))
67
+ }
68
+
69
+ /**
70
+ * Short axis date: 'MM-DD' inside one year, 'YYYY-MM' across years.
71
+ *
72
+ * @param {string} date - 'YYYY-MM-DD'.
73
+ * @returns {string} axis label.
74
+ */
75
+ export function formatAxisDate(date) {
76
+ return `${date.slice(5, 7)}-${date.slice(8, 10)}`
77
+ }
78
+
79
+ /**
80
+ * Axis date label that stays unambiguous across a multi-year window.
81
+ *
82
+ * A daily chart spanning years labelled only 'MM-DD' reads as repeating dates,
83
+ * so the month is enough inside one year and the year is shown once the window
84
+ * crosses a boundary (docs/05 §4.2).
85
+ *
86
+ * @param {string} date - 'YYYY-MM-DD'.
87
+ * @param {{ crossesYears?: boolean }} [options] - options.
88
+ * @returns {string} axis label.
89
+ */
90
+ export function formatSpanDate(date, options = {}) {
91
+ return options.crossesYears === true ? `${date.slice(0, 4)}-${date.slice(5, 7)}` : formatAxisDate(date)
92
+ }
93
+
94
+ /**
95
+ * X ticks for a time series drawn with the shared plot box.
96
+ *
97
+ * Placed by index rather than by value, because the chart is index-spaced (a
98
+ * weekend must not open a gap), and it always keeps the first and last point so
99
+ * the axis states the window it covers.
100
+ *
101
+ * @param {string[]} dates - ascending 'YYYY-MM-DD' values.
102
+ * @param {{ width: number, height?: number, paddingX?: number, paddingY?: number, padding?: number, count?: number }} geometry - chart geometry.
103
+ * @returns {Array<{ x: number, label: string, t: string, anchor: 'start'|'middle'|'end' }>} ticks.
104
+ */
105
+ export function buildTimeAxis(dates, geometry) {
106
+ if (!Array.isArray(dates) || dates.length === 0) return []
107
+ const { padX, innerW } = box({
108
+ width: geometry.width,
109
+ height: geometry.height ?? 1,
110
+ padding: geometry.padding,
111
+ paddingX: geometry.paddingX,
112
+ paddingY: geometry.paddingY,
113
+ padLeft: geometry.padLeft,
114
+ padRight: geometry.padRight,
115
+ padX: geometry.padX,
116
+ innerW: geometry.innerW,
117
+ })
118
+ const wanted = Math.min(Math.max(2, geometry.count ?? 5), dates.length)
119
+ const crossesYears = dates[0].slice(0, 4) !== dates[dates.length - 1].slice(0, 4)
120
+ const span = dates.length - 1
121
+ const picked = new Set([0, span])
122
+ for (let i = 1; i < wanted - 1; i += 1) picked.add(Math.round((i / (wanted - 1)) * span))
123
+ return [...picked]
124
+ .sort((a, b) => a - b)
125
+ .map((index) => ({
126
+ index,
127
+ x: Number((padX + (span === 0 ? innerW / 2 : (index / span) * innerW)).toFixed(2)),
128
+ t: dates[index],
129
+ label: formatSpanDate(dates[index], { crossesYears }),
130
+ // Edge labels are anchored inward so they never run off the plot.
131
+ anchor: index === 0 ? 'start' : index === span ? 'end' : 'middle',
132
+ }))
133
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Bar-chart rectangles and axis geometry (docs/05 §4.1).
3
+ *
4
+ * @module core/chart/bar
5
+ */
6
+ import { box } from './line.js'
7
+ import { extent, linearScale } from './scale.js'
8
+
9
+ /**
10
+ * Build bar rectangles for a column chart. Bars are anchored to the zero line
11
+ * when the domain crosses zero, so positive and negative values read correctly.
12
+ *
13
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
14
+ * @param {{ width: number, height: number, padding?: number, paddingX?: number, paddingY?: number, yDomain?: { min: number, max: number }, gap?: number }} geometry - chart geometry.
15
+ * @returns {Array<{ x: number, y: number, w: number, h: number, t: string, v: number }>} rectangles.
16
+ */
17
+ export function buildBarRects(points, geometry) {
18
+ if (!Array.isArray(points) || points.length === 0) return []
19
+ const { innerW, innerH, padX, padY } = box(geometry)
20
+ const finite = points.filter((p) => Number.isFinite(p?.v))
21
+ if (finite.length === 0) return []
22
+
23
+ const dataExtent = extent(finite.map((p) => p.v))
24
+ const yDomain = geometry.yDomain ?? { min: Math.min(0, dataExtent.min), max: Math.max(0, dataExtent.max) }
25
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
26
+ const gap = geometry.gap ?? 1
27
+ const slot = innerW / finite.length
28
+ const width = Math.max(1, slot - gap)
29
+ const zeroY = y(0)
30
+
31
+ return finite.map((point, index) => {
32
+ const valueY = y(point.v)
33
+ const top = Math.min(zeroY, valueY)
34
+ const height = Math.max(1, Math.abs(valueY - zeroY))
35
+ return {
36
+ x: Number((padX + index * slot + gap / 2).toFixed(2)),
37
+ y: Number(top.toFixed(2)),
38
+ w: Number(width.toFixed(2)),
39
+ h: Number(height.toFixed(2)),
40
+ t: point.t,
41
+ v: point.v,
42
+ }
43
+ })
44
+ }
45
+
46
+ /**
47
+ * The y coordinate of the zero line, or 'null' when zero is outside the domain.
48
+ *
49
+ * @param {{ min: number, max: number }} yDomain - y domain.
50
+ * @param {{ height: number, padding?: number, paddingY?: number }} geometry - chart geometry.
51
+ * @returns {number|null} zero-line y coordinate.
52
+ */
53
+ export function buildZeroLine(yDomain, geometry) {
54
+ const { innerH, padY } = box({ width: 1, height: geometry.height, padding: geometry.padding, paddingY: geometry.paddingY })
55
+ if (!(yDomain.min <= 0 && yDomain.max >= 0)) return null
56
+ const y = linearScale({ domain: [yDomain.min, yDomain.max], range: [padY + innerH, padY] })
57
+ return Number(y(0).toFixed(2))
58
+ }