ms-ai-architect/scripts/kb-update/lib/cosmo-persona.mjs
Kjell Tore Guttormsen 1c9af82daa refactor(ms-ai-architect): R13b — nøytraliser de mekaniske persona-forekomstene utenfor headinger, etter at ordrens egen korrigerte bøttegrense ble målt usann
Ordren ba meg behandle sitt eget premiss som et premiss: «PM-ens 87/45 er korrigert
til 85/47 ved maaling ... Behandle ogsaa 85/47 som et premiss og re-maal det foer du
bygger.» Re-målt over de 389 ref-filene holder 85/47 heller ikke — og feilen ligger i
KLASSIFIKATOREN, ikke i tellingen. Summen 132 står; fordelingen er 62/70.

TRE FUNN, alle pinnet i test:

1. FJORTEN bold-etiketter er DIALOG-ATTRIBUSJONER, ikke etiketter. Dialog-bøtta
   matchet bare kursivformen `*Cosmo:*`. Fetformen står foran sitert tale —
   `**Naar kunden sier:** "..."` / `**Cosmo svarer:** "..."` — så å slette den
   etterlater replikken uten taler. Redaksjonelt, ikke mekanisk.
2. TI tabellceller er PROVENIENSPÅSTANDER i kildekvalitets-kolonnen. Celleposisjon
   deler de 26 tabellforekomstene i tre klasser, ikke én: 12 radetiketter (kolonne 0),
   4 kolonneoverskrifter, 10 proveniensverdier (`Moenstre er Cosmo-design`,
   `Raadgivende innhold basert paa Cosmo-persona`). Å skrive om en av dem er å avgi en
   ny påstand om hvor innholdet kommer fra, og den siste har ikke noe slettemål i det
   hele tatt. Seks av ordrens ni sammensetninger ligger helt inne i denne klassen —
   ordrens egen advarsel traff, målingen lokaliserte den.
3. Lekkasje ANDRE veien, +1 mekanisk: `- **For arkitekten (Cosmo):** ...` i
   reasoning-models-o1-o3-optimization.md:549 er nøyaktig målklassen, men et
   linjestart-forankret nett ser den ikke bak `- ` og bokførte den som prosa.

Operatøren ratifiserte 2026-09-15 alt. A: kjør de 62, hold dialog og proveniens for
R14. Begge tilbakeholdte klasser henger på #R14-persona-ramme som aldri er besvart;
de 62 gjør ikke det, fordi R13 ALT har kjørt `For Cosmo` -> `For arkitekten` over 401
headinger — dette gjør bare etiketter og tabellrader konsistente med en beslutning
som allerede er utført.

UTFØRT: 62 forekomster i 50 filer (ordren sa 47) — 46 etiketter i 16 ratifiserte
varianter, 16 celler i 4. Diffen er 62 fjernet = 62 lagt til, ren in-place-erstatning;
hver endret linje klassifisert, ANNET = 0. Ingen måltekst innfører et ord kilden ikke
hadde, utover R13s sanksjonerte `arkitekten` (samme invariant-test, gjenbrukt).

GATEN, DEKOMPONERT I TRE KLAUSULER, hver validert BEGGE veier før den ble konsumert:
  R1 REFERENT      mekaniske sites 62 -> 0, produkt 451 uendret.
                   Kjent-pos: `**For Cosmo:**` OG `| Cosmos raad |` (genitiven er den
                   et bold-only nett mister) feller begge. Kjent-neg:
                   `**Cosmos DB-anbefaling:**` — ser ut som persona, er produkt —
                   og `| Azure Cosmos DB |` passerer urørt.
  R2 REKKEVIDDE    0 umålte varianter, 0 utenfor rekkevidde. Kjent-pos: en ukjent
                   etikettform rapporteres som umålt OG transformen KASTER, den
                   hopper ikke stille over. Kjent-neg: dialog + proveniens bokføres
                   utenfor scope og er byte-identiske.
  R3 HVA SOM STÅR  16 redaksjonelle etiketter + 10 celler uendret, heading/TOC
                   fortsatt 0 (R13 ikke regradert), prosa 132 -> 70. Kjent-pos: et
                   fjernet tegn i `Cosmos DB` feller produkt-differansen. Kjent-neg:
                   alle kun-produkt-filer byte-identiske gjennom transformen.

