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:
chihlasm
2026-02-10 09:45:26 -05:00
parent 2bd47004e7
commit eac6e184ec
32 changed files with 3369 additions and 52 deletions

View File

@@ -295,6 +295,12 @@ async def export_session(
content = generate_text_export(session, export_options)
media_type = "text/plain"
# Resolve variables in export output
session_vars = getattr(session, 'session_variables', None) or {}
if session_vars:
from app.services.variable_service import resolve_variables
content = resolve_variables(content, session_vars)
# Mark as exported
session.exported = True
await db.commit()

View File

@@ -0,0 +1,132 @@
"""API endpoints for tree markdown import/export."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.models.tree import Tree
from app.models.user import User
from app.api.deps import get_current_active_user, require_engineer_or_admin
from app.core.permissions import can_access_tree, can_edit_tree
from app.schemas.tree_markdown import (
TreeMarkdownExportResponse,
TreeMarkdownImportRequest,
TreeMarkdownValidationResponse,
MarkdownValidationError,
)
from app.services.tree_markdown_service import serialize_tree_to_markdown
from app.services.tree_markdown_parser import parse_markdown_to_tree
from app.services.tree_markdown_validator import validate_tree_markdown
router = APIRouter(prefix="/trees", tags=["tree-markdown"])
@router.get("/{tree_id}/export-markdown", response_model=TreeMarkdownExportResponse)
async def export_tree_markdown(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Export a tree's JSONB structure as ResolutionFlow markdown."""
result = await db.execute(
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
if not can_access_tree(current_user, tree):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
metadata = {
"name": tree.name or "",
"description": tree.description or "",
"category": tree.category or "",
}
markdown = serialize_tree_to_markdown(tree.tree_structure, metadata=metadata)
return TreeMarkdownExportResponse(markdown=markdown)
@router.put("/{tree_id}/import-markdown", response_model=TreeMarkdownValidationResponse)
async def import_tree_markdown(
tree_id: UUID,
body: TreeMarkdownImportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)],
):
"""Parse markdown and update a tree's JSONB structure."""
result = await db.execute(
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
)
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
if not can_edit_tree(current_user, tree):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
parse_result = parse_markdown_to_tree(body.markdown)
has_errors = any(e.severity == "error" for e in parse_result.errors)
if has_errors:
return TreeMarkdownValidationResponse(
valid=False,
errors=[
MarkdownValidationError(
line=e.line, column=e.column, message=e.message, severity=e.severity
)
for e in parse_result.errors
],
tree_structure=None,
)
# Apply the parsed tree structure
tree.tree_structure = parse_result.tree_structure
await db.commit()
return TreeMarkdownValidationResponse(
valid=True,
errors=[
MarkdownValidationError(
line=e.line, column=e.column, message=e.message, severity=e.severity
)
for e in parse_result.errors
],
tree_structure=parse_result.tree_structure,
)
@router.post("/validate-markdown", response_model=TreeMarkdownValidationResponse)
async def validate_markdown(
body: TreeMarkdownImportRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Validate markdown without saving. Returns errors and preview JSONB."""
parse_result = parse_markdown_to_tree(body.markdown)
all_errors = validate_tree_markdown(body.markdown)
# Deduplicate errors by message
seen: set[str] = set()
unique_errors: list[MarkdownValidationError] = []
for e in all_errors:
key = f"{e.line}:{e.column}:{e.message}"
if key not in seen:
seen.add(key)
unique_errors.append(
MarkdownValidationError(
line=e.line, column=e.column, message=e.message, severity=e.severity
)
)
has_hard_errors = any(e.severity == "error" for e in unique_errors)
return TreeMarkdownValidationResponse(
valid=not has_hard_errors,
errors=unique_errors,
tree_structure=parse_result.tree_structure,
metadata=parse_result.metadata,
)

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
api_router = APIRouter()
@@ -24,3 +24,4 @@ api_router.include_router(accounts.router)
api_router.include_router(webhooks.router)
api_router.include_router(shares.router)
api_router.include_router(shared.router) # Public endpoints (no auth)
api_router.include_router(tree_markdown.router)

View File

@@ -48,6 +48,9 @@ class Session(Base):
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
exported: Mapped[bool] = mapped_column(Boolean, default=False)
session_variables: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
)
scratchpad: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, server_default=sa.text("''")
)

View File

@@ -44,6 +44,7 @@ class SessionUpdate(BaseModel):
ticket_number: Optional[str] = Field(None, max_length=100)
client_name: Optional[str] = Field(None, max_length=255)
scratchpad: Optional[str] = None
session_variables: Optional[dict[str, str]] = None
class SessionResponse(BaseModel):
@@ -60,6 +61,7 @@ class SessionResponse(BaseModel):
client_name: Optional[str] = None
exported: bool
scratchpad: str = ""
session_variables: dict[str, str] = Field(default_factory=dict)
@validator('scratchpad', pre=True, always=True)
def normalize_scratchpad(cls, v):

View File

@@ -0,0 +1,28 @@
"""Pydantic schemas for tree markdown import/export."""
from pydantic import BaseModel
class TreeMarkdownExportResponse(BaseModel):
"""Response for markdown export endpoint."""
markdown: str
class TreeMarkdownImportRequest(BaseModel):
"""Request body for markdown import endpoint."""
markdown: str
class MarkdownValidationError(BaseModel):
"""A single validation error with location info."""
line: int
column: int
message: str
severity: str # 'error' or 'warning'
class TreeMarkdownValidationResponse(BaseModel):
"""Response for markdown validation endpoint."""
valid: bool
errors: list[MarkdownValidationError]
tree_structure: dict | None = None
metadata: dict | None = None

View File

@@ -0,0 +1,491 @@
"""
Markdown → JSONB parser for ResolutionFlow tree structures.
Parses ResolutionFlow Markdown format (frontmatter-delimited node blocks)
back into the recursive tree_structure JSONB dict.
"""
import re
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ParseError:
"""A validation/parse error with location info."""
line: int
column: int
message: str
severity: str = "error" # 'error' or 'warning'
@dataclass
class ParseResult:
"""Result of parsing markdown into a tree structure."""
tree_structure: dict[str, Any] | None
errors: list[ParseError] = field(default_factory=list)
metadata: dict[str, Any] | None = None
# Regex patterns
FRONTMATTER_RE = re.compile(r"^---\s*$", re.MULTILINE)
OPTION_RE = re.compile(
r"^-\s*\[([A-Za-z0-9]+)\]\s*(.+?)(?:\s*→\s*@(\S+))?\s*$"
)
NEXT_NODE_RE = re.compile(r"^→\s*@(\S+)\s*$")
EXPECTED_RE = re.compile(r"^\*\*Expected:\*\*\s*(.+)$")
HEADING1_RE = re.compile(r"^#\s+(.+)$")
HEADING2_RE = re.compile(r"^##\s+(.+)$")
BLOCKQUOTE_RE = re.compile(r"^>\s*(.*)$")
ORDERED_LIST_RE = re.compile(r"^\d+\.\s+(.+)$")
COMMAND_BLOCK_START = re.compile(r"^```commands\s*$")
COMMAND_BLOCK_END = re.compile(r"^```\s*$")
def parse_markdown_to_tree(markdown: str) -> ParseResult:
"""Parse ResolutionFlow markdown into a tree structure JSONB dict.
Args:
markdown: The markdown string to parse.
Returns:
ParseResult with tree_structure, errors, and optional metadata.
"""
errors: list[ParseError] = []
raw_blocks = _split_into_blocks(markdown)
if not raw_blocks:
errors.append(ParseError(line=1, column=1, message="No node blocks found"))
return ParseResult(tree_structure=None, errors=errors)
# Check if the first block is a metadata block (has 'name' but no 'id'/'type')
metadata = None
node_blocks = raw_blocks
first_block_text, _ = raw_blocks[0]
meta = _try_parse_metadata_block(first_block_text)
if meta is not None:
metadata = meta
node_blocks = raw_blocks[1:]
if not node_blocks:
errors.append(ParseError(line=1, column=1, message="No node blocks found (only metadata)"))
return ParseResult(tree_structure=None, errors=errors, metadata=metadata)
# Parse each block into a flat node dict
flat_nodes: list[dict[str, Any]] = []
for block_text, start_line in node_blocks:
node, block_errors = _parse_block(block_text, start_line)
errors.extend(block_errors)
if node:
flat_nodes.append(node)
if not flat_nodes:
errors.append(ParseError(line=1, column=1, message="No valid nodes parsed"))
return ParseResult(tree_structure=None, errors=errors)
# Check for duplicate IDs
seen_ids: dict[str, int] = {}
for node in flat_nodes:
nid = node.get("id", "")
if nid in seen_ids:
errors.append(ParseError(
line=node.get("_start_line", 1),
column=1,
message=f"Duplicate node ID: '{nid}'"
))
else:
seen_ids[nid] = node.get("_start_line", 1)
# Reconstruct recursive tree from flat nodes
tree, reconstruct_errors = _reconstruct_tree(flat_nodes)
errors.extend(reconstruct_errors)
return ParseResult(tree_structure=tree, errors=errors, metadata=metadata)
def _try_parse_metadata_block(block_text: str) -> dict[str, Any] | None:
"""Try to parse a block as tree metadata (name, description, category, tags).
Returns metadata dict if the block contains 'name' but no 'id'/'type'.
Returns None if it's a regular node block.
"""
lines = block_text.split("\n")
fm_start = None
fm_end = None
for i, line in enumerate(lines):
if line.strip() == "---":
if fm_start is None:
fm_start = i
else:
fm_end = i
break
if fm_start is None or fm_end is None:
return None
fm_data: dict[str, str] = {}
for i in range(fm_start + 1, fm_end):
line = lines[i].strip()
if not line:
continue
if ":" in line:
key, _, value = line.partition(":")
fm_data[key.strip()] = value.strip()
# It's a metadata block if it has 'name' but no 'id' and no 'type'
if "name" in fm_data and "id" not in fm_data and "type" not in fm_data:
metadata: dict[str, Any] = {"name": fm_data["name"]}
if "description" in fm_data:
metadata["description"] = fm_data["description"]
if "category" in fm_data:
metadata["category"] = fm_data["category"]
if "tags" in fm_data:
tags_str = fm_data["tags"].strip("[]")
metadata["tags"] = [t.strip() for t in tags_str.split(",") if t.strip()]
return metadata
return None
def _split_into_blocks(markdown: str) -> list[tuple[str, int]]:
"""Split markdown into blocks delimited by --- frontmatter markers.
Returns list of (block_text, start_line_number) tuples.
"""
lines = markdown.split("\n")
blocks: list[tuple[str, int]] = []
# Find frontmatter boundaries (--- on its own line)
fm_lines: list[int] = []
for i, line in enumerate(lines):
if line.strip() == "---":
fm_lines.append(i)
# Pair up frontmatter markers: each block starts at a `---` and the
# frontmatter ends at the next `---`. The body follows until the
# next block's first `---` (or end of file).
i = 0
while i < len(fm_lines) - 1:
start = fm_lines[i]
end_fm = fm_lines[i + 1]
# Find the next block start (or EOF)
next_block_start = len(lines)
if i + 2 < len(fm_lines):
next_block_start = fm_lines[i + 2]
block_lines = lines[start:next_block_start]
block_text = "\n".join(block_lines)
blocks.append((block_text, start + 1)) # 1-indexed line number
i += 2 # Jump to next frontmatter pair
return blocks
def _parse_block(block_text: str, start_line: int) -> tuple[dict[str, Any] | None, list[ParseError]]:
"""Parse a single frontmatter+body block into a node dict."""
errors: list[ParseError] = []
lines = block_text.split("\n")
# Extract frontmatter (between first and second ---)
fm_start = None
fm_end = None
for i, line in enumerate(lines):
if line.strip() == "---":
if fm_start is None:
fm_start = i
else:
fm_end = i
break
if fm_start is None or fm_end is None:
errors.append(ParseError(
line=start_line, column=1,
message="Block missing valid frontmatter delimiters"
))
return None, errors
# Parse YAML-like frontmatter (simple key: value)
fm_data: dict[str, str] = {}
for i in range(fm_start + 1, fm_end):
line = lines[i].strip()
if not line:
continue
if ":" in line:
key, _, value = line.partition(":")
fm_data[key.strip()] = value.strip()
node_id = fm_data.get("id", "")
node_type = fm_data.get("type", "")
parent_id = fm_data.get("parent")
if not node_id:
errors.append(ParseError(
line=start_line, column=1,
message="Node block missing 'id' in frontmatter"
))
return None, errors
if node_type not in ("decision", "action", "solution"):
errors.append(ParseError(
line=start_line, column=1,
message=f"Invalid node type: '{node_type}' (must be decision, action, or solution)"
))
return None, errors
# Parse body (everything after frontmatter)
body_lines = lines[fm_end + 1:]
body_text = "\n".join(body_lines)
node: dict[str, Any] = {
"id": node_id,
"type": node_type,
"_parent_id": parent_id,
"_start_line": start_line,
}
if node_type == "decision":
_parse_decision_body(body_lines, node, start_line + fm_end + 1, errors)
elif node_type == "action":
_parse_action_body(body_lines, node, start_line + fm_end + 1, errors)
elif node_type == "solution":
_parse_solution_body(body_lines, node, start_line + fm_end + 1, errors)
return node, errors
def _parse_decision_body(
lines: list[str],
node: dict[str, Any],
body_start_line: int,
errors: list[ParseError],
) -> None:
"""Parse the body of a decision node."""
question = ""
help_text_lines: list[str] = []
options: list[dict[str, Any]] = []
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped:
continue
# Check for heading (question)
m = HEADING1_RE.match(stripped)
if m:
question = m.group(1).strip()
continue
# Check for blockquote (help_text)
m = BLOCKQUOTE_RE.match(stripped)
if m:
help_text_lines.append(m.group(1))
continue
# Check for option
m = OPTION_RE.match(stripped)
if m:
opt_label = m.group(2).strip()
opt_next = m.group(3) or ""
options.append({
"id": f"opt_{node['id']}_{len(options)}",
"label": opt_label,
"next_node_id": opt_next,
})
continue
node["question"] = question
node["help_text"] = "\n".join(help_text_lines) if help_text_lines else ""
node["options"] = options
node["children"] = []
def _parse_action_body(
lines: list[str],
node: dict[str, Any],
body_start_line: int,
errors: list[ParseError],
) -> None:
"""Parse the body of an action node."""
title = ""
description_lines: list[str] = []
commands: list[str] = []
expected_outcome = ""
next_node_id = ""
in_command_block = False
for i, line in enumerate(lines):
stripped = line.strip()
# Command block handling
if in_command_block:
if COMMAND_BLOCK_END.match(stripped):
in_command_block = False
else:
commands.append(line.rstrip())
continue
if COMMAND_BLOCK_START.match(stripped):
in_command_block = True
continue
if not stripped:
# Blank lines are part of description
if title and not expected_outcome and not next_node_id:
description_lines.append("")
continue
# Title
m = HEADING2_RE.match(stripped)
if m:
title = m.group(1).strip()
continue
# Expected outcome
m = EXPECTED_RE.match(stripped)
if m:
expected_outcome = m.group(1).strip()
continue
# Next node reference
m = NEXT_NODE_RE.match(stripped)
if m:
next_node_id = m.group(1).strip()
continue
# Everything else is description
description_lines.append(stripped)
# Trim leading and trailing empty lines from description
while description_lines and not description_lines[-1].strip():
description_lines.pop()
while description_lines and not description_lines[0].strip():
description_lines.pop(0)
node["title"] = title
node["description"] = "\n".join(description_lines)
node["commands"] = commands if commands else []
node["expected_outcome"] = expected_outcome
node["next_node_id"] = next_node_id
node["children"] = []
def _parse_solution_body(
lines: list[str],
node: dict[str, Any],
body_start_line: int,
errors: list[ParseError],
) -> None:
"""Parse the body of a solution node."""
title = ""
description_lines: list[str] = []
resolution_steps: list[str] = []
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped:
if title:
description_lines.append("")
continue
# Title
m = HEADING2_RE.match(stripped)
if m:
title = m.group(1).strip()
continue
# Ordered list item (resolution step)
m = ORDERED_LIST_RE.match(stripped)
if m:
resolution_steps.append(m.group(1).strip())
continue
# Everything else is description
description_lines.append(stripped)
# Trim leading and trailing empty lines
while description_lines and not description_lines[-1].strip():
description_lines.pop()
while description_lines and not description_lines[0].strip():
description_lines.pop(0)
node["title"] = title
node["description"] = "\n".join(description_lines)
node["resolution_steps"] = resolution_steps
node["solution"] = title # solution field required for publishing
def _reconstruct_tree(flat_nodes: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, list[ParseError]]:
"""Reconstruct a recursive tree from flat nodes using parent references.
Returns (tree_structure, errors).
"""
errors: list[ParseError] = []
if not flat_nodes:
return None, errors
# Build lookup
node_map: dict[str, dict[str, Any]] = {}
for node in flat_nodes:
nid = node["id"]
# Clean node (remove internal fields)
clean = {k: v for k, v in node.items() if not k.startswith("_")}
if "children" not in clean:
clean["children"] = []
node_map[nid] = clean
# Find root (node with no parent)
root_id = None
for node in flat_nodes:
if node.get("_parent_id") is None:
if root_id is not None:
errors.append(ParseError(
line=node.get("_start_line", 1),
column=1,
message=f"Multiple root nodes found: '{root_id}' and '{node['id']}'",
))
root_id = node["id"]
if root_id is None:
# Fall back to first node
root_id = flat_nodes[0]["id"]
errors.append(ParseError(
line=1, column=1,
message="No root node found (no node without a parent). Using first node as root.",
severity="warning"
))
# Build children relationships
for node in flat_nodes:
parent_id = node.get("_parent_id")
if parent_id and parent_id in node_map:
child = node_map[node["id"]]
node_map[parent_id]["children"].append(child)
elif parent_id and parent_id not in node_map:
errors.append(ParseError(
line=node.get("_start_line", 1),
column=1,
message=f"Node '{node['id']}' references non-existent parent '{parent_id}'"
))
# Validate option references
for nid, node in node_map.items():
if node.get("type") == "decision":
for opt in node.get("options", []):
ref = opt.get("next_node_id", "")
if ref and ref not in node_map:
errors.append(ParseError(
line=1, column=1,
message=f"Option '{opt.get('label', '')}' in node '{nid}' references non-existent node '@{ref}'"
))
elif node.get("type") == "action":
ref = node.get("next_node_id", "")
if ref and ref not in node_map:
errors.append(ParseError(
line=1, column=1,
message=f"Action node '{nid}' references non-existent next node '@{ref}'"
))
root = node_map.get(root_id)
return root, errors

View File

@@ -0,0 +1,157 @@
"""
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)

