Files
resolutionflow/backend/app/services/tree_markdown_service.py
chihlasm eac6e184ec feat: add dual-mode tree editor with Code Mode, variables, and markdown sync
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>
2026-02-10 09:45:26 -05:00

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)