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>
This commit is contained in:
93
backend/app/services/tree_markdown_validator.py
Normal file
93
backend/app/services/tree_markdown_validator.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Validation for ResolutionFlow tree markdown.
|
||||
|
||||
Validates markdown without saving, returning detailed errors with line numbers.
|
||||
"""
|
||||
from app.services.tree_markdown_parser import parse_markdown_to_tree, ParseError
|
||||
|
||||
|
||||
def validate_tree_markdown(markdown: str) -> list[ParseError]:
|
||||
"""Validate tree markdown and return all errors/warnings.
|
||||
|
||||
This wraps the parser and adds additional semantic checks.
|
||||
|
||||
Args:
|
||||
markdown: The markdown string to validate.
|
||||
|
||||
Returns:
|
||||
List of ParseError objects with line, column, message, severity.
|
||||
"""
|
||||
result = parse_markdown_to_tree(markdown)
|
||||
errors = list(result.errors)
|
||||
|
||||
# If parsing completely failed, return early
|
||||
if result.tree_structure is None:
|
||||
return errors
|
||||
|
||||
# Additional semantic validation on the parsed tree
|
||||
_validate_tree_semantics(result.tree_structure, errors)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_tree_semantics(tree: dict, errors: list[ParseError]) -> None:
|
||||
"""Run semantic checks on a parsed tree structure."""
|
||||
all_ids: set[str] = set()
|
||||
has_solution = False
|
||||
|
||||
def _collect_ids(node: dict) -> None:
|
||||
nonlocal has_solution
|
||||
all_ids.add(node.get("id", ""))
|
||||
if node.get("type") == "solution":
|
||||
has_solution = True
|
||||
for child in node.get("children", []):
|
||||
_collect_ids(child)
|
||||
|
||||
_collect_ids(tree)
|
||||
|
||||
if not has_solution:
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message="Tree must have at least one solution node",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
# Check for empty required fields
|
||||
def _validate_node(node: dict) -> None:
|
||||
ntype = node.get("type", "")
|
||||
nid = node.get("id", "")
|
||||
|
||||
if ntype == "decision":
|
||||
if not node.get("question", "").strip():
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Decision node '{nid}' has an empty question",
|
||||
severity="warning"
|
||||
))
|
||||
if not node.get("options"):
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Decision node '{nid}' has no options",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
elif ntype == "action":
|
||||
if not node.get("title", "").strip():
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Action node '{nid}' has an empty title",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
elif ntype == "solution":
|
||||
if not node.get("title", "").strip():
|
||||
errors.append(ParseError(
|
||||
line=1, column=1,
|
||||
message=f"Solution node '{nid}' has an empty title",
|
||||
severity="warning"
|
||||
))
|
||||
|
||||
for child in node.get("children", []):
|
||||
_validate_node(child)
|
||||
|
||||
_validate_node(tree)
|
||||
Reference in New Issue
Block a user