- Expanded the interview configuration YAML to include new profiles for experience and insight, with detailed steps for capturing user input. - Updated the parsing logic to support new input types, including select options, enhancing user interaction during interviews. - Improved the rendering logic to ensure correct handling of section edges and types, aligning with the updated configuration structure. - Enhanced tests to validate the new configurations and rendering behavior, ensuring robustness in the interview process.
582 lines
16 KiB
TypeScript
582 lines
16 KiB
TypeScript
import { parse } from "yaml";
|
|
import type {
|
|
InterviewConfig,
|
|
InterviewProfile,
|
|
InterviewStep,
|
|
} from "./types";
|
|
|
|
export interface ParseResult {
|
|
config: InterviewConfig;
|
|
errors: string[];
|
|
}
|
|
|
|
/**
|
|
* Parse YAML interview config file.
|
|
*/
|
|
export function parseInterviewConfig(yamlText: string): ParseResult {
|
|
const errors: string[] = [];
|
|
|
|
try {
|
|
const raw = parse(yamlText) as unknown;
|
|
|
|
if (!raw || typeof raw !== "object") {
|
|
return {
|
|
config: {
|
|
version: "2.0",
|
|
frontmatterWhitelist: [],
|
|
profiles: [],
|
|
},
|
|
errors: ["Invalid YAML: root must be an object"],
|
|
};
|
|
}
|
|
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
// Extract version
|
|
const version = typeof obj.version === "string" ? obj.version : "2.0";
|
|
|
|
// Extract frontmatterWhitelist (accept both camelCase and snake_case)
|
|
const frontmatterWhitelist: string[] = [];
|
|
const whitelistSource = obj.frontmatterWhitelist || obj.frontmatter_whitelist;
|
|
if (Array.isArray(whitelistSource)) {
|
|
for (const item of whitelistSource) {
|
|
if (typeof item === "string") {
|
|
frontmatterWhitelist.push(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract profiles
|
|
const profiles: InterviewProfile[] = [];
|
|
if (Array.isArray(obj.profiles)) {
|
|
for (let i = 0; i < obj.profiles.length; i++) {
|
|
const profileRaw = obj.profiles[i];
|
|
if (!profileRaw || typeof profileRaw !== "object") {
|
|
errors.push(`Profile at index ${i} is not an object`);
|
|
continue;
|
|
}
|
|
|
|
const profile = parseProfile(profileRaw as Record<string, unknown>, i);
|
|
if (profile) {
|
|
profiles.push(profile);
|
|
} else {
|
|
errors.push(`Failed to parse profile at index ${i}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const config: InterviewConfig = {
|
|
version,
|
|
frontmatterWhitelist,
|
|
profiles,
|
|
};
|
|
|
|
// Validate
|
|
const validationErrors = validateConfig(config);
|
|
errors.push(...validationErrors);
|
|
|
|
return { config, errors };
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
return {
|
|
config: {
|
|
version: "2.0",
|
|
frontmatterWhitelist: [],
|
|
profiles: [],
|
|
},
|
|
errors: [`YAML parse error: ${msg}`],
|
|
};
|
|
}
|
|
}
|
|
|
|
function parseProfile(
|
|
raw: Record<string, unknown>,
|
|
index: number
|
|
): InterviewProfile | null {
|
|
if (typeof raw.key !== "string" || !raw.key.trim()) {
|
|
return null;
|
|
}
|
|
if (typeof raw.label !== "string" || !raw.label.trim()) {
|
|
return null;
|
|
}
|
|
if (typeof raw.note_type !== "string" || !raw.note_type.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const profile: InterviewProfile = {
|
|
key: raw.key.trim(),
|
|
label: raw.label.trim(),
|
|
note_type: raw.note_type.trim(),
|
|
steps: [],
|
|
};
|
|
|
|
if (typeof raw.group === "string" && raw.group.trim()) {
|
|
profile.group = raw.group.trim();
|
|
}
|
|
|
|
if (raw.defaults && typeof raw.defaults === "object") {
|
|
profile.defaults = raw.defaults as Record<string, unknown>;
|
|
}
|
|
|
|
// Parse edging config
|
|
if (raw.edging && typeof raw.edging === "object") {
|
|
const edgingRaw = raw.edging as Record<string, unknown>;
|
|
profile.edging = {};
|
|
|
|
if (edgingRaw.mode === "none" || edgingRaw.mode === "post_run" || edgingRaw.mode === "inline_micro" || edgingRaw.mode === "both") {
|
|
profile.edging.mode = edgingRaw.mode;
|
|
}
|
|
if (typeof edgingRaw.wrapperCalloutType === "string") {
|
|
profile.edging.wrapperCalloutType = edgingRaw.wrapperCalloutType;
|
|
}
|
|
if (typeof edgingRaw.wrapperTitle === "string") {
|
|
profile.edging.wrapperTitle = edgingRaw.wrapperTitle;
|
|
}
|
|
if (typeof edgingRaw.wrapperFolded === "boolean") {
|
|
profile.edging.wrapperFolded = edgingRaw.wrapperFolded;
|
|
}
|
|
}
|
|
|
|
// Parse steps
|
|
if (Array.isArray(raw.steps)) {
|
|
for (let i = 0; i < raw.steps.length; i++) {
|
|
const stepRaw = raw.steps[i];
|
|
if (!stepRaw || typeof stepRaw !== "object") {
|
|
continue;
|
|
}
|
|
const step = parseStep(stepRaw as Record<string, unknown>);
|
|
if (step) {
|
|
profile.steps.push(step);
|
|
}
|
|
}
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
function parseStep(raw: Record<string, unknown>): InterviewStep | null {
|
|
// Accept both "type" and "kind" (YAML uses "kind", but we normalize to "type" internally)
|
|
const stepType = raw.type || raw.kind;
|
|
if (typeof stepType !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const type = stepType;
|
|
|
|
// Helper: get key from "key" or "id" field
|
|
const getKey = (): string | null => {
|
|
if (typeof raw.key === "string" && raw.key.trim()) {
|
|
return raw.key.trim();
|
|
}
|
|
if (typeof raw.id === "string" && raw.id.trim()) {
|
|
return raw.id.trim();
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (type === "loop") {
|
|
const key = getKey();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
// Accept both "items" and "steps" for loop nested steps
|
|
const nestedSteps = raw.items || raw.steps;
|
|
if (!Array.isArray(nestedSteps)) {
|
|
return null;
|
|
}
|
|
|
|
const step: InterviewStep = {
|
|
type: "loop",
|
|
key: key,
|
|
items: [],
|
|
};
|
|
|
|
if (typeof raw.label === "string" && raw.label.trim()) {
|
|
step.label = raw.label.trim();
|
|
}
|
|
|
|
// Parse output.join
|
|
if (raw.output && typeof raw.output === "object") {
|
|
const output = raw.output as Record<string, unknown>;
|
|
if (typeof output.join === "string") {
|
|
step.output = { join: output.join };
|
|
}
|
|
}
|
|
|
|
// Parse ui.commit
|
|
if (raw.ui && typeof raw.ui === "object") {
|
|
const ui = raw.ui as Record<string, unknown>;
|
|
if (ui.commit === "explicit_add" || ui.commit === "on_next") {
|
|
step.ui = { commit: ui.commit };
|
|
}
|
|
}
|
|
|
|
for (const itemRaw of nestedSteps) {
|
|
if (!itemRaw || typeof itemRaw !== "object") {
|
|
continue;
|
|
}
|
|
const item = parseStep(itemRaw as Record<string, unknown>);
|
|
if (item) {
|
|
step.items.push(item);
|
|
}
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
if (type === "llm_dialog") {
|
|
const key = getKey();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
// Accept both "prompt" and "prompt_template"
|
|
const promptText = raw.prompt || raw.prompt_template;
|
|
if (typeof promptText !== "string" || !promptText.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const step: InterviewStep = {
|
|
type: "llm_dialog",
|
|
key: key,
|
|
prompt: promptText.trim(),
|
|
};
|
|
|
|
if (typeof raw.label === "string" && raw.label.trim()) {
|
|
step.label = raw.label.trim();
|
|
}
|
|
if (typeof raw.system_prompt === "string" && raw.system_prompt.trim()) {
|
|
step.system_prompt = raw.system_prompt.trim();
|
|
}
|
|
if (typeof raw.model === "string" && raw.model.trim()) {
|
|
step.model = raw.model.trim();
|
|
}
|
|
if (typeof raw.temperature === "number") {
|
|
step.temperature = raw.temperature;
|
|
}
|
|
if (typeof raw.max_tokens === "number") {
|
|
step.max_tokens = raw.max_tokens;
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
if (type === "capture_frontmatter") {
|
|
const key = getKey();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
if (typeof raw.field !== "string" || !raw.field.trim()) {
|
|
return null;
|
|
}
|
|
|
|
const step: InterviewStep = {
|
|
type: "capture_frontmatter",
|
|
key: key,
|
|
field: raw.field.trim(),
|
|
};
|
|
|
|
if (typeof raw.label === "string" && raw.label.trim()) {
|
|
step.label = raw.label.trim();
|
|
}
|
|
if (typeof raw.required === "boolean") {
|
|
step.required = raw.required;
|
|
}
|
|
if (typeof raw.prompt === "string" && raw.prompt.trim()) {
|
|
step.prompt = raw.prompt.trim();
|
|
}
|
|
|
|
// Parse input (kind: select | number | text_line, options for select)
|
|
if (raw.input && typeof raw.input === "object") {
|
|
const input = raw.input as Record<string, unknown>;
|
|
const inputKind =
|
|
typeof input.kind === "string" && input.kind.trim()
|
|
? (input.kind.trim() as "text_line" | "number" | "select")
|
|
: "text_line";
|
|
step.input = { kind: inputKind };
|
|
if (inputKind === "select" && Array.isArray(input.options)) {
|
|
step.input.options = [];
|
|
for (const opt of input.options) {
|
|
if (opt && typeof opt === "object") {
|
|
const o = opt as Record<string, unknown>;
|
|
const label = typeof o.label === "string" ? o.label.trim() : "";
|
|
const value = typeof o.value === "number" ? o.value : typeof o.value === "string" ? o.value : "";
|
|
if (label !== "" || value !== "") {
|
|
step.input!.options!.push({ label: label || String(value), value });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (typeof input.min === "number") step.input.min = input.min;
|
|
if (typeof input.max === "number") step.input.max = input.max;
|
|
if (typeof input.step === "number") step.input.step = input.step;
|
|
}
|
|
|
|
// Parse output.template
|
|
if (raw.output && typeof raw.output === "object") {
|
|
const output = raw.output as Record<string, unknown>;
|
|
if (typeof output.template === "string") {
|
|
step.output = { template: output.template };
|
|
}
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
if (type === "capture_text") {
|
|
const key = getKey();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
|
|
const step: InterviewStep = {
|
|
type: "capture_text",
|
|
key: key,
|
|
};
|
|
|
|
if (typeof raw.label === "string" && raw.label.trim()) {
|
|
step.label = raw.label.trim();
|
|
}
|
|
if (typeof raw.required === "boolean") {
|
|
step.required = raw.required;
|
|
}
|
|
if (typeof raw.section === "string" && raw.section.trim()) {
|
|
step.section = raw.section.trim();
|
|
}
|
|
if (typeof raw.prompt === "string" && raw.prompt.trim()) {
|
|
step.prompt = raw.prompt.trim();
|
|
}
|
|
|
|
// WP-26: Parse section_type
|
|
if (typeof raw.section_type === "string" && raw.section_type.trim()) {
|
|
step.section_type = raw.section_type.trim();
|
|
}
|
|
|
|
// WP-26: Parse block_id
|
|
if (typeof raw.block_id === "string" && raw.block_id.trim()) {
|
|
step.block_id = raw.block_id.trim();
|
|
}
|
|
|
|
// WP-26: Parse generate_block_id
|
|
if (typeof raw.generate_block_id === "boolean") {
|
|
step.generate_block_id = raw.generate_block_id;
|
|
}
|
|
|
|
// WP-26: Parse references
|
|
if (Array.isArray(raw.references)) {
|
|
step.references = [];
|
|
for (const refRaw of raw.references) {
|
|
if (refRaw && typeof refRaw === "object") {
|
|
const ref = refRaw as Record<string, unknown>;
|
|
if (typeof ref.block_id === "string" && ref.block_id.trim()) {
|
|
const reference: { block_id: string; edge_type?: string } = {
|
|
block_id: ref.block_id.trim(),
|
|
};
|
|
if (typeof ref.edge_type === "string" && ref.edge_type.trim()) {
|
|
reference.edge_type = ref.edge_type.trim();
|
|
}
|
|
step.references.push(reference);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse output.template
|
|
if (raw.output && typeof raw.output === "object") {
|
|
const output = raw.output as Record<string, unknown>;
|
|
if (typeof output.template === "string") {
|
|
step.output = { template: output.template };
|
|
}
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
if (type === "capture_text_line") {
|
|
const key = getKey();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
|
|
const step: InterviewStep = {
|
|
type: "capture_text_line",
|
|
key: key,
|
|
};
|
|
|
|
if (typeof raw.label === "string" && raw.label.trim()) {
|
|
step.label = raw.label.trim();
|
|
}
|
|
if (typeof raw.required === "boolean") {
|
|
step.required = raw.required;
|
|
}
|
|
if (typeof raw.prompt === "string" && raw.prompt.trim()) {
|
|
step.prompt = raw.prompt.trim();
|
|
}
|
|
|
|
// Parse heading_level
|
|
if (raw.heading_level && typeof raw.heading_level === "object") {
|
|
const headingLevel = raw.heading_level as Record<string, unknown>;
|
|
step.heading_level = {
|
|
enabled: headingLevel.enabled === true,
|
|
default: typeof headingLevel.default === "number"
|
|
? Math.max(1, Math.min(6, headingLevel.default))
|
|
: 2,
|
|
};
|
|
}
|
|
|
|
// WP-26: Parse section_type
|
|
if (typeof raw.section_type === "string" && raw.section_type.trim()) {
|
|
step.section_type = raw.section_type.trim();
|
|
}
|
|
|
|
// WP-26: Parse block_id
|
|
if (typeof raw.block_id === "string" && raw.block_id.trim()) {
|
|
step.block_id = raw.block_id.trim();
|
|
}
|
|
|
|
// WP-26: Parse generate_block_id
|
|
if (typeof raw.generate_block_id === "boolean") {
|
|
step.generate_block_id = raw.generate_block_id;
|
|
}
|
|
|
|
// WP-26: Parse references
|
|
if (Array.isArray(raw.references)) {
|
|
step.references = [];
|
|
for (const refRaw of raw.references) {
|
|
if (refRaw && typeof refRaw === "object") {
|
|
const ref = refRaw as Record<string, unknown>;
|
|
if (typeof ref.block_id === "string" && ref.block_id.trim()) {
|
|
const reference: { block_id: string; edge_type?: string } = {
|
|
block_id: ref.block_id.trim(),
|
|
};
|
|
if (typeof ref.edge_type === "string" && ref.edge_type.trim()) {
|
|
reference.edge_type = ref.edge_type.trim();
|
|
}
|
|
step.references.push(reference);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse output.template
|
|
if (raw.output && typeof raw.output === "object") {
|
|
const output = raw.output as Record<string, unknown>;
|
|
if (typeof output.template === "string") {
|
|
step.output = { template: output.template };
|
|
}
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
if (type === "instruction") {
|
|
const key = getKey();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
if (typeof raw.content !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const step: InterviewStep = {
|
|
type: "instruction",
|
|
key: key,
|
|
content: raw.content,
|
|
};
|
|
|
|
if (typeof raw.label === "string" && raw.label.trim()) {
|
|
step.label = raw.label.trim();
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
if (type === "entity_picker") {
|
|
const key = getKey();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
|
|
const step: InterviewStep = {
|
|
type: "entity_picker",
|
|
key: key,
|
|
};
|
|
|
|
if (typeof raw.label === "string" && raw.label.trim()) {
|
|
step.label = raw.label.trim();
|
|
}
|
|
if (typeof raw.prompt === "string" && raw.prompt.trim()) {
|
|
step.prompt = raw.prompt.trim();
|
|
}
|
|
if (typeof raw.required === "boolean") {
|
|
step.required = raw.required;
|
|
}
|
|
if (typeof raw.labelField === "string" && raw.labelField.trim()) {
|
|
step.labelField = raw.labelField.trim();
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
if (type === "review") {
|
|
const key = getKey();
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
|
|
const step: InterviewStep = {
|
|
type: "review",
|
|
key: key,
|
|
};
|
|
|
|
if (typeof raw.label === "string" && raw.label.trim()) {
|
|
step.label = raw.label.trim();
|
|
}
|
|
|
|
return step;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validate parsed config.
|
|
*/
|
|
export function validateConfig(config: InterviewConfig): string[] {
|
|
const errors: string[] = [];
|
|
|
|
// Check unique profile keys
|
|
const profileKeys = new Set<string>();
|
|
for (const profile of config.profiles) {
|
|
if (profileKeys.has(profile.key)) {
|
|
errors.push(`Duplicate profile key: ${profile.key}`);
|
|
} else {
|
|
profileKeys.add(profile.key);
|
|
}
|
|
}
|
|
|
|
// Check capture_frontmatter.field in whitelist
|
|
for (const profile of config.profiles) {
|
|
validateSteps(profile.steps, config.frontmatterWhitelist, errors);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateSteps(
|
|
steps: InterviewStep[],
|
|
whitelist: string[],
|
|
errors: string[]
|
|
): void {
|
|
for (const step of steps) {
|
|
if (step.type === "capture_frontmatter") {
|
|
if (!whitelist.includes(step.field)) {
|
|
errors.push(
|
|
`capture_frontmatter field '${step.field}' not in frontmatterWhitelist`
|
|
);
|
|
}
|
|
} else if (step.type === "loop") {
|
|
validateSteps(step.items, whitelist, errors);
|
|
}
|
|
}
|
|
}
|