/**
* Verification — fetch-back and validate document after creation.
*
* Checks:
* 1. All sections present
* 2. All data rows present
* 3. Heading backgrounds applied
* 4. Red highlights present
* 5. Bold headers present
* 6. No empty bullets
*/
import type { LarkCli } from "../cli.js";
// ─── Types ──────────────────────────────────────────────────────────────
export interface VerifyReport {
totalBlocks: number;
rootChildren: number;
// Block-level
headingCount: number;
headingRootCount: number;
headingsWithBg: number;
headingBgCoverage: number;
tableCount: number;
redHighlightCount: number;
boldHeaderCount: number;
bulletCount: number;
emptyBulletCount: number;
residualHtmlTags: string[];
// Content-level (from markdown fetch-back)
contentChecks: {
hasSectionHeadings: boolean;
hasLarkTables: boolean;
noResidualHtml: boolean;
linksPreserved: boolean;
boldPreserved: boolean;
codeColumnPresent: boolean;
titleColumnPresent: boolean;
html001Present: boolean;
html003Present: boolean;
bulletItemsPresent: boolean;
linksInCells: boolean;
chineseTextPresent: boolean;
blueNumberedHeading: boolean;
chineseOrdinalNormalized: boolean;
paragraphsSeparated: boolean;
};
// Overall
checks: { name: string; pass: boolean; detail: string }[];
ok: boolean;
}
// ─── Verify ─────────────────────────────────────────────────────────────
/** HTML tag patterns that should never appear in rendered doc blocks. */
const RESIDUAL_HTML_RE = /<\/?(?:p|ul|ol|li|strong|b|em|i)\b[^>]*>/gi;
function defaultContentChecks() {
return {
hasSectionHeadings: false,
hasLarkTables: false,
noResidualHtml: false,
linksPreserved: false,
boldPreserved: false,
codeColumnPresent: false,
titleColumnPresent: false,
html001Present: false,
html003Present: false,
bulletItemsPresent: false,
linksInCells: false,
chineseTextPresent: false,
blueNumberedHeading: false,
chineseOrdinalNormalized: false,
paragraphsSeparated: false,
};
}
export interface PipelineContext {
hasTables?: boolean;
documentType?: string;
}
export function verifyDoc(cli: LarkCli, docId: string, ctx?: PipelineContext): VerifyReport {
const blocks = cli.getBlocks(docId);
const md = cli.fetchDoc(docId) ?? "";
const isNarrative = ctx?.documentType === "narrative" || ctx?.hasTables === false;
const report: VerifyReport = {
totalBlocks: blocks.length,
rootChildren: 0,
headingCount: 0,
headingRootCount: 0,
headingsWithBg: 0,
headingBgCoverage: 0,
tableCount: 0,
redHighlightCount: 0,
boldHeaderCount: 0,
bulletCount: 0,
emptyBulletCount: 0,
residualHtmlTags: [],
contentChecks: defaultContentChecks(),
checks: [],
ok: false,
};
// ── Block-level analysis ───────────────────────────────────────────
const root = blocks.find((b) => b.block_type === 1);
const rootChildIds: string[] = root ? ((root as any).children || []) : [];
report.rootChildren = rootChildIds.length;
const tableChildIds = new Set
should produce // separate lines in table cells (e.g. "Important:" on its own line) cc.paragraphsSeparated = !/<\/?p>\s*Important/i.test(md); // ── Build checks ─────────────────────────────────────────────────── report.checks = [ // Block-level { name: "Headings at root level", pass: report.headingRootCount >= (isNarrative ? 5 : 10), detail: `${report.headingRootCount}/${report.headingCount} root-level`, }, { name: "Heading bg coverage", pass: report.headingBgCoverage >= 90, detail: `${report.headingsWithBg}/${report.headingCount} (${report.headingBgCoverage}%)`, }, { name: "Tables present", pass: isNarrative ? true : report.tableCount > 0, detail: isNarrative ? `narrative doc, skipping` : `${report.tableCount} tables`, }, { name: "Native bullets", pass: true, // GTC data may have 0 bullets (text-only cells) detail: `${report.bulletCount} bullets`, }, { name: "No empty bullets", pass: report.emptyBulletCount <= 3, detail: `${report.emptyBulletCount} empty bullets`, }, { name: "Red highlights", pass: isNarrative ? true : report.redHighlightCount >= 2, detail: isNarrative ? `narrative doc, skipping` : `${report.redHighlightCount} highlights`, }, { name: "Bold headers", pass: isNarrative ? true : report.boldHeaderCount >= 7, detail: isNarrative ? `narrative doc, skipping` : `${report.boldHeaderCount} bold headers`, }, { name: "No residual HTML (blocks)", pass: report.residualHtmlTags.length === 0, detail: report.residualHtmlTags.length === 0 ? "clean" : `found: ${report.residualHtmlTags.join(", ")}`, }, // Content-level { name: "Section headings (md)", pass: cc.hasSectionHeadings, detail: cc.hasSectionHeadings ? "present" : "missing", }, { name: "Lark tables (md)", pass: isNarrative ? true : cc.hasLarkTables, detail: isNarrative ? `narrative doc, skipping` : cc.hasLarkTables ? "present" : "missing", }, { name: "No residual HTML (md)", pass: cc.noResidualHtml, detail: cc.noResidualHtml ? "clean" : "found HTML tags", }, { name: "Links preserved (md)", pass: isNarrative ? true : cc.linksPreserved, detail: cc.linksPreserved ? "present" : isNarrative ? "narrative doc" : "missing", }, { name: "Bold preserved (md)", pass: cc.boldPreserved, detail: cc.boldPreserved ? "present" : "missing", }, { name: "Data columns (md)", pass: isNarrative ? true : cc.codeColumnPresent && cc.titleColumnPresent, detail: isNarrative ? `narrative doc, skipping` : cc.codeColumnPresent && cc.titleColumnPresent ? "Code + Title" : "missing", }, { name: "Bullet items (md)", pass: cc.bulletItemsPresent, detail: cc.bulletItemsPresent ? "present" : "missing", }, { name: "Links in cells (md)", pass: isNarrative ? true : cc.linksInCells, detail: isNarrative ? `narrative doc, skipping` : cc.linksInCells ? "present" : "missing", }, { name: "Chinese text (md)", pass: cc.chineseTextPresent, detail: cc.chineseTextPresent ? "present" : "missing", }, { name: "Blue headings (md)", pass: cc.blueNumberedHeading, detail: cc.blueNumberedHeading ? "present" : "missing", }, { name: "Chinese ordinals normalized (md)", pass: cc.chineseOrdinalNormalized, detail: cc.chineseOrdinalNormalized ? "normalized" : "raw ordinals found", }, ]; report.ok = report.checks.every((c) => c.pass); return report; } /** * Human-readable report summary. */ export function formatReport(report: VerifyReport): string { const lines = [ `Blocks: ${report.totalBlocks} (${report.rootChildren} root children)`, `Headings: ${report.headingRootCount}/${report.headingCount} root-level (${report.headingBgCoverage}% bg)`, `Tables: ${report.tableCount}`, `Bullets: ${report.bulletCount} (${report.emptyBulletCount} empty)`, `Red highlights: ${report.redHighlightCount}`, `Bold headers: ${report.boldHeaderCount}`, `Residual HTML: ${report.residualHtmlTags.length === 0 ? "none" : report.residualHtmlTags.join(", ")}`, ``, ...report.checks.map((c) => `${c.pass ? "✅" : "❌"} ${c.name}: ${c.detail}`), ``, `Status: ${report.ok ? `✅ PASS (${report.checks.filter(c => c.pass).length}/${report.checks.length})` : "❌ FAIL"}`, ]; return lines.join("\n"); }