mindnet_obsidian/src/interview/parseInterviewConfig.ts
Lars 054cfcf82d
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run
Enhance interview configuration and rendering for WP-26 integration
- 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.
2026-01-30 18:27:38 +01:00

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);
}
}
}