Gaten ble kjørt FØR transformen og felte R1 (exit 1) — et nullresultat som aldri er
tvunget til det andre svaret er ingen måling. R13b har egen baseline-fil;
cosmo-gate-baseline.json er et referansepunkt-artefakt (personaHeadings 401) og er
IKKE re-emittert. Ny .gitignore-negasjon (6 entries), aldri `git add -f`.

Målt underveis: 0 av de 62 målinjene ligger i en kodeblokk, så fence-agnostisk
transform er trygg her — samme konklusjon R13 nådde for headinger. Nøyaktig 1 linje
bærer begge klasser; dens `Cosmo-persona` står igjen, som den skal.

Suite 1134/1134 (1120 + 14 nye), validate-plugin 250/0/0, transform idempotent,
R13-driveren fortsatt no-op. RX-OPS1 adversarial-scan kjørt MANUELT på de 56 stagede
filene (OK, exit 0) — `core.hooksPath` skygger repoets pre-commit, fiksen eies av
`.claude`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:09:00 +02:00

521 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// cosmo-persona.mjs — R13 Cosmo del 1: classify a `cosmo` occurrence as PERSONA or
// PRODUCT, and neutralise the persona out of reference-corpus headings (and the TOC
// entries that anchor to them) without touching a single Azure Cosmos DB mention.
//
// WHY THIS LIB EXISTS. The roadmap's R13 acceptance gate
// (.claude/projects/2026-07-02-helhetlig-roadmap § R13) reads
// `grep -rl "Cosmo" skills/*/references | wc -l -> 0`. Measured 2026-09-12 over the
// 389 reference files, that gate is false twice over:
//
// 1. Of 1 638 case-insensitive `cosmo` occurrences, 451 are AZURE COSMOS DB — a real
// Microsoft product, legitimate content in the BCDR / engineering / cost files.
// A gate demanding zero `Cosmo` demands they be deleted. The discriminator is NOT
// the letter s: `Cosmos DB` / `CosmosClient` / `cosmos_ru` are product, while
// `Cosmos <norsk substantiv>` is the persona's GENITIVE (`### Cosmos tonalitet`).
// 2. Of the 1 187 persona occurrences, 132 live in prose, tables, dialogue and
// provenance lines that heading neutralisation cannot reach. "Zero persona" is
// therefore unreachable under the ratified heading-only scope either.
//
// THE RATIFIED GATE (operator 2026-09-12, alternative A) — three clauses, baselines
// measured over skills/*/references:
// G1 persona occurrences on markdown heading lines 401 -> 0 (374 files, 40 variants)
// G2 every fragment link resolves to a real heading unchanged (1 pre-existing dead)
// G3 product occurrences unchanged 451 -> 451
// The 132 residual persona occurrences are booked as an open decision for R13b/R14.
//
// WHY EXACT-STRING KEYING AND NOT FENCE-AWARENESS. Measured: BOTH fence rules are
// wrong on this corpus. A naive ``` toggle (what transform.mjs buildToc uses) leaves
// chain-of-thought-prompting.md unbalanced — 19 markers — and hides the real heading at
// line 403; the CommonMark "a closing fence carries no info string" rule instead
// unbalances service-level-documentation-dr.md, whose ```markdown blocks contain
// ```mermaid / ```bash examples as literal content. Measured resolution: fence-agnostic
// detection finds 401 heading-like persona lines in EXACTLY the same 40 variants that
// fence-aware detection finds 400 in — i.e. no code-block line in the corpus is
// byte-identical to a persona heading, so fence state cannot change the verdict. The
// transform therefore keys on the 40 enumerated heading texts and ignores fences. That
// is both correct here and auditable: every rewrite is a table entry a human ratified.
//
// ARCHITECTURE INVARIANT: this lib NEVER writes and never guesses. An unmapped persona
// heading throws; an in-file slug collision throws. Applying content is the driver's
// job (neutralize-cosmo-headings.mjs), behind the operator gate.
// Function words that may follow a bare product mention of "Cosmos" in prose, e.g.
// "Redis for hot, Cosmos for warm data" / "autoscale for Cosmos i DR-region". A
// genitive persona use is always "Cosmos <substantiv>", never "Cosmos <funksjonsord>".
// Enumerated rather than inferred: the net is validated by running it over all 1 638
// occurrences and hand-checking every bare-Cosmos line (25 of them).
const PRODUCT_FOLLOWERS = [
'for', 'og', 'eller', 'i', 'med', 'til', 'som', 'er', 'ellers', 'via',
'på', 'pa', 'av', 'fra', 'ved', 'kan', 'skal', 'hvis', 'men', 'the', 'to',
];
/**
* Is the `cosmo` occurrence at the START of `text` the persona (not the product)?
*
* PERSONA ⇔ /cosmo(?!s)/i — 1 178 measured (851 `Cosmo`,
* 327 lowercase TOC anchors)
* `Cosmos <lowercase substantiv>` — 9 measured (Norwegian genitive)
* PRODUCT ⇔ everything else — 451 measured
*
* @param {string} text — the document text from the occurrence's first character on
* @returns {boolean}
*/
export function isPersonaOccurrence(text) {
const s = String(text ?? '');
// `Cosmo`, `Cosmo's`, `Cosmo-tilnærming`, and the lowercase `#...-cosmo)` anchors.
if (/^cosmo(?!s)/i.test(s)) return true;
// Norwegian genitive: capital `Cosmos` + space + a lowercase noun. A capital follower
// (DB, PITR, L2, Kusto, Snowflake) or no space at all (`Cosmos;`, `Cosmos-DB`,
// `CosmosClient`) is product, as is a lowercase function word.
const gen = /^Cosmos[ \t]+(\S+)/.exec(s);
if (!gen) return false;
const follower = gen[1];
if (!/^[a-zæøå]/.test(follower)) return false;
const word = follower.replace(/[^\p{L}]/gu, '').toLowerCase();
return word !== '' && !PRODUCT_FOLLOWERS.includes(word);
}
/** Does this line carry at least one persona occurrence? */
function hasPersona(line) {
for (const m of String(line).matchAll(/cosmo/gi)) {
if (isPersonaOccurrence(String(line).slice(m.index))) return true;
}
return false;
}
/**
* GitHub-style heading slug. MIRRORS transform.mjs slugify (lowercase, drop all
* punctuation, spaces → hyphens, Unicode-aware so æ/ø/å survive) — kept local rather
* than imported so this lib stays dependency-free, and pinned to parity by
* test-cosmo-persona's slug cases against known corpus anchors.
* @param {string} heading
* @returns {string}
*/
export function slugifyHeading(heading) {
return String(heading ?? '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
.trim()
.replace(/\s/g, '-');
}
/**
* Count every `cosmo` occurrence, bucketing the persona ones by where they sit.
* The buckets are what make the ratified scope legible: `heading` + `toc` is what R13
* neutralises, `prose` is the 132 booked for R13b/R14, `product` is what G3 protects.
*
* @param {string} content
* @returns {{persona: {heading: number, toc: number, prose: number},
* personaTotal: number, product: number, total: number}}
*/
export function classifyCosmo(content) {
const persona = { heading: 0, toc: 0, prose: 0 };
let product = 0;
for (const line of String(content ?? '').split('\n')) {
const isHeading = /^#{1,6}\s+/.test(line);
// Spans covered by a markdown fragment link `[text](#anchor)` — both the visible
// link text and the anchor sit inside one span, which is why a TOC entry for a
// persona heading contributes two occurrences.
const linkSpans = [...line.matchAll(/\[[^\]]*\]\(#[^)]*\)/g)]
.map((m) => [m.index, m.index + m[0].length]);
for (const m of line.matchAll(/cosmo/gi)) {
if (!isPersonaOccurrence(line.slice(m.index))) { product++; continue; }
if (isHeading) persona.heading++;
else if (linkSpans.some(([a, b]) => m.index >= a && m.index < b)) persona.toc++;
else persona.prose++;
}
}
const personaTotal = persona.heading + persona.toc + persona.prose;
return { persona, personaTotal, product, total: personaTotal + product };
}
/**
* The ratified rewrite table: all 40 persona heading variants measured in
* the reference corpus on 2026-09-12, keyed by heading text (the line without its `#`
* prefix) → neutral text. Operator-ratified 2026-09-12: `For Cosmo`,
* `For Cosmo Skyberg` and `For arkitekten (Cosmo)` all converge on `For arkitekten`,
* giving the corpus ONE heading vocabulary where it had three.
*
* No target introduces a word its source did not carry, except the two sanctioned
* substitutions for the persona token — `arkitekten` (the role Cosmo played) and `å`
* (where the name was the clause's subject). Pinned by test-cosmo-persona.
*/
export const HEADING_MAP = {
// --- the four bulk variants (363 of 401 occurrences) ---
'For arkitekten (Cosmo)': 'For arkitekten',
'For Cosmo': 'For arkitekten',
'For Cosmo Skyberg': 'For arkitekten',
'For Cosmo: Beslutningsveiledning': 'Beslutningsveiledning',
// --- the tail: 36 variants, 1-2 occurrences each ---
'Oppsummering for Cosmo': 'Oppsummering',
'10. For Cosmo: Modellvalgveiledning': '10. Modellvalgveiledning',
'Key insights for Cosmo': 'Key insights',
'Architecture decision prompts for Cosmo': 'Architecture decision prompts',
'Røde flagg (når skal Cosmo advare?)': 'Røde flagg (når skal arkitekten advare?)',
'Fallgruver (Cosmo Har Sett Før)': 'Fallgruver',
"Desicion Matrix (Cosmo's Cheat Sheet)": 'Desicion Matrix',
"Shortcut Design Patterns (Cosmo's Checklist)": 'Shortcut Design Patterns',
'For Cosmo — Beslutningsveiledning': 'Beslutningsveiledning',
'Praktiske anbefalinger for arkitekten (Cosmo)': 'Praktiske anbefalinger for arkitekten',
'For arkitekten (Cosmo) — spørsmål, fallgruver og anbefalinger':
'For arkitekten — spørsmål, fallgruver og anbefalinger',
'For Cosmo — når bruker denne kunnskapen?': 'For arkitekten — når bruker denne kunnskapen?',
'For Cosmo: Quick Reference Card': 'Quick Reference Card',
"Cosmo's Talking Points": 'Talking Points',
'Når skal Cosmo foreslå Impact Assessment?': 'Når skal arkitekten foreslå Impact Assessment?',
'Cosmos veiledningsstrategi': 'Veiledningsstrategi',
'Cosmos spørsmål for å utdype': 'Spørsmål for å utdype',
'Red flags Cosmo skal varsle om': 'Red flags arkitekten skal varsle om',
'Cosmos tonalitet': 'Tonalitet',
'Cosmos sjekkliste før avslutning': 'Sjekkliste før avslutning',
"Cosmo's quick decision tree": 'Quick decision tree',
"Cosmo's Stakeholder Communication Checklist": 'Stakeholder Communication Checklist',
'Cosmo-tilnærming': 'Tilnærming',
'Decision trees for Cosmo': 'Decision trees',
'Red flags for Cosmo å se etter': 'Red flags å se etter',
'For Cosmo Skyberg: Application Insights for LLM Monitoring':
'Application Insights for LLM Monitoring',
'For Cosmo: Anvendelse i Arkitekturrådgivning': 'Anvendelse i Arkitekturrådgivning',
'For Arkitekten (Cosmo)': 'For Arkitekten',
"Cosmo's Quick Decision Matrix": 'Quick Decision Matrix',
'Cosmos anbefalinger': 'Anbefalinger',
'For Cosmo: Practical Implementation': 'Practical Implementation',
'For Cosmo: Veiledning i Arkitekturdialog': 'Veiledning i Arkitekturdialog',
'10. Anbefalinger for Cosmo Skyberg': '10. Anbefalinger',
'For Cosmo: Anvendelse i Microsoft AI-arkitektur': 'Anvendelse i Microsoft AI-arkitektur',
'Cosmo-oppsummering': 'Oppsummering',
'Spørsmål Cosmo bør stille kunden': 'Spørsmål å stille kunden',
};
/**
* Per-file overrides where the shared table would collide inside one document. Measured
* by sweeping the ratified mapping over all 389 files and collecting every slug that
* would end up shared: exactly TWO, both adjudicated by reading the file.
*
* 1. feedback-loops-continuous-improvement.md carries both `## For arkitekten (Cosmo)`
* (L674) and `## For Cosmo` (L745), which the ratified mapping would fold onto one
* slug. The second section's own body names the alternative the brief already
* sanctions — "Nøkkelpunkter å fremheve i konsultasjon"
* (docs/cosmo-removal-brief-2026-06.md:24) — so the target is read off the content
* rather than invented.
* 2. application-insights-llm-monitoring.md's `## For Cosmo Skyberg: Application
* Insights for LLM Monitoring` (L717) would collide with the document's own H1 title
* (L1), because the heading's suffix merely restated that title. Dropping the suffix
* loses nothing and lands on the ratified `For arkitekten` vocabulary.
*/
export const FILE_HEADING_OVERRIDES = {
'skills/ms-ai-engineering/references/mlops-genaiops/feedback-loops-continuous-improvement.md': {
'For Cosmo': 'Nøkkelpunkter for rådgivning',
},
'skills/ms-ai-governance/references/monitoring-observability/application-insights-llm-monitoring.md': {
'For Cosmo Skyberg: Application Insights for LLM Monitoring': 'For arkitekten',
},
};
/**
* Every fragment link whose anchor matches no heading slug in the same document.
* Heading collection is fence-agnostic, matching the transform — see the header note.
*
* @param {string} content
* @returns {Array<{text: string, anchor: string}>}
*/
export function findDeadAnchors(content) {
const src = String(content ?? '');
const slugs = new Set();
for (const line of src.split('\n')) {
const m = /^#{1,6}\s+(.+?)\s*$/.exec(line);
if (m) slugs.add(slugifyHeading(m[1].trim()));
}
const dead = [];
for (const m of src.matchAll(/\[([^\]]*)\]\(#([^)]*)\)/g)) {
if (!slugs.has(m[2])) dead.push({ text: m[1], anchor: m[2] });
}
return dead;
}
/**
* Neutralise the persona out of this document's headings, and rewrite the TOC entries
* that anchor to them in the SAME operation — so no step of the run ever leaves a dead
* fragment link. Pure: returns new content, writes nothing.
*
* Surgical by design. The TOC is NOT regenerated (buildToc would reorder entries,
* re-derive every anchor and drop `###` entries); only the persona entries are
* rewritten, in place, leaving all 2 709 non-persona fragment links byte-identical.
* Measured precondition that makes this exact: all 327 persona TOC links carry a link
* text that is one of the 40 heading texts AND an anchor equal to slugify of it
* (327/327, zero deviations).
*
* Throws — never writes a guess — when:
* - a heading carries persona but is not in HEADING_MAP (an unmeasured variant)
* - a rewrite would make two headings in this file share one slug
* - a persona fragment link's anchor is not slugify of its own link text
*
* Idempotent: neutral content has no persona headings left to match.
*
* @param {string} content
* @param {{relPath?: string}} [opts] — relPath selects FILE_HEADING_OVERRIDES
* @returns {{content: string, changes: Array<{kind: string, line: number, from: string, to: string}>}}
*/
export function neutralizeContent(content, { relPath = '' } = {}) {
const src = String(content ?? '');
const overrides = FILE_HEADING_OVERRIDES[relPath] || {};
const target = (text) => (Object.prototype.hasOwnProperty.call(overrides, text)
? overrides[text]
: HEADING_MAP[text]);
const lines = src.split('\n');
const changes = [];
// --- pass 1: headings ---
const rewritten = lines.map((line, i) => {
const m = /^(#{1,6})(\s+)(.+?)(\s*)$/.exec(line);
if (!m || !hasPersona(line)) return line;
const from = m[3];
const to = target(from);
if (to === undefined) {
throw new Error(`ukjent persona-heading i ${relPath || '<ukjent fil>'}:${i + 1}: "${from}"`);
}
changes.push({ kind: 'heading', line: i + 1, from, to });
return `${m[1]}${m[2]}${to}${m[4]}`;
});
// --- collision check, BEFORE any TOC work: would two headings share one slug? ---
const slugCount = new Map();
for (const line of rewritten) {
const m = /^#{1,6}\s+(.+?)\s*$/.exec(line);
if (!m) continue;
const s = slugifyHeading(m[1].trim());
slugCount.set(s, (slugCount.get(s) || 0) + 1);
}
for (const c of changes) {
const s = slugifyHeading(c.to);
if (slugCount.get(s) > 1) {
throw new Error(
`slug-kollisjon i ${relPath || '<ukjent fil>'}:${c.line}: "${c.from}" -> "${c.to}" `
+ `gir slug "#${s}" som alt finnes i fila. Krever en FILE_HEADING_OVERRIDES-entry.`,
);
}
}
// --- pass 2: the TOC entries anchoring to the headings we just renamed ---
const out = rewritten.map((line, i) => line.replace(
/\[([^\]]*)\]\(#([^)]*)\)/g,
(whole, text, anchor) => {
if (!hasPersona(whole)) return whole;
const to = target(text);
if (to === undefined) {
throw new Error(
`persona-fragmentlenke som ikke peker paa en kjent heading, `
+ `${relPath || '<ukjent fil>'}:${i + 1}: "${whole}"`,
);
}
if (anchor !== slugifyHeading(text)) {
throw new Error(
`anker matcher ikke slugify(lenketekst) i ${relPath || '<ukjent fil>'}:${i + 1}: `
+ `"${whole}" — forventet "#${slugifyHeading(text)}"`,
);
}
changes.push({ kind: 'toc', line: i + 1, from: text, to });
return `[${to}](#${slugifyHeading(to)})`;
},
));
return { content: out.join('\n'), changes };
}
// ===========================================================================================
// R13b — the MECHANICAL persona sites OUTSIDE headings: bold labels and table cells.
//
// THE SPLIT IS A MEASUREMENT, NOT THE ORDER'S PREMISE. The dispatching order carried
// "MEKANISK 85 / REDAKSJONELT 47" — itself a correction of an earlier "87/45" — and asked for
// it to be re-measured before anything was built. Re-measured over the 389 reference files, it
// is wrong again, and wrong in the CLASSIFIER rather than the count:
//
// 1. FOURTEEN bold labels are DIALOGUE SPEAKER ATTRIBUTIONS. The dialogue bucket only ever
// matched the italic `*Cosmo:*`; the bold form introduces quoted speech —
// `**Naar kunden sier:** "..."` / `**Cosmo svarer:** "..."` — so deleting it leaves the
// quote with no speaker. That is editorial work, not a scripted rewrite.
// 2. TEN table cells are PROVENANCE CLAIMS in the source-quality column. Cell position splits
// the 26 table occurrences into three classes, not one: 12 row labels (column 0),
// 4 column headers, 10 provenance values such as `Moenstre er Cosmo-design` and
// `Raadgivende innhold basert paa Cosmo-persona`. Rewriting one asserts something new
// about where the content came from, and the last of them has no deletion target at all.
// Six of the order's nine compound forms live entirely inside this class.
// 3. Leaking the other way, +1 mechanical: `- **For arkitekten (Cosmo):** ...` in
// reasoning-models-o1-o3-optimization.md:549 is exactly the target class, but a
// line-start-anchored net cannot see it behind the `- ` marker and books it as prose.
//
// MEASURED: MEKANISK 62 (46 labels + 16 cells) / REDAKSJONELT 70; sum 132 unchanged.
// Operator-ratified 2026-09-15, alternative A: execute the 62, hold dialogue and provenance
// for R14. Both held-back classes depend on the never-answered #R14-persona-ramme (neutral
// frame / new named persona / none); the 62 do not, because R13 has ALREADY executed
// `For Cosmo` -> `For arkitekten` across 401 headings, so this only brings labels and table
// rows into line with a decision that has already run.
//
// KEYED ON EXACT TEXT, like HEADING_MAP, and for the same reason: every rewrite is then a
// table entry a human ratified, and an unmeasured variant THROWS instead of being guessed at.
// Exact keying is also what keeps the two table classes apart without any row-role heuristic —
// the header cell is `Cosmo-anbefaling`, the provenance cell is
// `Cosmo-anbefaling basert paa verified data`, and they simply are different keys.
/**
* The ratified bold-label rewrites: 16 variants, 46 occurrences. Keys are the text INSIDE the
* `**...**` span, colon included where the source has it inside the bold.
* No target introduces a word its source did not carry, except R13's sanctioned `arkitekten`.
*/
export const LABEL_MAP = {
// --- the bulk: the three forms that converge on R13's ratified heading vocabulary ---
'For Cosmo:': 'For arkitekten:',
'For Cosmo Skyberg:': 'For arkitekten:',
'For arkitekten (Cosmo):': 'For arkitekten:',
// --- compound nouns: the persona is the first element, so the head stands alone ---
'Cosmo-anbefaling:': 'Anbefaling:',
"Cosmo's anbefaling:": 'Anbefaling:',
'Cosmo Skybergs anbefaling:': 'Anbefaling:',
'Cosmo-svar:': 'Svar:',
'Cosmo-spørsmål:': 'Spørsmål:',
'Cosmo-checkpoint:': 'Checkpoint:',
"Cosmo's one-liner:": 'One-liner:',
"Cosmo's rule of thumb:": 'Rule of thumb:',
// --- parenthetical or prepositional: the name drops out and the label survives intact ---
'Owner (Cosmo):': 'Owner:',
'Note til Cosmo:': 'Note:',
'Oppsummering for Cosmo': 'Oppsummering',
'Oppsummering for Cosmo:': 'Oppsummering:',
// --- addressed TO the architect, so the role takes the name's place ---
'Til Cosmo:': 'Til arkitekten:',
};
/**
* Bold labels that carry persona but are OUT of R13b's scope: speaker attributions in front of
* quoted speech. Enumerated rather than pattern-matched, so that a variant in NEITHER table is
* a hole in the net and throws, instead of being silently skipped. 6 variants, 14 occurrences.
*/
export const EDITORIAL_LABELS = new Set([
'Cosmo:', // 5 — `**Cosmo:** "Jeg forstaar at dere er klare for produksjon..."`
'Cosmo svarer:', // 4 — paired with `**Naar kunden sier:**`
'Cosmo forklarer:', // 2 — same dialogue frame
'Cosmo spør:', // 1 — same dialogue frame
'Mitt svar (Cosmo):', // 1 — first person, inside a scripted exchange
'Cosmo Skyberg tipset:', // 1 — past-tense attribution of advice to a person
// The two the ORDER already booked as editorial, and the reason its predecessor undercounted
// provenance: both are whole sentences that merely happen to be bold, and the first also
// points at `/architect`, an R14 delivery surface. Listed here so the net has no hole.
'For spørsmål om NLU-konfigurasjon, kontakt Cosmo Skyberg via `/architect`.',
'Sist oppdatert av Cosmo Skyberg, Microsoft AI Solution Architect',
]);
/** The ratified table-cell rewrites: 4 variants, 16 occurrences (12 row labels, 4 headers). */
export const TABLE_CELL_MAP = {
'For arkitekten (Cosmo)': 'For arkitekten',
'**For arkitekten (Cosmo)**': '**For arkitekten**',
'Cosmo-anbefaling': 'Anbefaling',
'Cosmos råd': 'Råd',
};
/** Provenance cells held back for R14's editorial pass. 10 occurrences, measured. */
export const EDITORIAL_CELLS = new Set([
'Cosmo-syntese av verified sources',
'Cosmo-anbefaling basert på verified data',
'Cosmo-anbefalinger basert på best practices',
"Cosmo's domain expertise + maturity model",
"Cosmo's domain knowledge + norsk lovverk",
"Cosmo's expertise + best practices fra docs",
'Rådgivende innhold basert på Cosmo-persona',
'Mønstre er Cosmo-design, PowerShell-kode er Verified',
'Tabeller og anbefalinger basert på Cosmo-erfaring + Microsoft docs',
'GDPR/AI Act-mapping er Cosmo-tolkninger (ikke Microsoft-spesifikk)',
]);
/** A bold label at the start of a line, optionally behind a list marker. */
const LABEL_RE = /^(\s*(?:[-*+]\s+)?)\*\*([^*\n]+)\*\*/;
/** A markdown table row. */
const TABLE_RE = /^\s*\|/;
/**
* Where do this document's non-heading persona occurrences sit, and is each one in a ratified
* table? Counts only the SITES R13b acts on — bold labels and table cells; prose, dialogue
* outside bold labels and provenance lines are R14's and are not counted here.
*
* `unmapped` is the fail-safe: a persona label or cell in neither the mechanical nor the
* editorial table is an unmeasured variant, and the driver refuses to run rather than guess.
*
* @param {string} content
* @returns {{label: {mechanical: number, editorial: number},
* table: {mechanical: number, editorial: number}, unmapped: string[]}}
*/
export function classifyPersonaSites(content) {
const label = { mechanical: 0, editorial: 0 };
const table = { mechanical: 0, editorial: 0 };
const unmapped = [];
for (const line of String(content ?? '').split('\n')) {
if (/^#{1,6}\s+/.test(line)) continue;
if (TABLE_RE.test(line)) {
const segs = line.split('|');
for (let i = 1; i < segs.length - 1; i++) {
const cell = segs[i].trim();
if (!hasPersona(cell)) continue;
if (TABLE_CELL_MAP[cell] !== undefined) table.mechanical++;
else if (EDITORIAL_CELLS.has(cell)) table.editorial++;
else unmapped.push(`cell: ${JSON.stringify(cell)}`);
}
continue;
}
const m = LABEL_RE.exec(line);
if (!m || !hasPersona(m[2])) continue;
if (LABEL_MAP[m[2]] !== undefined) label.mechanical++;
else if (EDITORIAL_LABELS.has(m[2])) label.editorial++;
else unmapped.push(`label: ${JSON.stringify(m[2])}`);
}
return { label, table, unmapped };
}
/**
* Neutralise the ratified bold labels and table cells. Pure: returns new content, writes
* nothing. Surgical — only the label span or the single matching cell is rewritten, so the
* rest of the line, including any persona R14 still owns, stays byte-identical. Splitting and
* rejoining a row on `|` is lossless, so cell padding survives untouched.
*
* Throws — never writes a guess — on a persona label or cell in neither ratified table.
* Idempotent: a neutral target matches no key.
*
* @param {string} content
* @param {{relPath?: string}} [opts]
* @returns {{content: string, changes: Array<{kind: string, line: number, from: string, to: string}>}}
*/
export function neutralizeLabels(content, { relPath = '' } = {}) {
const where = (i) => `${relPath || '<ukjent fil>'}:${i + 1}`;
const changes = [];
const out = String(content ?? '').split('\n').map((line, i) => {
if (/^#{1,6}\s+/.test(line)) return line;
if (TABLE_RE.test(line)) {
const segs = line.split('|');
let touched = false;
for (let c = 1; c < segs.length - 1; c++) {
const cell = segs[c].trim();
if (!hasPersona(cell)) continue;
const to = TABLE_CELL_MAP[cell];
if (to === undefined) {
if (EDITORIAL_CELLS.has(cell)) continue;
throw new Error(`ukjent persona-tabellcelle i ${where(i)}: ${JSON.stringify(cell)}`);
}
segs[c] = segs[c].replace(cell, to);
changes.push({ kind: 'cell', line: i + 1, from: cell, to });
touched = true;
}
return touched ? segs.join('|') : line;
}
const m = LABEL_RE.exec(line);
if (!m || !hasPersona(m[2])) return line;
const to = LABEL_MAP[m[2]];
if (to === undefined) {
if (EDITORIAL_LABELS.has(m[2])) return line;
throw new Error(`ukjent persona-etikett i ${where(i)}: ${JSON.stringify(m[2])}`);
}
changes.push({ kind: 'label', line: i + 1, from: m[2], to });
return `${m[1]}**${to}**${line.slice(m[0].length)}`;
});
return { content: out.join('\n'), changes };
}