Implements the full dual-mode tree editor (Plan Phases 1-5): Backend: - JSONB↔Markdown bidirectional serializer/parser with mistune - Markdown validator with line/column error reporting - 3 API endpoints: export-markdown, import-markdown, validate-markdown - Variable extraction/resolution service ([USER_INPUT], [VAR], [SAVE_AS]) - Session variables JSONB column (migration 028) - 39 tree markdown tests + variable service tests (403 total passing) Frontend: - Monaco-based Code Mode with custom Monarch tokenizer and dark theme - Autocomplete for @node_id refs, type values, variable names - Debounced validation (800ms) with inline Monaco error markers - Syntax help panel (absolute overlay, toggleable) - Starter template for new trees with valid cross-references - Bidirectional metadata sync (name/description/category/tags frontmatter) - Synchronous tree→markdown serializer (fixes async race condition) - Pre-save validation blocks save on broken refs or missing tree name - Mode-aware undo/redo: Monaco native in Code Mode, throttled zundo in Flow Mode - Variable prompt modal and frontend resolver for session navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
158 lines
4.7 KiB
Python
158 lines
4.7 KiB
Python
"""
|
|
JSONB → Markdown serializer for ResolutionFlow tree structures.
|
|
|
|
Converts a tree_structure JSONB dict into the ResolutionFlow Markdown format
|
|
where each node is a block delimited by YAML frontmatter.
|
|
"""
|
|
from typing import Any
|
|
|
|
|
|
def serialize_tree_to_markdown(
|
|
tree_structure: dict[str, Any],
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> str:
|
|
"""Convert a tree structure JSONB dict to ResolutionFlow Markdown format.
|
|
|
|
Args:
|
|
tree_structure: The recursive tree_structure dict from the database.
|
|
metadata: Optional tree metadata (name, description, category, tags)
|
|
to include as a frontmatter block at the top.
|
|
|
|
Returns:
|
|
Markdown string with YAML frontmatter blocks for each node.
|
|
"""
|
|
blocks: list[str] = []
|
|
|
|
if metadata:
|
|
meta_lines = ["---"]
|
|
if metadata.get("name"):
|
|
meta_lines.append(f"name: {metadata['name']}")
|
|
if metadata.get("description"):
|
|
meta_lines.append(f"description: {metadata['description']}")
|
|
if metadata.get("category"):
|
|
meta_lines.append(f"category: {metadata['category']}")
|
|
tags = metadata.get("tags")
|
|
if tags:
|
|
tags_str = ", ".join(tags) if isinstance(tags, list) else str(tags)
|
|
meta_lines.append(f"tags: [{tags_str}]")
|
|
meta_lines.append("---")
|
|
blocks.append("\n".join(meta_lines))
|
|
|
|
_serialize_node(tree_structure, parent_id=None, blocks=blocks)
|
|
return "\n\n".join(blocks) + "\n"
|
|
|
|
|
|
def _serialize_node(
|
|
node: dict[str, Any],
|
|
parent_id: str | None,
|
|
blocks: list[str],
|
|
) -> None:
|
|
"""Recursively serialize a single node and its children."""
|
|
node_type = node.get("type", "decision")
|
|
node_id = node.get("id", "unknown")
|
|
|
|
# Build frontmatter
|
|
frontmatter_lines = [
|
|
"---",
|
|
f"id: {node_id}",
|
|
f"type: {node_type}",
|
|
]
|
|
if parent_id is not None:
|
|
frontmatter_lines.append(f"parent: {parent_id}")
|
|
frontmatter_lines.append("---")
|
|
|
|
# Build body based on node type
|
|
body_lines: list[str] = []
|
|
|
|
if node_type == "decision":
|
|
_serialize_decision(node, body_lines)
|
|
elif node_type == "action":
|
|
_serialize_action(node, body_lines)
|
|
elif node_type == "solution":
|
|
_serialize_solution(node, body_lines)
|
|
|
|
block = "\n".join(frontmatter_lines) + "\n" + "\n".join(body_lines)
|
|
blocks.append(block)
|
|
|
|
# Recurse into children
|
|
children = node.get("children", [])
|
|
if children:
|
|
for child in children:
|
|
_serialize_node(child, parent_id=node_id, blocks=blocks)
|
|
|
|
|
|
def _serialize_decision(node: dict[str, Any], lines: list[str]) -> None:
|
|
"""Serialize a decision node body."""
|
|
question = node.get("question", "")
|
|
if question:
|
|
lines.append(f"# {question}")
|
|
lines.append("")
|
|
|
|
help_text = node.get("help_text", "")
|
|
if help_text:
|
|
for ht_line in help_text.split("\n"):
|
|
lines.append(f"> {ht_line}")
|
|
lines.append("")
|
|
|
|
options = node.get("options", [])
|
|
for i, opt in enumerate(options):
|
|
label = opt.get("label", "")
|
|
next_id = opt.get("next_node_id", "")
|
|
letter = chr(ord("A") + i) if i < 26 else str(i + 1)
|
|
if next_id:
|
|
lines.append(f"- [{letter}] {label} → @{next_id}")
|
|
else:
|
|
lines.append(f"- [{letter}] {label}")
|
|
|
|
|
|
def _serialize_action(node: dict[str, Any], lines: list[str]) -> None:
|
|
"""Serialize an action node body."""
|
|
title = node.get("title", "")
|
|
if title:
|
|
lines.append(f"## {title}")
|
|
lines.append("")
|
|
|
|
description = node.get("description", "")
|
|
if description:
|
|
lines.append(description)
|
|
lines.append("")
|
|
|
|
commands = node.get("commands", [])
|
|
if commands:
|
|
lines.append("```commands")
|
|
for cmd in commands:
|
|
lines.append(cmd)
|
|
lines.append("```")
|
|
lines.append("")
|
|
|
|
expected = node.get("expected_outcome", "")
|
|
if expected:
|
|
lines.append(f"**Expected:** {expected}")
|
|
lines.append("")
|
|
|
|
next_id = node.get("next_node_id", "")
|
|
if next_id:
|
|
lines.append(f"→ @{next_id}")
|
|
|
|
|
|
def _serialize_solution(node: dict[str, Any], lines: list[str]) -> None:
|
|
"""Serialize a solution node body."""
|
|
title = node.get("title", "")
|
|
if title:
|
|
lines.append(f"## {title}")
|
|
lines.append("")
|
|
|
|
description = node.get("description", "")
|
|
if description:
|
|
lines.append(description)
|
|
lines.append("")
|
|
|
|
steps = node.get("resolution_steps", [])
|
|
if steps:
|
|
for i, step in enumerate(steps, 1):
|
|
lines.append(f"{i}. {step}")
|
|
|
|
solution = node.get("solution", "")
|
|
if solution and not description and not steps:
|
|
lines.append(solution)
|