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:
@@ -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()
|
||||
|
||||
132
backend/app/api/endpoints/tree_markdown.py
Normal file
132
backend/app/api/endpoints/tree_markdown.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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("''")
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
28
backend/app/schemas/tree_markdown.py
Normal file
28
backend/app/schemas/tree_markdown.py
Normal 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
|
||||
491
backend/app/services/tree_markdown_parser.py
Normal file
491
backend/app/services/tree_markdown_parser.py
Normal 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
|
||||
157
backend/app/services/tree_markdown_service.py
Normal file
157
backend/app/services/tree_markdown_service.py
Normal 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)
|
||||
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)
|
||||
134
backend/app/services/variable_service.py
Normal file
134
backend/app/services/variable_service.py
Normal 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
|
||||
Reference in New Issue
Block a user