mindnet_obsidian/src/interview/renderer.ts

242 lines
6.5 KiB
TypeScript
Raw Normal View History

/**
* Markdown renderer for interview profiles.
* Converts collected answers into markdown output based on profile configuration.
*/
import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep } from "./types";
export interface RenderAnswers {
collectedData: Map<string, unknown>;
loopContexts: Map<string, unknown[]>;
}
/**
* Render profile answers to markdown string.
* Deterministic and testable.
*/
export function renderProfileToMarkdown(
profile: InterviewProfile,
answers: RenderAnswers
): string {
const output: string[] = [];
for (const step of profile.steps) {
const stepOutput = renderStep(step, answers);
if (stepOutput) {
output.push(stepOutput);
}
}
return output.join("\n\n").trim();
}
/**
* Render a single step to markdown.
*/
function renderStep(step: InterviewStep, answers: RenderAnswers): string | null {
switch (step.type) {
case "capture_text":
return renderCaptureText(step, answers);
case "capture_text_line":
return renderCaptureTextLine(step, answers);
case "capture_frontmatter":
// Frontmatter is handled separately, skip here
return null;
case "loop":
return renderLoop(step, answers);
case "instruction":
case "llm_dialog":
case "review":
// These don't produce markdown output
return null;
default:
return null;
}
}
/**
* Render capture_text step.
*/
function renderCaptureText(step: CaptureTextStep, answers: RenderAnswers): string | null {
const value = answers.collectedData.get(step.key);
if (!value || String(value).trim() === "") {
return null;
}
const text = String(value);
// Use template if provided
if (step.output?.template) {
return renderTemplate(step.output.template, {
text,
field: step.key,
value: text,
});
}
// Default: use section if provided, otherwise just the text
if (step.section) {
return `${step.section}\n\n${text}`;
}
return text;
}
/**
* Render capture_text_line step.
*/
function renderCaptureTextLine(step: CaptureTextLineStep, answers: RenderAnswers): string | null {
const value = answers.collectedData.get(step.key);
if (!value || String(value).trim() === "") {
return null;
}
const text = String(value);
// Use template if provided
if (step.output?.template) {
return renderTemplate(step.output.template, {
text,
field: step.key,
value: text,
});
}
// Default: just the text
return text;
}
/**
* Render capture_frontmatter step.
* Note: This is typically handled separately, but included for completeness.
*/
function renderCaptureFrontmatter(step: CaptureFrontmatterStep, answers: RenderAnswers): string | null {
const value = answers.collectedData.get(step.key);
if (value === undefined || value === null || String(value).trim() === "") {
return null;
}
// Use template if provided
if (step.output?.template) {
return renderTemplate(step.output.template, {
text: String(value),
field: step.field,
value: String(value),
});
}
// Default: no markdown output (frontmatter is handled separately)
return null;
}
/**
* Render loop step.
*/
function renderLoop(step: LoopStep, answers: RenderAnswers): string | null {
const items = answers.loopContexts.get(step.key);
if (!items || items.length === 0) {
return null;
}
const itemOutputs: string[] = [];
for (const item of items) {
if (!item || typeof item !== "object") {
continue;
}
const itemParts: string[] = [];
// Render each nested step for this item
for (const nestedStep of step.items) {
const fieldValue = (item as Record<string, unknown>)[nestedStep.key];
if (nestedStep.type === "capture_text") {
if (fieldValue && String(fieldValue).trim()) {
const text = String(fieldValue);
const captureStep = nestedStep as CaptureTextStep;
// Use template if provided
if (captureStep.output?.template) {
itemParts.push(renderTemplate(captureStep.output.template, {
text,
field: nestedStep.key,
value: text,
}));
} else {
// Default: just the text
itemParts.push(text);
}
}
} else if (nestedStep.type === "capture_text_line") {
if (fieldValue && String(fieldValue).trim()) {
const text = String(fieldValue);
const captureStep = nestedStep as CaptureTextLineStep;
// Use template if provided
if (captureStep.output?.template) {
itemParts.push(renderTemplate(captureStep.output.template, {
text,
field: nestedStep.key,
value: text,
}));
} else {
// Default: just the text
itemParts.push(text);
}
}
} else if (nestedStep.type === "capture_frontmatter") {
if (fieldValue && String(fieldValue).trim()) {
const captureStep = nestedStep as CaptureFrontmatterStep;
// Use template if provided
if (captureStep.output?.template) {
itemParts.push(renderTemplate(captureStep.output.template, {
text: String(fieldValue),
field: captureStep.field,
value: String(fieldValue),
}));
}
}
}
}
if (itemParts.length > 0) {
itemOutputs.push(itemParts.join("\n\n"));
}
}
if (itemOutputs.length === 0) {
return null;
}
// Check if first nested step has a section header (for backwards compatibility)
const firstNestedStep = step.items[0];
let sectionHeader: string | null = null;
if (firstNestedStep && firstNestedStep.type === "capture_text" && firstNestedStep.section) {
sectionHeader = firstNestedStep.section;
}
// Join items with configured separator or default newline
const joinStr = step.output?.join || "\n\n";
const loopContent = itemOutputs.join(joinStr);
// Prepend section header if available
if (sectionHeader) {
return `${sectionHeader}\n\n${loopContent}`;
}
return loopContent;
}
/**
* Render template string with token replacement.
* Tokens: {text}, {field}, {value}
*/
function renderTemplate(template: string, tokens: { text: string; field: string; value: string }): string {
return template
.replace(/\{text\}/g, tokens.text)
.replace(/\{field\}/g, tokens.field)
.replace(/\{value\}/g, tokens.value);
}