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.
- package/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- package/src/sources/worldbank.js +170 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output validation — the enforcement point that turns "AI explanation" into a
|
|
3
|
+
* verifiable feature (docs/06 §3).
|
|
4
|
+
*
|
|
5
|
+
* Three checks, in order of severity:
|
|
6
|
+
* 1. every '[id@date, value]' citation must match a real digest point;
|
|
7
|
+
* 2. every number in the text must be traceable to the digest (or be a
|
|
8
|
+
* whitelisted shape such as a year, a section ordinal or a percentage sign);
|
|
9
|
+
* 3. the answer must not claim the future or give advice.
|
|
10
|
+
*
|
|
11
|
+
* A violation never reaches the user: the caller retries once with the feedback,
|
|
12
|
+
* then degrades to the deterministic summary.
|
|
13
|
+
*
|
|
14
|
+
* @module app/ai-validate
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Citation syntax the model is told to use: '[id@date, value]'. */
|
|
18
|
+
export const CITATION_RE = /\[([a-z0-9]+(?:\.[a-z0-9]+)+)@(\d{4}-\d{2}-\d{2})\s*,\s*(-?\d+(?:\.\d+)?)\s*\]/g
|
|
19
|
+
|
|
20
|
+
/** Numbers that are allowed without a data source, with the reason why. */
|
|
21
|
+
export const NUMBER_WHITELIST = [
|
|
22
|
+
{ pattern: /^(19|20)\d{2}$/, reason: 'year' },
|
|
23
|
+
{ pattern: /^\d{4}-\d{2}-\d{2}$/, reason: 'date' },
|
|
24
|
+
{ pattern: /^(0|1|2|3|4|5|6|7|8|9|10)$/, reason: 'small ordinal' },
|
|
25
|
+
{ pattern: /^(100|12|4|3|2|1)$/, reason: 'structural constant (percent/month/quarter counts)' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a number is the difference between two published values.
|
|
30
|
+
*
|
|
31
|
+
* "从 4.5% 到 4.1%,两端相差 0.4 个百分点" is the whole point of a data panel,
|
|
32
|
+
* and both observations are cited in the same sentence. Refusing it degraded
|
|
33
|
+
* correct answers; accepting it still refuses a number that appears nowhere in
|
|
34
|
+
* the data (a valuation multiple, a policy rate the panel never fetched).
|
|
35
|
+
*
|
|
36
|
+
* @param {number} value - number found in the text.
|
|
37
|
+
* @param {number[]} candidates - published numbers.
|
|
38
|
+
* @param {number} [tolerance] - rounding tolerance for the difference.
|
|
39
|
+
* @returns {boolean} whether it is a difference of two published values.
|
|
40
|
+
*/
|
|
41
|
+
export function isPublishedDifference(value, candidates) {
|
|
42
|
+
// Tolerance follows the precision the digest prints: two readings quoted to
|
|
43
|
+
// one decimal are subtracted to one decimal by anyone reading them, so the
|
|
44
|
+
// allowance is half of that last digit and no more. A wider window would wave
|
|
45
|
+
// through a number that merely happens to sit near *some* difference.
|
|
46
|
+
const tolerance = 0.05 / 10 ** Math.max(0, decimalsOf(value) - 1)
|
|
47
|
+
for (const a of candidates) {
|
|
48
|
+
if (!Number.isFinite(a)) continue
|
|
49
|
+
for (const b of candidates) {
|
|
50
|
+
if (!Number.isFinite(b)) continue
|
|
51
|
+
if (Math.abs(Math.abs(a - b) - value) <= tolerance) return true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Digits after the decimal point in the *written* form of a number.
|
|
59
|
+
*
|
|
60
|
+
* @param {number} value - number from the text.
|
|
61
|
+
* @returns {number} decimal places (0 for an integer).
|
|
62
|
+
*/
|
|
63
|
+
function decimalsOf(value) {
|
|
64
|
+
const text = String(value)
|
|
65
|
+
const dot = text.indexOf('.')
|
|
66
|
+
return dot === -1 ? 0 : text.length - dot - 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Phrases that assert a prediction or investment advice. */
|
|
70
|
+
export const FORBIDDEN_PATTERNS = [
|
|
71
|
+
{ pattern: /必然|一定会|肯定会|毫无疑问/, reason: 'absolute wording' },
|
|
72
|
+
{ pattern: /建议(买入|卖出|加仓|减仓|做多|做空)|买入信号|卖出信号/, reason: 'investment advice' },
|
|
73
|
+
// A modal future marker followed by a direction verb. Deliberately not
|
|
74
|
+
// date-anchored: in practice any `会/将 + 涨跌` in a data panel's output is a
|
|
75
|
+
// forecast, and requiring a nearby date made the check miss the common
|
|
76
|
+
// phrasing `明天会上涨`.
|
|
77
|
+
{ pattern: /(?:会|将)\s*(?:继续)?\s*(?:上涨|涨|下跌|跌|上行|下行|走高|走低|突破|跌破)/, reason: 'price prediction' },
|
|
78
|
+
{ pattern: /目标价|看到\d+点|涨到\d+|跌到\d+/, reason: 'price target' },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One digest entry flattened for validation.
|
|
83
|
+
*
|
|
84
|
+
* @typedef {Object} ValidationData
|
|
85
|
+
* @property {Record<string, { values: number[], dates: string[], stats: number[] }>} byIndicator
|
|
86
|
+
* @property {number[]} counts - observation counts the digest states about itself.
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the validation index from digest entries.
|
|
91
|
+
*
|
|
92
|
+
* @param {Array<{ indicator: object, stats?: object, points?: Array<{ t: string, v: number }> }>} entries - digest entries.
|
|
93
|
+
* @returns {ValidationData} validation data.
|
|
94
|
+
*/
|
|
95
|
+
export function buildValidationData(entries) {
|
|
96
|
+
const byIndicator = {}
|
|
97
|
+
// Counts the digest itself publishes ("points=13 missing=2"). They describe the
|
|
98
|
+
// series rather than measure it, so a model restating them is not inventing a
|
|
99
|
+
// number — refusing them would penalise it for reading our own output.
|
|
100
|
+
const counts = []
|
|
101
|
+
for (const entry of entries ?? []) {
|
|
102
|
+
const id = entry?.indicator?.id
|
|
103
|
+
if (typeof id !== 'string') continue
|
|
104
|
+
const bucket = byIndicator[id] ?? { values: [], dates: [], stats: [] }
|
|
105
|
+
for (const point of entry.points ?? []) {
|
|
106
|
+
bucket.values.push(point.v)
|
|
107
|
+
bucket.dates.push(point.t)
|
|
108
|
+
}
|
|
109
|
+
const stats = entry.stats ?? {}
|
|
110
|
+
for (const key of ['count', 'missingCount']) {
|
|
111
|
+
if (typeof stats[key] === 'number' && Number.isFinite(stats[key])) counts.push(stats[key])
|
|
112
|
+
}
|
|
113
|
+
counts.push((entry.points ?? []).length)
|
|
114
|
+
for (const key of ['latest', 'prev', 'changeAbs', 'changePct', 'yoy', 'mom', 'mean', 'min', 'max', 'stdDev', 'slope', 'zScoreLatestChange', 'percentile']) {
|
|
115
|
+
if (typeof stats[key] === 'number' && Number.isFinite(stats[key])) bucket.stats.push(stats[key])
|
|
116
|
+
}
|
|
117
|
+
if (typeof stats.latest === 'number') bucket.values.push(stats.latest)
|
|
118
|
+
if (typeof stats.latestAt === 'string') bucket.dates.push(stats.latestAt)
|
|
119
|
+
byIndicator[id] = bucket
|
|
120
|
+
}
|
|
121
|
+
return { byIndicator, counts }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a number appears in the digest, within a display rounding tolerance.
|
|
126
|
+
*
|
|
127
|
+
* @param {number} value - the number found in the text.
|
|
128
|
+
* @param {number[]} candidates - digest numbers.
|
|
129
|
+
* @returns {boolean} whether it is traceable.
|
|
130
|
+
*/
|
|
131
|
+
export function matchesData(value, candidates) {
|
|
132
|
+
// Tolerance is deliberately tight: a number the model invented must not pass
|
|
133
|
+
// just because it is "close enough" to a real observation.
|
|
134
|
+
const tolerance = 1e-6
|
|
135
|
+
for (const candidate of candidates) {
|
|
136
|
+
if (!Number.isFinite(candidate)) continue
|
|
137
|
+
if (Math.abs(candidate - value) <= tolerance) return true
|
|
138
|
+
// The model is asked to round to `decimals`; accept any rounding of the value.
|
|
139
|
+
for (const decimals of [0, 1, 2, 3, 4]) {
|
|
140
|
+
if (Number(candidate.toFixed(decimals)) === value) return true
|
|
141
|
+
}
|
|
142
|
+
// Percentage points written without the sign, and unit-scaled forms
|
|
143
|
+
// (e.g. a percentage reported in decimal form).
|
|
144
|
+
if (Math.abs(candidate * 100 - value) <= tolerance) return true
|
|
145
|
+
if (Math.abs(candidate / 100 - value) <= tolerance) return true
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract every number literal from a text, with its position.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} text - answer text.
|
|
154
|
+
* @returns {Array<{ raw: string, value: number, index: number }>} numbers.
|
|
155
|
+
*/
|
|
156
|
+
export function extractNumbers(text) {
|
|
157
|
+
const found = []
|
|
158
|
+
// Strip citations (they are validated separately) and calendar dates: the
|
|
159
|
+
// digits inside `2026-08-01` are not claims, and matching them produced
|
|
160
|
+
// nonsense violations like `-08`.
|
|
161
|
+
const cleaned = String(text ?? '')
|
|
162
|
+
.replace(CITATION_RE, ' ')
|
|
163
|
+
.replace(/\d{4}-\d{2}-\d{2}/g, ' ')
|
|
164
|
+
.replace(/\d{4}-\d{2}(?!\d)/g, ' ')
|
|
165
|
+
for (const match of cleaned.matchAll(/(?<![\d-])-?\d+(?:\.\d+)?/g)) {
|
|
166
|
+
found.push({ raw: match[0], value: Number(match[0]), index: match.index ?? 0 })
|
|
167
|
+
}
|
|
168
|
+
return found
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validate one answer against the digest.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} input - validation input.
|
|
175
|
+
* @param {string} input.text - the model's answer.
|
|
176
|
+
* @param {ValidationData} input.data - digest index.
|
|
177
|
+
* @param {boolean} [input.allowEmptyCitations] - permit "数据不足" answers without citations.
|
|
178
|
+
* @param {string} [input.echoOf] - text the answer may legitimately contain verbatim (the user's question).
|
|
179
|
+
* @returns {{ ok: boolean, violations: Array<{ kind: string, detail: string, raw?: string }>, usedPoints: Array<{ indicatorId: string, t: string, v: number }>, insufficient: string|undefined }} verdict.
|
|
180
|
+
*/
|
|
181
|
+
export function validateAnswer({ text, data, allowEmptyCitations = true, echoOf, digestText }) {
|
|
182
|
+
const violations = []
|
|
183
|
+
const usedPoints = []
|
|
184
|
+
const source = typeof text === 'string' ? text : ''
|
|
185
|
+
const knownIds = new Set(Object.keys(data?.byIndicator ?? {}))
|
|
186
|
+
|
|
187
|
+
// An empty answer is never acceptable: the caller degrades to the
|
|
188
|
+
// deterministic summary instead of showing the user a blank panel.
|
|
189
|
+
if (source.trim() === '') {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
violations: [{ kind: 'empty-answer', detail: '模型返回了空回答' }],
|
|
193
|
+
usedPoints: [],
|
|
194
|
+
insufficient: undefined,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 1) citations
|
|
199
|
+
for (const match of source.matchAll(CITATION_RE)) {
|
|
200
|
+
const [, indicatorId, date, rawValue] = match
|
|
201
|
+
const bucket = data?.byIndicator?.[indicatorId]
|
|
202
|
+
if (bucket === undefined) {
|
|
203
|
+
violations.push({ kind: 'unknown-indicator', detail: `引用不存在的指标 ${indicatorId}`, raw: match[0] })
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (!bucket.dates.includes(date)) {
|
|
207
|
+
violations.push({ kind: 'unknown-date', detail: `${indicatorId} 在 ${date} 没有观测`, raw: match[0] })
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
const value = Number(rawValue)
|
|
211
|
+
if (!matchesData(value, bucket.values)) {
|
|
212
|
+
violations.push({ kind: 'value-mismatch', detail: `${indicatorId}@${date} 的值 ${value} 与数据不符`, raw: match[0] })
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
usedPoints.push({ indicatorId, t: date, v: value })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 2) unsourced numbers
|
|
219
|
+
//
|
|
220
|
+
// "Sourced" means the digest shows it. Statistics and observations are the
|
|
221
|
+
// obvious part, but a label ("标普 500"), a field name and a computed delta all
|
|
222
|
+
// print there too, and a model restating any of them was being accused of
|
|
223
|
+
// inventing a number — which degraded perfectly good answers to the
|
|
224
|
+
// deterministic summary. Everything the digest prints is therefore traceable
|
|
225
|
+
// by definition, so the digest's own text is scanned as one more source.
|
|
226
|
+
const printed = extractNumbers(typeof digestText === 'string' ? digestText : '')
|
|
227
|
+
// Measurements only. `counts` and the digest text carry structural numbers
|
|
228
|
+
// ("points=250", a label's "500"), and differencing those against a reading
|
|
229
|
+
// would let an invented number through as "4.1 - 3".
|
|
230
|
+
const measurements = Object.values(data?.byIndicator ?? {}).flatMap((bucket) => [...bucket.values, ...bucket.stats])
|
|
231
|
+
const allNumbers = [...measurements, ...(data?.counts ?? []), ...printed.map((number) => number.value)]
|
|
232
|
+
for (const number of extractNumbers(source)) {
|
|
233
|
+
if (NUMBER_WHITELIST.some((entry) => entry.pattern.test(number.raw))) continue
|
|
234
|
+
if (matchesData(number.value, allNumbers)) continue
|
|
235
|
+
// Rounding tolerance, not a free pass: a reader comparing two published
|
|
236
|
+
// points does exactly this subtraction, and a published difference is
|
|
237
|
+
// arithmetic on our data rather than a fabricated observation.
|
|
238
|
+
if (isPublishedDifference(number.value, measurements)) continue
|
|
239
|
+
violations.push({ kind: 'unsourced-number', detail: `数字 ${number.raw} 无法在数据中找到来源`, raw: number.raw })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 3) predictions and advice. A phrase the user themselves wrote is echoed by
|
|
243
|
+
// the answer ("数据不足:无法回答「明天油价会涨吗?」"), which is not the
|
|
244
|
+
// answer asserting a forecast — so the question's own wording is excused.
|
|
245
|
+
const echo = typeof echoOf === 'string' ? echoOf : ''
|
|
246
|
+
const withoutEcho = echo === '' ? source : source.split(echo).join(' ')
|
|
247
|
+
for (const rule of FORBIDDEN_PATTERNS) {
|
|
248
|
+
const match = rule.pattern.exec(withoutEcho)
|
|
249
|
+
if (match !== null) violations.push({ kind: 'forbidden-wording', detail: `${rule.reason}:${match[0]}`, raw: match[0] })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const insufficient = /数据不足/.test(source) ? source.match(/数据不足[^。\n]*/)?.[0] ?? '数据不足' : undefined
|
|
253
|
+
if (usedPoints.length === 0 && insufficient === undefined && !allowEmptyCitations) {
|
|
254
|
+
violations.push({ kind: 'no-citations', detail: '回答没有引用任何数据点' })
|
|
255
|
+
}
|
|
256
|
+
if (usedPoints.length === 0 && insufficient === undefined && knownIds.size > 0 && source.trim() !== '') {
|
|
257
|
+
violations.push({ kind: 'no-citations', detail: '回答没有引用任何数据点,也没有说明数据不足' })
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { ok: violations.length === 0, violations, usedPoints, insufficient }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Decide what to do with a verdict (docs/06 §3 rule 3).
|
|
265
|
+
*
|
|
266
|
+
* @param {{ ok: boolean }} verdict - validation verdict.
|
|
267
|
+
* @param {number} attempt - how many attempts have been made (1-based).
|
|
268
|
+
* @param {number} [maxAttempts] - retry budget.
|
|
269
|
+
* @returns {'accept'|'retry'|'degrade'} action.
|
|
270
|
+
*/
|
|
271
|
+
export function decideAction(verdict, attempt, maxAttempts = 2) {
|
|
272
|
+
if (verdict.ok) return 'accept'
|
|
273
|
+
return attempt < maxAttempts ? 'retry' : 'degrade'
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Human-readable violation list for the retry prompt.
|
|
278
|
+
*
|
|
279
|
+
* @param {Array<{ detail: string }>} violations - violations.
|
|
280
|
+
* @returns {string[]} lines.
|
|
281
|
+
*/
|
|
282
|
+
export function violationLines(violations) {
|
|
283
|
+
return (violations ?? []).map((entry) => entry.detail)
|
|
284
|
+
}
|