flex-md 1.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -29
- package/SPEC.md +559 -0
- package/dist/__tests__/validate.test.d.ts +1 -0
- package/dist/__tests__/validate.test.js +108 -0
- package/dist/detect/json/detectIntent.d.ts +2 -0
- package/dist/detect/json/detectIntent.js +79 -0
- package/dist/detect/json/detectPresence.d.ts +6 -0
- package/dist/detect/json/detectPresence.js +191 -0
- package/dist/detect/json/index.d.ts +7 -0
- package/dist/detect/json/index.js +12 -0
- package/dist/detect/json/types.d.ts +43 -0
- package/dist/detect/json/types.js +1 -0
- package/dist/detection/detector.d.ts +6 -0
- package/dist/detection/detector.js +104 -0
- package/dist/detection/extractor.d.ts +10 -0
- package/dist/detection/extractor.js +54 -0
- package/dist/extract/extract.d.ts +5 -0
- package/dist/extract/extract.js +50 -0
- package/dist/extract/types.d.ts +11 -0
- package/dist/extract/types.js +1 -0
- package/dist/index.d.ts +13 -3
- package/dist/index.js +20 -3
- package/dist/issues/build.d.ts +26 -0
- package/dist/issues/build.js +62 -0
- package/dist/md/lists.d.ts +14 -0
- package/dist/md/lists.js +33 -0
- package/dist/md/match.d.ts +12 -0
- package/dist/md/match.js +44 -0
- package/dist/md/outline.d.ts +6 -0
- package/dist/md/outline.js +67 -0
- package/dist/md/parse.d.ts +29 -0
- package/dist/md/parse.js +105 -0
- package/dist/md/tables.d.ts +25 -0
- package/dist/md/tables.js +72 -0
- package/dist/ofs/enricher.d.ts +16 -0
- package/dist/ofs/enricher.js +77 -0
- package/dist/ofs/extractor.d.ts +9 -0
- package/dist/ofs/extractor.js +75 -0
- package/dist/ofs/issues.d.ts +14 -0
- package/dist/ofs/issues.js +92 -0
- package/dist/ofs/issuesEnvelope.d.ts +15 -0
- package/dist/ofs/issuesEnvelope.js +71 -0
- package/dist/ofs/parser.d.ts +9 -0
- package/dist/ofs/parser.js +133 -0
- package/dist/ofs/stringify.d.ts +5 -0
- package/dist/ofs/stringify.js +32 -0
- package/dist/ofs/validator.d.ts +10 -0
- package/dist/ofs/validator.js +91 -0
- package/dist/outline/builder.d.ts +10 -0
- package/dist/outline/builder.js +85 -0
- package/dist/outline/renderer.d.ts +6 -0
- package/dist/outline/renderer.js +23 -0
- package/dist/parser.js +58 -10
- package/dist/parsers/lists.d.ts +6 -0
- package/dist/parsers/lists.js +36 -0
- package/dist/parsers/tables.d.ts +10 -0
- package/dist/parsers/tables.js +58 -0
- package/dist/pipeline/enforce.d.ts +10 -0
- package/dist/pipeline/enforce.js +46 -0
- package/dist/pipeline/kind.d.ts +16 -0
- package/dist/pipeline/kind.js +24 -0
- package/dist/pipeline/repair.d.ts +14 -0
- package/dist/pipeline/repair.js +112 -0
- package/dist/strictness/container.d.ts +14 -0
- package/dist/strictness/container.js +46 -0
- package/dist/strictness/processor.d.ts +5 -0
- package/dist/strictness/processor.js +29 -0
- package/dist/strictness/types.d.ts +77 -0
- package/dist/strictness/types.js +106 -0
- package/dist/test-pipeline.d.ts +1 -0
- package/dist/test-pipeline.js +53 -0
- package/dist/test-runner.d.ts +1 -0
- package/dist/test-runner.js +331 -0
- package/dist/test-strictness.d.ts +1 -0
- package/dist/test-strictness.js +213 -0
- package/dist/types.d.ts +140 -22
- package/dist/validate/policy.d.ts +10 -0
- package/dist/validate/policy.js +17 -0
- package/dist/validate/types.d.ts +11 -0
- package/dist/validate/types.js +1 -0
- package/dist/validate/validate.d.ts +2 -0
- package/dist/validate/validate.js +308 -0
- package/docs/mdflex-compliance.md +216 -0
- package/package.json +15 -6
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates Markdown guidance instructions for the LLM based on the OFS and contract level.
|
|
3
|
+
* Strictly avoids the word "flex-md" and remains "tax-aware" by only including relevant rules.
|
|
4
|
+
*/
|
|
5
|
+
export function buildMarkdownGuidance(spec, strict, opts) {
|
|
6
|
+
const level = strict.level ?? 0;
|
|
7
|
+
const fence = opts?.containerFence === "flexmd" ? "```flexmd" : "```markdown";
|
|
8
|
+
// L0 - Minimal Markdown
|
|
9
|
+
if (level === 0) {
|
|
10
|
+
return "Reply in Markdown.";
|
|
11
|
+
}
|
|
12
|
+
const lines = [];
|
|
13
|
+
// L2+ Container Rule
|
|
14
|
+
if (level >= 2) {
|
|
15
|
+
lines.push(`Return your entire answer inside a single ${fence} fenced block and nothing else.`);
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push("Inside the block, include these section headings somewhere (order does not matter):");
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
lines.push("Reply in Markdown.");
|
|
21
|
+
lines.push("");
|
|
22
|
+
lines.push("Include these section headings somewhere (order does not matter):");
|
|
23
|
+
}
|
|
24
|
+
// Heading List
|
|
25
|
+
for (const s of spec.sections) {
|
|
26
|
+
let suffix = "";
|
|
27
|
+
if (level >= 3) {
|
|
28
|
+
if (s.kind === "list")
|
|
29
|
+
suffix = " (list)";
|
|
30
|
+
else if (s.kind === "ordered_list")
|
|
31
|
+
suffix = " (ordered list)";
|
|
32
|
+
}
|
|
33
|
+
lines.push(`- ${s.name}${suffix}`);
|
|
34
|
+
}
|
|
35
|
+
lines.push("");
|
|
36
|
+
// None Rule
|
|
37
|
+
const noneRule = opts?.includeNoneRule ?? "auto";
|
|
38
|
+
const hasRequired = spec.sections.some(s => s.required !== false);
|
|
39
|
+
const includeNone = noneRule === "always" || (noneRule === "auto" && (hasRequired || level >= 3));
|
|
40
|
+
if (includeNone) {
|
|
41
|
+
lines.push(`If a section is empty, write \`None\`.`);
|
|
42
|
+
lines.push("");
|
|
43
|
+
}
|
|
44
|
+
// List Rules
|
|
45
|
+
const listRule = opts?.includeListRules ?? "auto";
|
|
46
|
+
const hasListDecls = spec.sections.some(s => s.kind === "list" || s.kind === "ordered_list");
|
|
47
|
+
const includeList = listRule === "always" || (listRule === "auto" && (hasListDecls || level >= 3));
|
|
48
|
+
if (includeList) {
|
|
49
|
+
lines.push("List rules:");
|
|
50
|
+
lines.push("- Use numbered lists (`1.`, `2.`, …) for ordered lists.");
|
|
51
|
+
lines.push("- Use `-` bullets for unordered lists.");
|
|
52
|
+
lines.push("");
|
|
53
|
+
}
|
|
54
|
+
// Table Rules
|
|
55
|
+
const tableRule = opts?.includeTableRules ?? "auto";
|
|
56
|
+
const hasTableDecls = (spec.tables && spec.tables.length > 0) || spec.sections.some(s => s.kind === "table" || s.kind === "ordered_table");
|
|
57
|
+
const includeTable = tableRule === "always" || (tableRule === "auto" && (hasTableDecls || level >= 3));
|
|
58
|
+
if (includeTable) {
|
|
59
|
+
const hasOrderedTable = spec.tables?.some(t => t.kind === "ordered_table") || spec.sections.some(s => s.kind === "ordered_table");
|
|
60
|
+
lines.push("Tables:");
|
|
61
|
+
lines.push("- If you include a table, use a Markdown pipe table.");
|
|
62
|
+
if (hasOrderedTable || level >= 3) {
|
|
63
|
+
lines.push("- For ordered tables, add a first column named `#` and number rows 1..N.");
|
|
64
|
+
}
|
|
65
|
+
lines.push("");
|
|
66
|
+
}
|
|
67
|
+
if (level >= 3) {
|
|
68
|
+
lines.push("Do not return JSON as the response format.");
|
|
69
|
+
}
|
|
70
|
+
return lines.join("\n").trim();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* @deprecated Use buildMarkdownGuidance
|
|
74
|
+
*/
|
|
75
|
+
export function enrichInstructions(spec, strict) {
|
|
76
|
+
return buildMarkdownGuidance(spec, strict);
|
|
77
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OutputFormatSpec, ExtractedResult } from "../types.js";
|
|
2
|
+
export interface ExtractOptions {
|
|
3
|
+
/** Parse lists even for prose sections */
|
|
4
|
+
parseAllLists?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Extract structured data from Markdown based on an OutputFormatSpec.
|
|
8
|
+
*/
|
|
9
|
+
export declare function extractOutput(md: string, spec: OutputFormatSpec, opts?: ExtractOptions): ExtractedResult;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { buildOutline } from "../outline/builder.js";
|
|
2
|
+
import { parseList } from "../parsers/lists.js";
|
|
3
|
+
import { extractAllTables } from "../parsers/tables.js";
|
|
4
|
+
/**
|
|
5
|
+
* Extract structured data from Markdown based on an OutputFormatSpec.
|
|
6
|
+
*/
|
|
7
|
+
export function extractOutput(md, spec, opts = {}) {
|
|
8
|
+
const outline = buildOutline(md);
|
|
9
|
+
const tables = extractAllTables(md);
|
|
10
|
+
// Index nodes by normalized title
|
|
11
|
+
const matches = collectMatches(outline.nodes);
|
|
12
|
+
const sectionsByName = {};
|
|
13
|
+
// Extract each section
|
|
14
|
+
for (const section of spec.sections) {
|
|
15
|
+
const key = normalizeTitle(section.name);
|
|
16
|
+
const nodes = matches.get(key) ?? [];
|
|
17
|
+
if (nodes.length === 0) {
|
|
18
|
+
// Section not found - could add to a "missing" array if needed
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
// Choose best node (highest level)
|
|
22
|
+
const chosen = chooseBestNode(nodes);
|
|
23
|
+
const body = chosen.content_md.trim();
|
|
24
|
+
const extracted = {
|
|
25
|
+
nodeKey: chosen.key,
|
|
26
|
+
nodeLevel: chosen.level,
|
|
27
|
+
md: body
|
|
28
|
+
};
|
|
29
|
+
// Parse lists if required by section kind or if parseAllLists is enabled
|
|
30
|
+
if (section.kind === "list" || section.kind === "ordered_list" || opts.parseAllLists) {
|
|
31
|
+
const list = parseList(body);
|
|
32
|
+
if (list) {
|
|
33
|
+
extracted.list = list;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
sectionsByName[section.name] = extracted;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
outline,
|
|
40
|
+
sectionsByName,
|
|
41
|
+
tables
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Collect all nodes by normalized title.
|
|
46
|
+
*/
|
|
47
|
+
function collectMatches(nodes) {
|
|
48
|
+
const map = new Map();
|
|
49
|
+
function visit(node) {
|
|
50
|
+
const key = normalizeTitle(node.title);
|
|
51
|
+
const existing = map.get(key) ?? [];
|
|
52
|
+
existing.push(node);
|
|
53
|
+
map.set(key, existing);
|
|
54
|
+
node.children.forEach(visit);
|
|
55
|
+
}
|
|
56
|
+
nodes.forEach(visit);
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Choose the best node from multiple matches.
|
|
61
|
+
* Prefer highest-level heading (smallest level number).
|
|
62
|
+
*/
|
|
63
|
+
function chooseBestNode(nodes) {
|
|
64
|
+
if (nodes.length === 1)
|
|
65
|
+
return nodes[0];
|
|
66
|
+
// Sort by level (ascending) and take first
|
|
67
|
+
const sorted = [...nodes].sort((a, b) => a.level - b.level);
|
|
68
|
+
return sorted[0];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Normalize title for comparison: lowercase, remove trailing punctuation.
|
|
72
|
+
*/
|
|
73
|
+
function normalizeTitle(t) {
|
|
74
|
+
return t.trim().replace(/[:\-–—]\s*$/, "").trim().toLowerCase();
|
|
75
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { IssuesEnvelope, FlexMdResponseMode } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Detects if the response is an Issues envelope (Mode B).
|
|
4
|
+
*/
|
|
5
|
+
export declare function detectIssuesEnvelope(md: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Historical alias for processResponseMarkdown.
|
|
8
|
+
* @deprecated Use detectIssuesEnvelope
|
|
9
|
+
*/
|
|
10
|
+
export declare function detectResponseMode(md: string): FlexMdResponseMode;
|
|
11
|
+
/**
|
|
12
|
+
* Parses a Markdown Issues envelope into a structured JSON object.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseIssuesEnvelope(md: string): IssuesEnvelope | null;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects if the response is an Issues envelope (Mode B).
|
|
3
|
+
*/
|
|
4
|
+
export function detectIssuesEnvelope(md) {
|
|
5
|
+
const hasStatus = /(^|\n)##\s+Status\s*$/im.test(md);
|
|
6
|
+
const hasIssuesHeading = /(^|\n)##\s+Issues\s*$/im.test(md);
|
|
7
|
+
const hasStatusLine = /(^|\n)-\s*status:\s*(error|partial)\s*$/im.test(md);
|
|
8
|
+
return hasStatus && hasIssuesHeading && hasStatusLine;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Historical alias for processResponseMarkdown.
|
|
12
|
+
* @deprecated Use detectIssuesEnvelope
|
|
13
|
+
*/
|
|
14
|
+
export function detectResponseMode(md) {
|
|
15
|
+
return detectIssuesEnvelope(md) ? "issues" : "answer";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parses a Markdown Issues envelope into a structured JSON object.
|
|
19
|
+
*/
|
|
20
|
+
export function parseIssuesEnvelope(md) {
|
|
21
|
+
if (detectResponseMode(md) !== "issues")
|
|
22
|
+
return null;
|
|
23
|
+
const statusBlock = sectionBody(md, "Status");
|
|
24
|
+
const issuesBlock = sectionBody(md, "Issues");
|
|
25
|
+
if (!statusBlock || !issuesBlock)
|
|
26
|
+
return null;
|
|
27
|
+
const statusKv = parseTopLevelKvBullets(statusBlock);
|
|
28
|
+
const status = {
|
|
29
|
+
status: statusKv["status"] ?? "error",
|
|
30
|
+
code: statusKv["code"] ?? "invalid_format",
|
|
31
|
+
message: statusKv["message"] ?? "An error occurred."
|
|
32
|
+
};
|
|
33
|
+
const missingInputs = parseSimpleList(sectionBody(md, "Missing inputs"));
|
|
34
|
+
const clarificationsNeeded = parseSimpleList(sectionBody(md, "Clarifications needed"));
|
|
35
|
+
const issues = parseIssueItems(issuesBlock);
|
|
36
|
+
return {
|
|
37
|
+
mode: "issues",
|
|
38
|
+
status,
|
|
39
|
+
missingInputs: missingInputs.length ? missingInputs : undefined,
|
|
40
|
+
clarificationsNeeded: clarificationsNeeded.length ? clarificationsNeeded : undefined,
|
|
41
|
+
issues
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function sectionBody(md, heading) {
|
|
45
|
+
const rx = new RegExp(`(^|\\n)##\\s+${escapeRx(heading)}\\s*\\n([\\s\\S]*?)(\\n##\\s+|$)`, "i");
|
|
46
|
+
const m = md.match(rx);
|
|
47
|
+
return m ? (m[2] ?? "").trim() : null;
|
|
48
|
+
}
|
|
49
|
+
function parseTopLevelKvBullets(block) {
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const line of block.split("\n")) {
|
|
52
|
+
const m = line.match(/^\s*-\s*([a-zA-Z0-9_-]+)\s*:\s*(.+?)\s*$/);
|
|
53
|
+
if (m)
|
|
54
|
+
out[m[1]] = m[2];
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function parseSimpleList(block) {
|
|
59
|
+
if (!block)
|
|
60
|
+
return [];
|
|
61
|
+
const items = [];
|
|
62
|
+
for (const line of block.split("\n")) {
|
|
63
|
+
const m = line.match(/^\s*-\s+(.+?)\s*$/);
|
|
64
|
+
if (m)
|
|
65
|
+
items.push(m[1].trim());
|
|
66
|
+
}
|
|
67
|
+
return items;
|
|
68
|
+
}
|
|
69
|
+
function parseIssueItems(block) {
|
|
70
|
+
const lines = block.split("\n");
|
|
71
|
+
const items = [];
|
|
72
|
+
let current = null;
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const top = line.match(/^\s*-\s*issue:\s*(.+?)\s*$/i);
|
|
75
|
+
if (top) {
|
|
76
|
+
if (current)
|
|
77
|
+
items.push(current);
|
|
78
|
+
current = { issue: top[1].trim() };
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const sub = line.match(/^\s{2,}([a-zA-Z0-9_-]+)\s*:\s*(.+?)\s*$/);
|
|
82
|
+
if (sub && current) {
|
|
83
|
+
current[sub[1]] = sub[2].trim();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (current)
|
|
87
|
+
items.push(current);
|
|
88
|
+
return items;
|
|
89
|
+
}
|
|
90
|
+
function escapeRx(s) {
|
|
91
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
92
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { IssuesEnvelope, ValidationResult } from "../types.js";
|
|
2
|
+
export declare function parseIssuesEnvelope(md: string): IssuesEnvelope;
|
|
3
|
+
export declare function buildIssuesEnvelope(args: {
|
|
4
|
+
validation: ValidationResult;
|
|
5
|
+
level: 0 | 1 | 2 | 3;
|
|
6
|
+
expectedSummary: string[];
|
|
7
|
+
foundSummary: string[];
|
|
8
|
+
howToFix: string[];
|
|
9
|
+
}): string;
|
|
10
|
+
export declare function buildIssuesEnvelopeAuto(args: {
|
|
11
|
+
validation: ValidationResult;
|
|
12
|
+
level: 0 | 1 | 2 | 3;
|
|
13
|
+
requiredSectionNames?: string[];
|
|
14
|
+
kindRequirements?: string[];
|
|
15
|
+
}): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isIssuesEnvelopeCheck } from "../md/parse.js";
|
|
2
|
+
export function parseIssuesEnvelope(md) {
|
|
3
|
+
return isIssuesEnvelopeCheck(md);
|
|
4
|
+
}
|
|
5
|
+
export function buildIssuesEnvelope(args) {
|
|
6
|
+
const issues = args.validation.issues
|
|
7
|
+
.filter(i => i.severity === "warn" || i.severity === "error")
|
|
8
|
+
.map(i => `- ${i.message}`);
|
|
9
|
+
const expected = args.expectedSummary.map(x => `- ${x}`);
|
|
10
|
+
const found = args.foundSummary.map(x => `- ${x}`);
|
|
11
|
+
const how = args.howToFix.map(x => `- ${x}`);
|
|
12
|
+
return [
|
|
13
|
+
"## Status",
|
|
14
|
+
"Non-compliant output (cannot be repaired to the required format).",
|
|
15
|
+
"",
|
|
16
|
+
"## Issues",
|
|
17
|
+
issues.length ? issues.join("\n") : "- None",
|
|
18
|
+
"",
|
|
19
|
+
"## Expected",
|
|
20
|
+
expected.length ? expected.join("\n") : "- None",
|
|
21
|
+
"",
|
|
22
|
+
"## Found",
|
|
23
|
+
found.length ? found.join("\n") : "- None",
|
|
24
|
+
"",
|
|
25
|
+
"## How to fix",
|
|
26
|
+
how.length ? how.join("\n") : "- None",
|
|
27
|
+
"",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
export function buildIssuesEnvelopeAuto(args) {
|
|
31
|
+
const v = args.validation;
|
|
32
|
+
const expected = [];
|
|
33
|
+
const found = [];
|
|
34
|
+
const how = [];
|
|
35
|
+
if (args.level >= 2) {
|
|
36
|
+
expected.push("One single ```markdown fenced block containing the entire answer (no text outside).");
|
|
37
|
+
}
|
|
38
|
+
if (args.level >= 1 && (args.requiredSectionNames?.length ?? 0) > 0) {
|
|
39
|
+
expected.push(`Section headings present (order does not matter): ${args.requiredSectionNames.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
if (args.level >= 3 && (args.kindRequirements?.length ?? 0) > 0) {
|
|
42
|
+
for (const k of args.kindRequirements)
|
|
43
|
+
expected.push(k);
|
|
44
|
+
}
|
|
45
|
+
if (v.stats) {
|
|
46
|
+
found.push(`Detected sections: ${v.stats.sectionCount}`);
|
|
47
|
+
if (v.stats.missingRequired.length)
|
|
48
|
+
found.push(`Missing: ${v.stats.missingRequired.join(", ")}`);
|
|
49
|
+
if (v.stats.duplicates.length)
|
|
50
|
+
found.push(`Duplicates: ${v.stats.duplicates.join(", ")}`);
|
|
51
|
+
found.push(`Fences: ${v.stats.container.fenceCount} (langs: ${v.stats.container.fenceLangs.join(", ") || "none"})`);
|
|
52
|
+
found.push(`Outside text: ${v.stats.container.hasOutsideText ? "yes" : "no"}`);
|
|
53
|
+
}
|
|
54
|
+
if (args.level >= 2)
|
|
55
|
+
how.push("Return a single ```markdown fenced block and nothing else.");
|
|
56
|
+
if (args.level >= 1)
|
|
57
|
+
how.push("Include the required headings and put content under each (order does not matter).");
|
|
58
|
+
how.push('If a section is empty, write "None".');
|
|
59
|
+
if (args.level >= 3) {
|
|
60
|
+
how.push('Use numbered lists ("1.", "2.", …) for ordered lists and "-" bullets for unordered lists.');
|
|
61
|
+
how.push("Use Markdown pipe tables for table sections; ordered tables include a first column '#'.");
|
|
62
|
+
how.push("Do not return JSON as the response format.");
|
|
63
|
+
}
|
|
64
|
+
return buildIssuesEnvelope({
|
|
65
|
+
validation: v,
|
|
66
|
+
level: args.level,
|
|
67
|
+
expectedSummary: expected,
|
|
68
|
+
foundSummary: found,
|
|
69
|
+
howToFix: how,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { OutputFormatSpec } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse an Output Format Spec block from Markdown.
|
|
4
|
+
*/
|
|
5
|
+
export interface ParseOfsOptions {
|
|
6
|
+
headingRegex?: RegExp;
|
|
7
|
+
allowDelimiterFallbacks?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseOutputFormatSpec(md: string, opts?: ParseOfsOptions): OutputFormatSpec | null;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export function parseOutputFormatSpec(md, opts = {}) {
|
|
2
|
+
const headingRx = opts.headingRegex ?? /^##\s*Output format\b/i;
|
|
3
|
+
const lines = md.split("\n");
|
|
4
|
+
// find OFS start
|
|
5
|
+
let start = -1;
|
|
6
|
+
for (let i = 0; i < lines.length; i++) {
|
|
7
|
+
const line = lines[i] ?? "";
|
|
8
|
+
if (headingRx.test(line)) {
|
|
9
|
+
start = i;
|
|
10
|
+
break;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (start === -1)
|
|
14
|
+
return null;
|
|
15
|
+
// capture block until next H2 (##) or end
|
|
16
|
+
let end = lines.length;
|
|
17
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
18
|
+
const line = lines[i] ?? "";
|
|
19
|
+
if (/^##\s+/.test(line)) {
|
|
20
|
+
end = i;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const block = lines.slice(start, end).join("\n");
|
|
25
|
+
const sections = [];
|
|
26
|
+
const tables = [];
|
|
27
|
+
let emptySectionValue;
|
|
28
|
+
let inTables = false;
|
|
29
|
+
for (const rawLine of block.split("\n")) {
|
|
30
|
+
const line = rawLine.trim();
|
|
31
|
+
if (/^tables\b/i.test(line)) {
|
|
32
|
+
inTables = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (/^empty sections\b/i.test(line)) {
|
|
36
|
+
inTables = false;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Empty section rule
|
|
40
|
+
const mNone = line.match(/write\s+`([^`]+)`/i);
|
|
41
|
+
if (/empty/i.test(line) && mNone) {
|
|
42
|
+
emptySectionValue = mNone[1];
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// bullet items
|
|
46
|
+
const bullet = line.match(/^- (.+)$/);
|
|
47
|
+
if (!bullet)
|
|
48
|
+
continue;
|
|
49
|
+
const item = bullet[1];
|
|
50
|
+
if (inTables) {
|
|
51
|
+
const t = parseTableDecl(item);
|
|
52
|
+
if (t)
|
|
53
|
+
tables.push(t);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const s = parseSectionDecl(item, !!opts.allowDelimiterFallbacks);
|
|
57
|
+
if (s)
|
|
58
|
+
sections.push(s);
|
|
59
|
+
}
|
|
60
|
+
if (!sections.length)
|
|
61
|
+
return null;
|
|
62
|
+
return {
|
|
63
|
+
descriptorType: "output_format_spec",
|
|
64
|
+
format: "markdown",
|
|
65
|
+
sectionOrderMatters: false,
|
|
66
|
+
sections,
|
|
67
|
+
tablesOptional: true,
|
|
68
|
+
tables,
|
|
69
|
+
emptySectionValue: emptySectionValue ?? "None"
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function parseSectionDecl(text, allowFallbacks) {
|
|
73
|
+
const delims = ["—"];
|
|
74
|
+
if (allowFallbacks)
|
|
75
|
+
delims.push(":", "-");
|
|
76
|
+
let best = null;
|
|
77
|
+
for (const d of delims) {
|
|
78
|
+
const idx = text.indexOf(d);
|
|
79
|
+
if (idx > 0) {
|
|
80
|
+
best = { name: text.slice(0, idx).trim(), rest: text.slice(idx + 1).trim() };
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!best)
|
|
85
|
+
return null;
|
|
86
|
+
const name = best.name;
|
|
87
|
+
const rest = best.rest;
|
|
88
|
+
const kind = normalizeSectionKind(rest);
|
|
89
|
+
if (!kind)
|
|
90
|
+
return null;
|
|
91
|
+
const requiredFlag = parseRequiredOptional(rest);
|
|
92
|
+
return { name, kind, required: requiredFlag };
|
|
93
|
+
}
|
|
94
|
+
function normalizeSectionKind(rest) {
|
|
95
|
+
const r = rest.toLowerCase();
|
|
96
|
+
if (r.includes("ordered") && r.includes("list"))
|
|
97
|
+
return "ordered_list";
|
|
98
|
+
if (r.includes("list"))
|
|
99
|
+
return "list";
|
|
100
|
+
if (r.includes("prose") || r.includes("text"))
|
|
101
|
+
return "text";
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function parseTableDecl(item) {
|
|
105
|
+
const m = item.match(/^\((.+)\)\s*(.*)$/);
|
|
106
|
+
if (!m)
|
|
107
|
+
return null;
|
|
108
|
+
const inside = m[1];
|
|
109
|
+
const parts = inside.split(/[—–-]/).map(s => s.trim());
|
|
110
|
+
if (parts.length < 2)
|
|
111
|
+
return null;
|
|
112
|
+
const colsPart = parts[0];
|
|
113
|
+
const kindPart = parts.slice(1).join(" — ").trim().toLowerCase();
|
|
114
|
+
const columns = colsPart.split(",").map(s => s.trim()).filter(Boolean);
|
|
115
|
+
const requiredFlag = parseRequiredOptional(item);
|
|
116
|
+
if (kindPart.includes("ordered table")) {
|
|
117
|
+
const by = (kindPart.match(/\bby\s+([^,)\s]+)/)?.[1] ?? "").trim();
|
|
118
|
+
return { columns, kind: "ordered_table", by: by || undefined, required: requiredFlag };
|
|
119
|
+
}
|
|
120
|
+
if (kindPart.includes("table")) {
|
|
121
|
+
return { columns, kind: "table", required: requiredFlag };
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
function parseRequiredOptional(s) {
|
|
126
|
+
const hasReq = /\(required\)/i.test(s);
|
|
127
|
+
const hasOpt = /\(optional\)/i.test(s);
|
|
128
|
+
if (hasReq)
|
|
129
|
+
return true;
|
|
130
|
+
if (hasOpt)
|
|
131
|
+
return false;
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an OutputFormatSpec to canonical Markdown format.
|
|
3
|
+
*/
|
|
4
|
+
export function stringifyOutputFormatSpec(spec) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
lines.push(`## Output format (Markdown)`);
|
|
7
|
+
lines.push(`Include these sections somewhere (order does not matter):`);
|
|
8
|
+
lines.push("");
|
|
9
|
+
for (const s of spec.sections) {
|
|
10
|
+
const k = s.kind === "ordered_list" ? "ordered list" : (s.kind || "text");
|
|
11
|
+
const required = s.required === true ? " (required)" : (s.required === false ? " (optional)" : "");
|
|
12
|
+
lines.push(`- ${s.name} — ${k}${required}`);
|
|
13
|
+
}
|
|
14
|
+
const tables = spec.tables || [];
|
|
15
|
+
if (tables.length) {
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(`tables:`);
|
|
18
|
+
for (const t of tables) {
|
|
19
|
+
const required = t.required === true ? " (required)" : (t.required === false ? " (optional)" : "");
|
|
20
|
+
if (t.kind === "table") {
|
|
21
|
+
lines.push(`- (${t.columns.join(", ")} — table)${required}`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
lines.push(`- (${t.columns.join(", ")} — ordered table, by ${t.by ?? t.columns[0] ?? ""})${required}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
lines.push("");
|
|
29
|
+
lines.push(`empty sections:`);
|
|
30
|
+
lines.push(`- If a section is empty, write \`${spec.emptySectionValue ?? "None"}\`.`);
|
|
31
|
+
return lines.join("\n") + "\n";
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { OutputFormatSpec } from "../types.js";
|
|
2
|
+
export interface OfsValidationResult {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
errors: string[];
|
|
5
|
+
warnings: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validate Markdown output against an OutputFormatSpec.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateOutput(md: string, spec: OutputFormatSpec): OfsValidationResult;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { buildOutline } from "../outline/builder.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validate Markdown output against an OutputFormatSpec.
|
|
4
|
+
*/
|
|
5
|
+
export function validateOutput(md, spec) {
|
|
6
|
+
const outline = buildOutline(md);
|
|
7
|
+
const errors = [];
|
|
8
|
+
const warnings = [];
|
|
9
|
+
// Index nodes by normalized title
|
|
10
|
+
const matches = collectMatches(outline.nodes);
|
|
11
|
+
// Validate each required section
|
|
12
|
+
for (const section of spec.sections) {
|
|
13
|
+
const key = normalizeTitle(section.name);
|
|
14
|
+
const nodes = matches.get(key) ?? [];
|
|
15
|
+
if (nodes.length === 0) {
|
|
16
|
+
errors.push(`missing_section:${section.name}`);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
// Choose best node (highest level, i.e., smallest level number)
|
|
20
|
+
const chosen = chooseBestNode(nodes);
|
|
21
|
+
const body = chosen.content_md.trim();
|
|
22
|
+
// Check for empty sections
|
|
23
|
+
if (spec.emptySectionValue) {
|
|
24
|
+
if (body === "" && !normalizeNone(body, spec.emptySectionValue)) {
|
|
25
|
+
errors.push(`empty_section_without_none:${section.name}`);
|
|
26
|
+
}
|
|
27
|
+
// If it's "None", skip further validation
|
|
28
|
+
if (normalizeNone(body, spec.emptySectionValue)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Validate section kind
|
|
33
|
+
if (section.kind === "list") {
|
|
34
|
+
if (!/^\s*-\s+/m.test(body)) {
|
|
35
|
+
errors.push(`section_not_bullets:${section.name}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (section.kind === "ordered_list") {
|
|
39
|
+
if (!/^\s*\d+\.\s+/m.test(body)) {
|
|
40
|
+
errors.push(`section_not_numbered:${section.name}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Validate tables (if any)
|
|
45
|
+
// Note: Full table validation would require parsing tables from the markdown
|
|
46
|
+
// For now, we just check if tables exist when required
|
|
47
|
+
// This is a simplified version; full implementation would use extractAllTables
|
|
48
|
+
return {
|
|
49
|
+
ok: errors.length === 0,
|
|
50
|
+
errors,
|
|
51
|
+
warnings
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Collect all nodes by normalized title.
|
|
56
|
+
*/
|
|
57
|
+
function collectMatches(nodes) {
|
|
58
|
+
const map = new Map();
|
|
59
|
+
function visit(node) {
|
|
60
|
+
const key = normalizeTitle(node.title);
|
|
61
|
+
const existing = map.get(key) ?? [];
|
|
62
|
+
existing.push(node);
|
|
63
|
+
map.set(key, existing);
|
|
64
|
+
node.children.forEach(visit);
|
|
65
|
+
}
|
|
66
|
+
nodes.forEach(visit);
|
|
67
|
+
return map;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Choose the best node from multiple matches.
|
|
71
|
+
* Prefer highest-level heading (smallest level number).
|
|
72
|
+
*/
|
|
73
|
+
function chooseBestNode(nodes) {
|
|
74
|
+
if (nodes.length === 1)
|
|
75
|
+
return nodes[0];
|
|
76
|
+
// Sort by level (ascending) and take first
|
|
77
|
+
const sorted = [...nodes].sort((a, b) => a.level - b.level);
|
|
78
|
+
return sorted[0];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Normalize title for comparison: lowercase, remove trailing punctuation.
|
|
82
|
+
*/
|
|
83
|
+
function normalizeTitle(t) {
|
|
84
|
+
return t.trim().replace(/[:\-–—]\s*$/, "").trim().toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Check if body is the "None" value.
|
|
88
|
+
*/
|
|
89
|
+
function normalizeNone(body, noneValue) {
|
|
90
|
+
return body.trim().toLowerCase() === noneValue.toLowerCase();
|
|
91
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MdOutline } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Build a nested outline tree from Markdown headings.
|
|
4
|
+
* Accepts any heading level (#..######) and builds parent/child relationships.
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildOutline(md: string): MdOutline;
|
|
7
|
+
/**
|
|
8
|
+
* Convert title to slug: lowercase, replace spaces with _, remove special chars
|
|
9
|
+
*/
|
|
10
|
+
export declare function slugify(t: string): string;
|