View 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)

View File

@@ -0,0 +1,134 @@
"""
Variable extraction and resolution for ResolutionFlow tree structures.
Supports three variable tokens:
- [USER_INPUT:prompt] — prompts user for input during session navigation
- [VAR:name] — references a previously saved variable value
- [SAVE_AS:name] — saves the current context as a named variable
"""
import re
from dataclasses import dataclass
from typing import Any
@dataclass
class VariableDefinition:
"""A variable found in a tree structure."""
name: str
kind: str # 'user_input', 'reference', 'save_as'
prompt: str # For user_input: the prompt text; for others: empty
node_id: str # The node where this variable appears
# Regex patterns for variable tokens
USER_INPUT_RE = re.compile(r'\[USER_INPUT:([^\]]+)\]')
VAR_REF_RE = re.compile(r'\[VAR:([^\]]+)\]')
SAVE_AS_RE = re.compile(r'\[SAVE_AS:([^\]]+)\]')
def extract_variables(tree_structure: dict[str, Any]) -> list[VariableDefinition]:
"""Extract all variable definitions from a tree structure.
Traverses the tree recursively and finds all [USER_INPUT:...],
[VAR:...], and [SAVE_AS:...] tokens.
Args:
tree_structure: The recursive tree_structure dict.
Returns:
List of VariableDefinition objects found in the tree.
"""
variables: list[VariableDefinition] = []
_scan_node(tree_structure, variables)
return variables
def _scan_node(node: dict[str, Any], variables: list[VariableDefinition]) -> None:
"""Scan a node and its children for variable tokens."""
node_id = node.get("id", "unknown")
# Collect all text fields to scan
text_fields = [
node.get("question", ""),
node.get("title", ""),
node.get("description", ""),
node.get("help_text", ""),
node.get("expected_outcome", ""),
node.get("solution", ""),
]
# Include option labels
for opt in node.get("options", []):
text_fields.append(opt.get("label", ""))
# Include resolution steps
for step in node.get("resolution_steps", []):
text_fields.append(step)
# Include commands
for cmd in node.get("commands", []):
text_fields.append(cmd)
# Scan all text fields
for text in text_fields:
if not text:
continue
for match in USER_INPUT_RE.finditer(text):
variables.append(VariableDefinition(
name=match.group(1).strip(),
kind="user_input",
prompt=match.group(1).strip(),
node_id=node_id,
))
for match in VAR_REF_RE.finditer(text):
variables.append(VariableDefinition(
name=match.group(1).strip(),
kind="reference",
prompt="",
node_id=node_id,
))
for match in SAVE_AS_RE.finditer(text):
variables.append(VariableDefinition(
name=match.group(1).strip(),
kind="save_as",
prompt="",
node_id=node_id,
))
# Recurse into children
for child in node.get("children", []):
_scan_node(child, variables)
def resolve_variables(text: str, variables: dict[str, str]) -> str:
"""Replace variable tokens in text with their resolved values.
- [VAR:name] → replaced with variables[name] if present
- [USER_INPUT:prompt] → replaced with variables[prompt] if present
- [SAVE_AS:name] → removed (save directives don't appear in output)
Args:
text: The text containing variable tokens.
variables: Dict mapping variable names to their values.
Returns:
Text with variable tokens replaced.
"""
def _replace_var(match: re.Match) -> str:
name = match.group(1).strip()
return variables.get(name, f"[VAR:{name}]")
def _replace_input(match: re.Match) -> str:
prompt = match.group(1).strip()
return variables.get(prompt, f"[USER_INPUT:{prompt}]")
def _replace_save(match: re.Match) -> str:
return "" # Save directives are removed from output
result = VAR_REF_RE.sub(_replace_var, text)
result = USER_INPUT_RE.sub(_replace_input, result)
result = SAVE_AS_RE.sub(_replace_save, result)
return result