import { BOLD_MARK, HEADING_MARK, LEAD_IN_SEPARATOR, LINE_END, LIST_MARKER, } from "#configuration/constants/inventory.constants"; import { GATE_HEADING, ITS_PREFIX, MODEL_EXTENSION, SEED_ID_SEPARATOR, SEED_LEVELS, SLOT_SENTENCE_LEAD, } from "#configuration/constants/seed.constants"; import type { InvariantStatement, LessonSeed, SlotWords } from "#types/lesson.types"; import type { SourceText } from "#types/inventory.types"; import { slugify } from "@govlab/context/algo"; const SOURCE = "model"; const NO_LEAD = "with no "; const STATEMENT_LEADS: readonly string[] = [LEAD_IN_SEPARATOR, ".", ":", ",", " "]; const LABEL_STOPS: ReadonlySet = new Set([".", ",", ":", " "]); const MIN_WORD = 3; const isUpperWord = function isUpperWord(word: string): boolean { if (word.length < MIN_WORD) { return false; } for (const char of word) { if (char < "A" || char > "Z") { return false; } } return true; }; const upperWordsAfterLead = function upperWordsAfterLead(text: string): string[] { const lead = text.indexOf(SLOT_SENTENCE_LEAD); const close = lead === -1 ? -1 : text.indexOf(BOLD_MARK, lead); if (close === -1) { return []; } const paragraphEnd = text.indexOf(LINE_END + LINE_END, close); const body = text.slice(close, paragraphEnd === -1 ? text.length : paragraphEnd); return body.split(" ").filter(isUpperWord); }; const objectorWord = function objectorWord(text: string): string | null { const gate = text.indexOf(GATE_HEADING); const at = gate === -1 ? -1 : text.indexOf(NO_LEAD, gate); if (at === -1) { return null; } const rest = text.slice(at + NO_LEAD.length); const end = rest.indexOf(" "); return end === -1 ? rest : rest.slice(0, end); }; export const slotWordsOf = function slotWordsOf(template: string): SlotWords | null { const [property, set, parties] = upperWordsAfterLead(template).map((word) => word.toLowerCase()); const objector = objectorWord(template); if (property === undefined || set === undefined || parties === undefined || objector === null) { return null; } return { objector, parties, property, set }; }; const firstWordOf = function firstWordOf(label: string): string { const lower = label.trim().toLowerCase(); const bare = lower.startsWith(ITS_PREFIX.toLowerCase()) ? lower.slice(ITS_PREFIX.length) : lower; let end = 0; while (end < bare.length && !LABEL_STOPS.has(bare.charAt(end))) { end += 1; } return bare.slice(0, end); }; const stripLeads = function stripLeads(text: string): string { let out = text; let changed = true; while (changed) { changed = false; for (const lead of STATEMENT_LEADS) { if (out.startsWith(lead)) { out = out.slice(lead.length); changed = true; } } } return out.trim(); }; const slotOf = function slotOf(label: string, words: SlotWords): keyof SlotWords | null { const word = firstWordOf(label); const entries: [keyof SlotWords, string][] = [ ["property", words.property], ["set", words.set], ["parties", words.parties], ["objector", words.objector], ]; return entries.find(([, held]) => held === word)?.[0] ?? null; }; const unitsOf = function unitsOf(block: string): string[] { const units: string[] = []; for (const line of block.split(LINE_END)) { const last = units.length - 1; const previous = units[last]; if (line.trim().length === 0 || line.startsWith(LIST_MARKER) || previous === undefined) { units.push(line); continue; } units[last] = `${previous} ${line.trim()}`; } return units; }; const statementsOf = function statementsOf(block: string, words: SlotWords): Partial> { const found: Partial> = {}; for (const line of unitsOf(block)) { const segments = line.split(BOLD_MARK); for (let index = 1; index < segments.length; index += 2) { const slot = slotOf(segments[index] ?? "", words); const statement = stripLeads(segments[index + 1] ?? ""); if (slot !== null && found[slot] === undefined && statement.length > 0) { found[slot] = statement; } } } return found; }; const blocksOf = function blocksOf(text: string): { heading: string; body: string }[] { const blocks: { heading: string; body: string }[] = []; for (const line of text.split(LINE_END)) { if (line.startsWith(HEADING_MARK + HEADING_MARK)) { let at = 0; while (line.charAt(at) === HEADING_MARK) { at += 1; } blocks.push({ body: "", heading: line.slice(at).trim() }); continue; } const last = blocks.at(-1); if (last !== undefined) { last.body += line + LINE_END; } } return blocks; }; export const invariantsOf = function invariantsOf(text: string, words: SlotWords): InvariantStatement[] { const out: InvariantStatement[] = []; for (const block of blocksOf(text)) { const found = statementsOf(block.body, words); const { objector, parties, property, set } = found; if (objector !== undefined && parties !== undefined && property !== undefined && set !== undefined) { out.push({ heading: block.heading, objector, parties, property, set }); } } return out; }; const modelStem = function modelStem(path: string): string { const base = path.slice(path.lastIndexOf("/") + 1); return base.endsWith(MODEL_EXTENSION) ? base.slice(0, -MODEL_EXTENSION.length) : base; }; export const modelSeeds = function modelSeeds(models: readonly SourceText[], words: SlotWords): LessonSeed[] { return models.flatMap((model) => invariantsOf(model.text, words).map((held) => ({ application: [held.set], boundary: null, cause: [], decision: held.parties, domain: modelStem(model.path), failureMode: held.objector, id: modelStem(model.path) + SEED_ID_SEPARATOR + slugify(held.heading), level: SEED_LEVELS[SOURCE], principle: held.property, problem: null, source: SOURCE, validation: held.objector, })), ); };