diff --git a/assets/css/index.css b/assets/css/index.css index f8500f64f2..1fe681161d 100644 --- a/assets/css/index.css +++ b/assets/css/index.css @@ -94,6 +94,10 @@ section.prose { padding: 1rem; } +.prose pre.decision-tree-source { + display: none; +} + .prose pre > code { @apply bg-none font-monogeist; } diff --git a/build/render_hook_docs/AI_RENDER_HOOK_LESSONS.md b/build/render_hook_docs/AI_RENDER_HOOK_LESSONS.md index 49fddd83b4..26bad369bd 100644 --- a/build/render_hook_docs/AI_RENDER_HOOK_LESSONS.md +++ b/build/render_hook_docs/AI_RENDER_HOOK_LESSONS.md @@ -409,19 +409,197 @@ if ((metaValue.startsWith('"') && metaValue.endsWith('"'))) { --- +## 21. Server-Side Metadata Extraction for AI Agents + +**Pattern**: Extract metadata from structured content (YAML, JSON) in the Hugo render hook and embed it as JSON in the HTML output for AI agents that don't execute JavaScript. + +**Implementation**: +```html +{{- /* Extract top-level fields only (no indentation) */ -}} +{{- $lines := split .Inner "\n" -}} +{{- $id := "" -}} +{{- range $lines -}} + {{- /* Check if line starts without whitespace (32=space, 9=tab) */ -}} + {{- if and (gt (len .) 0) (ne (index . 0) 32) (ne (index . 0) 9) -}} + {{- $trimmed := strings.TrimSpace . -}} + {{- if strings.HasPrefix $trimmed "id:" -}} + {{- $afterPrefix := strings.Replace $trimmed "id:" "" 1 -}} + {{- $id = strings.TrimSpace $afterPrefix -}} + {{- end -}} + {{- end -}} +{{- end -}} + +{{- /* Embed as JSON for AI agents */ -}} +{{- $metadata := dict "type" "decision-tree" "id" $id -}} +{{ $jsonMetadata := $metadata | jsonify (dict "indent" " ") }} +{{ printf "" $jsonMetadata | safeHTML }} +``` + +**Key Considerations**: +- **Indentation detection**: When parsing nested structures, only extract top-level fields by checking if the line starts with whitespace +- **String manipulation**: Use `strings.Replace` instead of `strings.TrimPrefix` for more reliable extraction +- **Character codes**: Use ASCII codes (32 for space, 9 for tab) to detect indentation reliably +- **Metadata format**: Use simple JSON (not JSON-LD) for clarity and ease of parsing +- **Data attributes**: Use `data-*` attributes to mark metadata elements for AI agents + +**Why This Matters**: +- AI agents typically don't execute JavaScript, so metadata must be in static HTML +- Server-side extraction ensures metadata is available even if JavaScript fails +- Structured metadata helps AI agents understand the purpose and scope of components +- Separating metadata from content improves maintainability + +**Lesson**: Always provide metadata in static HTML for AI agents. Use server-side extraction to ensure accuracy and avoid relying on JavaScript parsing. + +--- + +## 22. Handling Nested Structures in Hugo Templates + +**Pattern**: When extracting data from nested YAML/JSON structures, distinguish between top-level and nested fields using indentation detection. + +**Problem**: If you extract all occurrences of a field (e.g., `id:`), you'll get nested occurrences too, leading to incorrect values. + +**Solution**: Check indentation before processing: +```html +{{- if and (gt (len .) 0) (ne (index . 0) 32) (ne (index . 0) 9) -}} + {{- /* Process only top-level lines */ -}} +{{- end -}} +``` + +**Why This Works**: +- YAML indentation is significant and indicates nesting level +- Top-level fields have no leading whitespace +- Nested fields have leading spaces or tabs +- Character code 32 = space, 9 = tab + +**Lesson**: When parsing nested structures in Hugo templates, use indentation detection to distinguish between levels. This prevents extracting nested values when you only want top-level ones. + +--- + +## 23. Progressive Enhancement with Metadata + +**Pattern**: Combine progressive enhancement with metadata embedding to serve both humans and AI agents from a single source. + +**Architecture**: +1. **Server-side (Hugo)**: + - Extract metadata from source content + - Embed metadata as JSON in HTML + - Preserve raw source in `
` element
+
+2. **Client-side (JavaScript)**:
+   - Parse raw source for rendering
+   - Use metadata for context/identification
+   - Enhance with interactivity
+
+3. **AI agents**:
+   - Read static JSON metadata
+   - Parse raw source from `
` element
+   - No JavaScript execution needed
+
+**Benefits**:
+- Single source of truth (the YAML/JSON in the Markdown)
+- Metadata available to all consumers (humans, AI agents, JavaScript)
+- Graceful degradation if JavaScript fails
+- AI-friendly without extra work
+
+**Lesson**: Design render hooks to serve multiple audiences simultaneously. Metadata should be available in static HTML, not just in JavaScript.
+
+---
+
+## 24. Text Wrapping and Box Sizing in SVG Diagrams
+
+**Pattern**: When rendering text in SVG boxes, calculate dimensions based on character width and implement text wrapping to fit within maximum width.
+
+**Implementation**:
+```javascript
+const charWidth = 8; // Space Mono at 14px
+const maxBoxWidth = 420;
+const maxCharsPerLine = Math.floor(maxBoxWidth / charWidth);
+
+function wrapText(text, maxChars) {
+  const words = text.split(' ');
+  const lines = [];
+  let currentLine = '';
+
+  for (const word of words) {
+    if ((currentLine + ' ' + word).length > maxChars) {
+      if (currentLine) lines.push(currentLine);
+      currentLine = word;
+    } else {
+      currentLine = currentLine ? currentLine + ' ' + word : word;
+    }
+  }
+  if (currentLine) lines.push(currentLine);
+  return lines;
+}
+```
+
+**Considerations**:
+- **Font metrics**: Different fonts have different character widths
+- **Padding**: Account for box padding when calculating available width
+- **Line height**: Multiply number of lines by line height for total box height
+- **Dynamic sizing**: Calculate SVG dimensions based on content, not fixed values
+
+**Common Pitfall**: Hardcoding SVG width can cause content to be cut off. Instead:
+```javascript
+const svgWidth = leftMargin + (maxDepth + 1) * indentWidth + maxBoxWidth + 40;
+```
+
+**Lesson**: Calculate SVG dimensions dynamically based on content. Account for all visual elements (padding, margins, decorations) when sizing boxes and containers.
+
+---
+
+## 25. Scope and Context Metadata for Component Discovery
+
+**Pattern**: Add `scope` or `category` metadata to components to help AI agents understand their purpose and applicability.
+
+**Implementation**:
+```yaml
+id: documents-tree
+scope: documents
+rootQuestion: root
+questions:
+  # ...
+```
+
+**Benefits**:
+- **Discoverability**: AI agents can filter components by scope
+- **Context awareness**: Agents know which problem domain each component addresses
+- **Prevents misapplication**: Agents won't use a "collections" tree to recommend document storage
+- **Relationship mapping**: Enables linking related components
+
+**Use Cases**:
+- Filtering decision trees by data type category
+- Finding all components related to a specific feature
+- Organizing components hierarchically
+- Providing context in search results
+
+**Lesson**: Add semantic metadata (scope, category, type) to components. This helps AI agents understand purpose and applicability, enabling better recommendations and filtering.
+
+---
+
 ## Quick Checklist for Future Render Hooks
 
+### Core Patterns
 - [ ] Preserve source content in a `
` or similar element
 - [ ] Use page store pattern to avoid duplicate resource loading
 - [ ] Place static JavaScript in `static/js/`, not `assets/js/`
 - [ ] Avoid `innerHTML` with dynamic content; use safe DOM methods
 - [ ] Use `data-*` attributes to pass server data to JavaScript
+
+### Testing & Accessibility
 - [ ] Test with multiple instances on the same page
 - [ ] Consider state persistence if needed
 - [ ] Use semantic HTML and proper accessibility attributes
 - [ ] Document the Markdown format clearly
 - [ ] Provide sensible defaults for optional parameters
+
+### Hugo-Specific
 - [ ] Remember Hugo converts attribute names to lowercase
+- [ ] Use indentation detection when parsing nested structures
+- [ ] Extract top-level metadata in render hook (not JavaScript)
+- [ ] Use `strings.Replace` for reliable string manipulation in templates
+
+### Advanced Features
 - [ ] Use SVG for complex visual structures
 - [ ] Track processed lines when parsing nested structures
 - [ ] Account for all visual elements in dimension calculations
@@ -429,3 +607,10 @@ if ((metaValue.startsWith('"') && metaValue.endsWith('"'))) {
 - [ ] Handle string unescaping during parsing, not rendering
 - [ ] Create comprehensive format documentation
 
+### AI Agent Compatibility
+- [ ] Embed metadata as JSON in static HTML (not just JavaScript)
+- [ ] Add `scope` or `category` metadata for component discovery
+- [ ] Use `data-*` attributes to mark metadata elements
+- [ ] Ensure metadata is available without JavaScript execution
+- [ ] Preserve raw source content for AI parsing
+
diff --git a/build/render_hook_docs/DECISION_TREE_FORMAT.md b/build/render_hook_docs/DECISION_TREE_FORMAT.md
new file mode 100644
index 0000000000..9ee33ba524
--- /dev/null
+++ b/build/render_hook_docs/DECISION_TREE_FORMAT.md
@@ -0,0 +1,156 @@
+# Decision Tree Format Specification
+
+## Overview
+
+Decision trees are structured YAML documents that guide users through a series of questions to reach a recommendation. They are rendered as interactive SVG diagrams with boxes, connecting lines, and Yes/No branch labels.
+
+## Basic Structure
+
+```yaml
+```decision-tree {id="documents-tree"}
+id: documents-tree
+scope: documents
+rootQuestion: root
+questions:
+    root:
+        text: "Your question here?"
+        whyAsk: "Explanation of why this question matters"
+        answers:
+            yes:
+                value: "Yes"
+                outcome:
+                    label: "Recommendation"
+                    id: outcomeId
+            no:
+                value: "No"
+                nextQuestion: nextQuestionId
+    nextQuestionId:
+        text: "Follow-up question?"
+        whyAsk: "Why this matters"
+        answers:
+            yes:
+                value: "Yes"
+                outcome:
+                    label: "Recommendation"
+                    id: outcomeId
+            no:
+                value: "No"
+                outcome:
+                    label: "Alternative recommendation"
+                    id: altOutcomeId
+```
+```
+
+## Fields
+
+### Top-level
+
+- **`id`** (required): Unique identifier for this decision tree (e.g., `documents-tree`, `collections-tree`). Used for discovery and referencing by AI agents.
+- **`scope`** (required): Category or domain this tree applies to (e.g., `documents`, `collections`, `sequences`). Helps AI agents understand the tree's purpose and applicability.
+- **`rootQuestion`** (required): The ID of the starting question
+- **`questions`** (required): Object containing all questions, keyed by ID
+
+### Question Object
+
+- **`text`** (required): The question text. Can span multiple lines using YAML's `|` literal block syntax
+- **`whyAsk`** (required): Explanation of why this question matters. Helps AI agents understand the decision logic
+- **`answers`** (required): Object with `yes` and `no` keys
+
+### Answer Object
+
+Each answer (`yes` or `no`) contains:
+
+- **`value`** (required): Display text ("Yes" or "No")
+- **`outcome`** (optional): Terminal recommendation
+  - `label`: Text to display (e.g., "Use JSON")
+  - `id`: Unique identifier for this outcome
+- **`nextQuestion`** (optional): ID of the next question to ask
+
+**Note**: Each answer must have either `outcome` or `nextQuestion`, not both.
+
+### Outcome Object
+
+- **`label`** (required): The recommendation text
+- **`id`** (required): Unique identifier (e.g., `jsonOutcome`, `hashOutcome`)
+
+## Multi-line Text
+
+Use YAML's literal block syntax (`|`) for multi-line text:
+
+```yaml
+text: |
+    Do you need nested data structures
+    (fields and arrays) or geospatial
+    index/query with Redis query engine?
+whyAsk: |
+    JSON is the only document type that supports
+    deeply nested structures and integrates with
+    the query engine for those structures
+```
+
+## Code Block Attributes
+
+The code block fence supports the following attributes:
+
+- **`id`** (optional): Unique identifier for the tree. Should match the `id` field in the YAML. Used by Hugo to pass metadata to the render hook.
+
+Example:
+```markdown
+```decision-tree {id="documents-tree"}
+id: documents-tree
+scope: documents
+# ...
+```
+```
+
+## Best Practices
+
+1. **Use descriptive IDs**: `root`, `hashQuestion`, `jsonOutcome` are clearer than `q1`, `q2`
+2. **Keep questions concise**: Aim for 1-2 lines when possible
+3. **Explain the rationale**: The `whyAsk` field helps users and AI agents understand the decision logic
+4. **Reuse outcomes**: Multiple paths can lead to the same outcome (same `id`)
+5. **Consistent naming**: Use camelCase for IDs, end question IDs with "Question"
+6. **Match fence and YAML IDs**: The `id` in the code block fence should match the `id` field in the YAML for consistency
+7. **Use meaningful scopes**: Choose scope values that clearly indicate the tree's domain (e.g., `documents`, `collections`, `sequences`)
+
+## Example: Redis Data Type Selection
+
+See `content/develop/data-types/compare-data-types.md` for a complete example.
+
+## Rendering
+
+Decision trees are rendered as:
+- **SVG diagram** for humans (with boxes, lines, and labels)
+- **Normalized JSON** embedded for AI agents (accessible via `.html.md` URLs)
+- **Raw YAML** preserved in `
` element for accessibility
+
+## AI Agent Compatibility
+
+The format is designed to be easily parseable by AI agents:
+
+### Metadata Embedding
+- **Server-side JSON**: Each tree is embedded with metadata as `" $jsonMetadata | safeHTML }}
+
+{{ .Page.Store.Set "hasDecisionTree" true }}
+
diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html
index 32ff961bf3..9c8bbe33eb 100644
--- a/layouts/_default/baseof.html
+++ b/layouts/_default/baseof.html
@@ -108,5 +108,10 @@
     {{ if .Page.Store.Get "hasHierarchy" }}
     
     {{ end }}
+
+    
+    {{ if .Page.Store.Get "hasDecisionTree" }}
+    
+    {{ end }}
   
 
diff --git a/static/js/decision-tree.js b/static/js/decision-tree.js
new file mode 100644
index 0000000000..18578abece
--- /dev/null
+++ b/static/js/decision-tree.js
@@ -0,0 +1,339 @@
+// Decision tree rendering engine
+// Parses YAML decision trees and renders them as tree structure diagrams
+
+(function() {
+  'use strict';
+
+  // Parse YAML from the pre element
+  function parseDecisionTreeYAML(yamlText) {
+    const lines = yamlText.split('\n');
+    const root = {};
+    const stack = [{ node: root, indent: -1, key: null }];
+
+    for (let i = 0; i < lines.length; i++) {
+      const line = lines[i];
+      if (!line.trim() || line.trim().startsWith('#')) continue;
+
+      const indent = line.search(/\S/);
+      const content = line.trim();
+
+      while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
+        stack.pop();
+      }
+
+      const parent = stack[stack.length - 1].node;
+
+      if (content.includes(':')) {
+        const colonIndex = content.indexOf(':');
+        const key = content.substring(0, colonIndex).trim();
+        let value = content.substring(colonIndex + 1).trim();
+
+        if ((value.startsWith('"') && value.endsWith('"')) ||
+            (value.startsWith("'") && value.endsWith("'"))) {
+          value = value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
+        }
+
+        if (value === '|') {
+          const multiLineValue = [];
+          i++;
+          while (i < lines.length) {
+            const nextLine = lines[i];
+            if (nextLine.trim() === '') {
+              i++;
+              continue;
+            }
+            const nextIndent = nextLine.search(/\S/);
+            if (nextIndent <= indent) break;
+            multiLineValue.push(nextLine.trim());
+            i++;
+          }
+          i--;
+          value = multiLineValue.join(' ');
+        }
+
+        if (value === '') {
+          const newObj = {};
+          parent[key] = newObj;
+          stack.push({ node: newObj, indent: indent, key: key });
+        } else {
+          parent[key] = value;
+        }
+      }
+    }
+
+    return root;
+  }
+
+  // Flatten decision tree into a list for tree rendering
+  function flattenDecisionTree(questions, rootId) {
+    const items = [];
+    const visited = new Set();
+
+    function traverse(nodeId, depth) {
+      if (visited.has(nodeId)) return;
+      visited.add(nodeId);
+
+      const question = questions[nodeId];
+      if (!question) return;
+
+      items.push({
+        id: nodeId,
+        depth: depth,
+        type: 'question',
+        text: question.text || '',
+        whyAsk: question.whyAsk || ''
+      });
+
+      if (question.answers) {
+        // Process yes answer
+        if (question.answers.yes) {
+          if (question.answers.yes.nextQuestion) {
+            traverse(question.answers.yes.nextQuestion, depth + 1);
+          } else if (question.answers.yes.outcome) {
+            items.push({
+              id: question.answers.yes.outcome.id,
+              depth: depth + 1,
+              type: 'outcome',
+              text: question.answers.yes.outcome.label || '',
+              answer: 'Yes'
+            });
+          }
+        }
+
+        // Process no answer
+        if (question.answers.no) {
+          if (question.answers.no.nextQuestion) {
+            traverse(question.answers.no.nextQuestion, depth + 1);
+          } else if (question.answers.no.outcome) {
+            items.push({
+              id: question.answers.no.outcome.id,
+              depth: depth + 1,
+              type: 'outcome',
+              text: question.answers.no.outcome.label || '',
+              answer: 'No'
+            });
+          }
+        }
+      }
+    }
+
+    traverse(rootId, 0);
+    return items;
+  }
+
+  // Wrap text to fit within a maximum width
+  function wrapText(text, maxChars) {
+    const words = text.split(' ');
+    const lines = [];
+    let currentLine = '';
+
+    words.forEach(word => {
+      if ((currentLine + ' ' + word).trim().length <= maxChars) {
+        currentLine = currentLine ? currentLine + ' ' + word : word;
+      } else {
+        if (currentLine) lines.push(currentLine);
+        currentLine = word;
+      }
+    });
+    if (currentLine) lines.push(currentLine);
+    return lines;
+  }
+
+  // Render decision tree as SVG with tree structure and boxes
+  function renderDecisionTree(container, treeData) {
+    const rootId = treeData.rootQuestion;
+    const questions = treeData.questions;
+
+    if (!rootId || !questions[rootId]) {
+      container.textContent = 'Error: Invalid decision tree structure';
+      return;
+    }
+
+    const items = flattenDecisionTree(questions, rootId);
+
+    const lineHeight = 24;
+    const charWidth = 8;
+    const leftMargin = 20;
+    const topMargin = 10;
+    const indentWidth = 40; // Increased from 24 for wider indent
+    const boxPadding = 12; // Increased from 8 for more padding
+    const maxBoxWidth = 420; // Increased to accommodate longer questions
+    const maxCharsPerLine = Math.floor(maxBoxWidth / charWidth);
+
+    // Calculate box dimensions for each item
+    items.forEach(item => {
+      const lines = wrapText(item.text, maxCharsPerLine);
+      item.lines = lines;
+      item.boxHeight = lines.length * lineHeight + boxPadding * 2;
+      item.boxWidth = Math.min(maxBoxWidth, Math.max(...lines.map(l => l.length)) * charWidth + boxPadding * 2);
+    });
+
+    // Calculate SVG dimensions
+    let maxDepth = 0;
+    items.forEach(item => {
+      maxDepth = Math.max(maxDepth, item.depth);
+    });
+
+    const svgWidth = leftMargin + (maxDepth + 1) * indentWidth + maxBoxWidth + 40;
+    const svgHeight = topMargin + items.reduce((sum, item) => sum + item.boxHeight + 20, 0) + 10;
+
+    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+    svg.setAttribute('width', svgWidth);
+    svg.setAttribute('height', svgHeight);
+    svg.setAttribute('class', 'decision-tree-diagram');
+    svg.style.marginTop = '1em';
+    svg.style.marginBottom = '1em';
+    svg.style.fontFamily = '"Space Mono", monospace';
+    svg.style.fontSize = '13px';
+
+    let currentY = topMargin;
+
+    items.forEach((item, index) => {
+      const x = leftMargin + item.depth * indentWidth;
+      const y = currentY + item.boxHeight / 2;
+
+      if (item.depth > 0) {
+        drawTreeLines(svg, item, items, index, leftMargin, topMargin, lineHeight, indentWidth, currentY, item.boxHeight);
+      }
+
+      // Draw box with different styling for outcomes vs questions
+      const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+      rect.setAttribute('x', x + 10);
+      rect.setAttribute('y', currentY);
+      rect.setAttribute('width', item.boxWidth);
+      rect.setAttribute('height', item.boxHeight);
+
+      if (item.type === 'outcome') {
+        // Outcomes: pale red background, dashed border
+        rect.setAttribute('fill', '#ffe6e6');
+        rect.setAttribute('stroke', '#d9534f');
+        rect.setAttribute('stroke-width', '1');
+        rect.setAttribute('stroke-dasharray', '3,3');
+      } else {
+        // Questions: standard styling
+        rect.setAttribute('fill', '#f5f5f5');
+        rect.setAttribute('stroke', '#999');
+        rect.setAttribute('stroke-width', '1');
+      }
+      rect.setAttribute('rx', '4');
+      svg.appendChild(rect);
+
+      // Draw text lines
+      item.lines.forEach((line, lineIndex) => {
+        const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+        text.setAttribute('x', x + 10 + boxPadding);
+        text.setAttribute('y', currentY + boxPadding + (lineIndex + 1) * lineHeight - 6);
+        text.setAttribute('font-family', '"Space Mono", monospace');
+        text.setAttribute('font-size', '13');
+        text.setAttribute('fill', item.type === 'outcome' ? '#666' : '#333');
+        text.textContent = line;
+        svg.appendChild(text);
+      });
+
+      currentY += item.boxHeight + 20;
+    });
+
+    const jsonScript = document.createElement('script');
+    jsonScript.type = 'application/json';
+    jsonScript.className = 'decision-tree-data';
+    jsonScript.textContent = JSON.stringify(treeData, null, 2);
+
+    container.appendChild(svg);
+    container.parentNode.insertBefore(jsonScript, container.nextSibling);
+  }
+
+  function drawTreeLines(svg, item, items, itemIndex, leftMargin, topMargin, lineHeight, indentWidth, currentY, boxHeight) {
+    const y = currentY + boxHeight / 2;
+    const x = leftMargin + item.depth * indentWidth;
+    const parentX = x - indentWidth;
+    const connectorX = parentX + 10; // Left edge of parent box (vertical line position)
+
+    // Find parent item and its Y position, and determine the answer (Yes/No)
+    let parentY = null;
+    let parentBoxHeight = 0;
+    let parentCurrentY = null;
+    let answerLabel = '';
+
+    for (let i = itemIndex - 1; i >= 0; i--) {
+      if (items[i].depth === item.depth - 1) {
+        // Calculate parent's Y position
+        let calcY = topMargin;
+        for (let j = 0; j < i; j++) {
+          calcY += items[j].boxHeight + 20;
+        }
+        parentCurrentY = calcY;
+        parentY = calcY + items[i].boxHeight / 2;
+        parentBoxHeight = items[i].boxHeight;
+
+        // Determine if this is a Yes or No answer by checking the parent's answers
+        // Count how many siblings come before this item
+        let siblingCount = 0;
+        for (let k = i + 1; k < itemIndex; k++) {
+          if (items[k].depth === item.depth) {
+            siblingCount++;
+          }
+        }
+
+        // If this is the first child of the parent, it's the "yes" path, otherwise "no"
+        answerLabel = siblingCount === 0 ? 'Yes' : 'No';
+        break;
+      }
+    }
+
+    if (parentY !== null) {
+      // Vertical line starts from bottom of parent box
+      const verticalStartY = parentCurrentY + parentBoxHeight;
+
+      // Vertical line from parent box bottom
+      const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+      line.setAttribute('x1', connectorX);
+      line.setAttribute('y1', verticalStartY);
+      line.setAttribute('x2', connectorX);
+      line.setAttribute('y2', y);
+      line.setAttribute('stroke', '#999');
+      line.setAttribute('stroke-width', '1');
+      svg.appendChild(line);
+
+      // Horizontal line to child (extended to match wider indent)
+      const boxX = x + 10;
+      const hlineExtension = 15; // Extra space for label
+      const hline = document.createElementNS('http://www.w3.org/2000/svg', 'line');
+      hline.setAttribute('x1', connectorX);
+      hline.setAttribute('y1', y);
+      hline.setAttribute('x2', boxX + hlineExtension);
+      hline.setAttribute('y2', y);
+      hline.setAttribute('stroke', '#999');
+      hline.setAttribute('stroke-width', '1');
+      svg.appendChild(hline);
+
+      // Add answer label on the horizontal line, positioned below it to avoid boxes
+      const labelX = connectorX + (boxX + hlineExtension - connectorX) / 2;
+      const labelY = y + 10;
+
+      const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
+      label.setAttribute('x', labelX);
+      label.setAttribute('y', labelY);
+      label.setAttribute('font-family', '"Space Mono", monospace');
+      label.setAttribute('font-size', '11');
+      label.setAttribute('fill', '#666');
+      label.setAttribute('text-anchor', 'middle');
+      label.textContent = answerLabel;
+      svg.appendChild(label);
+    }
+  }
+
+  document.addEventListener('DOMContentLoaded', function() {
+    const sources = document.querySelectorAll('pre.decision-tree-source');
+
+    sources.forEach(pre => {
+      const yamlText = pre.textContent;
+      const treeData = parseDecisionTreeYAML(yamlText);
+
+      const container = document.createElement('div');
+      container.className = 'decision-tree-container';
+      pre.parentNode.insertBefore(container, pre.nextSibling);
+
+      renderDecisionTree(container, treeData);
+    });
+  });
+})();