mindnet_obsidian/src/interview/renderer.ts

698 lines
22 KiB
TypeScript
Raw Normal View History

/**
* Markdown renderer for interview profiles.
* Converts collected answers into markdown output based on profile configuration.
* WP-26: Erweitert um Section-Types, Block-IDs und automatische Edge-Vorschläge.
*/
2026-01-17 06:28:36 +01:00
import type { InterviewProfile, InterviewStep, CaptureTextStep, CaptureTextLineStep, CaptureFrontmatterStep, LoopStep, EntityPickerStep } from "./types";
import type { SectionInfo } from "./wizardState";
import type { GraphSchema } from "../mapping/graphSchema";
import type { Vocabulary } from "../vocab/Vocabulary";
import { slugify } from "./slugify";
import { getHints } from "../mapping/graphSchema";
export interface RenderAnswers {
collectedData: Map<string, unknown>;
loopContexts: Map<string, unknown[]>;
// WP-26: Optional - Section-Sequenz für automatische Edge-Vorschläge
sectionSequence?: SectionInfo[];
}
// WP-26: Render-Optionen für Section-Types und Edge-Vorschläge
export interface RenderOptions {
graphSchema?: GraphSchema | null;
vocabulary?: Vocabulary | null;
noteType?: string; // Fallback für effective_type
}
/**
* Render profile answers to markdown string.
* Deterministic and testable.
* WP-26: Erweitert um Section-Types, Block-IDs und automatische Edge-Vorschläge.
*/
export function renderProfileToMarkdown(
profile: InterviewProfile,
answers: RenderAnswers,
options?: RenderOptions
): string {
const output: string[] = [];
// WP-26: Verwende übergebene Section-Sequenz oder erstelle neue
// Die Section-Sequenz wird während des interaktiven Wizard-Durchlaufs getrackt
const sectionSequence: SectionInfo[] = answers.sectionSequence ? [...answers.sectionSequence] : [];
const generatedBlockIds = new Map<string, SectionInfo>();
// WP-26: Wenn Section-Sequenz übergeben wurde, fülle generatedBlockIds aus der Sequenz
if (answers.sectionSequence && answers.sectionSequence.length > 0) {
for (const sectionInfo of answers.sectionSequence) {
if (sectionInfo.blockId) {
generatedBlockIds.set(sectionInfo.blockId, sectionInfo);
}
}
}
// WP-26: Track Section-Sequenz während des Renderns (für Steps, die noch nicht getrackt wurden)
for (const step of profile.steps) {
const stepOutput = renderStep(
step,
answers,
{
profile,
sectionSequence,
generatedBlockIds,
options: options || {},
}
);
if (stepOutput) {
output.push(stepOutput);
}
}
return output.join("\n\n").trim();
}
// WP-26: Render-Kontext für Section-Types und Block-IDs
interface RenderContext {
profile: InterviewProfile;
sectionSequence: SectionInfo[];
generatedBlockIds: Map<string, SectionInfo>;
options: RenderOptions;
}
/**
* Render a single step to markdown.
* WP-26: Erweitert um RenderContext für Section-Types und Block-IDs.
*/
function renderStep(
step: InterviewStep,
answers: RenderAnswers,
context: RenderContext
): string | null {
switch (step.type) {
case "capture_text":
return renderCaptureText(step, answers, context);
case "capture_text_line":
return renderCaptureTextLine(step, answers, context);
case "capture_frontmatter":
// Frontmatter is handled separately, skip here
return null;
case "loop":
return renderLoop(step, answers, context);
2026-01-17 06:28:36 +01:00
case "entity_picker":
return renderEntityPicker(step, answers);
case "instruction":
case "llm_dialog":
case "review":
// These don't produce markdown output
return null;
default:
return null;
}
}
/**
* Render capture_text step.
* WP-26: Erweitert um Section-Types, Block-IDs und Edge-Vorschläge.
*/
function renderCaptureText(
step: CaptureTextStep,
answers: RenderAnswers,
context: RenderContext
): string | null {
const value = answers.collectedData.get(step.key);
if (!value || String(value).trim() === "") {
return null;
}
const text = String(value);
// WP-26: Block-ID-Generierung
let blockId: string | null = null;
if (step.block_id) {
blockId = step.block_id;
} else if (step.generate_block_id) {
blockId = slugify(step.key);
}
// WP-26: Heading mit Block-ID erweitern
let heading = step.section || "";
if (heading && blockId) {
// Füge Block-ID zu Heading hinzu: "## Heading" -> "## Heading ^block-id"
heading = heading.replace(/^(\s*#{1,6}\s+)(.+)$/, `$1$2 ^${blockId}`);
}
// WP-26: Section-Type-Callout generieren (direkt nach Heading)
const sectionTypeCallout = step.section_type
? `> [!section] ${step.section_type}`
: "";
// WP-26: Section-Info für Tracking
const sectionInfo: SectionInfo = {
stepKey: step.key,
sectionType: step.section_type || null,
heading: heading || step.key,
blockId: blockId,
noteType: context.options.noteType || context.profile.note_type,
};
// WP-26: Block-ID tracken
if (blockId) {
context.generatedBlockIds.set(blockId, sectionInfo);
}
// WP-26: Section-Sequenz aktualisieren (nur wenn Section vorhanden)
if (step.section) {
context.sectionSequence.push(sectionInfo);
}
// WP-26: Referenzen generieren (am Ende der Sektion)
const references = renderReferences(step.references || [], context, sectionInfo);
// WP-26: Automatische Edge-Vorschläge generieren (am Ende der Sektion)
const autoEdges = renderAutomaticEdges(sectionInfo, context);
// Use template if provided
if (step.output?.template) {
const templateText = renderTemplate(step.output.template, {
text,
field: step.key,
value: text,
});
// WP-26: Template mit Heading, Section-Type und Edges kombinieren
return combineSectionParts(heading, sectionTypeCallout, templateText, references, autoEdges);
}
// Default: use section if provided, otherwise just the text
if (step.section) {
return combineSectionParts(heading, sectionTypeCallout, text, references, autoEdges);
}
return text;
}
/**
* Render capture_text_line step.
* WP-26: Erweitert um Section-Types, Block-IDs und Edge-Vorschläge.
*/
function renderCaptureTextLine(
step: CaptureTextLineStep,
answers: RenderAnswers,
context: RenderContext
): string | null {
const value = answers.collectedData.get(step.key);
if (!value || String(value).trim() === "") {
return null;
}
const text = String(value);
// Get heading level if configured
const headingLevelKey = `${step.key}_heading_level`;
const headingLevel = answers.collectedData.get(headingLevelKey);
let headingPrefix = "";
if (step.heading_level?.enabled) {
if (typeof headingLevel === "number") {
const level = Math.max(1, Math.min(6, headingLevel));
headingPrefix = "#".repeat(level) + " ";
} else if (step.heading_level.default) {
// Fallback to default if not set
const level = Math.max(1, Math.min(6, step.heading_level.default));
headingPrefix = "#".repeat(level) + " ";
}
}
// WP-26: Block-ID-Generierung
let blockId: string | null = null;
if (step.block_id) {
blockId = step.block_id;
} else if (step.generate_block_id) {
blockId = slugify(step.key);
}
// WP-26: Heading mit Block-ID erweitern (wenn heading_prefix vorhanden)
let heading = "";
if (headingPrefix) {
heading = headingPrefix + text;
if (blockId) {
// Füge Block-ID zu Heading hinzu
heading = heading.replace(/^(\s*#{1,6}\s+)(.+)$/, `$1$2 ^${blockId}`);
}
}
// WP-26: Section-Type-Callout generieren (direkt nach Heading)
const sectionTypeCallout = step.section_type
? `> [!section] ${step.section_type}`
: "";
// WP-26: Section-Info für Tracking (nur wenn Heading vorhanden)
if (headingPrefix) {
const sectionInfo: SectionInfo = {
stepKey: step.key,
sectionType: step.section_type || null,
heading: heading || text,
blockId: blockId,
noteType: context.options.noteType || context.profile.note_type,
};
// WP-26: Block-ID tracken
if (blockId) {
context.generatedBlockIds.set(blockId, sectionInfo);
}
// WP-26: Section-Sequenz aktualisieren
context.sectionSequence.push(sectionInfo);
}
// WP-26: Section-Info für Tracking (nur wenn Heading vorhanden)
let sectionInfo: SectionInfo | null = null;
if (headingPrefix) {
sectionInfo = {
stepKey: step.key,
sectionType: step.section_type || null,
heading: heading || text,
blockId: blockId,
noteType: context.options.noteType || context.profile.note_type,
};
// WP-26: Block-ID tracken
if (blockId) {
context.generatedBlockIds.set(blockId, sectionInfo);
}
// WP-26: Section-Sequenz aktualisieren
context.sectionSequence.push(sectionInfo);
}
// WP-26: Referenzen generieren (am Ende der Sektion)
const references = sectionInfo
? renderReferences(step.references || [], context, sectionInfo)
: "";
// WP-26: Automatische Edge-Vorschläge generieren (nur wenn Heading vorhanden)
const autoEdges = sectionInfo
? renderAutomaticEdges(sectionInfo, context)
: "";
// Use template if provided
if (step.output?.template) {
const templateText = renderTemplate(step.output.template, {
text: headingPrefix + text,
field: step.key,
value: headingPrefix + text,
heading_level: headingLevel ? String(headingLevel) : "",
});
// WP-26: Template mit Heading, Section-Type und Edges kombinieren (wenn Heading vorhanden)
if (headingPrefix) {
return combineSectionParts(heading, sectionTypeCallout, "", references, autoEdges);
}
return templateText;
}
// WP-26: Wenn Heading vorhanden, mit Section-Type und Edges kombinieren
if (headingPrefix) {
return combineSectionParts(heading, sectionTypeCallout, "", references, autoEdges);
}
// Default: text with heading prefix if configured
return headingPrefix + text;
}
2026-01-17 06:28:36 +01:00
/**
* Render entity_picker step.
*/
function renderEntityPicker(step: EntityPickerStep, answers: RenderAnswers): string | null {
const basename = answers.collectedData.get(step.key);
if (!basename || typeof basename !== "string" || !basename.trim()) {
return null;
}
// Check if there's a label field
let label: string | undefined;
if (step.labelField) {
const labelValue = answers.collectedData.get(step.labelField);
if (labelValue && typeof labelValue === "string" && labelValue.trim()) {
label = labelValue.trim();
}
}
// Render wikilink
if (label) {
return `[[${basename}|${label}]]`;
} else {
return `[[${basename}]]`;
}
}
/**
* 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;
}
/**
2026-01-16 20:10:08 +01:00
* Render loop step (recursive, supports arbitrary nesting depth).
* WP-26: Erweitert um RenderContext für Section-Types und Block-IDs.
*/
function renderLoop(step: LoopStep, answers: RenderAnswers, context: RenderContext): string | null {
return renderLoopRecursive(step, answers, context, 0);
2026-01-16 20:10:08 +01:00
}
/**
* Recursive helper to render loop items with nested steps.
* Supports arbitrary nesting depth (no hard limit, but practical limits apply due to stack size).
* WP-26: Erweitert um RenderContext für Section-Types und Block-IDs.
2026-01-16 20:10:08 +01:00
*/
function renderLoopRecursive(
step: LoopStep,
answers: RenderAnswers,
context: RenderContext,
depth: number
): string | null {
2026-01-16 20:10:08 +01:00
// Safety check: prevent infinite recursion (practical limit: 100 levels)
if (depth > 100) {
console.warn(`Loop nesting depth ${depth} exceeds practical limit, stopping recursion`);
return 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[] = [];
2026-01-16 20:10:08 +01:00
// Render each nested step for this item (recursively)
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;
// WP-26: Verwende erweiterte renderCaptureText Funktion
const rendered = renderCaptureText(captureStep, {
collectedData: new Map([[nestedStep.key, fieldValue]]),
loopContexts: answers.loopContexts,
}, context);
if (rendered) {
itemParts.push(rendered);
}
}
} else if (nestedStep.type === "capture_text_line") {
if (fieldValue && String(fieldValue).trim()) {
const captureStep = nestedStep as CaptureTextLineStep;
// WP-26: Erstelle temporäres collectedData mit allen Item-Daten
const itemData = new Map<string, unknown>();
itemData.set(nestedStep.key, fieldValue);
// Füge heading_level hinzu falls vorhanden
const headingLevelKey = `${nestedStep.key}_heading_level`;
const headingLevel = (item as Record<string, unknown>)[headingLevelKey];
if (headingLevel !== undefined) {
itemData.set(headingLevelKey, headingLevel);
}
// WP-26: Verwende erweiterte renderCaptureTextLine Funktion
const rendered = renderCaptureTextLine(captureStep, {
collectedData: itemData,
loopContexts: answers.loopContexts,
}, context);
if (rendered) {
itemParts.push(rendered);
}
}
} 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),
}));
}
}
2026-01-16 20:10:08 +01:00
} else if (nestedStep.type === "loop") {
// Recursively handle nested loop: fieldValue should be an array of items
if (Array.isArray(fieldValue) && fieldValue.length > 0) {
const nestedLoopStep = nestedStep as LoopStep;
// Create a temporary answers object for the nested loop
// The nested loop items are stored in fieldValue, so we need to create
// a temporary loopContexts map for the nested loop
const nestedAnswers: RenderAnswers = {
collectedData: answers.collectedData,
loopContexts: new Map(answers.loopContexts),
};
nestedAnswers.loopContexts.set(nestedLoopStep.key, fieldValue);
// Recursively render the nested loop
const nestedOutput = renderLoopRecursive(nestedLoopStep, nestedAnswers, context, depth + 1);
2026-01-16 20:10:08 +01:00
if (nestedOutput) {
itemParts.push(nestedOutput);
}
}
}
}
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; heading_level?: string }): string {
let result = template
.replace(/\{text\}/g, tokens.text)
.replace(/\{field\}/g, tokens.field)
.replace(/\{value\}/g, tokens.value);
if (tokens.heading_level !== undefined) {
result = result.replace(/\{heading_level\}/g, tokens.heading_level);
}
return result;
}
// WP-26: Helper-Funktionen für Section-Types und Block-IDs
/**
* Kombiniert Heading, Section-Type-Callout, Content und Edges zu einer Section.
* Formatierungsregel: Section-Type direkt nach Heading, Edges am Ende.
*/
function combineSectionParts(
heading: string,
sectionTypeCallout: string,
content: string,
references: string,
autoEdges: string
): string {
const parts: string[] = [];
if (heading) {
parts.push(heading);
}
if (sectionTypeCallout) {
parts.push(sectionTypeCallout);
}
if (content) {
parts.push(content);
}
// WP-26: Edges am Ende der Sektion (Referenzen + automatische Edges)
const edges = [references, autoEdges].filter(e => e.trim()).join("\n");
if (edges) {
parts.push(edges);
}
return parts.join("\n\n");
}
/**
* Generiert Edge-Callouts für explizite Referenzen.
* Format: > [!edge] <edge_type>\n> [[#^block-id]]
*/
function renderReferences(
references: Array<{ block_id: string; edge_type?: string }>,
context: RenderContext,
currentSection: SectionInfo
): string {
if (!references || references.length === 0) {
return "";
}
const edgeCallouts: string[] = [];
for (const ref of references) {
// Prüfe, ob Block-ID existiert
if (!context.generatedBlockIds.has(ref.block_id)) {
console.warn(`[WP-26] Block-ID "${ref.block_id}" nicht gefunden für Referenz`);
continue;
}
// WP-26: Prüfe auf Selbstreferenz
if (ref.block_id === currentSection.blockId) {
console.warn(`[WP-26] Selbstreferenz verhindert: Block-ID "${ref.block_id}" zeigt auf sich selbst`);
continue;
}
const edgeType = ref.edge_type || "related_to";
edgeCallouts.push(`> [!edge] ${edgeType}`);
edgeCallouts.push(`> [[#^${ref.block_id}]]`);
}
return edgeCallouts.join("\n");
}
/**
* Generiert automatische Edge-Vorschläge zwischen Sections.
* Verwendet graph_schema.md für typische Edge-Types und edge_vocabulary.md für inverse Edges.
*
* Formatierungsregel: Edges werden am Ende der aktuellen Section eingefügt.
* Forward-Edge: prevSection -> currentSection (in aktueller Section)
* Rückwärts-Edge: currentSection -> prevSection (in aktueller Section, inverser Edge-Type)
*/
function renderAutomaticEdges(
currentSection: SectionInfo,
context: RenderContext
): string {
// Nur wenn aktuelle Section eine Block-ID hat
if (!currentSection.blockId) {
return "";
}
// Nur wenn es vorherige Sections gibt
if (context.sectionSequence.length <= 1) {
return "";
}
// Aktuelle Section ist die letzte in der Sequenz
// Wir generieren Edges von allen vorherigen Sections zur aktuellen Section
const prevSections = context.sectionSequence.slice(0, -1);
const edgeCallouts: string[] = [];
const { graphSchema, vocabulary } = context.options;
for (const prevSection of prevSections) {
// Nur wenn vorherige Section auch eine Block-ID hat
if (!prevSection.blockId) {
continue;
}
// WP-26: Prüfe auf Selbstreferenz (sollte nicht vorkommen, aber sicherheitshalber prüfen)
if (prevSection.blockId === currentSection.blockId) {
console.warn(`[WP-26] Selbstreferenz verhindert: Block-ID "${prevSection.blockId}" zeigt auf sich selbst`);
continue;
}
// Ermittle effective_types
const prevType = prevSection.sectionType || prevSection.noteType;
const currentType = currentSection.sectionType || currentSection.noteType;
// Lookup in graph_schema.md: prevType -> currentType
let forwardEdgeType: string | null = null;
if (graphSchema && prevType && currentType) {
const hints = getHints(graphSchema, prevType, currentType);
if (hints.typical.length > 0) {
forwardEdgeType = hints.typical[0]; // Erster typischer Edge-Type aus graph_schema
} else {
// Debug: Log wenn keine typischen Edges gefunden wurden
console.debug(`[WP-26] Keine typischen Edges gefunden für ${prevType} -> ${currentType}`);
}
}
// Fallback: related_to
if (!forwardEdgeType) {
forwardEdgeType = "related_to";
}
// WP-26: Automatische Edge-Vorschläge
// Wir generieren beide Richtungen:
// 1. Forward-Edge: prevSection -> currentSection (mit originalem Edge-Type)
// 2. Rückwärts-Edge: currentSection -> prevSection (mit inversem Edge-Type)
//
// Beide Edges werden in der aktuellen Section eingefügt und zeigen zur prevSection
// Der Backlink wird automatisch mitgesetzt
// Rückwärts-Edge: currentSection -> prevSection (mit inversem Edge-Type)
let inverseEdgeType: string | null = null;
if (vocabulary) {
inverseEdgeType = vocabulary.getInverse(forwardEdgeType);
}
// Generiere Rückwärts-Edge (currentSection -> prevSection)
if (inverseEdgeType) {
edgeCallouts.push(`> [!edge] ${inverseEdgeType}`);
edgeCallouts.push(`> [[#^${prevSection.blockId}]]`);
}
// Generiere Forward-Edge als Backlink (prevSection -> currentSection)
// Diese Edge beschreibt die Beziehung von prevSection zu currentSection
edgeCallouts.push(`> [!edge] ${forwardEdgeType}`);
edgeCallouts.push(`> [[#^${prevSection.blockId}]]`);
}
return edgeCallouts.join("\n");
}