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,196 @@
1
+ /**
2
+ * Catalog search — natural-language and fuzzy lookup (docs/02 §2,
3
+ * 'core/indicators/resolve.js').
4
+ *
5
+ * Used by the panel's "add indicator" box and by the model's 'data_search'
6
+ * tool, so it must be total: no input throws, an empty query returns the
7
+ * highest-importance indicators, and a miss returns suggestions instead of an
8
+ * error.
9
+ *
10
+ * @module core/indicators/resolve
11
+ */
12
+ import { CATALOG } from './catalog.js'
13
+
14
+ /** How much each kind of hit is worth before the importance factor. */
15
+ export const HIT_WEIGHTS = {
16
+ idExact: 100,
17
+ aliasExact: 80,
18
+ labelExact: 70,
19
+ aliasPrefix: 55,
20
+ labelPrefix: 45,
21
+ aliasPart: 30,
22
+ labelPart: 25,
23
+ tag: 18,
24
+ note: 10,
25
+ idPart: 8,
26
+ }
27
+
28
+ /**
29
+ * Normalize text for matching: lowercase, full-width digits and punctuation to
30
+ * ASCII, and whitespace collapsed.
31
+ *
32
+ * @param {string} text - input text.
33
+ * @returns {string} normalized text.
34
+ */
35
+ export function normalize(text) {
36
+ return String(text ?? '')
37
+ .toLowerCase()
38
+ .replace(/[\uFF01-\uFF5E]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
39
+ .replace(/[\s\-_/,.、,。]+/g, ' ')
40
+ .trim()
41
+ }
42
+
43
+ /**
44
+ * Split a query into searchable tokens: latin words, digits, and CJK bigrams
45
+ * (a single Chinese character alone is usually too broad to be a token).
46
+ *
47
+ * @param {string} text - normalized text.
48
+ * @returns {string[]} tokens.
49
+ */
50
+ export function tokenize(text) {
51
+ const normalized = normalize(text)
52
+ if (normalized === '') return []
53
+ const tokens = []
54
+ for (const part of normalized.split(' ')) {
55
+ if (part === '') continue
56
+ const cjk = /[\u4e00-\u9fff]/
57
+ if (cjk.test(part)) {
58
+ const chars = [...part]
59
+ // Single CJK characters are omitted on purpose: one character matches too
60
+ // many labels (曲/引/指 all appear somewhere) and turns a miss into noise.
61
+ // Bigrams are the useful unit for Chinese keyword matching.
62
+ for (let i = 0; i < chars.length - 1; i += 1) {
63
+ if (cjk.test(chars[i]) && cjk.test(chars[i + 1])) tokens.push(chars[i] + chars[i + 1])
64
+ }
65
+ if (chars.length === 1) tokens.push(chars[0])
66
+ } else {
67
+ tokens.push(part)
68
+ }
69
+ }
70
+ return [...new Set(tokens)]
71
+ }
72
+
73
+ /**
74
+ * Score one indicator against a query.
75
+ *
76
+ * @param {object} indicator - catalog entry.
77
+ * @param {string} rawQuery - user query.
78
+ * @returns {number} score (0 when nothing matches).
79
+ */
80
+ export function scoreIndicator(indicator, rawQuery) {
81
+ const query = normalize(rawQuery)
82
+ if (query === '') return 0
83
+ const compact = query.replace(/ /g, '')
84
+ let score = 0
85
+
86
+ if (normalize(indicator.id) === query || indicator.id.toLowerCase() === compact) score += HIT_WEIGHTS.idExact
87
+ if (indicator.id.toLowerCase().includes(compact)) score += HIT_WEIGHTS.idPart
88
+
89
+ const labels = [indicator.label?.zh, indicator.label?.en].filter(Boolean).map(normalize)
90
+ for (const label of labels) {
91
+ if (label === query || label.replace(/ /g, '') === compact) score += HIT_WEIGHTS.labelExact
92
+ else if (label.startsWith(query)) score += HIT_WEIGHTS.labelPrefix
93
+ else if (label.includes(query)) score += HIT_WEIGHTS.labelPart
94
+ }
95
+
96
+ for (const alias of indicator.aliases ?? []) {
97
+ const value = normalize(alias)
98
+ if (value === query) score += HIT_WEIGHTS.aliasExact
99
+ else if (value.startsWith(query)) score += HIT_WEIGHTS.aliasPrefix
100
+ else if (value.includes(query)) score += HIT_WEIGHTS.aliasPart
101
+ }
102
+
103
+ for (const token of tokenize(rawQuery)) {
104
+ if (token.length < 2) continue
105
+ for (const alias of indicator.aliases ?? []) {
106
+ if (normalize(alias).includes(token)) score += HIT_WEIGHTS.aliasPart
107
+ }
108
+ for (const label of labels) {
109
+ if (label.includes(token)) score += HIT_WEIGHTS.labelPart
110
+ }
111
+ for (const tag of indicator.tags ?? []) {
112
+ if (normalize(tag).includes(token)) score += HIT_WEIGHTS.tag
113
+ }
114
+ }
115
+ // Notes and units count only as corroboration: on their own they make a long
116
+ // "why this indicator exists" paragraph match almost any query.
117
+ if (score > 0) {
118
+ for (const token of tokenize(rawQuery)) {
119
+ if (token.length < 2) continue
120
+ if (normalize(indicator.notes?.zh ?? '').includes(token)) score += HIT_WEIGHTS.note
121
+ if (normalize(indicator.unit).includes(token)) score += HIT_WEIGHTS.note
122
+ }
123
+ }
124
+ return score
125
+ }
126
+
127
+ /**
128
+ * Search the catalog.
129
+ *
130
+ * @param {string} query - user query (may be empty).
131
+ * @param {{ catalog?: object[], limit?: number, groups?: string[], minImportance?: number }} [options] - search options.
132
+ * @returns {{ matches: Array<{ indicator: object, score: number }>, suggestions: string[] }} matches plus suggestions on a miss.
133
+ */
134
+ export function searchIndicators(query, { catalog = CATALOG, limit = 12, groups, minImportance } = {}) {
135
+ const pool = catalog.filter((indicator) => {
136
+ if (groups !== undefined && !groups.includes(indicator.group)) return false
137
+ if (minImportance !== undefined && indicator.importance < minImportance) return false
138
+ return true
139
+ })
140
+
141
+ if (normalize(query) === '') {
142
+ const matches = [...pool]
143
+ .sort((a, b) => b.importance - a.importance || a.id.localeCompare(b.id))
144
+ .slice(0, limit)
145
+ .map((indicator) => ({ indicator, score: indicator.importance }))
146
+ return { matches, suggestions: [] }
147
+ }
148
+
149
+ const scored = pool
150
+ .map((indicator) => ({ indicator, raw: scoreIndicator(indicator, query) }))
151
+ .filter((entry) => entry.raw > 0)
152
+
153
+ const matches = scored
154
+ .map((entry) => ({ indicator: entry.indicator, score: Math.round(entry.raw + entry.indicator.importance * 2) }))
155
+ .sort(
156
+ (a, b) =>
157
+ b.score - a.score ||
158
+ b.indicator.importance - a.indicator.importance ||
159
+ a.indicator.id.localeCompare(b.indicator.id),
160
+ )
161
+ .slice(0, limit)
162
+
163
+ if (matches.length > 0) return { matches, suggestions: [] }
164
+
165
+ const tokens = tokenize(query)
166
+ const suggestions = [...pool]
167
+ .map((indicator) => ({
168
+ indicator,
169
+ score: tokens.reduce(
170
+ (acc, token) =>
171
+ acc +
172
+ (normalize(indicator.label.zh).includes(token) || normalize(indicator.label.en).includes(token) ? 2 : 0) +
173
+ (indicator.tags ?? []).some((tag) => normalize(tag).includes(token)) ? 1 : 0,
174
+ 0,
175
+ ),
176
+ }))
177
+ .sort((a, b) => b.score - a.score || b.indicator.importance - a.indicator.importance)
178
+ .slice(0, 5)
179
+ .map((entry) => entry.indicator.id)
180
+ return { matches: [], suggestions }
181
+ }
182
+
183
+ /**
184
+ * Resolve one query to its single best indicator, for conversational flows.
185
+ *
186
+ * @param {string} query - user query.
187
+ * @param {object} [options] - search options.
188
+ * @returns {{ indicator: object|undefined, ambiguous: object[] }} best match and the runner-ups.
189
+ */
190
+ export function resolveIndicator(query, options = {}) {
191
+ const { matches } = searchIndicators(query, { ...options, limit: 5 })
192
+ if (matches.length === 0) return { indicator: undefined, ambiguous: [] }
193
+ const [best, ...rest] = matches
194
+ const ambiguous = rest.filter((entry) => entry.score === best.score).map((entry) => entry.indicator)
195
+ return { indicator: best.indicator, ambiguous }
196
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Build the AI's data digest — the '<data>' block that keeps the model on a
3
+ * token budget and on real numbers (docs/06 §2.3).
4
+ *
5
+ * Two invariants the tests pin:
6
+ * 1. the digest never exceeds its character budget, and when it truncates it
7
+ * **says so**, so the model knows it is looking at a subset;
8
+ * 2. every 'importance = 5' indicator makes it in regardless of score.
9
+ *
10
+ * @module core/insight/digest
11
+ */
12
+ import { transformSuffix } from '../stats/series.js'
13
+
14
+ /** Default character budget for one digest (docs/06 §2.3). */
15
+ export const DIGEST_BUDGET = 8000
16
+
17
+ /** How many observations per indicator are sent when the range is long. */
18
+ export const DIGEST_POINTS = 12
19
+
20
+ /**
21
+ * Format a number for the model: fixed decimals, no float noise, and a
22
+ * thousands separator only above 10 000 so small values stay copyable.
23
+ *
24
+ * @param {number|undefined} value - value.
25
+ * @param {number} [decimals] - decimal places.
26
+ * @returns {string} formatted value (''n/a'' when absent).
27
+ */
28
+ export function formatNumber(value, decimals = 2) {
29
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 'n/a'
30
+ const fixed = value.toFixed(decimals)
31
+ const [int, fraction] = fixed.split('.')
32
+ const grouped = Math.abs(Number(int)) >= 10000 ? Number(int).toLocaleString('en-US') : int
33
+ return fraction === undefined ? grouped : `${grouped}.${fraction}`
34
+ }
35
+
36
+ /**
37
+ * Reduce a point series to at most 'count' representative points, always keeping
38
+ * the first, the last and the global extremes (docs/07 T5.3).
39
+ *
40
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
41
+ * @param {number} [count] - target point count.
42
+ * @returns {Array<{ t: string, v: number }>} downsampled points.
43
+ */
44
+ export function downsample(points, count = DIGEST_POINTS) {
45
+ if (!Array.isArray(points) || points.length <= count) return points ?? []
46
+ const first = points[0]
47
+ const last = points[points.length - 1]
48
+ let min = points[0]
49
+ let max = points[0]
50
+ for (const point of points) {
51
+ if (point.v < min.v) min = point
52
+ if (point.v > max.v) max = point
53
+ }
54
+ const keep = new Map()
55
+ keep.set(first.t, first)
56
+ keep.set(last.t, last)
57
+ keep.set(min.t, min)
58
+ keep.set(max.t, max)
59
+ const step = (points.length - 1) / (count - 1)
60
+ for (let i = 0; i < count; i += 1) {
61
+ const point = points[Math.round(i * step)]
62
+ if (point !== undefined) keep.set(point.t, point)
63
+ }
64
+ return [...keep.values()].sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0)).slice(0, count + 4)
65
+ }
66
+
67
+ /**
68
+ * One indicator's digest entry.
69
+ *
70
+ * @param {object} input - entry input.
71
+ * @param {object} input.indicator - catalog definition.
72
+ * @param {object} [input.stats] - computed stats.
73
+ * @param {Array<{ t: string, v: number }>} [input.points] - resolved points.
74
+ * @param {string} [input.status] - fresh/stale/error/missing.
75
+ * @param {object} [input.sourceRef] - provenance.
76
+ * @param {number} [input.score] - rule score (ordering hint only; never printed).
77
+ * @returns {string} one digest block.
78
+ */
79
+ export function indicatorBlock({ indicator, stats, points = [], status = 'fresh', sourceRef }) {
80
+ const decimals = indicator.display?.decimals ?? 2
81
+ const suffix = transformSuffix(indicator.display)
82
+ const lines = []
83
+ const label = `${indicator.label.zh} / ${indicator.label.en}`
84
+ lines.push(`- ${indicator.id} | ${label} | ${indicator.unit}${suffix} | ${indicator.freq} | importance=${indicator.importance} | status=${status}`)
85
+ if (stats !== undefined && stats !== null) {
86
+ lines.push(
87
+ ` latest=${formatNumber(stats.latest, decimals)} @${stats.latestAt}` +
88
+ (stats.prev === undefined ? '' : ` prev=${formatNumber(stats.prev, decimals)}`) +
89
+ (stats.changeAbs === undefined ? '' : ` Δ=${formatNumber(stats.changeAbs, decimals)}`) +
90
+ (stats.changePct === undefined ? '' : ` Δ%=${formatNumber(stats.changePct, 1)}`) +
91
+ (stats.yoy === undefined ? '' : ` yoy=${formatNumber(stats.yoy, 1)}%`) +
92
+ (stats.zScoreLatestChange === undefined ? '' : ` z=${formatNumber(stats.zScoreLatestChange, 1)}`) +
93
+ (stats.percentile === undefined ? '' : ` pctile=${formatNumber(stats.percentile, 2)}`),
94
+ )
95
+ lines.push(
96
+ ` range: mean=${formatNumber(stats.mean, decimals)} min=${formatNumber(stats.min, decimals)}` +
97
+ ` max=${formatNumber(stats.max, decimals)} stdDev=${formatNumber(stats.stdDev, decimals)}` +
98
+ ` points=${stats.count ?? points.length} missing=${stats.missingCount ?? 0}`,
99
+ )
100
+ } else {
101
+ lines.push(' stats: unavailable (数据不足)')
102
+ }
103
+ // No ranking score here: it orders the internal list and is not a panel
104
+ // measurement, so publishing it invited the model to quote an opaque number
105
+ // ("attentionScore=271.5") as if it were a reading. Ordering happens in
106
+ // `buildDigest` and does not need to appear in the text.
107
+ if (Array.isArray(points) && points.length > 0) {
108
+ const sampled = downsample(points, DIGEST_POINTS)
109
+ lines.push(` points: ${sampled.map((point) => `${point.t}:${formatNumber(point.v, decimals)}`).join(' ')}`)
110
+ }
111
+ if (sourceRef?.url) lines.push(` source=${sourceRef.url}`)
112
+ return lines.join('\n')
113
+ }
114
+
115
+ /**
116
+ * Build the whole digest.
117
+ *
118
+ * @param {Array<object>} entries - entries for {@link indicatorBlock}.
119
+ * @param {{ budget?: number, title?: string, range?: { from: string, to: string }, extra?: string }} [options] - digest options.
120
+ * @returns {{ text: string, chars: number, included: string[], omitted: string[], truncated: boolean }} digest.
121
+ */
122
+ export function buildDigest(entries, { budget = DIGEST_BUDGET, title = '可关注指标', range, extra } = {}) {
123
+ const guaranteed = []
124
+ const rest = []
125
+ for (const entry of entries) {
126
+ if ((entry.indicator?.importance ?? 0) >= 5) guaranteed.push(entry)
127
+ else rest.push(entry)
128
+ }
129
+ rest.sort(
130
+ (a, b) =>
131
+ (b.score ?? 0) - (a.score ?? 0) ||
132
+ (b.indicator?.importance ?? 0) - (a.indicator?.importance ?? 0) ||
133
+ String(a.indicator?.id ?? '').localeCompare(String(b.indicator?.id ?? '')),
134
+ )
135
+ const ordered = [...guaranteed, ...rest]
136
+
137
+ const header = []
138
+ header.push(`## ${title}`)
139
+ if (range !== undefined) header.push(`区间: ${range.from} .. ${range.to}`)
140
+ header.push(`指标数: ${entries.length}`)
141
+ if (extra) header.push(extra)
142
+
143
+ const blocks = ordered.map((entry) => indicatorBlock(entry))
144
+ const included = []
145
+ const omitted = []
146
+ let text = header.join('\n')
147
+
148
+ for (let i = 0; i < blocks.length; i += 1) {
149
+ const entry = ordered[i]
150
+ // Reserve room for the trailing omission note so a truncation is always
151
+ // visible to the model (docs/06 §2.3 rule 2). The reserve is charged on
152
+ // every block, including the last: the note is appended after the loop, and
153
+ // a loop that only ever skips the final block would still need the room.
154
+ const reserve = 240
155
+ if (text.length + blocks[i].length + reserve + 1 > budget) {
156
+ omitted.push(entry.indicator.id)
157
+ continue
158
+ }
159
+ text += `\n${blocks[i]}`
160
+ included.push(entry.indicator.id)
161
+ }
162
+
163
+ if (omitted.length > 0) {
164
+ text += `\n(已省略 ${omitted.length} 个次要指标:${omitted.slice(0, 12).join(', ')}${omitted.length > 12 ? ' …' : ''})`
165
+ }
166
+ return { text, chars: text.length, included, omitted, truncated: omitted.length > 0 }
167
+ }
168
+
169
+ /**
170
+ * One line per indicator: what the panel *holds*, without the point series.
171
+ *
172
+ * A discussion session seeded with only its subject's block concluded that the
173
+ * panel had nothing else and told the reader so — "面板里也没有核心 CPI、CPI 环比"
174
+ * — while both indicators were sitting in the catalog the whole time. A detailed
175
+ * block answers "what do these numbers say"; this inventory answers "what else is
176
+ * here", which is what a session needs before it decides an answer is impossible.
177
+ *
178
+ * @param {Array<object>} entries - digest entries (`{ indicator, stats, status }`).
179
+ * @param {{ title?: string, skip?: string[] }} [options] - options.
180
+ * @returns {string} compact inventory, or '' when there is nothing to list.
181
+ */
182
+ export function buildInventory(entries, { title = '面板指标清单(未展开明细,可用 data_series 取完整序列)', skip = [] } = {}) {
183
+ const missing = new Set(skip)
184
+ const listed = (entries ?? []).filter((entry) => entry?.indicator !== undefined && missing.has(entry.indicator.id) === false)
185
+ if (listed.length === 0) return ''
186
+ const lines = [`## ${title}`]
187
+ for (const entry of listed) {
188
+ const { indicator, stats } = entry
189
+ const decimals = indicator.display?.decimals ?? 2
190
+ const suffix = transformSuffix(indicator.display)
191
+ const parts = [`- ${indicator.id}`, indicator.label.zh, `${indicator.unit}${suffix}`, indicator.freq]
192
+ if (stats?.latest !== undefined && stats.latest !== null) {
193
+ parts.push(`latest=${formatNumber(stats.latest, decimals)}`)
194
+ if (typeof stats.latestAt === 'string') parts.push(`@${stats.latestAt}`)
195
+ } else {
196
+ parts.push('latest=n/a')
197
+ }
198
+ if (stats?.changeAbs !== undefined) parts.push(`Δ=${formatNumber(stats.changeAbs, decimals)}`)
199
+ if (stats?.changePct !== undefined) parts.push(`Δ%=${formatNumber(stats.changePct, 1)}`)
200
+ parts.push(`status=${entry.status ?? 'unknown'}`)
201
+ lines.push(parts.join(' | '))
202
+ }
203
+ return lines.join('\n')
204
+ }
205
+
206
+ /**
207
+ * A compact digest for the deterministic (no-LLM) summary path: the same data,
208
+ * rendered as readable Chinese prose rather than a table.
209
+ *
210
+ * @param {Array<object>} entries - digest entries.
211
+ * @param {{ today?: string, range?: { from: string, to: string } }} [options] - options.
212
+ * @returns {string} markdown text.
213
+ */
214
+ export function renderDeterministicSummary(entries, { today, range } = {}) {
215
+ if (!Array.isArray(entries) || entries.length === 0) {
216
+ return '数据不足:当前没有任何可用的指标观测,无法生成摘要。请先检查数据源可用性或缩小时间范围。'
217
+ }
218
+ const lines = []
219
+ lines.push(`### 时段数据摘要${range === undefined ? '' : `(${range.from} 至 ${range.to})`}`)
220
+ lines.push('')
221
+ lines.push('**关键变化**')
222
+ const withChange = entries
223
+ .filter((entry) => entry.stats?.changeAbs !== undefined)
224
+ .sort((a, b) => Math.abs(b.stats.changeAbs) - Math.abs(a.stats.changeAbs) || b.indicator.importance - a.indicator.importance)
225
+ .slice(0, 5)
226
+ for (const entry of withChange) {
227
+ const decimals = entry.indicator.display?.decimals ?? 2
228
+ lines.push(
229
+ `- ${entry.indicator.label.zh}:${formatNumber(entry.stats.latest, decimals)}${entry.indicator.unit}` +
230
+ `(较上期 ${entry.stats.changeAbs >= 0 ? '+' : ''}${formatNumber(entry.stats.changeAbs, decimals)},${entry.stats.latestAt})`,
231
+ )
232
+ }
233
+ lines.push('')
234
+ lines.push('**值得留意的指标**')
235
+ const ranked = [...entries].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, 5)
236
+ for (const entry of ranked) {
237
+ lines.push(`- ${entry.indicator.label.zh}(importance=${entry.indicator.importance},关注分 ${entry.score ?? 0})`)
238
+ }
239
+ lines.push('')
240
+ lines.push('**数据边界**')
241
+ const stale = entries.filter((entry) => entry.status !== 'fresh')
242
+ lines.push(
243
+ stale.length === 0
244
+ ? '- 本次全部指标均为最新观测。'
245
+ : `- 其中 ${stale.length} 个指标为陈旧或缺失状态(${stale.slice(0, 5).map((entry) => entry.indicator.label.zh).join('、')}),结论需谨慎。`,
246
+ )
247
+ if (today) lines.push(`- 生成时间:${today}(确定性摘要,未使用模型)。`)
248
+ lines.push('- 数据来自公开源,仅供研究参考,不构成投资建议;口径以原始来源为准。')
249
+ return lines.join('\n')
250
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Merge rule hits into per-indicator scores and order the noteworthy list
3
+ * (docs/07 T5.2).
4
+ *
5
+ * The formula is fixed and lives here so it is reviewable:
6
+ *
7
+ * score = Σ(hit.score × hit.weight) × importanceFactor(importance)
8
+ *
9
+ * Ties break by 'importance' descending, then 'latestAt' descending, then id —
10
+ * a total order, so the panel never reshuffles between two identical renders.
11
+ *
12
+ * @module core/insight/rank
13
+ */
14
+
15
+ /** How much a catalog importance multiplies the raw rule score. */
16
+ export const IMPORTANCE_FACTOR = { 1: 0.6, 2: 0.8, 3: 1.0, 4: 1.25, 5: 1.5 }
17
+
18
+ /**
19
+ * Scale one indicator's raw rule score by its importance.
20
+ *
21
+ * @param {number} rawScore - sum of hit.score × hit.weight.
22
+ * @param {number} importance - catalog importance (1–5).
23
+ * @returns {number} final score, rounded to one decimal.
24
+ */
25
+ export function importanceFactor(rawScore, importance) {
26
+ const factor = IMPORTANCE_FACTOR[importance] ?? 1
27
+ return Math.round(rawScore * factor * 10) / 10
28
+ }
29
+
30
+ /**
31
+ * Group hits by indicator and score them.
32
+ *
33
+ * @param {Array<object>} hits - hits from {@link module:core/insight/rules.runRules}.
34
+ * @param {{ catalogById?: Record<string, object>, statusById?: Record<string, string> }} [options] - context.
35
+ * @returns {Array<{ indicatorId: string, hits: object[], rawScore: number, score: number, importance: number, latestAt?: string, status?: string }>} scored entries.
36
+ */
37
+ export function scoreHits(hits, { catalogById = {}, statusById = {} } = {}) {
38
+ const byIndicator = new Map()
39
+ for (const entry of hits) {
40
+ if (entry === null || typeof entry !== 'object') continue
41
+ const key = entry.indicatorId
42
+ if (typeof key !== 'string') continue
43
+ const bucket = byIndicator.get(key) ?? []
44
+ bucket.push(entry)
45
+ byIndicator.set(key, bucket)
46
+ }
47
+
48
+ return [...byIndicator.entries()].map(([indicatorId, bucket]) => {
49
+ const def = catalogById[indicatorId]
50
+ const importance = def?.importance ?? 1
51
+ const rawScore = bucket.reduce((acc, entry) => acc + (entry.score ?? 0) * (entry.weight ?? 1), 0)
52
+ const dates = bucket
53
+ .flatMap((entry) => entry.evidence?.dates ?? [])
54
+ .filter((date) => typeof date === 'string')
55
+ .sort()
56
+ return {
57
+ indicatorId,
58
+ hits: [...bucket].sort((a, b) => (b.score ?? 0) - (a.score ?? 0) || a.ruleId.localeCompare(b.ruleId)),
59
+ rawScore: Math.round(rawScore * 10) / 10,
60
+ score: importanceFactor(rawScore, importance),
61
+ importance,
62
+ latestAt: dates.length > 0 ? dates[dates.length - 1] : undefined,
63
+ status: statusById[indicatorId],
64
+ }
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Order scored entries: highest score first, ties by importance then recency.
70
+ *
71
+ * @param {Array<object>} scored - entries from {@link scoreHits}.
72
+ * @param {{ limit?: number, minScore?: number }} [options] - ranking options.
73
+ * @returns {Array<object>} ordered entries.
74
+ */
75
+ export function rankNoteworthy(scored, { limit, minScore = 0 } = {}) {
76
+ const filtered = scored.filter((entry) => entry.score >= minScore)
77
+ const ordered = [...filtered].sort(
78
+ (a, b) =>
79
+ b.score - a.score ||
80
+ b.importance - a.importance ||
81
+ String(b.latestAt ?? '').localeCompare(String(a.latestAt ?? '')) ||
82
+ a.indicatorId.localeCompare(b.indicatorId),
83
+ )
84
+ return limit === undefined ? ordered : ordered.slice(0, limit)
85
+ }
86
+
87
+ /**
88
+ * Score and order in one step — the entry point the app layer uses.
89
+ *
90
+ * @param {Array<object>} hits - rule hits.
91
+ * @param {{ catalogById?: Record<string, object>, statusById?: Record<string, string>, limit?: number, minScore?: number, exclude?: string[] }} [options] - options.
92
+ * @returns {Array<object>} ranked noteworthy entries.
93
+ */
94
+ export function buildNoteworthy(hits, { catalogById = {}, statusById = {}, limit, minScore = 0, exclude = [] } = {}) {
95
+ const scored = scoreHits(hits, { catalogById, statusById }).filter((entry) => !exclude.includes(entry.indicatorId))
96
+ return rankNoteworthy(scored, { limit, minScore })
97
+ }
98
+
99
+ /**
100
+ * A stable short fingerprint of an input, used for AI result caching
101
+ * (docs/06 §6). FNV-1a: deterministic, dependency-free, and never used for
102
+ * security.
103
+ *
104
+ * @param {string} text - input text.
105
+ * @returns {string} 8-hex-character fingerprint.
106
+ */
107
+ export function fingerprint(text) {
108
+ let hash = 0x811c9dc5
109
+ const source = String(text ?? '')
110
+ for (let i = 0; i < source.length; i += 1) {
111
+ hash ^= source.charCodeAt(i)
112
+ hash = Math.imul(hash, 0x01000193) >>> 0
113
+ }
114
+ return hash.toString(16).padStart(8, '0')
115
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Which indicators a discussion about one indicator should also see.
3
+ *
4
+ * A session seeded with a single block answers "why did CPI do this" by saying
5
+ * the panel has nothing else — and it is wrong: 核心 CPI 同比 and CPI 指数 are
6
+ * right there in the same group. The digest cannot carry the whole panel's point
7
+ * series, so it carries the subject's detail plus the *peers* that share a
8
+ * meaningful token in their id, and the caller appends a one-line inventory of
9
+ * everything else.
10
+ *
11
+ * Token overlap is deliberate rather than clever: `us.core.cpi.yoy` has to match
12
+ * `us.cpi.yoy`, which no prefix rule can do, and `us.payrolls.*` has to gather its
13
+ * own family. Country and shape tokens (`us`, `yoy`, `index`, `rate` …) carry no
14
+ * subject and are dropped, so they never pair `cn.cpi.yoy` with `us.cpi.yoy` — the
15
+ * group filter would reject that anyway, but an id-level rule that only works
16
+ * because of a second filter is a rule waiting to misfire.
17
+ *
18
+ * @module core/insight/related
19
+ */
20
+
21
+ /** Tokens that name a *shape* of the series, not its subject. */
22
+ export const GENERIC_TOKENS = new Set([
23
+ 'yoy', 'mom', 'qoq', 'yy', 'change', 'level', 'index', 'idx', 'avg', 'average',
24
+ 'rate', 'daily', 'weekly', 'monthly', 'quarterly', 'annual', 'total', 'value',
25
+ 'us', 'cn', 'global', 'eu', 'jp', 'wb', 'pct', 'diff', 'spread', 'ratio',
26
+ ])
27
+
28
+ /** How many peers a discussion context carries in full detail. */
29
+ export const RELATED_LIMIT = 6
30
+
31
+ /**
32
+ * Split an indicator id into its meaningful tokens.
33
+ *
34
+ * @param {string} id - indicator id, e.g. `us.core.cpi.yoy`.
35
+ * @returns {string[]} tokens, e.g. `['core', 'cpi']`.
36
+ */
37
+ export function subjectTokens(id) {
38
+ if (typeof id !== 'string') return []
39
+ return id
40
+ .toLowerCase()
41
+ .split('.')
42
+ .map((token) => token.replace(/[^a-z0-9]/g, ''))
43
+ .filter((token) => token !== '' && GENERIC_TOKENS.has(token) === false)
44
+ }
45
+
46
+ /**
47
+ * The comparable form of a subject token: digits and trailing unit letters name
48
+ * the *instance* (`dgs10`, `dgs2`, `dgs3m`, `m1`, `m2`), not the subject, so a
49
+ * yield-curve or money-supply discussion has to see its own family. Taking the
50
+ * leading alphabetic run makes `dgs10`, `dgs2` and `dgs3m` all `dgs`, and keeps
51
+ * `cpi`, `pmi`, `houseprice` exact.
52
+ *
53
+ * @param {string} token - subject token.
54
+ * @returns {string} comparable key.
55
+ */
56
+ export function subjectKey(token) {
57
+ const alpha = /^[a-z]+/.exec(token)
58
+ return alpha === null ? token : alpha[0]
59
+ }
60
+
61
+ /**
62
+ * Indicators that share a subject token with `indicatorId`.
63
+ *
64
+ * Same group only: a US indicator's peers are the other US indicators of the same
65
+ * subject, so the session reads one country's panel rather than a mixed list.
66
+ * Ordering prefers higher importance, then the id, so the selection is stable
67
+ * across calls and testable.
68
+ *
69
+ * @param {Array<{ id: string, group?: string, importance?: number }>} catalog - indicator definitions.
70
+ * @param {string} indicatorId - the subject.
71
+ * @param {{ limit?: number }} [options] - options.
72
+ * @returns {Array<object>} peer definitions (never includes the subject).
73
+ */
74
+ export function relatedIndicators(catalog, indicatorId, { limit = RELATED_LIMIT } = {}) {
75
+ const entries = Array.isArray(catalog) ? catalog : []
76
+ const subject = entries.find((entry) => entry?.id === indicatorId)
77
+ if (subject === undefined) return []
78
+ const keys = new Set(subjectTokens(indicatorId).map(subjectKey).filter((key) => key !== ''))
79
+ if (keys.size === 0) return []
80
+ return entries
81
+ .filter((entry) => entry?.id !== undefined && entry.id !== indicatorId)
82
+ .filter((entry) => entry.group === subject.group)
83
+ .filter((entry) => subjectTokens(entry.id).some((token) => keys.has(subjectKey(token))))
84
+ .sort(
85
+ (a, b) =>
86
+ (b.importance ?? 0) - (a.importance ?? 0) ||
87
+ String(a.id).localeCompare(String(b.id)),
88
+ )
89
+ .slice(0, limit)
90
+ }