feat(ms-ai-architect): playground v3 markdown parsers (14 archetypes) [skip-docs]

14 tolerant parsers per kanonisk archetype-routing-tabell (Step 11) + 3 helpers (parseTable, parseSections, extractField). Each parser returns {ok:true, data} or {ok:false, errors:[{section, reason}]} — never throws on bad input. PARSERS routing-objekt eksponert via window.__PARSERS.

Verified against all 17 fixtures: every parser produces expected shape. Empty input returns structured error per Verify-asserts.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-03 19:29:18 +02:00
commit 1034777d6b

View file

@ -2080,6 +2080,537 @@
);
}
// ============================================================
// MARKDOWN PARSERS (Step 11)
// ============================================================
//
// 14 parser-arketyper per kanonisk routing-tabell. Hver parser tar
// markdown-streng og returnerer { ok: true, data: {...} } eller
// { ok: false, errors: [{section, reason}] }. Parsers er tolerante
// (kaster aldri unntak) — tom/uventet input gir strukturert feil.
//
// Routing: PARSERS[archetype] for oppslag i handlePasteImport.
// ---- Felles helpers ----
function parseTableRow(line) {
const inner = line.replace(/^\|/, '').replace(/\|$/, '');
return inner.split('|').map(function (c) { return c.trim(); });
}
function parseTable(md, anchorRegex) {
if (typeof md !== 'string') return null;
let body = md;
if (anchorRegex) {
const m = anchorRegex.exec(md);
if (!m) return null;
body = md.slice(m.index + m[0].length);
}
const lines = body.split(/\r?\n/);
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
const next = (lines[i + 1] || '').trim();
if (line.indexOf('|') === 0 && /^\|[\s\-:|]+\|$/.test(next)) {
const headers = parseTableRow(line);
const rows = [];
for (let j = i + 2; j < lines.length; j++) {
const rowLine = lines[j].trim();
if (rowLine.indexOf('|') !== 0) break;
const cells = parseTableRow(rowLine);
if (cells.length === 0) break;
const row = {};
for (let k = 0; k < headers.length; k++) {
row[headers[k]] = (cells[k] || '').trim();
}
rows.push(row);
}
return { headers: headers, rows: rows };
}
}
return null;
}
function parseSections(md) {
if (typeof md !== 'string') return [];
const sections = [];
const lines = md.split(/\r?\n/);
let current = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = /^##\s+(.+)$/.exec(line);
if (m && line.charAt(2) === ' ') { // exactly two #
if (current) sections.push(current);
current = { heading: m[1].trim(), body: '' };
} else if (current) {
current.body += (current.body ? '\n' : '') + line;
}
}
if (current) sections.push(current);
return sections.map(function (s) {
return { heading: s.heading, body: s.body.trim() };
});
}
function extractField(md, label) {
if (typeof md !== 'string') return null;
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp('^\\s*' + escaped + '\\s*:\\s*(.+)$', 'mi');
const m = re.exec(md);
return m ? m[1].trim() : null;
}
function intOrZero(s) {
if (typeof s !== 'string') return 0;
const v = parseInt(s.replace(/[^\d-]/g, ''), 10);
return isNaN(v) ? 0 : v;
}
function emptyInput(md) {
return !md || typeof md !== 'string' || !md.trim();
}
// ---- 14 archetype parsers ----
function parseAiAct(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const errors = [];
const sections = parseSections(md);
let risk_level = extractField(md, 'Risk-level') || extractField(md, 'Risikonivå');
if (!risk_level) {
const sec = sections.find(function (s) { return /risikoniv|risk.level/i.test(s.heading); });
if (sec) {
const firstLine = sec.body.split(/\r?\n/)[0] || '';
risk_level = firstLine.replace(/^Risk-level:\s*/i, '').replace(/^Risikonivå:\s*/i, '').trim();
}
}
if (!risk_level) errors.push({ section: 'risk_level', reason: 'Fant ikke risikonivå' });
const role = extractField(md, 'Rolle') || extractField(md, 'Role') || '';
if (!role) errors.push({ section: 'role', reason: 'Fant ikke rolle' });
let reasoning = extractField(md, 'Reasoning') || extractField(md, 'Begrunnelse') || '';
if (!reasoning) {
const sec = sections.find(function (s) { return /begrunnelse|reasoning/i.test(s.heading); });
if (sec) reasoning = sec.body;
}
const obligations = [];
const oblSec = sections.find(function (s) { return /forpliktelser|obligations/i.test(s.heading); });
if (oblSec) {
oblSec.body.split(/\r?\n/).forEach(function (line) {
const m = /^[-*]\s+(.+)$/.exec(line.trim());
if (m) obligations.push(m[1].trim());
});
}
if (errors.length > 0) return { ok: false, errors: errors };
return {
ok: true,
data: {
risk_level: (risk_level || '').toLowerCase(),
role: role,
reasoning: reasoning,
obligations: obligations
}
};
}
function parseRequirements(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const tbl = parseTable(md);
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen krav-tabell funnet' }] };
const reqKey = tbl.headers.find(function (h) { return /krav|requirement/i.test(h); }) || tbl.headers[0];
const statusKey = tbl.headers.find(function (h) { return /status/i.test(h); }) || tbl.headers[1];
const sourceKey = tbl.headers.find(function (h) { return /kilde|source|art/i.test(h); }) || tbl.headers[2];
const items = tbl.rows.map(function (row) {
return {
requirement: row[reqKey] || '',
status: (row[statusKey] || '').toLowerCase().trim(),
source_article: row[sourceKey] || ''
};
});
return { ok: true, data: { items: items } };
}
function parseTextDocument(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const sections = parseSections(md);
if (!sections.length) {
return { ok: true, data: { sections: [{ heading: 'Innhold', body: md.trim() }] } };
}
return { ok: true, data: { sections: sections } };
}
function parseFria(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const tbl = parseTable(md);
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen rettighet-tabell funnet' }] };
const nameKey = tbl.headers.find(function (h) { return /rettighet|right/i.test(h); }) || tbl.headers[0];
const impactKey = tbl.headers.find(function (h) { return /impact|påvirkning/i.test(h); }) || tbl.headers[1];
const mitigKey = tbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); }) || tbl.headers[2];
const rights = tbl.rows.map(function (row) {
return {
name: row[nameKey] || '',
impact: intOrZero(row[impactKey] || '0'),
mitigation: row[mitigKey] || ''
};
});
return { ok: true, data: { rights: rights } };
}
function parseConformityChecklist(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const checklistTbl = parseTable(md, /##\s*Sjekkliste/i) || parseTable(md);
if (!checklistTbl) return { ok: false, errors: [{ section: 'checklist', reason: 'Ingen sjekkliste-tabell funnet' }] };
const reqKey = checklistTbl.headers.find(function (h) { return /krav|requirement/i.test(h); }) || checklistTbl.headers[0];
const statusKey = checklistTbl.headers.find(function (h) { return /status/i.test(h); }) || checklistTbl.headers[1];
const evidKey = checklistTbl.headers.find(function (h) { return /bevis|evidence/i.test(h); }) || checklistTbl.headers[2];
const checklist = checklistTbl.rows.map(function (row) {
return {
requirement: row[reqKey] || '',
status: (row[statusKey] || '').toLowerCase().trim(),
evidence: row[evidKey] || ''
};
});
const deadlinesTbl = parseTable(md, /##\s*Frister/i);
const deadlines = deadlinesTbl ? deadlinesTbl.rows.map(function (row) {
const dateKey = deadlinesTbl.headers.find(function (h) { return /dato|date/i.test(h); }) || deadlinesTbl.headers[0];
const mileKey = deadlinesTbl.headers.find(function (h) { return /milepæl|milestone/i.test(h); }) || deadlinesTbl.headers[1];
const stKey = deadlinesTbl.headers.find(function (h) { return /status/i.test(h); }) || deadlinesTbl.headers[2];
return {
date: row[dateKey] || '',
milestone: row[mileKey] || '',
status: (row[stKey] || '').toLowerCase().trim()
};
}) : [];
return { ok: true, data: { checklist: checklist, deadlines: deadlines } };
}
function parseMatrixRisk(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const matrixTbl = parseTable(md, /Risikomatrise.*5/i) || parseTable(md);
if (!matrixTbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen risikomatrise funnet' }] };
const labelKey = matrixTbl.headers[0];
const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); });
const matrix_cells = matrixTbl.rows.map(function (row) {
return {
label: row[labelKey] || '',
prob: intOrZero(row[sannKey] || '0'),
cons: intOrZero(row[konsKey] || '0'),
score: intOrZero(row[scoreKey] || '0')
};
});
const threatsTbl = parseTable(md, /##\s*Trusler/i);
const threats = threatsTbl ? threatsTbl.rows.map(function (row) {
const idKey = threatsTbl.headers[0];
const descKey = threatsTbl.headers.find(function (h) { return /beskrivelse|description/i.test(h); }) || threatsTbl.headers[1];
const sevKey = threatsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
const mitKey = threatsTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
return {
id: row[idKey] || '',
description: row[descKey] || '',
severity: (row[sevKey] || '').toLowerCase().trim(),
mitigation: row[mitKey] || ''
};
}) : [];
const radarTbl = parseTable(md, /Radar.akser/i);
const radar_axes = radarTbl ? radarTbl.rows.map(function (row) {
const akseKey = radarTbl.headers.find(function (h) { return /akse|axis/i.test(h); }) || radarTbl.headers[0];
const scKey = radarTbl.headers.find(function (h) { return /score/i.test(h); }) || radarTbl.headers[1];
return {
name: row[akseKey] || '',
score: intOrZero(row[scKey] || '0')
};
}) : null;
return { ok: true, data: { matrix_cells: matrix_cells, threats: threats, radar_axes: radar_axes } };
}
function parseMatrixRisk6x5(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const dimsTbl = parseTable(md, /Score per dimensjon/i);
if (!dimsTbl) return { ok: false, errors: [{ section: 'dimensions', reason: 'Ingen dimensjon-tabell funnet' }] };
const dimNameKey = dimsTbl.headers.find(function (h) { return /dimensjon/i.test(h); }) || dimsTbl.headers[0];
const dimScoreKey = dimsTbl.headers.find(function (h) { return /score/i.test(h); }) || dimsTbl.headers[1];
const dimVurdKey = dimsTbl.headers.find(function (h) { return /vurdering/i.test(h); });
const dimensions = dimsTbl.rows.map(function (row) {
return {
name: row[dimNameKey] || '',
score: intOrZero(row[dimScoreKey] || '0'),
assessment: row[dimVurdKey] || ''
};
});
const matrixTbl = parseTable(md, /Risikomatrise.*6/i);
const matrix_cells = matrixTbl ? matrixTbl.rows.map(function (row) {
const labelKey = matrixTbl.headers[0];
const sannKey = matrixTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
const konsKey = matrixTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
const scoreKey = matrixTbl.headers.find(function (h) { return /score/i.test(h); });
return {
label: row[labelKey] || '',
prob: intOrZero(row[sannKey] || '0'),
cons: intOrZero(row[konsKey] || '0'),
score: intOrZero(row[scoreKey] || '0')
};
}) : [];
const findingsTbl = parseTable(md, /##\s*Funn/i);
const findings = findingsTbl ? findingsTbl.rows.map(function (row) {
const idKey = findingsTbl.headers[0];
const sevKey = findingsTbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
const locKey = findingsTbl.headers.find(function (h) { return /lokasjon|location/i.test(h); });
const recKey = findingsTbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); });
return {
id: row[idKey] || '',
severity: (row[sevKey] || '').toLowerCase().trim(),
location: row[locKey] || '',
recommendation: row[recKey] || ''
};
}) : [];
return {
ok: true,
data: {
dimensions: dimensions,
matrix_cells: matrix_cells,
findings: findings,
scores: dimensions.map(function (d) { return d.score; })
}
};
}
function parseFindings(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const tbl = parseTable(md, /##\s*Funn/i) || parseTable(md);
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen funn-tabell funnet' }] };
const idKey = tbl.headers[0];
const sevKey = tbl.headers.find(function (h) { return /severity|alvorlighet/i.test(h); });
const locKey = tbl.headers.find(function (h) { return /lokasjon|location/i.test(h); });
const recKey = tbl.headers.find(function (h) { return /anbefaling|recommendation/i.test(h); });
const findings = tbl.rows.map(function (row) {
return {
id: row[idKey] || '',
severity: (row[sevKey] || '').toLowerCase().trim(),
location: row[locKey] || '',
recommendation: row[recKey] || ''
};
});
return { ok: true, data: { findings: findings } };
}
function parseCostDistribution(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const distTbl = parseTable(md, /Distribusjon/i);
if (!distTbl) return { ok: false, errors: [{ section: 'distribution', reason: 'Ingen distribusjons-tabell funnet' }] };
const persKey = distTbl.headers.find(function (h) { return /persentil|percentile/i.test(h); }) || distTbl.headers[0];
const monthlyKey = distTbl.headers.find(function (h) { return /månedlig|monthly/i.test(h); }) || distTbl.headers[1];
const yearlyKey = distTbl.headers.find(function (h) { return /årlig|yearly/i.test(h); });
let p10 = null, p50 = null, p90 = null;
distTbl.rows.forEach(function (row) {
const monthly = intOrZero(row[monthlyKey] || '0');
const yearly = yearlyKey ? intOrZero(row[yearlyKey] || '0') : null;
const entry = { monthly: monthly, yearly: yearly };
const tag = (row[persKey] || '').toUpperCase();
if (/P10|P\.10|P 10/.test(tag)) p10 = entry;
else if (/P50|P\.50|P 50/.test(tag)) p50 = entry;
else if (/P90|P\.90|P 90/.test(tag)) p90 = entry;
});
const monthlyTbl = parseTable(md, /Månedlig fordeling/i);
const monthly_breakdown = monthlyTbl ? monthlyTbl.rows.map(function (row) {
const compKey = monthlyTbl.headers[0];
const costKey = monthlyTbl.headers[1];
return {
component: row[compKey] || '',
cost: intOrZero(row[costKey] || '0')
};
}) : [];
const tcoTbl = parseTable(md, /TCO/i);
const tco_table = tcoTbl ? tcoTbl.rows : [];
return {
ok: true,
data: {
p10: p10, p50: p50, p90: p90,
monthly_breakdown: monthly_breakdown,
tco_table: tco_table,
tco_headers: tcoTbl ? tcoTbl.headers : []
}
};
}
function parseCapabilityMatrix(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const tbl = parseTable(md, /##\s*Matrise/i) || parseTable(md);
if (!tbl) return { ok: false, errors: [{ section: 'matrix', reason: 'Ingen matrise funnet' }] };
const capKey = tbl.headers[0];
const licenseNames = tbl.headers.slice(1);
const licenses = licenseNames.map(function (name) {
return { name: name, capabilities: [] };
});
tbl.rows.forEach(function (row) {
const capName = row[capKey];
licenseNames.forEach(function (licName, i) {
licenses[i].capabilities.push({
name: capName,
status: (row[licName] || '').toLowerCase().trim()
});
});
});
return { ok: true, data: { licenses: licenses } };
}
function parsePhasedPlan(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const phases = [];
const lines = md.split(/\r?\n/);
let current = null;
let bucket = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const phaseMatch = /^###\s+(?:Fase\s+\d+\s*[—-]\s*)?(.+?)\s*(?:\(.*\))?\s*$/i.exec(line.trim());
const isH3 = /^###\s+/.test(line);
const isH2 = /^##\s+/.test(line) && !isH3;
if (isH3 && phaseMatch) {
if (current) phases.push(current);
current = {
name: phaseMatch[1].trim(),
milestones: [],
success_criteria: [],
duration_weeks: null
};
bucket = null;
continue;
}
if (isH2) {
if (current) { phases.push(current); current = null; }
bucket = null;
continue;
}
if (!current) continue;
const trimmed = line.trim();
const durMatch = /^Varighet:\s*(\d+)\s*uke/i.exec(trimmed);
if (durMatch) {
current.duration_weeks = parseInt(durMatch[1], 10);
continue;
}
if (/^Milep[æa]ler\s*:?\s*$/i.test(trimmed)) { bucket = 'milestones'; continue; }
if (/^Suksesskriterier\s*:?\s*$/i.test(trimmed)) { bucket = 'success_criteria'; continue; }
const bulletMatch = /^[-*]\s+(.+)$/.exec(trimmed);
if (bulletMatch && bucket && current[bucket]) {
current[bucket].push(bulletMatch[1].trim());
}
}
if (current) phases.push(current);
const risksTbl = parseTable(md, /##\s*Risiko/i);
const risks = risksTbl ? risksTbl.rows.map(function (row) {
const risikoKey = risksTbl.headers[0];
const sannKey = risksTbl.headers.find(function (h) { return /sannsynlig/i.test(h); });
const konsKey = risksTbl.headers.find(function (h) { return /konsekvens/i.test(h); });
const tiltakKey = risksTbl.headers.find(function (h) { return /tiltak|mitigation/i.test(h); });
return {
risk: row[risikoKey] || '',
probability: row[sannKey] || '',
consequence: row[konsKey] || '',
mitigation: row[tiltakKey] || ''
};
}) : [];
if (!phases.length) return { ok: false, errors: [{ section: 'phases', reason: 'Ingen faser funnet (### Fase N)' }] };
return { ok: true, data: { phases: phases, risks: risks } };
}
function parseMarkdown(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const titleMatch = /^#\s+(.+)$/m.exec(md);
const title = titleMatch ? titleMatch[1].trim() : '';
const sections = parseSections(md);
// Frontmatter-style fields (Status, Date, Deciders) — typisk i ADR
const status = extractField(md, 'Status') || '';
const date = extractField(md, 'Date') || extractField(md, 'Dato') || '';
const deciders = extractField(md, 'Deciders') || extractField(md, 'Beslutningstakere') || '';
return { ok: true, data: { title: title, sections: sections, raw: md, status: status, date: date, deciders: deciders } };
}
function parseVerdict(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const verdictRaw = extractField(md, 'Verdict') || '';
const verdict = verdictRaw.toLowerCase().trim();
const sub = extractField(md, 'Sub') || '';
const sections = parseSections(md);
const ratSec = sections.find(function (s) { return /rationale|begrunnelse/i.test(s.heading); });
const rationale = ratSec ? ratSec.body : '';
const metricsTbl = parseTable(md, /Key Metrics|Nøkkelmetrikker/i);
const key_metrics = metricsTbl ? metricsTbl.rows : [];
const metrics_headers = metricsTbl ? metricsTbl.headers : [];
const nextSec = sections.find(function (s) { return /next steps|neste steg/i.test(s.heading); });
const next_steps = [];
if (nextSec) {
nextSec.body.split(/\r?\n/).forEach(function (line) {
const m = /^[-*]\s+(.+)$/.exec(line.trim());
if (m) next_steps.push(m[1].trim());
});
}
if (!verdict) return { ok: false, errors: [{ section: 'verdict', reason: 'Fant ikke "Verdict:"-linje' }] };
return {
ok: true,
data: {
verdict: verdict,
sub: sub,
rationale: rationale,
key_metrics: key_metrics,
metrics_headers: metrics_headers,
next_steps: next_steps
}
};
}
function parseComparison(md) {
if (emptyInput(md)) return { ok: false, errors: [{ section: 'input', reason: 'Tom input' }] };
const subject1 = extractField(md, 'Subject 1') || '';
const subject2 = extractField(md, 'Subject 2') || '';
const tbl = parseTable(md, /##\s*Sammenligning|##\s*Comparison/i) || parseTable(md);
if (!tbl) return { ok: false, errors: [{ section: 'table', reason: 'Ingen sammenligningstabell funnet' }] };
const aspectKey = tbl.headers[0];
const v1Key = tbl.headers[1];
const v2Key = tbl.headers[2];
const winnerKey = tbl.headers[3];
const subjects = [subject1 || v1Key || '', subject2 || v2Key || ''];
const rows = tbl.rows.map(function (row) {
return {
aspect: row[aspectKey] || '',
value1: row[v1Key] || '',
value2: row[v2Key] || '',
winner: winnerKey ? (row[winnerKey] || '') : ''
};
});
return { ok: true, data: { subjects: subjects, rows: rows } };
}
// ---- PARSERS routing-objekt ----
const PARSERS = {
'aiact': parseAiAct,
'requirements-list': parseRequirements,
'text-document': parseTextDocument,
'fria': parseFria,
'conformity-checklist': parseConformityChecklist,
'matrix-risk': parseMatrixRisk,
'matrix-risk-6x5': parseMatrixRisk6x5,
'findings': parseFindings,
'cost-distribution': parseCostDistribution,
'capability': parseCapabilityMatrix,
'phased-plan': parsePhasedPlan,
'markdown': parseMarkdown,
'verdict': parseVerdict,
'comparison': parseComparison
};
// Eksponer for Verify-asserts og Step 12.
window.__PARSERS = PARSERS;
window.__parseTable = parseTable;
window.__parseSections = parseSections;
window.__extractField = extractField;
// ---- Paste-import stub (Step 12 erstatter med faktisk routing) ----
function handlePasteImport(commandId, markdown) {