@haus-tech/haus-workflow 5.0.0 → 5.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/CHANGELOG.md +13 -0
- package/dist/cli.js +913 -751
- package/library/catalog/manifest.json +67 -61
- package/library/global/agents/request-assessor.md +10 -2
- package/library/global/agents/request-specifier-eskalerad.md +8 -0
- package/library/global/agents/request-specifier.md +8 -3
- package/library/global/skills/request/SKILL.md +590 -227
- package/library/global/skills/request/scripts/archive.mjs +736 -59
- package/library/global/skills/request/scripts/compare-scores.mjs +119 -13
- package/library/global/skills/request/scripts/contract-check.mjs +268 -40
- package/library/global/skills/request/scripts/kontroll.mjs +622 -0
- package/library/global/skills/request/scripts/korning.mjs +1502 -0
- package/library/global/skills/request/scripts/lib.mjs +477 -5
- package/library/global/skills/request/scripts/precheck.mjs +132 -5
- package/library/global/skills/request/scripts/report.mjs +85 -14
- package/library/global/skills/request/scripts/svep.mjs +640 -0
- package/library/global/skills/request-assess-impact/SKILL.md +45 -4
- package/library/global/skills/request-score/SKILL.md +51 -11
- package/library/global/skills/request-score/references/poangskala.md +3 -0
- package/library/global/skills/request-sync/SKILL.md +3 -3
- package/library/global/skills/request-write-spec/SKILL.md +9 -4
- package/library/global/skills/request-write-spec/references/specformat.md +48 -0
- package/package.json +1 -1
|
@@ -3,17 +3,31 @@
|
|
|
3
3
|
* MEDIUM+). Poängen har större driftyta än klassens enum och multipliceras
|
|
4
4
|
* direkt till pris — request-score:s eget acceptanstest är att två oberoende
|
|
5
5
|
* körningar ger samma poäng. Jämförelsefälten är outcome, score och
|
|
6
|
-
* inringningens golv- och takankare
|
|
7
|
-
*
|
|
6
|
+
* inringningens golv- och takankare, samt type (toleransrad), antalet
|
|
7
|
+
* conditions (hård diff) och alternatives per label (#509). Prosa (why,
|
|
8
|
+
* drivers, rationale) jämförs ALDRIG. Confidence (enum, inte prosa) visas
|
|
9
|
+
* alltid i utskriften, men fäller
|
|
8
10
|
* bara ett par utanför toleransen, se nedan.
|
|
9
11
|
*
|
|
10
12
|
* Toleransregel: en poängskillnad ≤ 0,5 med samma outcome är prisbrus, inte
|
|
11
13
|
* oenighet, oavsett confidence. En ankarskillnad tolereras dessutom bara när
|
|
12
14
|
* banden överlappar eller angränsar; icke-angränsande band är hård diff även
|
|
13
|
-
* vid samma poäng.
|
|
15
|
+
* vid samma poäng. Angränsning avgörs av skalans ordning i lib.mjs ANKARE,
|
|
16
|
+
* inte av ankarnumret (A17 ligger mellan A12 och A13). LIKA_TOLERANS hör till
|
|
17
|
+
* LIKA-familjen (exit 0) med
|
|
14
18
|
* en deterministisk vinnare (högsta poängen, skyddar marginalen). Den
|
|
15
19
|
* skriver aldrig om en fil och räknar aldrig fram ett medelvärde.
|
|
16
20
|
*
|
|
21
|
+
* Stegdiff (beslut D 2026-09-02, ADR-0107 amends 0078): två körningar som
|
|
22
|
+
* läser SAMMA inre ankare i bracket.steps åt motsatt håll (storre ↔ mindre)
|
|
23
|
+
* är hård diff, aldrig toleransrad, även när poängen ligger inom 0,5. Golv-
|
|
24
|
+
* och takankare i någon av körningarna undantas: när banden angränsar
|
|
25
|
+
* (A11/A12 mot A12/A13) flippar det delade gränsankaret av konstruktion, och
|
|
26
|
+
* det fallet äger redan angränsningstoleransen ovan. Utan undantaget hade
|
|
27
|
+
* varje angränsande par blivit OLIKA och toleransen upphävts i praktiken.
|
|
28
|
+
* Ankare som bara finns i ena körningen, saknat eller trasigt steps (äldre
|
|
29
|
+
* arkiv) ger ingen rad.
|
|
30
|
+
*
|
|
17
31
|
* Skriver LIKA, LIKA_TOLERANS (exit 0) eller OLIKA + diffrader (exit 1) på
|
|
18
32
|
* stdout; varningar går på stderr. Vid OLIKA väljer dispatchern ALDRIG en
|
|
19
33
|
* körning och räknar ALDRIG medelvärde — posten eskaleras till granskaren med
|
|
@@ -25,10 +39,10 @@
|
|
|
25
39
|
*
|
|
26
40
|
* Användning: node compare-scores.mjs <körning1.json> <körning2.json>
|
|
27
41
|
*/
|
|
28
|
-
import { loadRunPair, refDriftWarning, reportDiffs } from './lib.mjs'
|
|
42
|
+
import { ankarPosition, loadRunPair, norm, refDriftWarning, reportDiffs } from './lib.mjs'
|
|
29
43
|
|
|
30
44
|
// Jämförelsefälten speglar poängskalans kontrakt — varna (stderr) vid katalogdrift.
|
|
31
|
-
const REF_SHA = '
|
|
45
|
+
const REF_SHA = '810a71f2767173bdc1747e932f466f3fc25cb412b84f61fab0ca0894ff80438a'
|
|
32
46
|
{
|
|
33
47
|
const w = refDriftWarning('request-score/references/poangskala.md', REF_SHA)
|
|
34
48
|
if (w) console.error(`VARNING: ${w}`)
|
|
@@ -50,16 +64,19 @@ if (a.outcome !== b.outcome) diffs.push(`outcome: ${show(a.outcome)} ↔ ${show(
|
|
|
50
64
|
// fortfarande aldrig.
|
|
51
65
|
const TOLERANS = 0.5
|
|
52
66
|
|
|
53
|
-
/**
|
|
54
|
-
* Ankarnummer på skalan A1..A17. Golv null (under-A1-fallet) läses som 0, alltså
|
|
55
|
-
* steget under A1. Ett ankare som inte går att tolka ger null och behandlas som
|
|
56
|
-
* icke-angränsande, så en oläslig inringning aldrig kan tystas som brus.
|
|
57
|
-
*/
|
|
67
|
+
/** Ankarnummer, ENBART för deterministisk utskriftsordning av stegrader. Aldrig ett skalläge, se position(). */
|
|
58
68
|
const ankarNr = (v) => {
|
|
59
69
|
if (v === null || v === undefined) return 0
|
|
60
70
|
const m = /^A(\d+)$/.exec(String(v))
|
|
61
71
|
return m ? Number(m[1]) : null
|
|
62
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Position i skalans ordning ur lib.mjs ANKARE. Golv null (under-A1-fallet)
|
|
75
|
+
* läses som steget under A1. Ett okänt ankare ger null och behandlas som
|
|
76
|
+
* icke-angränsande, så en oläslig inringning aldrig kan tystas som brus.
|
|
77
|
+
* Ankarnumret är INTE en position: A17 ligger mellan A12 och A13 (#506).
|
|
78
|
+
*/
|
|
79
|
+
const position = (v) => (v === null || v === undefined ? -1 : ankarPosition(v))
|
|
63
80
|
/**
|
|
64
81
|
* Inringningarna får toleransbehandling bara när de faktiskt pekar på samma del
|
|
65
82
|
* av skalan: överlappande eller angränsande band. Två körningar som landar på
|
|
@@ -68,11 +85,29 @@ const ankarNr = (v) => {
|
|
|
68
85
|
* densamma.
|
|
69
86
|
*/
|
|
70
87
|
const nastanSammaBand = (fa, ta, fb, tb) => {
|
|
71
|
-
const [l1, h1, l2, h2] = [
|
|
88
|
+
const [l1, h1, l2, h2] = [position(fa), position(ta), position(fb), position(tb)]
|
|
72
89
|
if ([l1, h1, l2, h2].some((n) => n === null)) return false
|
|
73
90
|
return l1 <= h2 + 1 && l2 <= h1 + 1
|
|
74
91
|
}
|
|
75
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Ankare → relation ur bracket.steps. Saknat, icke-array eller element utan
|
|
95
|
+
* sträng-anchor/relation läses som tomt: äldre arkiv har andra stegformer och
|
|
96
|
+
* ska fortfarande gå att jämföra på poäng och band. Dubblett av samma ankare
|
|
97
|
+
* inom en körning: sista vinner.
|
|
98
|
+
*/
|
|
99
|
+
const stegRelationer = (bracket) => {
|
|
100
|
+
const out = new Map()
|
|
101
|
+
const steps = bracket?.steps
|
|
102
|
+
if (!Array.isArray(steps)) return out
|
|
103
|
+
for (const s of steps) {
|
|
104
|
+
if (!s || typeof s !== 'object') continue
|
|
105
|
+
if (typeof s.anchor !== 'string' || typeof s.relation !== 'string') continue
|
|
106
|
+
out.set(s.anchor, s.relation)
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
|
|
76
111
|
const tolerans = []
|
|
77
112
|
// Konfidensordet (hog/medel/lag) är en självrapporterad etikett. Den visas
|
|
78
113
|
// alltid i utskriften, men fäller aldrig ensam ett par vars poäng ligger inom
|
|
@@ -94,13 +129,84 @@ if (a.outcome === 'POANG' && b.outcome === 'POANG') {
|
|
|
94
129
|
if (fa !== fb) ankarDiffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
|
|
95
130
|
if (ta !== tb) ankarDiffs.push(`bracket.ceiling.anchor: ${show(ta)} ↔ ${show(tb)}`)
|
|
96
131
|
;(inomTolerans && nastanSammaBand(fa, ta, fb, tb) ? tolerans : diffs).push(...ankarDiffs)
|
|
132
|
+
// Stegdiff (beslut D, ADR-0107): samma inre ankare läst åt motsatt håll är
|
|
133
|
+
// oenighet om skalan, inte prisbrus, så raden går alltid till diffs. Golv och
|
|
134
|
+
// tak i någon av körningarna hoppas över: vid angränsande band flippar det
|
|
135
|
+
// delade gränsankaret av konstruktion (posten är större än A12 som golv,
|
|
136
|
+
// mindre än A12 som tak), och det fallet syns redan som floor/ceiling-rad
|
|
137
|
+
// under angränsningstoleransen (ADR-0078). Stigande ankarordning gör
|
|
138
|
+
// utskriften deterministisk oavsett i vilken ordning agenten skrev stegen.
|
|
139
|
+
const granser = new Set([fa, fb, ta, tb])
|
|
140
|
+
const stegA = stegRelationer(a.bracket)
|
|
141
|
+
const stegB = stegRelationer(b.bracket)
|
|
142
|
+
const flippar = [...stegA.keys()]
|
|
143
|
+
.filter((k) => stegB.has(k) && !granser.has(k) && stegA.get(k) !== stegB.get(k))
|
|
144
|
+
.sort((x, y) => (ankarNr(x) ?? Infinity) - (ankarNr(y) ?? Infinity) || x.localeCompare(y))
|
|
145
|
+
for (const k of flippar)
|
|
146
|
+
diffs.push(`bracket.steps[${k}].relation: ${show(stegA.get(k))} ↔ ${show(stegB.get(k))}`)
|
|
147
|
+
// Kundnära fält utanför huvudpoängen (#509). type är en självrapporterad
|
|
148
|
+
// etikett som loggas för mätning: toleransrad, fäller aldrig ensam.
|
|
149
|
+
// conditions är kundens kontraktsvillkor: ett villkor som finns i ena
|
|
150
|
+
// körningen men inte i den andra är oenighet, inte brus. alternatives
|
|
151
|
+
// matchas per normaliserad label och diffas under samma toleransregel som
|
|
152
|
+
// huvudpoängen; ett alternativ som bara finns i ena körningen visas som
|
|
153
|
+
// toleransrad så granskaren ser det utan att ett par som är ense om priset
|
|
154
|
+
// faller.
|
|
155
|
+
if ((a.type ?? null) !== (b.type ?? null))
|
|
156
|
+
tolerans.push(`type: ${show(a.type)} ↔ ${show(b.type)}`)
|
|
157
|
+
const antal = (v) => (Array.isArray(v) ? v.length : 0)
|
|
158
|
+
if (antal(a.conditions) !== antal(b.conditions))
|
|
159
|
+
diffs.push(`conditions.length: ${antal(a.conditions)} ↔ ${antal(b.conditions)}`)
|
|
160
|
+
const altNyckel = (x) => norm(x.label).toLowerCase()
|
|
161
|
+
const alternativ = (doc) =>
|
|
162
|
+
new Map(
|
|
163
|
+
(Array.isArray(doc.alternatives) ? doc.alternatives : [])
|
|
164
|
+
.filter((x) => x && typeof x === 'object' && typeof x.label === 'string')
|
|
165
|
+
.map((x) => [altNyckel(x), x]),
|
|
166
|
+
)
|
|
167
|
+
const altA = alternativ(a)
|
|
168
|
+
const altB = alternativ(b)
|
|
169
|
+
for (const [key, x] of altA) {
|
|
170
|
+
const y = altB.get(key)
|
|
171
|
+
const label = norm(x.label)
|
|
172
|
+
if (!y) {
|
|
173
|
+
tolerans.push(`alternatives[${JSON.stringify(label)}]: bara i körning 1`)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (x.score === y.score) continue
|
|
177
|
+
const d = Math.abs(x.score - y.score)
|
|
178
|
+
const rad = `alternatives[${JSON.stringify(label)}].score: ${show(x.score)} ↔ ${show(y.score)}`
|
|
179
|
+
;(Number.isFinite(d) && d <= TOLERANS ? tolerans : diffs).push(rad)
|
|
180
|
+
}
|
|
181
|
+
for (const [key, y] of altB)
|
|
182
|
+
if (!altA.has(key))
|
|
183
|
+
tolerans.push(`alternatives[${JSON.stringify(norm(y.label))}]: bara i körning 2`)
|
|
184
|
+
} else if (a.outcome === 'OVER_SKALAN' && b.outcome === 'OVER_SKALAN') {
|
|
185
|
+
// ADR-0116: inget tak är prisbärande. Utfallet är lika, golvet avgör.
|
|
186
|
+
const fa = a.bracket?.floor?.anchor ?? null
|
|
187
|
+
const fb = b.bracket?.floor?.anchor ?? null
|
|
188
|
+
if (fa !== fb) diffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
|
|
189
|
+
if (konfDiff) tolerans.push(konfDiff)
|
|
97
190
|
} else if (konfDiff) {
|
|
98
191
|
diffs.push(konfDiff)
|
|
99
192
|
}
|
|
100
193
|
|
|
194
|
+
// Täckningspost (ADR-0119): fältets närvaro är oenighet om vad som ska
|
|
195
|
+
// prissättas, innehållet jämförs inte.
|
|
196
|
+
const harCp = (d) => d.coverage_post !== undefined && d.coverage_post !== null
|
|
197
|
+
if (harCp(a) !== harCp(b))
|
|
198
|
+
diffs.push(`coverage_post: ${harCp(a) ? 'finns' : 'saknas'} ↔ ${harCp(b) ? 'finns' : 'saknas'}`)
|
|
199
|
+
|
|
101
200
|
if (diffs.length === 0 && tolerans.length > 0) {
|
|
102
|
-
|
|
103
|
-
|
|
201
|
+
// OVER_SKALAN bär score: null (ADR-0116), "högsta poängen" är obegripligt
|
|
202
|
+
// för ett par utan tal. Golvet redan lika (annars hade grenen ovan diffat),
|
|
203
|
+
// så bara konfidensen skiljer; körning 1 vinner deterministiskt.
|
|
204
|
+
if (a.outcome === 'OVER_SKALAN' && b.outcome === 'OVER_SKALAN') {
|
|
205
|
+
console.log('LIKA_TOLERANS: samma golv, konfidens skiljer (körning 1)')
|
|
206
|
+
} else {
|
|
207
|
+
const winner = b.score > a.score ? 2 : 1
|
|
208
|
+
console.log(`LIKA_TOLERANS: högsta gäller ${Math.max(a.score, b.score)} (körning ${winner})`)
|
|
209
|
+
}
|
|
104
210
|
for (const t of tolerans) console.log(`- ${t}`)
|
|
105
211
|
process.exit(0)
|
|
106
212
|
}
|
|
@@ -6,17 +6,52 @@
|
|
|
6
6
|
* ska fortsätta gå att kontrollera. Fångar det starka modeller ändå slarvar
|
|
7
7
|
* med i lång kontext:
|
|
8
8
|
* parafraserade "ordagranna" citat, läckta protokolltermer i kundtext, brutna
|
|
9
|
-
* enum- och upplösningsregler.
|
|
10
|
-
*
|
|
9
|
+
* enum- och upplösningsregler. Ett svartlistat ord som står i specen (kundens
|
|
10
|
+
* eget dokument; analysen är Haus-intern och friar inget) är kundvokabulär
|
|
11
|
+
* och fälls inte (#504). Bedömer aldrig innehållets rimlighet
|
|
12
|
+
* — bara kontraktet. Vid BROTT: dispatcha agenten om EN gång med felraderna, sedan
|
|
11
13
|
* stopp. VARNING är heuristik (t.ex. ankar-id som krockar med pappersformat) —
|
|
12
14
|
* visas, stoppar inte.
|
|
13
15
|
*
|
|
16
|
+
* Stegschemat: `bracket.steps` (och `alternatives[i].bracket.steps`) är en
|
|
17
|
+
* lista av objekt med exakt nycklarna `anchor` (A<n>), `relation`
|
|
18
|
+
* (storre/mindre/lika) och `grund` (icke-tom). Egna nycklar är brott, som i
|
|
19
|
+
* kuvertet. Äldre arkiv med strängsteg läses fortfarande av report.mjs;
|
|
20
|
+
* bara färska körningar går genom den här kontrollen.
|
|
21
|
+
*
|
|
22
|
+
* Inringningens konsistens (ADR-0115): golv före tak, between två angränsande
|
|
23
|
+
* ankare inuti [golv, tak] (får sammanfalla med dem), poängen mellan
|
|
24
|
+
* between-paret, och varje stegs relation mot poängen. Poäng PÅ ett ankare
|
|
25
|
+
* trots storre/mindre är VARNING bara när between-paret inte rymmer någon
|
|
26
|
+
* poäng emellan; annars BROTT. Ordningen kommer från lib.mjs ANKARE, aldrig
|
|
27
|
+
* ur ankarnumret.
|
|
28
|
+
*
|
|
29
|
+
* Motsägelsegrinden (ADR-0106): när impact.md ges och analysens rad
|
|
30
|
+
* `**Avvikelse mot specen:**` bär etiketten `motsägelse` är TILLBAKA_TILL_SPEC
|
|
31
|
+
* det enda giltiga utfallet. Varje annat outcome är brott. Saknad etikett
|
|
32
|
+
* fäller inget här, precheck äger etikettkravet.
|
|
33
|
+
*
|
|
14
34
|
* Användning: node contract-check.mjs <run.json> <spec.md> [impact.md]
|
|
15
35
|
* Exit 0 = ok, 1 = kontraktsbrott (listade), 2 = användningsfel.
|
|
16
36
|
*/
|
|
17
37
|
import { readFileSync } from 'node:fs'
|
|
18
38
|
|
|
19
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
refDriftWarning,
|
|
41
|
+
norm,
|
|
42
|
+
quoteOk,
|
|
43
|
+
avvikelseEtikett,
|
|
44
|
+
CLASS_UTFALL,
|
|
45
|
+
SCORE_OUTCOMES,
|
|
46
|
+
CUSTOMER_BLACKLIST,
|
|
47
|
+
MODES,
|
|
48
|
+
STEP_RELATIONS,
|
|
49
|
+
KONFIDENS,
|
|
50
|
+
ankarPosition,
|
|
51
|
+
ankarPoang,
|
|
52
|
+
toppAnkare,
|
|
53
|
+
TYPER,
|
|
54
|
+
} from './lib.mjs'
|
|
20
55
|
|
|
21
56
|
const argv = process.argv.slice(2)
|
|
22
57
|
let expectId = null
|
|
@@ -38,6 +73,20 @@ if (idFlag !== -1) {
|
|
|
38
73
|
expectId = v
|
|
39
74
|
argv.splice(idFlag, 2)
|
|
40
75
|
}
|
|
76
|
+
// --state pekar ut den buntdels/split-statefil (#503) som avgör om posten är
|
|
77
|
+
// en split-del (split.part) och därmed får sätta OVER_SKALAN. Utan värde är
|
|
78
|
+
// det, som --expect-id ovan, nästan säkert ett dispatcherfel: stanna hårt.
|
|
79
|
+
let statePath = null
|
|
80
|
+
const stateFlag = argv.indexOf('--state')
|
|
81
|
+
if (stateFlag !== -1) {
|
|
82
|
+
const v = argv[stateFlag + 1]
|
|
83
|
+
if (v === undefined || v.startsWith('--')) {
|
|
84
|
+
console.error('--state kräver ett värde')
|
|
85
|
+
process.exit(2)
|
|
86
|
+
}
|
|
87
|
+
statePath = v
|
|
88
|
+
argv.splice(stateFlag, 2)
|
|
89
|
+
}
|
|
41
90
|
// En felstavad flaggform (t.ex. `--expectid`, bindestrecket i "expect-id"
|
|
42
91
|
// tappat) matchar aldrig `--expect-id` ovan och blir kvar i `argv` som ett
|
|
43
92
|
// obehandlat argument. Utan den här kontrollen landade den tyst som
|
|
@@ -48,17 +97,31 @@ if (idFlag !== -1) {
|
|
|
48
97
|
// användningsfel, aldrig en gissning om vad anroparen menade.
|
|
49
98
|
const okandFlagga = argv.find((a) => a.startsWith('--'))
|
|
50
99
|
if (okandFlagga !== undefined) {
|
|
51
|
-
console.error(`okänd flagga: ${okandFlagga} (
|
|
100
|
+
console.error(`okänd flagga: ${okandFlagga} (giltiga: --expect-id <id>, --state <state.json>)`)
|
|
52
101
|
process.exit(2)
|
|
53
102
|
}
|
|
54
103
|
const [jsonPath, specPath, impactPath] = argv
|
|
55
104
|
if (!jsonPath || !specPath) {
|
|
56
105
|
console.error(
|
|
57
|
-
'användning: node contract-check.mjs <run.json> <spec.md> [impact.md] [--expect-id <id>]',
|
|
106
|
+
'användning: node contract-check.mjs <run.json> <spec.md> [impact.md] [--expect-id <id>] [--state <state.json>]',
|
|
58
107
|
)
|
|
59
108
|
process.exit(2)
|
|
60
109
|
}
|
|
61
110
|
|
|
111
|
+
// Split-status (#503): null när --state inte gavs (OVER_SKALAN blir då en
|
|
112
|
+
// VARNING, inte ett BROTT), annars true/false ur split.part i statefilen. En
|
|
113
|
+
// oläsbar statefil är ett dispatcherfel, inte agentens brott.
|
|
114
|
+
let splitDel = null
|
|
115
|
+
if (statePath !== null) {
|
|
116
|
+
try {
|
|
117
|
+
const st = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
118
|
+
splitDel = st?.split?.part != null
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error(`kunde inte läsa ${statePath}: ${e.message}`)
|
|
121
|
+
process.exit(2)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
62
125
|
const problems = []
|
|
63
126
|
const warnings = []
|
|
64
127
|
|
|
@@ -71,7 +134,7 @@ const warnings = []
|
|
|
71
134
|
// referensfil att drifta mot.
|
|
72
135
|
const EXPECTED_REFS = {
|
|
73
136
|
'request-score/references/poangskala.md':
|
|
74
|
-
'
|
|
137
|
+
'810a71f2767173bdc1747e932f466f3fc25cb412b84f61fab0ca0894ff80438a',
|
|
75
138
|
}
|
|
76
139
|
for (const [rel, expected] of Object.entries(EXPECTED_REFS)) {
|
|
77
140
|
const w = refDriftWarning(rel, expected)
|
|
@@ -87,10 +150,16 @@ try {
|
|
|
87
150
|
// Oläsbart underlag är dispatcherns fel (exit 2), aldrig ett kontraktsbrott —
|
|
88
151
|
// exit 1 skulle trigga en meningslös och dyr omdispatch av agenten.
|
|
89
152
|
let source
|
|
153
|
+
// Analysens råtext hålls isär från det normaliserade underlaget: etiketten på
|
|
154
|
+
// avvikelseraden läses ur den (motsägelsegrinden nedan), och norm() skulle
|
|
155
|
+
// kunna vika radbrytningar som rad-regexen i avvikelseEtikett förlitar sig på.
|
|
156
|
+
let impactText = null
|
|
157
|
+
let kundUnderlag
|
|
90
158
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
159
|
+
const specText = readFileSync(specPath, 'utf8')
|
|
160
|
+
if (impactPath) impactText = readFileSync(impactPath, 'utf8')
|
|
161
|
+
source = norm(specText + '\n' + (impactText ?? ''))
|
|
162
|
+
kundUnderlag = norm(specText)
|
|
94
163
|
} catch (e) {
|
|
95
164
|
console.error(`kunde inte läsa underlaget: ${e.message}`)
|
|
96
165
|
process.exit(2)
|
|
@@ -108,31 +177,8 @@ const collectStrings = (v) => {
|
|
|
108
177
|
return []
|
|
109
178
|
}
|
|
110
179
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// står medvetet INTE här. Poäng och skalans mekanik är interna. Gränserna är
|
|
114
|
-
// Unicode-medvetna (JS \b bryter vid å/ä/ö), och "protokoll" är snävat så att
|
|
115
|
-
// vardagssvenska som "protokollförs" går fri medan klassningsprotokollet fångas.
|
|
116
|
-
const B = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})`, 'iu')
|
|
117
|
-
const CUSTOMER_BLACKLIST = [
|
|
118
|
-
B('poäng\\p{L}*'),
|
|
119
|
-
B('basklass\\p{L}*'),
|
|
120
|
-
B('bump\\p{L}*'),
|
|
121
|
-
B('ankar\\p{L}*'),
|
|
122
|
-
B('referensuppdrag\\p{L}*'),
|
|
123
|
-
B('inringning\\p{L}*'),
|
|
124
|
-
B('konfidens\\p{L}*'),
|
|
125
|
-
// Substantivformer av skalan är mekanik; verbet "skalar" (skalbarhet) är
|
|
126
|
-
// legitim kundsvenska och går fritt.
|
|
127
|
-
B('skal(?:a|an|ans|or|orna)(?!\\p{L})'),
|
|
128
|
-
B('protokoll(?:et|en|s)?(?!\\p{L})'),
|
|
129
|
-
/klassningsprotokoll/iu,
|
|
130
|
-
B('blast\\s*radius'),
|
|
131
|
-
B('touchpoint\\p{L}*'),
|
|
132
|
-
// u-flaggan är obligatorisk: utan den är \p{L} i lookaround teckenklassen
|
|
133
|
-
// "p{L" och "era B2B-kunder" blir ett falskt BROTT.
|
|
134
|
-
/(?<![\p{L}\d_])[SB][1-3](?![\p{L}\d_])/u,
|
|
135
|
-
]
|
|
180
|
+
// CUSTOMER_BLACKLIST bor i lib.mjs (delad med kontroll.mjs, som avgör om ett
|
|
181
|
+
// brott är ett rent kundtextbrott innan en fältomskrivning kan täcka det).
|
|
136
182
|
// Ankar-id (A1–A17) kolliderar strukturellt med pappersformat (A3/A4/A5) och
|
|
137
183
|
// går inte att regexa isär — heuristik, alltså varning i stället för brott.
|
|
138
184
|
const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
|
|
@@ -168,12 +214,24 @@ const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
|
|
|
168
214
|
const IDIOM_ALLOW = [/hela\s+poängen\s+(?:med|i)\b(?![^.!?]*\d)/giu, /poängen\s+är\s+att\b/giu]
|
|
169
215
|
const maskIdioms = (text) =>
|
|
170
216
|
IDIOM_ALLOW.reduce((t, re) => t.replace(re, (m) => '�'.repeat(m.length)), text)
|
|
217
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
218
|
+
/**
|
|
219
|
+
* Ett svartlistat ord som redan förekommer som HELT ord i kundens underlag
|
|
220
|
+
* (specen) är kundens vokabulär, inte skalans mekanik, och fälls inte (#504).
|
|
221
|
+
* Analysen (impact.md) är Haus-intern och friar inget: den skrivs av en
|
|
222
|
+
* Haus-agent, inte kunden, så ett protokollord där är inget kundläckage.
|
|
223
|
+
* Kundens sajt hade en blocktyp "anchor" som agenten återgav som
|
|
224
|
+
* "ankartrigger" — ett läckage kan inte komma från kundens egna dokument.
|
|
225
|
+
* Helordsgräns: "ankartrigger" i specen friar inte "ankartriggern".
|
|
226
|
+
*/
|
|
227
|
+
const iUnderlaget = (ord) =>
|
|
228
|
+
new RegExp(`(?<![\\p{L}\\d_])${escapeRe(ord)}(?![\\p{L}\\d_])`, 'iu').test(kundUnderlag)
|
|
171
229
|
const checkCustomerText = (text, field) => {
|
|
172
230
|
if (typeof text !== 'string') return
|
|
173
231
|
const masked = maskIdioms(text)
|
|
174
232
|
for (const re of CUSTOMER_BLACKLIST) {
|
|
175
233
|
const m = masked.match(re)
|
|
176
|
-
if (m) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
|
|
234
|
+
if (m && !iUnderlaget(m[0])) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
|
|
177
235
|
}
|
|
178
236
|
// Ankarvarningen körs mot ORIGINALET, aldrig mot den maskade strängen.
|
|
179
237
|
// Maskningen bevarar längden men inte innehållet, och varningscitatet ska
|
|
@@ -193,7 +251,14 @@ const checkScoreValue = (score, field) => {
|
|
|
193
251
|
else if (score < 3 ? Math.round(score * 4) !== score * 4 : !Number.isInteger(score))
|
|
194
252
|
problems.push(`${field}: ${score} bryter upplösningen (0.25-steg under 3, heltal från 3)`)
|
|
195
253
|
}
|
|
196
|
-
|
|
254
|
+
/** Nästa giltiga poäng över `lo` på skalans upplösning (0,25 under 3, heltal från 3). */
|
|
255
|
+
const nastaSteg = (lo) => (lo < 3 ? lo + 0.25 : lo + 1)
|
|
256
|
+
/** Avrundningskollaps: between-paret rymmer ingen giltig poäng strikt emellan sig
|
|
257
|
+
* (A13/A14 = 12/13, A9/A10 = 4/4). Då landar avrundningen av nödvändighet PÅ
|
|
258
|
+
* ett av ankarna även när posten bedömts större/mindre än det (#502). */
|
|
259
|
+
const kollapsar = (lo, hi) => !(nastaSteg(lo) < hi)
|
|
260
|
+
|
|
261
|
+
const checkBracket = (b, field, score) => {
|
|
197
262
|
// Under-A1-fallet är kontraktsenligt golvlöst: "Får du inget golv: posten är
|
|
198
263
|
// mindre än A1 och får A1:s poäng" — då är floor null och taket A1.
|
|
199
264
|
const floorless = b?.floor == null && b?.ceiling?.anchor === 'A1'
|
|
@@ -214,10 +279,118 @@ const checkBracket = (b, field) => {
|
|
|
214
279
|
)
|
|
215
280
|
if (b.steps.length === 0)
|
|
216
281
|
problems.push(`${field}.steps: tom — inringningen ska redovisa ankarjämförelserna`)
|
|
282
|
+
|
|
283
|
+
// Konsistens (ADR-0115, #502/#496): delarna ska hänga ihop, inte bara finnas.
|
|
284
|
+
// Bara när poängen är ett tal — annars har checkScoreValue redan fällt.
|
|
285
|
+
const harPoang = typeof score === 'number'
|
|
286
|
+
let kollaps = false
|
|
287
|
+
if (floorless) {
|
|
288
|
+
// Kontraktet självt kollapsar hit: posten är MINDRE än A1 men får A1:s
|
|
289
|
+
// poäng, så "mindre än A1" med A1:s poäng är varning. "storre mot A1" är
|
|
290
|
+
// däremot en rak motsägelse av kontraktet (#514 p2), och between prövas
|
|
291
|
+
// mot ankartabellen precis som i den golvade grenen.
|
|
292
|
+
const a1 = ankarPoang('A1')
|
|
293
|
+
if (harPoang && score !== a1)
|
|
294
|
+
problems.push(
|
|
295
|
+
`${field}: golvlös inringning (under A1) kräver A1:s poäng ${a1}, fick ${score}`,
|
|
296
|
+
)
|
|
297
|
+
for (const id of b.between.filter((x) => ankarPosition(x) === null))
|
|
298
|
+
problems.push(`${field}: okänt ankare "${id}" (skalan är A1–A17)`)
|
|
299
|
+
for (const [i, step] of b.steps.entries())
|
|
300
|
+
if (step && step.anchor === 'A1' && step.relation === 'storre')
|
|
301
|
+
problems.push(
|
|
302
|
+
`${field}.steps[${i}]: storre mot A1 i golvlös inringning, posten är per kontrakt mindre än A1`,
|
|
303
|
+
)
|
|
304
|
+
kollaps = true
|
|
305
|
+
} else if (b.between.length === 2) {
|
|
306
|
+
const okanda = [b.floor.anchor, b.ceiling.anchor, ...b.between].filter(
|
|
307
|
+
(id) => ankarPosition(id) === null,
|
|
308
|
+
)
|
|
309
|
+
if (okanda.length > 0) {
|
|
310
|
+
for (const id of okanda) problems.push(`${field}: okänt ankare "${id}" (skalan är A1–A17)`)
|
|
311
|
+
} else {
|
|
312
|
+
const [pf, pc] = [ankarPosition(b.floor.anchor), ankarPosition(b.ceiling.anchor)]
|
|
313
|
+
const [b0, b1] = b.between
|
|
314
|
+
const [p0, p1] = [ankarPosition(b0), ankarPosition(b1)]
|
|
315
|
+
if (pf >= pc)
|
|
316
|
+
problems.push(
|
|
317
|
+
`${field}: golv ${b.floor.anchor} ligger inte före tak ${b.ceiling.anchor} i skalans ordning`,
|
|
318
|
+
)
|
|
319
|
+
if (p1 !== p0 + 1)
|
|
320
|
+
problems.push(
|
|
321
|
+
`${field}.between: ${b0}/${b1} är inte angränsande i skalans ordning (A17 ligger mellan A12 och A13)`,
|
|
322
|
+
)
|
|
323
|
+
else {
|
|
324
|
+
if (p0 < pf || p1 > pc)
|
|
325
|
+
problems.push(
|
|
326
|
+
`${field}.between: ${b0}/${b1} ligger utanför golv ${b.floor.anchor}–tak ${b.ceiling.anchor}`,
|
|
327
|
+
)
|
|
328
|
+
// Bara ett bekräftat angränsande par har ett lo/hi (#514 p3): ett
|
|
329
|
+
// reverserat par gav lo > hi, en vilseledande rad och en falsk kollaps.
|
|
330
|
+
const [lo, hi] = [ankarPoang(b0), ankarPoang(b1)]
|
|
331
|
+
if (harPoang && (score < lo || score > hi))
|
|
332
|
+
problems.push(
|
|
333
|
+
`${field === 'bracket' ? 'score' : field.replace(/\.bracket$/, '.score')}: ${score} ligger utanför between ${b0} (${lo})–${b1} (${hi})`,
|
|
334
|
+
)
|
|
335
|
+
kollaps = kollapsar(lo, hi)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const [i, step] of b.steps.entries())
|
|
340
|
+
checkStep(step, `${field}.steps[${i}]`, harPoang ? score : null, kollaps)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Ett steg i inringningen: exakt nycklarna anchor/relation/grund, inga egna.
|
|
344
|
+
* Okänd nyckel fälls för sig så omdispatchen ser vad som ska bort, och de
|
|
345
|
+
* tre obligatoriska kontrolleras oberoende av varandra. Med `score` prövas
|
|
346
|
+
* dessutom att relationen stämmer med poängen (ADR-0115). */
|
|
347
|
+
const checkStep = (step, field, score, kollaps) => {
|
|
348
|
+
if (step === null || typeof step !== 'object' || Array.isArray(step)) {
|
|
349
|
+
problems.push(`${field}: inte ett objekt (schemat är {anchor, relation, grund})`)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
for (const key of Object.keys(step))
|
|
353
|
+
if (!STEP_KEYS.includes(key))
|
|
354
|
+
problems.push(
|
|
355
|
+
`${field}: okänd nyckel "${key}" (schemat är exakt ${STEP_KEYS.join('/')}, egna nycklar är förbjudna)`,
|
|
356
|
+
)
|
|
357
|
+
if (step.anchor === undefined) problems.push(`${field}.anchor: saknas`)
|
|
358
|
+
else if (typeof step.anchor !== 'string' || !ANCHOR_ID_RE.test(step.anchor))
|
|
359
|
+
problems.push(`${field}.anchor: "${step.anchor}" är inte ett ankar-id (A<n>)`)
|
|
360
|
+
else if (ankarPoang(step.anchor) === null)
|
|
361
|
+
problems.push(`${field}.anchor: okänt ankare "${step.anchor}" (skalan är A1–A17)`)
|
|
362
|
+
if (step.relation === undefined) problems.push(`${field}.relation: saknas`)
|
|
363
|
+
else if (!STEP_RELATIONS.includes(step.relation))
|
|
364
|
+
problems.push(
|
|
365
|
+
`${field}.relation: ogiltigt värde "${step.relation}" (giltiga: ${STEP_RELATIONS.join(', ')})`,
|
|
366
|
+
)
|
|
367
|
+
if (typeof step.grund !== 'string' || step.grund.trim().length === 0)
|
|
368
|
+
problems.push(`${field}.grund: saknas eller tom sträng`)
|
|
369
|
+
|
|
370
|
+
// Relation mot poäng. "Landar den på samma nivå som ett referensuppdrag, ge
|
|
371
|
+
// den det ankarets poäng rakt av" (SKILL.md steg 4) — då är relationen lika.
|
|
372
|
+
const ap = ankarPoang(step.anchor)
|
|
373
|
+
if (score === null || ap === null || !STEP_RELATIONS.includes(step.relation)) return
|
|
374
|
+
const id = step.anchor
|
|
375
|
+
if (step.relation === 'lika') {
|
|
376
|
+
if (score !== ap) problems.push(`${field}: lika med ${id} (${ap}) men poängen är ${score}`)
|
|
377
|
+
} else if (step.relation === 'storre' && score < ap) {
|
|
378
|
+
problems.push(`${field}: storre mot ${id} (${ap}) men poängen ${score} är lägre`)
|
|
379
|
+
} else if (step.relation === 'mindre' && score > ap) {
|
|
380
|
+
problems.push(`${field}: mindre än ${id} (${ap}) men poängen ${score} är högre`)
|
|
381
|
+
} else if (score === ap) {
|
|
382
|
+
const rad = `${field}: ${step.relation === 'storre' ? 'storre mot' : 'mindre än'} ${id} men poängen ${score} är ${id}:s poäng`
|
|
383
|
+
if (kollaps)
|
|
384
|
+
warnings.push(`${rad} (avrundningskollaps: between-paret rymmer ingen poäng emellan)`)
|
|
385
|
+
else problems.push(`${rad} — relationen ska vara lika, eller poängen flyttas`)
|
|
386
|
+
}
|
|
217
387
|
}
|
|
218
388
|
|
|
219
|
-
const MODES = ['pricing', 'guard']
|
|
220
389
|
const BASKLASS = ['TINY', 'SMALL', 'MEDIUM', 'LARGE', 'OVER']
|
|
390
|
+
// MODES, STEP_RELATIONS och KONFIDENS bor i lib.mjs (delade med kontroll.mjs,
|
|
391
|
+
// som reparerar samma enumer innan dispatch).
|
|
392
|
+
const STEP_KEYS = ['anchor', 'relation', 'grund']
|
|
393
|
+
const ANCHOR_ID_RE = /^A\d+$/
|
|
221
394
|
|
|
222
395
|
// Kuverten är slutna: agenterna får aldrig lägga till egna nycklar ("ordagrant
|
|
223
396
|
// och orört"). Okänd toppnyckel är ett brott, inte en utökning.
|
|
@@ -237,6 +410,7 @@ const ENVELOPE_KEYS = {
|
|
|
237
410
|
'alternatives',
|
|
238
411
|
'dependencies',
|
|
239
412
|
'notes',
|
|
413
|
+
'coverage_post',
|
|
240
414
|
'customer_rationale',
|
|
241
415
|
'guard_diff',
|
|
242
416
|
],
|
|
@@ -331,11 +505,19 @@ if (doc.classification !== undefined) {
|
|
|
331
505
|
checkEnvelopeKeys('score')
|
|
332
506
|
if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
|
|
333
507
|
if (!OUTCOMES.includes(doc.outcome)) problems.push(`outcome: ogiltigt värde "${doc.outcome}"`)
|
|
334
|
-
if (!
|
|
508
|
+
if (!KONFIDENS.includes(doc.confidence))
|
|
335
509
|
problems.push(`confidence: ogiltigt värde "${doc.confidence}"`)
|
|
510
|
+
// Typen (#509): loggas för att mäta systematisk felplacering per typ, så
|
|
511
|
+
// värdet måste vara ett av översiktstabellens fem. Tom typ tolereras bara på
|
|
512
|
+
// stopputfall (där finns ingen placering att mäta).
|
|
513
|
+
const typTom = doc.type === undefined || doc.type === null || doc.type === ''
|
|
514
|
+
if (doc.outcome === 'POANG' && typTom)
|
|
515
|
+
problems.push('type: saknas (POANG kräver en av översiktstabellens typer)')
|
|
516
|
+
else if (!typTom && !TYPER.includes(doc.type))
|
|
517
|
+
problems.push(`type: ogiltigt värde "${doc.type}" (giltiga: ${TYPER.join(', ')})`)
|
|
336
518
|
if (doc.outcome === 'POANG') {
|
|
337
519
|
checkScoreValue(doc.score, 'score')
|
|
338
|
-
checkBracket(doc.bracket, 'bracket')
|
|
520
|
+
checkBracket(doc.bracket, 'bracket', doc.score)
|
|
339
521
|
if (!Array.isArray(doc.drivers) || doc.drivers.length < 2 || doc.drivers.length > 3)
|
|
340
522
|
problems.push(
|
|
341
523
|
`drivers: ${Array.isArray(doc.drivers) ? doc.drivers.length : 'saknas'} (skillen kräver två eller tre faktorer)`,
|
|
@@ -343,6 +525,52 @@ if (doc.classification !== undefined) {
|
|
|
343
525
|
} else if (doc.score != null) {
|
|
344
526
|
problems.push(`score: ska vara null när outcome är ${doc.outcome}`)
|
|
345
527
|
}
|
|
528
|
+
// #503: inget tak är ett prisbärande utfall. OVER_SKALAN får bara sättas på
|
|
529
|
+
// en split-del (skillen steg 2), en buntdel är inte en split-del; och båda
|
|
530
|
+
// inget-tak-utfallen måste ha jämfört posten mot skalans översta ankare
|
|
531
|
+
// innan de ger upp.
|
|
532
|
+
if (doc.outcome === 'OVER_SKALAN') {
|
|
533
|
+
if (splitDel === null)
|
|
534
|
+
warnings.push('OVER_SKALAN: split-status okänd (kör med --state <state.json> för att grinda)')
|
|
535
|
+
else if (!splitDel)
|
|
536
|
+
problems.push(
|
|
537
|
+
'OVER_SKALAN kräver att posten är en split-del (split.part), en buntdel är inte en split-del: utfallet är DELA_UPP',
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
if (doc.outcome === 'DELA_UPP' || doc.outcome === 'OVER_SKALAN') {
|
|
541
|
+
const topp = toppAnkare()
|
|
542
|
+
const steps = Array.isArray(doc.bracket?.steps) ? doc.bracket.steps : []
|
|
543
|
+
if (!steps.some((s) => s && s.anchor === topp))
|
|
544
|
+
problems.push(
|
|
545
|
+
`inget tak kräver en jämförelse mot ${topp} i bracket.steps, annars är taket inte prövat`,
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
// Motsägelsegrinden (ADR-0106): analysen har redan sagt att specen och
|
|
549
|
+
// koden inte går ihop, då finns inget att poängsätta och inget att
|
|
550
|
+
// konceptera. Regeln har inga undantag (samma text som i skillen). Utan
|
|
551
|
+
// etikett (äldre analys) fälls inget, det kravet äger precheck.
|
|
552
|
+
if (impactText !== null && avvikelseEtikett(impactText) === 'motsägelse') {
|
|
553
|
+
if (doc.outcome !== 'TILLBAKA_TILL_SPEC')
|
|
554
|
+
problems.push(
|
|
555
|
+
`analysen bär motsägelse mot specen: ${doc.outcome} är inte giltigt, TILLBAKA_TILL_SPEC krävs`,
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
// Täckningspost (ADR-0119, #508): antagandet "satt som om tester fanns" är
|
|
559
|
+
// ett strukturerat fält, inte en fritextnot, så det kan bli en egen post.
|
|
560
|
+
if (doc.coverage_post !== undefined) {
|
|
561
|
+
const cp = doc.coverage_post
|
|
562
|
+
if (cp === null || typeof cp !== 'object' || Array.isArray(cp))
|
|
563
|
+
problems.push('coverage_post: inte ett objekt (schemat är {yta, omfattning})')
|
|
564
|
+
else {
|
|
565
|
+
for (const key of Object.keys(cp))
|
|
566
|
+
if (!['yta', 'omfattning'].includes(key))
|
|
567
|
+
problems.push(`coverage_post: okänd nyckel "${key}"`)
|
|
568
|
+
for (const key of ['yta', 'omfattning'])
|
|
569
|
+
if (typeof cp[key] !== 'string' || cp[key].trim() === '')
|
|
570
|
+
problems.push(`coverage_post.${key}: saknas eller tom sträng`)
|
|
571
|
+
if (typeof cp.yta === 'string') checkCustomerText(cp.yta, 'coverage_post.yta')
|
|
572
|
+
}
|
|
573
|
+
}
|
|
346
574
|
// Ett ankare utan sitt lösta citat betyder att dispatchern hoppat över
|
|
347
575
|
// resolve-anchors.mjs: dispatcherns fel (exit 2), aldrig agentens brott.
|
|
348
576
|
for (const [i, d] of (Array.isArray(doc.drivers) ? doc.drivers : []).entries())
|
|
@@ -374,7 +602,7 @@ if (doc.classification !== undefined) {
|
|
|
374
602
|
checkCustomerText(alt?.adds, `alternatives[${i}].adds`)
|
|
375
603
|
if (alt?.score != null) {
|
|
376
604
|
checkScoreValue(alt.score, `alternatives[${i}].score`)
|
|
377
|
-
checkBracket(alt?.bracket, `alternatives[${i}].bracket
|
|
605
|
+
checkBracket(alt?.bracket, `alternatives[${i}].bracket`, alt.score)
|
|
378
606
|
}
|
|
379
607
|
}
|
|
380
608
|
} else {
|