backpr's picture
CareRules Handbook Inspector
43aabba
Raw History Blame Contribute Delete
44.8 kB
/* CareRules engine for the browser: the same quote checker, page search, prompts, verification, scoring and report
as the Python package (carerules/corpus.py, audit.py, view.py), so the Space can run as a free static page. Beam is
reached through a small Worker that holds the token and enforces access codes; every check of Beam's output runs
here, against the official texts this page serves. Also loads in Node for parity tests (module.exports). */
(function (root) {
"use strict";
const CR = {};
const BASE = (typeof location !== "undefined" ? "" : "") + "";
CR.data = { base: "./", sources: null, cache: {} };
/* ---------- text normalization and word-for-word matching (corpus.py) ---------- */
const DASHES = /[‐‑‒–—―−­-]/g;
// Texas prints a weight after each rule ("[Medium-High]"); it isn't part of the rule's words, so quotes may leave it out.
const WEIGHT = /\[(?:low|medium|high)(?:[- ]?(?:low|medium|high))?\]/gi;
CR.norm = (text) => (String(text || "").normalize("NFKC").replace(WEIGHT, " ").toLowerCase().replace(DASHES, "").match(/[a-z0-9§]+/g) || []).join(" ");
CR.unweight = (text) => String(text || "").replace(WEIGHT, "").replace(/\s+([;,.])/g, "$1").replace(/ {2,}/g, " ").trim();
const MAX_GAP = 300; // normalized characters (about 60 words) a "..." may skip
const SHORT_SKIP = 120, NEGATION = new Set(["not", "no", "never", "nor", "neither", "cannot", "t", "except", "unless", "without"]);
/* One string per document with spaces removed ("9 – 13 years" must match "9-13 years"), page offsets, and where
words start and end, so a quote must begin and end on whole words ("attended" never matches in "unattended"). */
CR.buildIndex = (pages) => {
const parts = [], offsets = [], starts = [], ends = [];
let pos = 0;
for (const [number, text] of pages) {
offsets.push([pos, number]);
for (const w of CR.norm(text).split(" ").filter(Boolean)) {
starts.push(pos);
if (w.startsWith("§") && w.length > 1) starts.push(pos + 1); // "746.1601" may be quoted without its §
pos += w.length;
ends.push(pos);
parts.push(w);
}
}
const flags = (list) => { const a = new Uint8Array(pos + 2); for (const i of list) a[i] = 1; return a; };
return { hay: parts.join(""), offsets, starts: flags(starts), ends: flags(ends) };
};
const pageAt = (index, i) => {
let page = index.offsets.length ? index.offsets[0][1] : 1;
for (const [begin, number] of index.offsets) { if (begin > i) break; page = number; }
return page;
};
/* (page, start, end) where the quote appears word for word, or null. "..." skips text between fragments that
appear in order within maxGap characters; a quote needs 6+ words with one fragment of 4+. */
CR.match = (index, quote, maxGap = MAX_GAP) => {
let frags = String(quote || "").split(/\.\.\.|…/).map(CR.norm).filter(Boolean);
if (!frags.length) return null;
const words = frags.map((f) => f.split(" ").length);
if (words.reduce((a, b) => a + b, 0) < 6 || Math.max(...words) < 4) return null;
frags = frags.map((f) => f.replace(/ /g, ""));
const { hay, starts, ends } = index;
const find = (f, at) => { // next whole-word occurrence of f at or after `at`
let i = hay.indexOf(f, at);
while (i !== -1 && !(starts[i] && ends[i + f.length])) i = hay.indexOf(f, i + 1);
return i;
};
const flips = (a, b) => { // a short "..." skip must not drop a "not" (it would reverse the rule)
if (b - a > SHORT_SKIP || maxGap > MAX_GAP) return false;
const cuts = [];
for (let j = a; j < b; j++) if (starts[j]) cuts.push(j);
cuts.push(b);
return cuts.slice(0, -1).some((x, i) => NEGATION.has(hay.slice(x, cuts[i + 1])));
};
let start = find(frags[0], 0);
while (start !== -1) {
let pos = start + frags[0].length, ok = true;
for (const f of frags.slice(1)) {
const nxt = find(f, pos);
if (nxt === -1 || nxt - pos > maxGap || flips(pos, nxt)) { ok = false; break; }
pos = nxt + f.length;
}
if (ok) return [pageAt(index, start), start, pos];
start = find(frags[0], start + 1);
}
return null;
};
/* Like match, but when a quote runs past what the document says (two table rows joined into one quote), keep the
longest leading or trailing part that does appear: at least 6 words and half the quote. [match, verified text] */
CR.matchLoose = (index, quote) => {
const m = CR.match(index, quote);
if (m) return [m, quote];
const words = String(quote || "").split(/\s+/).filter(Boolean);
if (/\.\.\.|…/.test(quote) || words.length < 7) return null;
for (let k = words.length - 1; k >= Math.max(6, Math.ceil(words.length / 2)); k--) {
for (const part of [words.slice(0, k).join(" "), words.slice(-k).join(" ")]) {
const got = CR.match(index, part);
if (got) return [got, part.replace(/^[\s,;:]+|[\s,;:]+$/g, "")];
}
}
return null;
};
/* ---------- BM25 over rulebook pages (corpus.Index) ---------- */
class Index {
constructor(pages, k1 = 1.2, b = 0.75) {
this.docs = pages;
this.toks = pages.map((p) => CR.norm(p.text).split(" ").filter(Boolean));
this.tf = this.toks.map((t) => { const m = new Map(); for (const w of t) m.set(w, (m.get(w) || 0) + 1); return m; });
this.avg = this.toks.reduce((a, t) => a + t.length, 0) / Math.max(1, this.toks.length);
const df = new Map();
for (const m of this.tf) for (const w of m.keys()) df.set(w, (df.get(w) || 0) + 1);
const n = pages.length;
this.idf = new Map([...df].map(([w, d]) => [w, Math.log(1 + (n - d + 0.5) / (d + 0.5))]));
this.k1 = k1; this.b = b;
}
search(query, k = 5) {
const q = CR.norm(query).split(" ").filter((w) => this.idf.has(w));
const scored = [];
this.docs.forEach((p, i) => {
const tf = this.tf[i], dl = this.toks[i].length;
let s = 0;
for (const w of q) {
const f = tf.get(w);
if (f) s += this.idf.get(w) * f * (this.k1 + 1) / (f + this.k1 * (1 - this.b + this.b * dl / this.avg));
}
if (s > 0) scored.push([s, p]);
});
scored.sort((a, b) => b[0] - a[0]);
return scored.slice(0, k);
}
}
CR.Index = Index;
/* ---------- data: sources, rulebook pages, checklists ---------- */
const fetchText = async (path) => {
if (CR.data.loader) return CR.data.loader(path); // Node tests read local files
const r = await fetch(CR.data.base + path);
if (!r.ok) throw new Error(`Couldn't load ${path}`);
return r.text();
};
CR.sources = async () => {
if (!CR.data.sources) CR.data.sources = Object.fromEntries(JSON.parse(await fetchText("data/sources.json")).map((s) => [s.state, s]));
return CR.data.sources;
};
CR.rulebook = async (state) => {
const key = "rb:" + state;
if (!CR.data.cache[key]) {
const raw = await fetchText(`text/${state}.txt`);
const pages = raw.split("\f").map((t, i) => ({ number: i + 1, text: t })).filter((p) => p.text.trim());
const byNumber = new Map(pages.map((p) => [p.number, p]));
CR.data.cache[key] = { pages, byNumber, index: null, normed: null };
}
return CR.data.cache[key];
};
CR.checklist = async (state) => {
const key = "cl:" + state;
if (!CR.data.cache[key]) CR.data.cache[key] = JSON.parse(await fetchText(`data/checklists/${state}.json`)).items;
return CR.data.cache[key];
};
const normed = async (state) => {
const rb = await CR.rulebook(state);
if (!rb.normed) rb.normed = CR.buildIndex(rb.pages.map((p) => [p.number, p.text]));
return rb;
};
CR.findQuote = async (state, quote) => {
const m = CR.match((await normed(state)).normed, quote);
return m ? m[0] : null;
};
/* Guidance inside a rulebook (Texas "Technical Assistance" boxes, California "Handbook" text) explains the rules
but isn't one, so it never counts as the rule a handbook breaks. (corpus.is_guidance) */
const GUIDANCE = { TX: [/^technical assistance\b/i, /^§\s?746\.\d+\./], CA: [/^handbook begins here/i, /^handbook ends here/i] };
CR.GUIDANCE_NAME = { TX: 'a Texas "Technical Assistance" box', CA: "California's handbook (explanatory) text" };
CR.isGuidance = async (state, start) => {
if (!GUIDANCE[state]) return false;
const rb = await normed(state);
if (!rb.lines) {
rb.lines = [];
let pos = 0;
for (const p of rb.pages) for (const line of p.text.split("\n")) { rb.lines.push([pos, line.trim()]); pos += CR.norm(line).replace(/ /g, "").length; }
}
const [opener, closer] = GUIDANCE[state], lines = rb.lines;
let lo = 0, hi = lines.length - 1;
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (lines[mid][0] <= start) lo = mid; else hi = mid - 1; }
for (let i = lo; i > Math.max(-1, lo - 400); i--) {
if (closer.test(lines[i][1])) return false;
if (opener.test(lines[i][1])) return true;
}
return false;
};
/* [page, verified text, is guidance] for a rule quote, allowing the loose match above. (corpus.find_rule) */
CR.findRule = async (state, quote) => {
const got = CR.matchLoose((await normed(state)).normed, quote);
if (!got) return null;
const [[page, start], text] = got;
return [page, text, await CR.isGuidance(state, start)];
};
/* A short table row ("0 – 11 months 4") proves itself only on the page of the rule it belongs to: whole words,
at least 3, with a number, on `page` or the next one. (corpus.find_row) */
CR.findRow = async (state, quote, page) => {
const words = CR.norm(quote).split(" ").filter(Boolean);
if (words.length < 3 || !words.some((w) => /^\d+$/.test(w))) return null;
const idx = (await normed(state)).normed;
const inRange = idx.offsets.filter(([, n]) => n === page || n === page + 1);
if (!inRange.length) return null;
const after = idx.offsets.find(([, n]) => n > page + 1);
const lo = inRange[0][0], hi = after ? after[0] : idx.hay.length, f = words.join("");
for (let i = idx.hay.indexOf(f, lo); i !== -1 && i + f.length <= hi; i = idx.hay.indexOf(f, i + 1)) {
if (idx.starts[i] && idx.ends[i + f.length]) return [pageAt(idx, i), String(quote).trim(), await CR.isGuidance(state, i)];
}
return null;
};
CR.pageUrl = (src, page) => (!src || !src.url ? "" : (src.format || "pdf") === "pdf" ? `${src.url}#page=${page}` : src.url);
/* ---------- focused context and prompt (audit.py) ---------- */
const MAX_CONTEXT_CHARS = 140000, CHUNK = 60;
CR.focusedPages = async (state, items, handbook) => {
const rb = await CR.rulebook(state);
const chosen = [];
for (const it of items) {
if (/^\d+$/.test(String(it.page ?? ""))) for (const n of [+it.page, +it.page + 1]) if (rb.byNumber.has(n) && !chosen.includes(n)) chosen.push(n);
}
if (!rb.index) rb.index = new Index(rb.pages);
for (const [, text] of handbook) {
for (let i = 0; i < text.length; i += 1500) {
for (const [, p] of rb.index.search(text.slice(i, i + 1500), 2)) if (!chosen.includes(p.number)) chosen.push(p.number);
}
}
const out = [];
let size = 0;
for (const n of chosen) { size += rb.byNumber.get(n).text.length; if (size > MAX_CONTEXT_CHARS) break; out.push(n); }
return out.sort((a, b) => a - b);
};
CR.prompt = async (state, items, handbook) => {
const src = (await CR.sources())[state] || {};
const rb = await CR.rulebook(state);
const keep = new Set(await CR.focusedPages(state, items, handbook));
const body = rb.pages.filter((p) => keep.has(p.number)).map((p) => `[${state} page ${p.number}]\n${p.text.trim()}`).join("\n");
const context = `=== RULEBOOK ${state}: ${src.title || state} — the ${keep.size} pages relevant to this handbook, out of ${src.pdf_pages ?? "?"} (page numbers are the official document's) ===\n${body}`;
const listing = items.map((i) => `- ${i.id} [${i.kind}] ${i.requirement} (${i.section}, page ${i.page}): "${i.quote}"`).join("\n");
const doc = handbook.map(([n, t]) => `[Handbook page ${n}]\n${t.trim()}`).join("\n");
return `${context}\n\n=== CHECKLIST for ${src.title || state} ===\n${listing}\n\n=== THE CENTER'S HANDBOOK ===\n${doc}\n\n` +
"You are a careful licensing inspector. Audit the handbook against the checklist and the rulebook above.\n" +
"For EVERY checklist item, give a status:\n" +
"- covered: the handbook clearly addresses it and agrees with the rule\n" +
"- partial: it addresses it but leaves out something the rule requires\n" +
"- conflict: the handbook says something the rule forbids or contradicts (for example a number the rule " +
"doesn't allow, or permission the rule requires in writing given verbally)\n" +
"Call something a conflict only if the exact rule words you give as rule_quote show it, and write the " +
"explanation from those words alone: if a rule allows several ways (for example in writing, electronically, " +
"or by phone for one dose), say which condition the handbook misses, never that an allowed way is forbidden.\n" +
"Apply only rules for the kind of program the handbook describes. Rules that apply only to special programs " +
"(for example a state-funded pre-K program, get-well or sick care, night care, or family child care homes) " +
"don't apply unless the handbook says the center runs one; a room merely named \"Pre-K\" isn't one.\n" +
"- missing: a [must_include] item the handbook doesn't address at all\n" +
"- not_mentioned: a [rule] item the handbook doesn't mention (that's allowed)\n" +
"Then look for any OTHER statement in the handbook that contradicts the rulebook.\n\n" +
"Quotes must be copied exactly (8 to 60 words): handbook_quote from the handbook, rule_quote from the " +
"rulebook. For missing and partial items and every conflict, write a fix: a paragraph the center can paste into " +
"its handbook, in plain, warm language, that meets the rule without adding requirements the rule doesn't " +
"have. Use [brackets] for details only the center knows (times, names, phone numbers).\n\n" +
"Keep it short: for covered items give only id, status and handbook_quote; for not_mentioned items only id " +
"and status. Explanations and fixes are only for partial, missing and conflict items.\n\n" +
'Reply with only a JSON object: {"items": [{"id", "status", "handbook_quote", "handbook_page", ' +
'"rule_quote" (for a conflict: the exact rulebook words the handbook breaks, such as the table row with the ' +
'number; else empty), "rule_section" (the section that rule_quote comes from, as printed), ' +
'"explanation" (one plain sentence), "fix"}], "extra_conflicts": [{"title", "handbook_quote", ' +
'"handbook_page", "rule_quote", "section", "page", "explanation", "fix"}], "summary" (two sentences)}.';
};
/* ---------- reading Beam's reply (grade.parse_json) ---------- */
CR.parseJson = (text) => {
text = String(text || "").trim();
try { return JSON.parse(text); } catch (e) { /* fall through */ }
const blocks = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1]).reverse();
for (const b of blocks) { try { return JSON.parse(b); } catch (e) { /* next */ } }
const starts = [text.indexOf("{"), text.indexOf("[")].filter((i) => i !== -1).sort((a, b) => a - b);
for (const start of starts) {
const closer = text[start] === "{" ? "}" : "]";
let end = text.lastIndexOf(closer);
while (end > start) {
try { return JSON.parse(text.slice(start, end + 1)); } catch (e) { end = text.lastIndexOf(closer, end - 1); }
}
}
return null;
};
/* ---------- verification and scoring (audit.assemble, score, statements, cross_state) ---------- */
const STATUSES = ["covered", "partial", "missing", "conflict", "not_mentioned"];
const NOT_THERE = "but the words it quoted don't appear in your handbook exactly as written (it may have joined two lines, such as two rows of a table).";
const HANDBOOK_NOTE = { conflict: `Beam flagged this as a conflict, ${NOT_THERE}`, partial: `Beam flagged this as incomplete, ${NOT_THERE}`,
covered: `Beam marked this as covered, ${NOT_THERE}` };
const CONFLICT_PENALTY = 5, ROW = 80;
CR.sectionLabel = async (state, label, page) => {
label = String(label || "").replace(/,?\s*\(?page\s*\d+\)?\s*$/i, "").trim();
const nums = label.match(/\d[\d.\-]*\d/g) || [];
const core = nums.reduce((a, b) => (b.length > a.length ? b : a), "");
const rb = await CR.rulebook(state);
const near = " " + rb.pages.filter((p) => p.number === page || p.number === page - 1).map((p) => CR.norm(p.text)).join(" ").replace(/§/g, " ") + " ";
return label && core && near.includes(` ${CR.norm(core)} `) ? label : `rule on page ${page}`;
};
CR.statements = (findings) => {
const hits = findings.filter((f) => f.status === "conflict" && f.span).sort((a, b) => a.span[0] - b.span[0]);
const groups = [];
for (const f of hits) {
if (groups.length) {
const g = groups[groups.length - 1];
const end = Math.max(...g.map((x) => x.span[1]));
const last = g[g.length - 1];
const short = f.span[1] - f.span[0] <= ROW && last.span[1] - last.span[0] <= ROW;
if (f.span[0] < end || (short && f.span[0] - end <= ROW)) { g.push(f); continue; }
}
groups.push([f]);
}
return groups;
};
const pyRound = (x) => { const r = Math.round(x); return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r; }; // Python's round()
CR.score = (findings) => {
const must = findings.filter((f) => f.kind === "must_include");
const credit = must.reduce((a, f) => a + (f.status === "covered" ? 1 : f.status === "partial" ? 0.5 : 0), 0);
const passages = CR.statements(findings).length;
const base = must.length ? 100 * credit / must.length : 100;
const counts = Object.fromEntries([...STATUSES, "unverified"].map((s) => [s, findings.filter((f) => f.status === s).length]));
counts.conflict_statements = passages;
return { score: Math.max(0, pyRound(base - CONFLICT_PENALTY * passages)), coverage: pyRound(base), required: must.length, counts };
};
CR.assemble = async (state, handbook, content, meta) => {
const items = await CR.checklist(state);
const src = (await CR.sources())[state] || {};
const got = CR.parseJson(content);
if (meta.finish === "length" || !got || typeof got !== "object" || Array.isArray(got) || !Array.isArray(got.items) || !got.items.length) {
throw new Error(`Beam's answer for ${name(state)} was cut off or couldn't be read. Please try again.`);
}
const hb = CR.buildIndex(handbook);
const answers = Object.fromEntries(got.items.filter((a) => a && typeof a === "object").map((a) => [String(a.id), a]));
const findings = [];
for (let it of items) {
const a = answers[it.id] || {};
let status = STATUSES.includes(a.status) ? a.status : it.kind === "must_include" ? "missing" : "not_mentioned";
if (status === "not_mentioned" && it.kind === "must_include") status = "missing";
const hq = String(a.handbook_quote || "").trim();
const m = hq ? CR.match(hb, hq) : null;
const hpage = m ? m[0] : null, span = m ? [m[1], m[2]] : null;
let note = "";
if (["covered", "partial", "conflict"].includes(status) && !hpage) { // the words aren't there: never shown as fact
note = hq ? HANDBOOK_NOTE[status] : "Beam didn't quote your handbook for this.";
status = "unverified";
}
const rq = String(a.rule_quote || "").trim();
if (status === "conflict") {
let rule = rq ? await CR.findRule(state, rq) : null;
const row = rule || !rq || !/^\d+$/.test(String(it.page)) ? null : await CR.findRow(state, rq, +it.page);
if (row) rule = [row[0], `${it.quote} … ${row[1]}`, row[2]]; // a table row, proven on the checklist rule's page
if (!rule) { // a conflict has to name rule words that are really there
status = "unverified"; note = "Beam flagged this as a conflict, but the rule words it gave don't match the official text.";
} else if (rule[2]) {
status = "unverified"; note = `Beam flagged this as a conflict, but the words it quoted are from ${CR.GUIDANCE_NAME[state]}, which is guidance, not a rule.`;
} else if (row) {
it = { ...it, quote: rule[1], page: rule[0] };
} else { // the exact clause broken, proven word for word, replaces the checklist's general quote
it = { ...it, quote: rule[1], page: rule[0], section: await CR.sectionLabel(state, a.rule_section, rule[0]) };
}
}
findings.push({ ...it, status, handbook_quote: hpage ? hq : "", handbook_page: hpage, span,
explanation: String(a.explanation || "").trim(), note,
fix: ["missing", "partial", "conflict"].includes(status) ? String(a.fix || "").trim() : "",
rule_url: /^\d+$/.test(String(it.page)) ? CR.pageUrl(src, it.page) : "" });
}
const extras = [];
for (const x of Array.isArray(got.extra_conflicts) ? got.extra_conflicts : []) {
if (!x || typeof x !== "object") continue;
const m = CR.match(hb, String(x.handbook_quote || ""));
const rule = await CR.findRule(state, String(x.rule_quote || ""));
if (m && rule && !rule[2]) { // both quotes proven, from a rule (not guidance), or it isn't shown
const rpage = rule[0];
extras.push({ id: "extra_" + (extras.length + 1), topic: "other", kind: "rule", status: "conflict",
requirement: String(x.title || "Conflict with the rulebook"), section: await CR.sectionLabel(state, x.section, rpage),
page: rpage, quote: rule[1], handbook_quote: String(x.handbook_quote).trim(), handbook_page: m[0],
span: [m[1], m[2]], explanation: String(x.explanation || "").trim(), note: "", fix: String(x.fix || "").trim(),
rule_url: CR.pageUrl(src, rpage) });
}
}
findings.push(...extras);
return { state, rulebook: src.title || "", findings, summary: String(got.summary || "").trim(), ...CR.score(findings), ...meta, raw: content };
};
CR.crossState = (results) => {
const every = [];
for (const [s, r] of Object.entries(results)) for (const f of r.findings) if (f.status === "conflict" && f.span) every.push({ ...f, _state: s });
const rows = CR.statements(every).map((group) => {
const byState = {};
for (const f of group) if (!byState[f._state]) byState[f._state] = f;
const lead = group.reduce((a, f) => (f.handbook_quote.length > a.handbook_quote.length ? f : a), group[0]);
return { handbook_quote: lead.handbook_quote, handbook_page: lead.handbook_page, states: byState,
verdict: Object.fromEntries(Object.keys(results).map((s) => [s, byState[s] ? "conflict" : "ok"])) };
});
return rows.sort((a, b) => (Object.keys(b.states).length - Object.keys(a.states).length) || ((a.handbook_page || 0) - (b.handbook_page || 0)));
};
/* ---------- running a check: one Beam call per state (split when the checklist is long) ---------- */
CR.auditState = async (state, handbook, effort, callBeam) => {
const items = await CR.checklist(state);
const chunks = [];
for (let i = 0; i < items.length; i += CHUNK) chunks.push(items.slice(i, i + CHUNK));
const replies = await Promise.all(chunks.map(async (part, i) => {
let prompt = await CR.prompt(state, part, handbook);
if (i > 0) prompt += "\nFor this part, leave extra_conflicts empty.";
return callBeam(prompt, effort);
}));
const merged = { items: [], extra_conflicts: [], summary: "" };
let finish = "stop";
for (const rep of replies) {
const got = CR.parseJson(rep.content);
if (rep.finish === "length" || !got || typeof got !== "object" || !Array.isArray(got.items) || !got.items.length) { finish = "length"; continue; }
merged.items.push(...got.items);
if (Array.isArray(got.extra_conflicts)) merged.extra_conflicts.push(...got.extra_conflicts);
merged.summary = merged.summary || String(got.summary || "");
}
const meta = { cost: +replies.reduce((a, r) => a + (r.cost || 0), 0).toFixed(4),
tokens_in: replies.reduce((a, r) => a + (r.tokens_in || 0), 0), tokens_out: replies.reduce((a, r) => a + (r.tokens_out || 0), 0),
finish, seconds: Math.max(...replies.map((r) => r.seconds || 0)), effort, calls: replies.length,
cached: replies.every((r) => r.cached) };
return CR.assemble(state, handbook, JSON.stringify(merged), meta);
};
/* Every state at once; one state failing doesn't lose the others. Returns {results, errors} by state. */
CR.audit = async (handbook, states, effort, callBeam) => {
const one = async (s) => {
try { return await CR.auditState(s, handbook, effort, callBeam); }
catch (err) {
if (!/cut off|couldn't be read/i.test(err.message)) throw err; // only a cut-off or unreadable answer is retried
// One more try at low effort, asking Beam to sample instead of repeating the same runaway answer.
const r = await CR.auditState(s, handbook, "low", (prompt, eff) => callBeam(prompt, eff, true));
if (effort !== "low") r.effort = `low (the ${effort}-effort answer was cut off)`;
return r;
}
};
const settled = await Promise.allSettled(states.map(one));
const results = {}, errors = {};
settled.forEach((x, i) => { if (x.status === "fulfilled") results[states[i]] = x.value; else errors[states[i]] = x.reason.message || String(x.reason); });
return { results, errors };
};
/* ---------- reading an uploaded handbook into pages (document.py) ---------- */
const MAX_CHARS = 240000;
CR.chunk = (text, size = 3000) => {
const pages = [];
let cur = "";
for (const para of text.split(/\n\s*\n/)) {
if (cur && cur.length + para.length > size) { pages.push(cur); cur = ""; }
cur += para.trim() + "\n\n";
}
if (cur.trim()) pages.push(cur);
return pages.map((t, i) => [i + 1, t]);
};
CR.readFile = async (file, libs) => {
const ext = (file.name.match(/\.[^.]+$/) || [""])[0].toLowerCase();
let pages;
if (ext === ".pdf") {
const pdf = await libs.pdfjs.getDocument({ data: await file.arrayBuffer() }).promise;
pages = [];
for (let i = 1; i <= pdf.numPages; i++) {
const content = await (await pdf.getPage(i)).getTextContent();
pages.push([i, content.items.map((x) => x.str + (x.hasEOL ? "\n" : " ")).join("")]);
}
} else if (ext === ".docx") {
pages = CR.chunk((await libs.mammoth.extractRawText({ arrayBuffer: await file.arrayBuffer() })).value);
} else if (ext === ".txt" || ext === ".md") {
pages = CR.chunk(await file.text());
} else {
throw new Error("Upload a PDF, Word (.docx) or text file.");
}
const out = [];
let total = 0;
for (const [n, t] of pages) {
if (total >= MAX_CHARS) break;
const text = t.slice(0, MAX_CHARS - total);
total += text.length;
out.push([n, text]);
}
if (out.reduce((a, [, t]) => a + t.split(/\s+/).filter(Boolean).length, 0) < 150) {
throw new Error("Couldn't read enough text from this file. If it's a scanned PDF, upload the Word version.");
}
return out;
};
/* ---------- the report (view.py) ---------- */
const NAMES = { AL: "Alabama", AR: "Arkansas", AZ: "Arizona", CA: "California", CO: "Colorado", DC: "District of Columbia",
FL: "Florida", GA: "Georgia", IA: "Iowa", IL: "Illinois", MA: "Massachusetts", MI: "Michigan", MO: "Missouri",
MS: "Mississippi", NC: "North Carolina", ND: "North Dakota", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
OH: "Ohio", OK: "Oklahoma", OR: "Oregon", PA: "Pennsylvania", SC: "South Carolina", TX: "Texas", VA: "Virginia", WA: "Washington" };
const COLORS = { conflict: "#ff5d6c", missing: "#f5a524", partial: "#ffe186", covered: "#3ddc97", not_mentioned: "#71717a", unverified: "#a78bfa" };
const LABELS = { conflict: "Breaks a rule", missing: "Missing", partial: "Incomplete", covered: "Covered",
not_mentioned: "Not mentioned (not required)", unverified: "Couldn't confirm: check by hand" };
CR.NAMES = NAMES; CR.COLORS = COLORS;
const e = (x) => String(x ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#x27;" }[c]));
const name = (s) => NAMES[s] || s;
const ring = (score) => {
const r = 38, c = 2 * Math.PI * 38;
const color = score >= 80 ? "#3ddc97" : score >= 50 ? "#ffe186" : "#ff5d6c";
return `<svg width="92" height="92" viewBox="0 0 92 92" role="img" aria-label="Score ${score} of 100">` +
`<circle cx="46" cy="46" r="${r}" fill="none" stroke="currentColor" stroke-opacity=".12" stroke-width="8"/>` +
`<circle cx="46" cy="46" r="${r}" fill="none" stroke="${color}" stroke-width="8" stroke-linecap="round" ` +
`stroke-dasharray="${(c * score / 100).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 46 46)"/>` +
`<text x="46" y="49" text-anchor="middle" font-size="24" font-weight="700" fill="currentColor">${score}</text>` +
`<text x="46" y="64" text-anchor="middle" font-size="11" fill="currentColor" fill-opacity=".6">/100</text></svg>`;
};
/* Score cards: the score, then the two things it's made of, so the numbers add up on their face. */
const scoreCards = (results, src) => `<div class="ci-cards">${Object.entries(results).map(([st, r]) => {
const c = r.counts, must = r.findings.filter((f) => f.kind === "must_include");
const n = (s) => must.filter((f) => f.status === s).length;
const plural = (k, one, many) => `${k} ${k === 1 ? one : many}`;
const req = [["covered", "covered", n("covered")], ["partial", "incomplete", n("partial")], ["missing", "missing", n("missing")],
["conflict", "break a rule", n("conflict")], ["unverified", "to check", n("unverified")]]
.filter(([s, , k]) => k || ["covered", "partial", "missing"].includes(s)).map(([s, l, k]) => `<span style="color:${COLORS[s]}">${k} ${l}</span>`).join(" · ");
const other = [["conflict", plural(c.conflict_statements, "sentence breaks a rule", "sentences break a rule")], ["unverified", `${c.unverified} to check by hand`]]
.filter(([s]) => (s === "conflict" ? c.conflict_statements : c.unverified)).map(([s, l]) => `<span style="color:${COLORS[s]}">${l}</span>`).join(" · ");
return `<div class="ci-card">${ring(r.score)}<div><div class="ci-state">${e(name(st))}</div>` +
`<div class="ci-dim">Score out of 100 · ${e((src[st] || {}).short || "")}</div>` +
`<div class="ci-line">Required policies (${r.required}): ${req || "none"}</div>` +
`<div class="ci-line">${other || '<span style="color:' + COLORS.covered + '">No sentence breaks a rule</span>'}</div></div></div>`;
}).join("")}</div>`;
const matrix = (results) => {
const rows = CR.crossState(results), states = Object.keys(results);
if (!rows.length || states.length < 2) return "";
const head = states.map((s) => `<th>${e(name(s))}</th>`).join("");
const body = rows.map((row) => `<tr><td class="ci-q">${CR.isTableRow(row.handbook_quote) ? "Table row: " : ""}“${e(row.handbook_quote.slice(0, 150))}${row.handbook_quote.length > 150 ? "…" : ""}”<div class="ci-dim">Handbook ${where(row.handbook_page)}</div></td>` +
states.map((s) => { const f = row.states[s]; return f ? `<td class="ci-bad" title="${e(f.requirement)}">✕ <span>${e(f.section)}</span></td>` : '<td class="ci-ok">✓ <span>none found</span></td>'; }).join("") + "</tr>").join("");
return `<h3 class="ci-h">One handbook, ${states.length} states</h3><p class="ci-dim">A sentence can be fine in one state and break a rule in another. "None found" covers only that state's licensing rules; other laws, like child abuse reporting laws, also apply.</p>` +
`<div class="ci-scroll"><table class="ci-matrix"><tr><th>Your handbook says</th>${head}</tr>${body}</table></div>` + '<p class="ci-scroll-hint">Scroll the table sideways to see every state.</p>' ;
};
const LANES = 6; // rows of marks, so the map stays short
const rulebookMap = (state, r, src) => {
const total = +((src[state] || {}).pdf_pages || 1);
const order = { conflict: 0, missing: 1, partial: 2, unverified: 3, covered: 4, not_mentioned: 5 };
const items = r.findings.filter((f) => /^\d+$/.test(String(f.page ?? ""))).sort((a, b) => (+a.page - +b.page) || (order[a.status] - order[b.status]));
const lanes = [], marks = [];
for (const f of items) {
const x = 1 + 98 * (+f.page - 1) / Math.max(1, total - 1);
let lane = lanes.findIndex((last) => x - last >= 1.1);
if (lane === -1 && lanes.length < LANES) { lane = lanes.length; lanes.push(x); }
if (lane === -1) lane = lanes.indexOf(Math.min(...lanes)); // full: stack on the row with the most room
lanes[lane] = x;
marks.push([x, lane, f]);
}
const h = 14 + Math.max(1, lanes.length) * 9;
const svg = [`<svg viewBox="0 0 100 ${h}" preserveAspectRatio="none" width="100%" height="${h * 2}" class="ci-timeline" role="img" aria-label="${e(name(state))} rulebook map">`,
`<rect x="0" y="${h - 6}" width="100" height="2" rx="1" fill="currentColor" fill-opacity=".18"/>`,
...marks.map(([x, lane, f]) => `<rect x="${(x - 0.45).toFixed(2)}" y="${h - 10 - lane * 9 - 4}" width=".9" height="8" rx=".3" fill="${COLORS[f.status]}"><title>p. ${e(f.page)} · ${e(f.section)} · ${e(LABELS[f.status])}: ${e(f.requirement)}</title></rect>`),
"</svg>"];
const legend = ["conflict", "missing", "partial", "covered", "not_mentioned", "unverified"].map((k) => `<span><i style="background:${COLORS[k]}"></i>${LABELS[k]}</span>`).join("");
return `<div class="ci-map"><div class="ci-dim">The whole ${e(name(state))} rulebook, page 1 to ${total}. Each mark is a rule your handbook should cover, at its page. Hover for details.</div>${svg.join("")}` +
`<div class="ci-axis"><span>p. 1</span><span>p. ${total}</span></div><div class="ci-legend">${legend}</div></div>`;
};
const ruleBlock = (f, state, src) => {
const pdf = ((src[state] || {}).format || "pdf") === "pdf";
const link = f.rule_url ? ` <a href="${e(f.rule_url)}" target="_blank" rel="noopener">${pdf ? `open page ${e(f.page)}` : "open the official source"}</a>` : "";
return `<div class="ci-quote ci-rule"><b>${e(name(state))} ${e(f.section)} ✅ checked word for word${link}</b>“${e(CR.unweight(f.quote))}”</div>`;
};
const card = (f, state, src, also = []) => {
const color = COLORS[f.status];
let html = `<div class="ci-finding" style="border-left-color:${color}"><div class="ci-tag" style="color:${color}">${e(LABELS[f.status])}</div><div class="ci-req">${e(f.requirement)}</div>`;
if (f.handbook_quote) html += `<div class="ci-quote"><b>Your handbook, ${where(f.handbook_page)}</b>“${e(f.handbook_quote)}”</div>`;
if (f.quote) html += ruleBlock(f, state, src);
const shown = new Set([`${f.section}|${f.page}`]);
for (const o of also) { // the same sentence can break several rules; show each rule once
const key = `${o.section}|${o.page}`;
if (o.quote && !shown.has(key)) { shown.add(key); html += ruleBlock(o, state, src); }
}
if (f.note) html += `<p class="ci-why ci-note">${e(f.note)} Check this one yourself before changing anything.</p>`;
if (f.explanation) html += `<p class="ci-why">${f.note ? "Beam's reading: " : ""}${e(f.explanation)}</p>`;
const fix = f.fix || (also.find((o) => o.fix) || {}).fix || "";
if (fix) html += `<div class="ci-fix"><div class="ci-fixhead">Suggested wording, drafted by AI (edit before you use it)<button class="ci-copy" data-copy="${e(fix)}">Copy</button></div>${e(fix)}</div>`;
return html + "</div>";
};
const FIRST = 4; // cards shown per group before "show more"
const listing = (cards) => cards.slice(0, FIRST).join("") + (cards.length > FIRST
? `<details class="ci-more"><summary>Show ${cards.length - FIRST} more</summary>${cards.slice(FIRST).join("")}</details>` : "");
const findingsHtml = (state, r, src) => {
let out = "";
const groups = CR.statements(r.findings);
if (groups.length) out += `<h4 class="ci-h4">${LABELS.conflict} (${groups.length})</h4>` +
listing(groups.map((g) => card(g.reduce((a, f) => (f.handbook_quote.length > a.handbook_quote.length ? f : a), g[0]), state, src, g)));
// Headings count every rule checked; the score cards count required policies only, so say how many are required.
const count = (group) => {
const req = group.filter((f) => f.kind === "must_include").length;
return req === group.length || req === 0 ? `${group.length}` : `${group.length}: ${req} required ${req === 1 ? "policy" : "policies"}, ${group.length - req} other rule${group.length - req === 1 ? "" : "s"}`;
};
for (const status of ["missing", "partial", "unverified"]) {
const group = r.findings.filter((f) => f.status === status);
if (group.length) out += `<h4 class="ci-h4">${LABELS[status]} (${count(group)})</h4>` + listing(group.map((f) => card(f, state, src)));
}
const covered = r.findings.filter((f) => f.status === "covered");
if (covered.length) out += `<details><summary class="ci-h4">Covered (${count(covered)})</summary>${covered.map((f) => card(f, state, src)).join("")}</details>`;
return out;
};
CR.scoreCards = async (results) => scoreCards(results, await CR.sources());
CR.isTableRow = (quote) => /\d/.test(quote) && quote.split(/\s+/).length <= 12 && !/[.;:!?]$/.test(quote.trim());
CR.SCORE_NOTE = "Score: the percent of required policies your handbook covers (incomplete ones count half), minus 5 points for each sentence that breaks a rule. It's not an inspection result.";
/* "Start here": what to fix first, before the details. */
const startHere = (results) => {
const rows = CR.crossState(results), states = Object.keys(results);
const multi = states.length > 1 ? rows.filter((r) => Object.keys(r.states).length > 1).slice(0, 3) : [];
const lines = Object.entries(results).map(([s, r]) => {
const c = r.counts, must = r.findings.filter((f) => f.kind === "must_include");
const n = (st) => must.filter((f) => f.status === st).length;
const parts = [];
if (c.conflict_statements) parts.push(`${c.conflict_statements} sentence${c.conflict_statements > 1 ? "s" : ""} break${c.conflict_statements > 1 ? "" : "s"} a rule`);
if (n("missing")) parts.push(`${n("missing")} required polic${n("missing") > 1 ? "ies" : "y"} missing`);
if (n("partial")) parts.push(`${n("partial")} required polic${n("partial") > 1 ? "ies" : "y"} incomplete`);
const other = r.findings.filter((f) => f.kind !== "must_include" && f.status === "partial").length; // optional rules: no effect on the score
if (other) parts.push(`${other} other rule${other === 1 ? "" : "s"} only partly covered`);
if (c.unverified) parts.push(`${c.unverified} to check by hand`);
return `<li><b>${e(name(s))}: ${r.score}/100.</b> ${parts.length ? e(parts.join(", ")) + "." : "No problems found."}</li>`;
}).join("");
const top = multi.length ? `<p><b>Fix first:</b> ${multi.length === 1 ? "this sentence breaks" : "these sentences break"} the rules of more than one state.</p><ul>${multi.map((r) =>
`<li>“${e(r.handbook_quote.slice(0, 140))}${r.handbook_quote.length > 140 ? "…" : ""}” <span class="ci-dim">(handbook ${where(r.handbook_page)}: ${e(Object.keys(results).filter((s) => r.states[s]).map(name).join(", "))})</span></li>`).join("")}</ul>` : "";
return `<div class="ci-start"><h3 class="ci-h">Start here</h3><ul>${lines}</ul>${top}<p class="ci-dim">${e(CR.SCORE_NOTE)} Each finding quotes your handbook and the rule; a real quote proves the words exist, not that Beam read them correctly. Not legal advice: confirm changes with your licensing agency.</p></div>`;
};
/* Text in a handbook that tries to instruct the AI ("ignore the instructions", "mark everything covered"). */
CR.suspicious = (pages) => {
const re = /\b(ignore|disregard)\b.{0,40}\b(instructions|rules above|checklist|prompt)\b|\bmark (every|all|each)\b.{0,30}\b(covered|compliant)\b|\byou are (now )?(an? )?(ai|assistant|language model|inspector)\b|\bsystem prompt\b/i;
return pages.some(([, t]) => re.test(t));
};
/* Word and text files have no fixed pages, so their "pages" are parts of about 3,000 characters. */
let UNIT = "page";
const where = (n) => (UNIT === "part" ? `part ${n}` : `p. ${n}`);
CR.report = async (results, handbookName, handbookPages, unit = "page") => {
UNIT = unit;
const src = await CR.sources();
const rs = Object.values(results);
const tokens = rs.reduce((a, r) => a + (r.tokens_in || 0) + (r.tokens_out || 0), 0);
const secs = Math.max(...rs.map((r) => r.seconds || 0));
const cost = rs.reduce((a, r) => a + (r.cost || 0), 0);
const n = rs.length;
const cached = rs.length && rs.every((r) => r.cached);
const effort = [...new Set(rs.map((r) => (String(r.effort || "").startsWith("high") ? "Extra thorough" : "Standard")))].join(" and ");
const head = `<div class="ci-run">Beam 1.0 read <b>${e(handbookName)}</b> (${handbookPages} ${unit}${handbookPages === 1 ? "" : "s"}) with the licensing rules of ${n} state${n > 1 ? "s" : ""}: ` +
`${tokens.toLocaleString("en-US")} tokens in ${Math.round(secs)} seconds, $${cost.toFixed(2)}, ${effort}${cached ? " (served from the cache at no cost this time)" : ""}. ` +
"Every quote below was checked word for word against its document.</div>";
const tabs = Object.entries(results).map(([s, r], i) => `<details class="ci-state-block" ${i === 0 ? "open" : ""}><summary>${e(name(s))}: ${r.score}/100</summary>${rulebookMap(s, r, src)}${findingsHtml(s, r, src)}</details>`).join("");
return `<div class="ci">${head}${scoreCards(results, src)}${startHere(results)}${matrix(results)}<h3 class="ci-h">${n > 1 ? "Findings by state" : `Findings for ${e(name(Object.keys(results)[0]))}`}</h3>${tabs}</div>`;
};
CR.fixesMarkdown = (results, handbookName, unit = "page") => {
UNIT = unit;
const lines = [`# Suggested handbook fixes: ${handbookName}`, "",
"Drafted by Beam 1.0 from each state's official licensing rules. Every rule quote was checked word for word. Review and edit before you use them; this is not legal advice.", ""];
for (const [st, r] of Object.entries(results)) {
lines.push(`## ${name(st)} (score ${r.score}/100)`, "");
for (const f of r.findings) {
if (!f.fix) continue;
lines.push(`### ${f.requirement}`);
if (f.handbook_quote) lines.push(`Your handbook says (${where(f.handbook_page)}): "${f.handbook_quote}"`);
lines.push(`Rule: ${f.section}, page ${f.page}: "${CR.unweight(f.quote)}"`, "", `Suggested wording (AI draft): ${f.fix}`, "");
}
}
lines.push("---", "", "Made with CareRules (https://care.backpr.com), a free tool from BackPR. Suggested wording was drafted by AI " +
"from your state's official licensing rules and isn't legal advice; confirm changes with your licensing agency. When your " +
"handbook is final, BackPR can collect each family's e-signed acknowledgment at registration: " +
"https://backpr.com/solutions/childcare?utm_source=carerules&utm_medium=referral&utm_campaign=fixes", "");
return lines.join("\n");
};
/* what the page needs for the hero card and the "fine here, conflict there" section */
CR.light = async (results) => {
const src = await CR.sources();
return {
scores: Object.fromEntries(Object.entries(results).map(([s, r]) => [s, { score: r.score, name: name(s), counts: r.counts,
pages: (src[s] || {}).pdf_pages, marks: r.findings.filter((f) => /^\d+$/.test(String(f.page ?? ""))).map((f) => [+f.page, f.status]) }])),
rows: CR.crossState(results).map((row) => ({ quote: row.handbook_quote, page: row.handbook_page, verdict: row.verdict,
sections: Object.fromEntries(Object.entries(row.states).map(([s, f]) => [s, f.section])),
detail: Object.fromEntries(Object.entries(row.states).map(([s, f]) => [s, { section: f.section, page: f.page, rule: CR.unweight(f.quote), fix: f.fix, why: f.explanation, url: f.rule_url || "", quote: f.handbook_quote }])) })),
};
};
if (typeof module !== "undefined" && module.exports) module.exports = CR; else root.CR = CR;
})(typeof window !== "undefined" ? window : globalThis);