@haus-tech/haus-workflow 4.9.0 → 5.0.1
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/CHANGELOG.md +33 -0
- package/dist/cli.js +1381 -871
- package/library/catalog/manifest.json +97 -34
- package/library/global/agents/request-assessor.md +2 -2
- package/library/global/agents/request-scorer.md +13 -8
- package/library/global/agents/request-specifier-eskalerad.md +1 -1
- package/library/global/agents/request-specifier.md +1 -1
- package/library/global/skills/request/SKILL.md +691 -374
- package/library/global/skills/request/scripts/archive.mjs +1376 -0
- package/library/global/skills/request/scripts/check-lasta.mjs +64 -0
- package/library/global/skills/request/scripts/compare-scores.mjs +23 -15
- package/library/global/skills/request/scripts/contract-check.mjs +108 -36
- package/library/global/skills/request/scripts/lib.mjs +66 -0
- package/library/global/skills/request/scripts/precheck.mjs +30 -27
- package/library/global/skills/request/scripts/report.mjs +571 -0
- package/library/global/skills/request/scripts/resolve-anchors.mjs +243 -0
- package/library/global/skills/request-assess-impact/SKILL.md +10 -10
- package/library/global/skills/request-score/SKILL.md +19 -10
- package/library/global/skills/request-sync/SKILL.md +7 -7
- package/library/global/skills/request-write-spec/SKILL.md +6 -6
- package/library/global/skills/request-write-spec/references/specformat.md +2 -2
- package/package.json +1 -1
- package/library/global/agents/request-classifier.md +0 -55
- package/library/global/skills/request/scripts/compare-classifications.mjs +0 -94
- package/library/global/skills/request/scripts/quote-repair.mjs +0 -166
- package/library/global/skills/request-classify/SKILL.md +0 -79
- package/library/global/skills/request-classify/references/klassningsinstruktion.md +0 -152
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Läser experimentytan och renderar en självständig HTML-rapport.
|
|
3
|
+
*
|
|
4
|
+
* Rapporten är ett LÄSVERKTYG. Den rangordnar aldrig modeller, rekommenderar
|
|
5
|
+
* aldrig en nivå och räknar aldrig fram ett facit: bedömningen av vad som är
|
|
6
|
+
* ett korrekt utfall är människans, gjord på erfarenhet av systemet. Scriptet
|
|
7
|
+
* visar spridningen och håller tyst om vem som hade rätt.
|
|
8
|
+
*
|
|
9
|
+
* Matrisen är med flit ojämn. En post kan ha opus och sonnet, en annan opus,
|
|
10
|
+
* sonnet och fable, eftersom en dyrare modell läggs till bara där de billigare
|
|
11
|
+
* inte räckte. Därför förutsätter rapporten aldrig samma kolumnuppsättning och
|
|
12
|
+
* räknar aldrig aggregat tvärs poster: ett medelvärde över olika
|
|
13
|
+
* modelluppsättningar jämför olika saker.
|
|
14
|
+
*
|
|
15
|
+
* Användning: node report.mjs <experimentyta> [--ut <fil>]
|
|
16
|
+
* Exit 0 = rapport skriven, 2 = användnings- eller lässfel.
|
|
17
|
+
*
|
|
18
|
+
* Varje radbyggare returnerar färdig HTML och äger sin egen escaping;
|
|
19
|
+
* `sektion` interpolerar cellerna oescapade. En ny sektion som glömmer detta
|
|
20
|
+
* öppnar för injektion, så regeln måste stå här, inte bara i författarnas huvud.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import { sectionLines } from './lib.mjs'
|
|
26
|
+
|
|
27
|
+
const SPECFALT = ['Problem', 'Lösning', 'När det är klart', 'Ingår inte']
|
|
28
|
+
|
|
29
|
+
/** Körningskataloger heter k<N>. Sorteringen är numerisk: k10 kommer efter k2,
|
|
30
|
+
* vilket strängordning inte ger. */
|
|
31
|
+
const KORNING_RE = /^k(\d+)$/
|
|
32
|
+
|
|
33
|
+
const läsJson = (p) => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'))
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const kataloger = (p) =>
|
|
42
|
+
fs
|
|
43
|
+
.readdirSync(p, { withFileTypes: true })
|
|
44
|
+
.filter((e) => e.isDirectory())
|
|
45
|
+
.map((e) => e.name)
|
|
46
|
+
|
|
47
|
+
/** Gällande pris-JSON för en axel: högsta vN UTAN suffix. En `-run2`- eller
|
|
48
|
+
* `-avvisad`-fil är aldrig det gällande dokumentet, samma regel som
|
|
49
|
+
* `detaljer` följer.
|
|
50
|
+
*
|
|
51
|
+
* Axelnamnen är arkivets filnamn (`classification`, `score`), aldrig skillens
|
|
52
|
+
* svenska axelord: filerna på disk heter `classification-pricing-vN.json` och
|
|
53
|
+
* `score-pricing-vN.json`, pinnade av PRICING_RE i archive.mjs. */
|
|
54
|
+
const gallandePrisfil = (dir, axel) => {
|
|
55
|
+
const re = new RegExp(`^${axel}-pricing-v(\\d+)\\.json$`)
|
|
56
|
+
const träffar = fs
|
|
57
|
+
.readdirSync(dir)
|
|
58
|
+
.map((f) => [f, re.exec(f)])
|
|
59
|
+
.filter(([, m]) => m)
|
|
60
|
+
.sort((a, b) => Number(a[1][1]) - Number(b[1][1]))
|
|
61
|
+
return träffar.length ? läsJson(path.join(dir, träffar.at(-1)[0])) : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const läsSpec = (dir) => {
|
|
65
|
+
let rå
|
|
66
|
+
try {
|
|
67
|
+
rå = fs.readFileSync(path.join(dir, 'spec.md'), 'utf8')
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
return Object.fromEntries(
|
|
72
|
+
SPECFALT.map((f) => {
|
|
73
|
+
const rader = sectionLines(rå, f)
|
|
74
|
+
return [f, rader === null ? null : rader.join('\n').trim()]
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Arkiverat dokument, eller null. Ett saknat dokument är inte samma sak som en
|
|
80
|
+
* ofullständig körning: analysen finns inte förrän bedömningsbenet körts, och
|
|
81
|
+
* en spec-only-körning är fortfarande en hel körning av det den var. */
|
|
82
|
+
const läsDok = (dir, namn) => {
|
|
83
|
+
try {
|
|
84
|
+
return fs.readFileSync(path.join(dir, namn), 'utf8')
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const läsKorning = (dir, namn) => {
|
|
91
|
+
const state = läsJson(path.join(dir, 'state.json'))
|
|
92
|
+
const spec = läsSpec(dir)
|
|
93
|
+
return {
|
|
94
|
+
namn,
|
|
95
|
+
sokvag: dir,
|
|
96
|
+
// Ofullständig betyder att rapporten inte kan visa körningen som en hel
|
|
97
|
+
// körning. Den visas ändå, märkt: tyst utelämnande är den enda utgången
|
|
98
|
+
// som skulle ljuga för läsaren.
|
|
99
|
+
ofullstandig: state === null || spec === null,
|
|
100
|
+
state,
|
|
101
|
+
spec,
|
|
102
|
+
impact: läsDok(dir, 'impact.md'),
|
|
103
|
+
granskarblock: läsDok(dir, 'granskarblock.md'),
|
|
104
|
+
klass: gallandePrisfil(dir, 'classification'),
|
|
105
|
+
poang: gallandePrisfil(dir, 'score'),
|
|
106
|
+
matning: läsJson(path.join(dir, 'korning.json')),
|
|
107
|
+
// Startarens egen mätning. Egen fil med flit: `korning.json` är
|
|
108
|
+
// headless-svaret rått, och en haus-ägd nyckel i den gör den till ett
|
|
109
|
+
// blandat format som inte längre går att diffa mot CLI:ns utdata.
|
|
110
|
+
klocka: läsJson(path.join(dir, 'matning.json')),
|
|
111
|
+
delar: kataloger(dir)
|
|
112
|
+
.filter((d) => /^del-\d+$/.test(d))
|
|
113
|
+
.sort()
|
|
114
|
+
.map((d) => läsKorning(path.join(dir, d), d)),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function läsExperimentyta(rot) {
|
|
119
|
+
if (!fs.existsSync(rot)) throw new Error(`experimentytan ${rot} finns inte`)
|
|
120
|
+
const alla = kataloger(rot)
|
|
121
|
+
.sort()
|
|
122
|
+
.map((slug) => {
|
|
123
|
+
const postDir = path.join(rot, slug)
|
|
124
|
+
const experiment = läsJson(path.join(postDir, 'experiment.json'))
|
|
125
|
+
const modeller = kataloger(postDir)
|
|
126
|
+
.sort()
|
|
127
|
+
.map((modell) => ({
|
|
128
|
+
namn: modell,
|
|
129
|
+
korningar: kataloger(path.join(postDir, modell))
|
|
130
|
+
.map((k) => [k, KORNING_RE.exec(k)])
|
|
131
|
+
.filter(([, m]) => m)
|
|
132
|
+
.sort((a, b) => Number(a[1][1]) - Number(b[1][1]))
|
|
133
|
+
.map(([k]) => läsKorning(path.join(postDir, modell, k), k)),
|
|
134
|
+
}))
|
|
135
|
+
.filter((m) => m.korningar.length > 0)
|
|
136
|
+
return { slug, experiment, modeller }
|
|
137
|
+
})
|
|
138
|
+
// En post utan k<N>-körningar är inte samma sak som en post som inte finns:
|
|
139
|
+
// ett äldre arkivlager lägger sina delar direkt under modellkatalogen, utan
|
|
140
|
+
// k<N>-nivå, och den posten ska synas som skippad, aldrig som frånvarande.
|
|
141
|
+
// `alla` är redan sorterad på slug (kataloger(rot).sort() ovan), så
|
|
142
|
+
// filtreringen behåller ordningen utan en extra sort.
|
|
143
|
+
const poster = alla.filter((p) => p.modeller.length > 0)
|
|
144
|
+
const utan_korningar = alla.filter((p) => p.modeller.length === 0).map((p) => p.slug)
|
|
145
|
+
return { rot, poster, utan_korningar }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const esc = (s) =>
|
|
149
|
+
String(s ?? '').replace(
|
|
150
|
+
/[&<>"']/g,
|
|
151
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const tal = (n) => (n == null ? '–' : new Intl.NumberFormat('sv-SE').format(n))
|
|
155
|
+
const usd = (n) => (n == null ? '–' : `$${n.toFixed(2)}`)
|
|
156
|
+
const minuter = (ms) => (ms == null ? '–' : `${(ms / 60000).toFixed(1)} min`)
|
|
157
|
+
|
|
158
|
+
/** Ett gid är siffror och inget annat. En slug-post (spec utan Asana-ticket)
|
|
159
|
+
* har inget gid och ska inte få ett påhittat: ett felaktigt gid i rapporten
|
|
160
|
+
* leder läsaren till fel ticket, vilket är värre än inget gid alls. */
|
|
161
|
+
const GID_RE = /^\d+$/
|
|
162
|
+
|
|
163
|
+
/** Postens identitet. Titeln och permalänken är experimentets, aldrig
|
|
164
|
+
* rapportens: Asanas permalänkar bär ett workspace-gid som inte finns någonstans
|
|
165
|
+
* på experimentytan, så en konstruerad URL vore en gissning som tyst slutar
|
|
166
|
+
* fungera. Datumet läses under båda namnen, äldre filer bär `date`. */
|
|
167
|
+
const identitet = (post) => {
|
|
168
|
+
const e = post.experiment ?? {}
|
|
169
|
+
const rid = String(e.request_id ?? '')
|
|
170
|
+
return {
|
|
171
|
+
titel: e.title || post.slug,
|
|
172
|
+
gid: GID_RE.test(rid) ? rid : GID_RE.test(post.slug) ? post.slug : null,
|
|
173
|
+
lank:
|
|
174
|
+
typeof e.permalink_url === 'string' && e.permalink_url.startsWith('https://')
|
|
175
|
+
? e.permalink_url
|
|
176
|
+
: null,
|
|
177
|
+
datum: e.datum ?? e.date ?? null,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Identitetsraden under rubriken. Gid:t utelämnas när det redan ÄR rubriken
|
|
182
|
+
* (titellös post), men länken och datumet står kvar, annars tappas de för
|
|
183
|
+
* precis de poster som är svårast att känna igen. */
|
|
184
|
+
const identRad = (id) => {
|
|
185
|
+
const bitar = []
|
|
186
|
+
if (id.gid && id.gid !== id.titel)
|
|
187
|
+
bitar.push(id.lank ? `<a href="${esc(id.lank)}">${esc(id.gid)}</a>` : esc(id.gid))
|
|
188
|
+
else if (id.lank) bitar.push(`<a href="${esc(id.lank)}">Asana</a>`)
|
|
189
|
+
if (id.datum) bitar.push(esc(id.datum))
|
|
190
|
+
return bitar.length ? `<p class="ident">${bitar.join(' · ')}</p>` : ''
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Kostnadsraderna. En körning utan korning.json är OMÄTT, inte noll: nollor
|
|
194
|
+
* skulle läsas som "gratis" och är den enda siffra rapporten kan ljuga med.
|
|
195
|
+
*
|
|
196
|
+
* Radlistan har ALLTID samma längd och samma fältnamn, mätt eller omätt. Varje
|
|
197
|
+
* sektion tar sina fältnamn från den första körningens lista, så en omätt
|
|
198
|
+
* körning med färre rader skulle förskjuta hela tabellen och ställa USD mot
|
|
199
|
+
* väggtid.
|
|
200
|
+
*
|
|
201
|
+
* Varje fält deklarerar sin egen källa: kostnaden och tokens kommer ur
|
|
202
|
+
* `korning.json` (headless-svaret), klocktiden ur `matning.json` (startarens
|
|
203
|
+
* mätning). */
|
|
204
|
+
const KOSTNADSFALT = [
|
|
205
|
+
['USD', 'matning', (m) => usd(m.total_cost_usd)],
|
|
206
|
+
['Tokens in', 'matning', (m) => tal(m.usage?.input_tokens)],
|
|
207
|
+
['Tokens ut', 'matning', (m) => tal(m.usage?.output_tokens)],
|
|
208
|
+
['Cache läst', 'matning', (m) => tal(m.usage?.cache_read_input_tokens)],
|
|
209
|
+
['Cache skriven', 'matning', (m) => tal(m.usage?.cache_creation_input_tokens)],
|
|
210
|
+
// Klocktiden kommer ur startarens mätning, aldrig ur headless-svarets
|
|
211
|
+
// `duration_ms`: det fältet räknar inte in väntan på bakgrundssubagenter och
|
|
212
|
+
// sa 5,5 minuter om en sonnetkörning som tog omkring 34. Ett `wall_ms` som
|
|
213
|
+
// saknas eller inte är ett tal är omätt, aldrig noll: en nolla skulle läsas
|
|
214
|
+
// som en ögonblicklig körning.
|
|
215
|
+
['Väggtid', 'klocka', (m) => (Number.isFinite(m.wall_ms) ? minuter(m.wall_ms) : null)],
|
|
216
|
+
['Dispatcher', 'matning', (m) => tal(m.subagent_stats?.spawned)],
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
const kostnadsrader = (k) =>
|
|
220
|
+
KOSTNADSFALT.map(([namn, källa, hämta]) => {
|
|
221
|
+
// En del är ingen egen process: mätningen kommer ur headless-svaret för
|
|
222
|
+
// hela körningen. "omätt" skulle påstå att mätningen misslyckades, vilket
|
|
223
|
+
// är en annan sak än att den inte finns på den här nivån.
|
|
224
|
+
if (k.rot) return [namn, `<em>ingår i ${esc(k.rot)}</em>`]
|
|
225
|
+
// Två oberoende källor: en saknad fil säger omätt för SINA rader och
|
|
226
|
+
// ljuger inte om de andra. Radlistan har ändå alltid samma längd och
|
|
227
|
+
// samma fältnamn, mätt eller omätt.
|
|
228
|
+
const dok = k[källa]
|
|
229
|
+
const värde = dok ? hämta(dok) : null
|
|
230
|
+
return [namn, värde === null ? '<em>omätt</em>' : esc(värde)]
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const provenienrader = (k) => {
|
|
234
|
+
const p = k.state?.proveniens ?? {}
|
|
235
|
+
return [
|
|
236
|
+
['Modell', esc(p.modell ?? '–')],
|
|
237
|
+
['Datum', esc(p.datum ?? '–')],
|
|
238
|
+
['Konto', esc(p.konto ?? '–')],
|
|
239
|
+
['SKILL.md', esc((p.skill_sha256 ?? '–').slice(0, 12))],
|
|
240
|
+
['Underlag', esc((p.underlag_sha256 ?? '–').slice(0, 12))],
|
|
241
|
+
]
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Inringningen är ett objekt, inte en sträng: `esc()` rakt på den ger
|
|
245
|
+
* `[object Object]`. Golvlöst är kontraktsenligt, `floor` är null när taket är
|
|
246
|
+
* A1 och posten alltså är mindre än A1, och då är "under A1" det ärliga
|
|
247
|
+
* läsvärdet. Saknas inringningen helt får den samma streck som övriga saknade
|
|
248
|
+
* värden. */
|
|
249
|
+
const inringning = (b) => {
|
|
250
|
+
const tak = b?.ceiling?.anchor
|
|
251
|
+
if (!tak) return '–'
|
|
252
|
+
const golv = b?.floor?.anchor
|
|
253
|
+
return golv ? `${golv} till ${tak}` : `under ${tak}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Renderar ett godtyckligt kuvertvärde. Kontraktet pinnar bara ytterhöljet
|
|
257
|
+
* (contract-check kräver floor/ceiling/between/steps och två till tre drivers);
|
|
258
|
+
* vad som ligger INUTI ett steg eller en driver är inte pinnat och skiljer sig
|
|
259
|
+
* mellan arkiv: en floor bär `score` och `why` i en körning och `poang` i en
|
|
260
|
+
* annan, en driver `faktor` i en och `ankare` i en annan. En renderare som
|
|
261
|
+
* plockar namngivna innernycklar tappar därför tyst innehåll i hälften av
|
|
262
|
+
* arkiven. Den här skriver ut de nycklar som faktiskt finns. */
|
|
263
|
+
const varde = (v) => {
|
|
264
|
+
if (v == null || v === '') return '–'
|
|
265
|
+
if (Array.isArray(v))
|
|
266
|
+
return v.length ? `<ul>${v.map((x) => `<li>${varde(x)}</li>`).join('')}</ul>` : '–'
|
|
267
|
+
if (typeof v === 'object') {
|
|
268
|
+
const rader = Object.entries(v).filter(([, x]) => x != null && x !== '')
|
|
269
|
+
return rader.length ? rader.map(([k, x]) => `<b>${esc(k)}:</b> ${varde(x)}`).join('<br>') : '–'
|
|
270
|
+
}
|
|
271
|
+
return esc(v)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Kuverten är slutna och nycklarna deras, inte rapportens: klassningen bär sitt
|
|
275
|
+
* utfall i `classification.utfall`, poängen sitt tal i `score` och sin
|
|
276
|
+
* inringning i `bracket`. Formerna är pinnade i contract-check.mjs. */
|
|
277
|
+
const utfallsrader = (k, harKlass = true) => [
|
|
278
|
+
...(harKlass ? [['Klass', esc(k.klass?.classification?.utfall ?? '–')]] : []),
|
|
279
|
+
['Poäng', esc(k.poang?.score ?? '–')],
|
|
280
|
+
['Inringning', esc(inringning(k.poang?.bracket))],
|
|
281
|
+
['Utfall poäng', esc(k.poang?.outcome ?? '–')],
|
|
282
|
+
['Delning', k.delar.length ? esc(`${k.delar.length} delar`) : 'delade sig inte'],
|
|
283
|
+
['Dubbelkörning', esc(k.state?.runs?.at(-1)?.double_run ?? '–')],
|
|
284
|
+
['Status', k.ofullstandig ? '<strong>ofullständig</strong>' : esc(k.state?.status ?? '–')],
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
const specrader = (k) => SPECFALT.map((f) => [f, `<pre>${esc(k.spec?.[f] ?? '–')}</pre>`])
|
|
288
|
+
|
|
289
|
+
/** Hopfälld som default: impact.md är flera sidor, och tre öppna analyser sida
|
|
290
|
+
* vid sida trycker ner utfallstabellen ur bild. Saknat dokument ger streck,
|
|
291
|
+
* aldrig en tom hopfällare som lovar innehåll som inte finns. */
|
|
292
|
+
const hopfalld = (text) =>
|
|
293
|
+
text == null ? '–' : `<details><summary>visa</summary><pre>${esc(text)}</pre></details>`
|
|
294
|
+
|
|
295
|
+
const analysrader = (k) => [['impact.md', hopfalld(k.impact)]]
|
|
296
|
+
const granskarrader = (k) => [['granskarblock.md', hopfalld(k.granskarblock)]]
|
|
297
|
+
|
|
298
|
+
/** Klassningens fältvisa rader. Frågeraderna är dynamiska: id-listan kommer ur
|
|
299
|
+
* kuvertet, inte ur en hårdkodad lista, eftersom protokollets frågor och
|
|
300
|
+
* stopputfallens delmängder skiljer sig mellan körningar. Belägget står under
|
|
301
|
+
* svaret därför att det är den enda delen som avslöjar om två lika svar vilar
|
|
302
|
+
* på samma läsning av underlaget. */
|
|
303
|
+
const klassrader = (k) => {
|
|
304
|
+
const c = k.klass?.classification
|
|
305
|
+
const kund = k.klass?.customer
|
|
306
|
+
// En handredigerad eller trunkerad envelope kan ha en null-post i fragor;
|
|
307
|
+
// contract-check.mjs stoppar det för välformade arkiv, men rapporten
|
|
308
|
+
// degraderar per cell, aldrig per sida, så en icke-objekt-rad filtreras bort
|
|
309
|
+
// i stället för att kasta och riva ner hela sidan.
|
|
310
|
+
const fragor = Array.isArray(c?.fragor) ? c.fragor.filter((f) => f && typeof f === 'object') : []
|
|
311
|
+
return [
|
|
312
|
+
['Utfall', varde(c?.utfall)],
|
|
313
|
+
['Basklass', varde(c?.basklass)],
|
|
314
|
+
...fragor.map((f) => [
|
|
315
|
+
esc(f.id ?? '?'),
|
|
316
|
+
`${varde(f.svar)}${f.konfidens == null ? '' : ` (konfidens ${varde(f.konfidens)})`}` +
|
|
317
|
+
`<br><em>${esc(f.belagg ?? '–')}</em>`,
|
|
318
|
+
]),
|
|
319
|
+
['Bumpar träffade', varde(c?.bumpar_traffade)],
|
|
320
|
+
['Genomförbarhetsvillkor', varde(c?.genomforbarhetsvillkor)],
|
|
321
|
+
['Beroenden', varde(c?.beroenden)],
|
|
322
|
+
['Saknas', varde(c?.saknas)],
|
|
323
|
+
['Eskaleringsskäl', varde(c?.eskaleringsskal)],
|
|
324
|
+
['Slutklassmotivering', varde(c?.slutklass_motivering)],
|
|
325
|
+
['Kundmotivering', varde(kund?.rationale)],
|
|
326
|
+
]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Poängens fältvisa rader. `inringning` behålls som sammanfattningsrad och
|
|
330
|
+
* golv/tak/steg visas dessutom hela: sammanfattningen svarar på VAR posten
|
|
331
|
+
* landade, de tre raderna på VARFÖR, och det är varför-delen som skiljer två
|
|
332
|
+
* modeller som råkade landa på samma tal. */
|
|
333
|
+
const poangrader = (k) => {
|
|
334
|
+
const p = k.poang
|
|
335
|
+
return [
|
|
336
|
+
['Utfall', varde(p?.outcome)],
|
|
337
|
+
['Poäng', varde(p?.score)],
|
|
338
|
+
['Typ', varde(p?.type)],
|
|
339
|
+
['Inringning', esc(inringning(p?.bracket))],
|
|
340
|
+
['Golv', varde(p?.bracket?.floor)],
|
|
341
|
+
['Tak', varde(p?.bracket?.ceiling)],
|
|
342
|
+
['Mellan', varde(p?.bracket?.between)],
|
|
343
|
+
['Steg', varde(p?.bracket?.steps)],
|
|
344
|
+
['Drivers', varde(p?.drivers)],
|
|
345
|
+
['Konfidens', varde(p?.confidence)],
|
|
346
|
+
['Antaganden', varde(p?.assumptions_stated)],
|
|
347
|
+
['Villkor', varde(p?.conditions)],
|
|
348
|
+
['Beroenden', varde(p?.dependencies)],
|
|
349
|
+
['Noter', varde(p?.notes)],
|
|
350
|
+
['Alternativ', varde(p?.alternatives)],
|
|
351
|
+
['Kundmotivering', varde(p?.customer_rationale)],
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** En sektion är en rubrik plus rader, där varje rad har ett fältnamn och en
|
|
356
|
+
* cell per körning. Fältvis, inte dokumentvis: det som ska jämföras är samma
|
|
357
|
+
* fält, inte samma sidnummer. */
|
|
358
|
+
const sektion = (rubrik, rader, korningar) => `
|
|
359
|
+
<h3>${esc(rubrik)}</h3>
|
|
360
|
+
<div class="scroll">
|
|
361
|
+
<table>
|
|
362
|
+
<thead><tr><th>Fält</th>${korningar
|
|
363
|
+
.map((k) => `<th>${esc(k.rubrik)}</th>`)
|
|
364
|
+
.join('')}</tr></thead>
|
|
365
|
+
<tbody>
|
|
366
|
+
${rader
|
|
367
|
+
.map(
|
|
368
|
+
(fältnamn, i) =>
|
|
369
|
+
`<tr><th>${esc(fältnamn)}</th>${korningar
|
|
370
|
+
.map((k) => `<td>${k.celler[i]}</td>`)
|
|
371
|
+
.join('')}</tr>`,
|
|
372
|
+
)
|
|
373
|
+
.join('\n')}
|
|
374
|
+
</tbody>
|
|
375
|
+
</table>
|
|
376
|
+
</div>`
|
|
377
|
+
|
|
378
|
+
/** Bygger en sektion ur en radfunktion per kolumn. Fältnamnen är UNIONEN över
|
|
379
|
+
* kolumnerna i första-förekomst-ordning, aldrig kolumn noll:s lista: två
|
|
380
|
+
* modeller som besvarat olika frågor är normalfallet vid stopputfall, och ett
|
|
381
|
+
* fält som bara en kolumn har måste synas, annars döljer rapporten just den
|
|
382
|
+
* skillnad läsaren letar efter. En kolumn utan fältet får streck. */
|
|
383
|
+
const byggSektion = (rubrik, kolumner, radfn) => {
|
|
384
|
+
const perKolumn = kolumner.map((k) => new Map(radfn(k)))
|
|
385
|
+
const namn = []
|
|
386
|
+
for (const m of perKolumn) for (const n of m.keys()) if (!namn.includes(n)) namn.push(n)
|
|
387
|
+
return sektion(
|
|
388
|
+
rubrik,
|
|
389
|
+
namn,
|
|
390
|
+
kolumner.map((k, i) => ({ ...k, celler: namn.map((n) => perKolumn[i].get(n) ?? '–') })),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Kolumnerna: varje körning följd av sina delar. Delarna är egna kolumner
|
|
395
|
+
* eftersom det är DÄR utfallen bor i en delad post; räknaren "N delar" döljer
|
|
396
|
+
* hela resultatet. `rot` är null för en riktig körning och namnet på
|
|
397
|
+
* körningen för en del, vilket är det kostnadsraderna behöver för att säga
|
|
398
|
+
* "ingår i k1" i stället för att påstå att mätningen misslyckades.
|
|
399
|
+
*
|
|
400
|
+
* Rekursivt trots att kontraktet stannar på en nivå (en del utan tak blir
|
|
401
|
+
* OVER_SKALAN och delas aldrig vidare): en oväntad nivå ska synas som en
|
|
402
|
+
* kolumn, aldrig tappas tyst. */
|
|
403
|
+
const plattaKolumner = (k, modell, prefix, rot) => {
|
|
404
|
+
const rubrik = `${prefix} / ${k.namn}`
|
|
405
|
+
return [
|
|
406
|
+
{ ...k, modell, rubrik, rot },
|
|
407
|
+
...k.delar.flatMap((d) => plattaKolumner(d, modell, rubrik, rot ?? k.namn)),
|
|
408
|
+
]
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const postSektion = (post) => {
|
|
412
|
+
// Kolumnerna är alla körningar av alla modeller för posten, plus varje
|
|
413
|
+
// körnings delar som egna kolumner direkt efter den. Ojämn matris är
|
|
414
|
+
// normalfallet, inte ett undantag som ska jämnas ut.
|
|
415
|
+
const korningar = post.modeller.flatMap((m) =>
|
|
416
|
+
m.korningar.flatMap((k) => plattaKolumner(k, m.namn, m.namn, null)),
|
|
417
|
+
)
|
|
418
|
+
const id = identitet(post)
|
|
419
|
+
// ADR-0098: klassaxeln är borttagen — Klassning-sektionen (och Klass-raden
|
|
420
|
+
// i Utfall) renderas bara när posten faktiskt bär klassningsfiler, dvs. för
|
|
421
|
+
// äldre arkiv. Kalibreringshistoriken kastas inte, men en post utan axeln
|
|
422
|
+
// ska inte visa en tom sektion för den.
|
|
423
|
+
const harKlass = korningar.some((k) => k.klass != null)
|
|
424
|
+
const block = [
|
|
425
|
+
['Utfall', (k) => utfallsrader(k, harKlass)],
|
|
426
|
+
...(harKlass ? [['Klassning', klassrader]] : []),
|
|
427
|
+
['Poäng', poangrader],
|
|
428
|
+
['Spec', specrader],
|
|
429
|
+
['Analys', analysrader],
|
|
430
|
+
['Granskarblock', granskarrader],
|
|
431
|
+
['Kostnad', kostnadsrader],
|
|
432
|
+
['Proveniens', provenienrader],
|
|
433
|
+
]
|
|
434
|
+
return `
|
|
435
|
+
<section class="post" id="post-${esc(post.slug)}" hidden>
|
|
436
|
+
<h2>${esc(id.titel)}</h2>
|
|
437
|
+
${identRad(id)}
|
|
438
|
+
${block.map(([rubrik, fn]) => byggSektion(rubrik, korningar, fn)).join('\n')}
|
|
439
|
+
</section>`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Anledningen upprepas i båda hoppa-över-fallen nedan: en post utan
|
|
443
|
+
* k<N>-katalog är inte tom, den är skriven i ett äldre arkivlager som ligger
|
|
444
|
+
* utanför vad rapporten läser. Att kalla den tom skulle påstå att datan är
|
|
445
|
+
* borta, när den bara ligger på en nivå rapporten inte tolkar. */
|
|
446
|
+
const K_FORMAT_FORKLARING =
|
|
447
|
+
'Rapporten läser körningar ur kataloger som heter k1, k2 och så vidare. Ett äldre arkiv, ' +
|
|
448
|
+
'skrivet innan den namngivningen togs i bruk, har sina delar direkt under modellkatalogen, ' +
|
|
449
|
+
'och de syns därför inte i den här rapporten.'
|
|
450
|
+
|
|
451
|
+
const postord = (n) => (n === 1 ? 'postkatalog' : 'postkataloger')
|
|
452
|
+
|
|
453
|
+
/** Katalognamnen kommer från filsystemet, inte från något som redan är
|
|
454
|
+
* HTML-säkert, så de escapas precis som varje annat arkivnamn i filen. */
|
|
455
|
+
const hoppadeLista = (namn) =>
|
|
456
|
+
`<details><summary>visa</summary><pre>${namn.map(esc).join('\n')}</pre></details>`
|
|
457
|
+
|
|
458
|
+
/** De tre sanna fallen. "Tom" är bara sant när det inte fanns några
|
|
459
|
+
* postkataloger alls: finns kataloger som skippades är ytan inte tom, den
|
|
460
|
+
* har lästa poster på fel nivå, och det är en annan sak att säga. */
|
|
461
|
+
const innehall = (modell) => {
|
|
462
|
+
const { poster, utan_korningar: skippade } = modell
|
|
463
|
+
if (poster.length === 0 && skippade.length === 0) {
|
|
464
|
+
return '<p class="tom">Experimentytan är tom.</p>'
|
|
465
|
+
}
|
|
466
|
+
if (poster.length === 0) {
|
|
467
|
+
return `<p class="tom">${skippade.length} ${postord(skippade.length)} hittades, men ingen har körningar under k<N>. ${K_FORMAT_FORKLARING}</p>
|
|
468
|
+
${hoppadeLista(skippade)}`
|
|
469
|
+
}
|
|
470
|
+
const meny = poster
|
|
471
|
+
.map((p) => {
|
|
472
|
+
const namn = identitet(p).titel
|
|
473
|
+
const suffix = namn === p.slug ? '' : `, ${p.slug}`
|
|
474
|
+
return `<option value="post-${esc(p.slug)}">${esc(namn)}${esc(suffix)} (${p.modeller
|
|
475
|
+
.map((m) => `${esc(m.namn)}×${m.korningar.length}`)
|
|
476
|
+
.join(', ')})</option>`
|
|
477
|
+
})
|
|
478
|
+
.join('\n')
|
|
479
|
+
// Notisen står ovanför menyn, inte längst ner: en läsare som jämför
|
|
480
|
+
// modeller ska se att ytan är ofullständig INNAN hen börjar jämföra, inte
|
|
481
|
+
// upptäcka det av misstag efter att redan ha bildat sig en uppfattning.
|
|
482
|
+
const notis = skippade.length
|
|
483
|
+
? `<p class="notis">${skippade.length} ${postord(skippade.length)} hoppades över: ingen körning under k<N> hittades. ${K_FORMAT_FORKLARING}</p>
|
|
484
|
+
${hoppadeLista(skippade)}`
|
|
485
|
+
: ''
|
|
486
|
+
return `${notis}
|
|
487
|
+
<p><label>Post: <select id="val">${meny}</select></label></p>
|
|
488
|
+
${poster.map(postSektion).join('\n')}
|
|
489
|
+
<script>
|
|
490
|
+
const val = document.getElementById('val')
|
|
491
|
+
const visa = () => {
|
|
492
|
+
for (const s of document.querySelectorAll('section.post')) s.hidden = s.id !== val.value
|
|
493
|
+
}
|
|
494
|
+
val.addEventListener('change', visa)
|
|
495
|
+
visa()
|
|
496
|
+
</script>`
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function renderaHtml(modell) {
|
|
500
|
+
return `<!doctype html>
|
|
501
|
+
<meta charset="utf-8">
|
|
502
|
+
<title>Modellrapport</title>
|
|
503
|
+
<style>
|
|
504
|
+
body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; max-width: 100%; }
|
|
505
|
+
select { font-size: 1rem; padding: .4rem; }
|
|
506
|
+
/* table-layout: fixed gör kolumnbredderna oberoende av innehållet: fältkolumnen
|
|
507
|
+
får sina 9rem, och datakolumnerna sin angivna bredd. Med auto-layout skulle
|
|
508
|
+
i stället den kolumn som råkar ha längst spec bli bredast, och fältkolumnen
|
|
509
|
+
hoppa i sidled mellan sektionerna.
|
|
510
|
+
min-width i stället för width på tabellen är det som låter .scroll göra sitt
|
|
511
|
+
jobb: får kolumnerna plats sträcks tabellen till full bredd och de delar
|
|
512
|
+
utrymmet, får de inte plats växer tabellen förbi behållaren och den scrollar
|
|
513
|
+
i sidled. Med width: 100% kunde tabellen aldrig bli bredare än behållaren,
|
|
514
|
+
så scrollen slog aldrig in och kolumnerna krympte i stället obegränsat: en
|
|
515
|
+
post delad i tre delar över fyra modeller ger sexton datakolumner, och de
|
|
516
|
+
blev då för smala för att läsa. */
|
|
517
|
+
table { border-collapse: collapse; margin: 1rem 0; width: max-content; min-width: 100%; table-layout: fixed; }
|
|
518
|
+
th, td { border: 1px solid #ccc; padding: .4rem .6rem; text-align: left; vertical-align: top; }
|
|
519
|
+
thead th { background: #f3f3f3; }
|
|
520
|
+
tbody th, thead th:first-child { width: 9rem; font-weight: 600; }
|
|
521
|
+
/* Golvbredden per datakolumn. Räcker utrymmet fördelas överskottet över dem,
|
|
522
|
+
annars är det här den bredd de behåller medan tabellen scrollar. */
|
|
523
|
+
tbody td, thead th:not(:first-child) { width: 14rem; }
|
|
524
|
+
/* Fast layout ger cellen ingen chans att växa, så allt som inte ryms måste
|
|
525
|
+
kunna brytas. Utan detta spiller en lång URL i en spec ut över grannkolumnen. */
|
|
526
|
+
td, th { overflow-wrap: anywhere; }
|
|
527
|
+
pre { white-space: pre-wrap; margin: 0; font: inherit; }
|
|
528
|
+
.tom { color: #666; }
|
|
529
|
+
.notis { color: #666; }
|
|
530
|
+
.ident { color: #666; margin: -.5rem 0 1rem; }
|
|
531
|
+
.scroll { overflow-x: auto; }
|
|
532
|
+
</style>
|
|
533
|
+
<h1>Modellrapport</h1>
|
|
534
|
+
<p>Underlag för manuell bedömning av lägsta acceptabla modellnivå. Rapporten tar
|
|
535
|
+
inte ställning, den visar spridningen mellan körningarna.</p>
|
|
536
|
+
${innehall(modell)}
|
|
537
|
+
`
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ---- CLI -------------------------------------------------------------------
|
|
541
|
+
// `file://${argv[1]}` bryter mot import.meta.url när argv är /var/... och
|
|
542
|
+
// Node resolvar till /private/var/... (macOS-temp, och ~/.claude bakom länk).
|
|
543
|
+
const isCli = (() => {
|
|
544
|
+
if (!process.argv[1]) return false
|
|
545
|
+
try {
|
|
546
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1])
|
|
547
|
+
} catch {
|
|
548
|
+
return false
|
|
549
|
+
}
|
|
550
|
+
})()
|
|
551
|
+
const [rot, ...rest] = process.argv.slice(2)
|
|
552
|
+
if (isCli) {
|
|
553
|
+
if (!rot) {
|
|
554
|
+
console.error('användning: node report.mjs <experimentyta> [--ut <fil>]')
|
|
555
|
+
process.exit(2)
|
|
556
|
+
}
|
|
557
|
+
const iUt = rest.indexOf('--ut')
|
|
558
|
+
const utFil = iUt === -1 ? null : rest[iUt + 1]
|
|
559
|
+
if (iUt !== -1 && (!utFil || utFil.startsWith('--'))) {
|
|
560
|
+
console.error('--ut kräver ett värde')
|
|
561
|
+
process.exit(2)
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
const html = renderaHtml(läsExperimentyta(rot))
|
|
565
|
+
if (utFil) fs.writeFileSync(utFil, html)
|
|
566
|
+
else process.stdout.write(html)
|
|
567
|
+
} catch (e) {
|
|
568
|
+
console.error(e.message)
|
|
569
|
+
process.exit(2)
|
|
570
|
+
}
|
|
571
|
+
}
|