feat: add procedural flows with intake forms, navigation, and seed templates

Adds a new "procedural" tree type for linear step-by-step project workflows
(domain controller setup, M365 onboarding, VPN config, etc). Includes intake
form builder, two-panel step navigation, variable resolution, procedural
exports, 3 seed templates, and UI rename from "Trees" to "Flows".

Also archives 19 implemented plan docs and creates deferred features backlog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-14 04:13:52 -05:00
parent 303570ca2c
commit 350c977eda
58 changed files with 11686 additions and 167 deletions

View File

@@ -171,6 +171,8 @@ def _escape_markdown_table(value: str) -> str:
def generate_markdown_export(session: Session, options: SessionExport) -> str:
"""Generate markdown export."""
if _is_procedural_session(session):
return _generate_procedural_markdown(session, options)
lines = []
outcome_label = _get_outcome_label(session)
@@ -284,6 +286,8 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
def generate_text_export(session: Session, options: SessionExport) -> str:
"""Generate plain text export."""
if _is_procedural_session(session):
return _generate_procedural_text(session, options)
lines = []
outcome_label = _get_outcome_label(session)
@@ -378,6 +382,8 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
def generate_html_export(session: Session, options: SessionExport) -> str:
"""Generate HTML export."""
if _is_procedural_session(session):
return _generate_procedural_html(session, options)
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
outcome_label = _get_outcome_label(session)
@@ -484,6 +490,8 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
def generate_psa_export(session: Session, options: SessionExport) -> str:
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
if _is_procedural_session(session):
return _generate_procedural_psa(session, options)
lines = []
outcome_label = _get_outcome_label(session)
@@ -588,3 +596,311 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
lines.append(scratchpad.strip() if scratchpad.strip() else "None")
return "\n".join(lines)
def _is_procedural_session(session: Session) -> bool:
"""Check if session is for a procedural flow."""
return session.tree_snapshot.get("tree_type") == "procedural"
def _get_session_variables(session: Session) -> dict[str, str]:
"""Get session variables (intake form values) from session."""
variables = getattr(session, "session_variables", None)
if isinstance(variables, dict):
return variables
return {}
def _generate_procedural_markdown(session: Session, options: SessionExport) -> str:
"""Generate markdown export for procedural sessions."""
lines = []
outcome_label = _get_outcome_label(session)
tree_name = session.tree_snapshot.get("name", "Procedure")
if options.include_tree_info:
lines.append(f"# {tree_name}")
lines.append("")
if session.ticket_number:
lines.append(f"**Ticket:** {session.ticket_number}")
if session.client_name:
lines.append(f"**Client:** {session.client_name}")
if options.include_timestamps:
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"**Duration:** {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"**Outcome:** {outcome_label}")
lines.append("")
lines.append("---")
lines.append("")
# Project Parameters
variables = _get_session_variables(session)
if variables:
lines.append("## Project Parameters")
lines.append("")
lines.append("| Parameter | Value |")
lines.append("|-----------|-------|")
for key, value in variables.items():
lines.append(f"| `{_escape_markdown_table(key)}` | {_escape_markdown_table(value)} |")
lines.append("")
lines.append("---")
lines.append("")
# Steps
lines.append("## Procedure Steps")
lines.append("")
decisions = session.decisions or []
if options.max_step_index is not None:
decisions = decisions[:options.max_step_index]
for i, decision in enumerate(decisions, 1):
title = decision.get("question") or decision.get("action_performed", "Step")
notes = decision.get("notes", "")
verification = _get_command_output(decision)
completed = decision.get("answer") == "completed"
checkbox = "[x]" if completed else "[ ]"
lines.append(f"- {checkbox} **Step {i}: {title}**")
if notes:
lines.append(f" - Notes: {notes}")
if verification:
lines.append(f" - Verification: {verification}")
duration_seconds = _get_step_duration_seconds(decision)
if duration_seconds is not None:
lines.append(f" - Duration: {_format_step_duration(duration_seconds)}")
lines.append("")
# Resolution
_raw_notes = getattr(session, 'outcome_notes', None)
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
if outcome_notes.strip() and options.include_outcome_notes:
lines.append("---")
lines.append("")
lines.append("## Notes")
lines.append("")
lines.append(outcome_notes.strip())
lines.append("")
return "\n".join(lines)
def _generate_procedural_text(session: Session, options: SessionExport) -> str:
"""Generate plain text export for procedural sessions."""
lines = []
outcome_label = _get_outcome_label(session)
tree_name = session.tree_snapshot.get("name", "Procedure")
if options.include_tree_info:
lines.append(tree_name)
lines.append("=" * len(tree_name))
if session.ticket_number:
lines.append(f"Ticket: {session.ticket_number}")
if session.client_name:
lines.append(f"Client: {session.client_name}")
if options.include_timestamps:
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
if session.completed_at:
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Project Parameters
variables = _get_session_variables(session)
if variables:
lines.append("PROJECT PARAMETERS")
lines.append("-" * 20)
for key, value in variables.items():
lines.append(f" {key}: {value}")
lines.append("")
lines.append("PROCEDURE STEPS")
lines.append("-" * 20)
decisions = session.decisions or []
if options.max_step_index is not None:
decisions = decisions[:options.max_step_index]
for i, decision in enumerate(decisions, 1):
title = decision.get("question") or decision.get("action_performed", "Step")
notes = decision.get("notes", "")
verification = _get_command_output(decision)
completed = decision.get("answer") == "completed"
marker = "[DONE]" if completed else "[ ]"
lines.append(f"\n{marker} {i}. {title}")
if notes:
lines.append(f" Notes: {notes}")
if verification:
lines.append(f" Verification: {verification}")
duration_seconds = _get_step_duration_seconds(decision)
if duration_seconds is not None:
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
lines.append("")
_raw_notes = getattr(session, 'outcome_notes', None)
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
if outcome_notes.strip() and options.include_outcome_notes:
lines.append("NOTES")
lines.append("-" * 20)
lines.append(outcome_notes.strip())
return "\n".join(lines)
def _generate_procedural_html(session: Session, options: SessionExport) -> str:
"""Generate HTML export for procedural sessions."""
tree_name = html.escape(session.tree_snapshot.get("name", "Procedure"))
outcome_label = _get_outcome_label(session)
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
'<meta charset="UTF-8">',
f'<title>{tree_name}</title>',
'<style>',
'body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }',
'h1 { color: #333; }',
'.meta { color: #666; margin-bottom: 20px; }',
'.params { margin-bottom: 20px; }',
'.params table { width: 100%; border-collapse: collapse; }',
'.params td { padding: 6px 12px; border: 1px solid #ddd; }',
'.params td:first-child { font-family: monospace; font-weight: bold; width: 200px; background: #f9f9f9; }',
'.step { margin-bottom: 10px; padding: 10px 15px; background: #f5f5f5; border-radius: 5px; border-left: 4px solid #ccc; }',
'.step.done { border-left-color: #22c55e; }',
'.step h3 { margin: 0 0 5px 0; color: #444; }',
'.notes { font-style: italic; color: #555; font-size: 0.9em; }',
'</style>',
'</head>', '<body>']
if options.include_tree_info:
html_parts.append(f'<h1>{tree_name}</h1>')
html_parts.append('<div class="meta">')
if session.ticket_number:
html_parts.append(f'<p><strong>Ticket:</strong> {html.escape(session.ticket_number)}</p>')
if session.client_name:
html_parts.append(f'<p><strong>Client:</strong> {html.escape(session.client_name)}</p>')
if options.include_timestamps:
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
if session.completed_at:
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
html_parts.append(f'<p><strong>Duration:</strong> {_format_duration(session.started_at, session.completed_at)}</p>')
if outcome_label:
html_parts.append(f'<p><strong>Outcome:</strong> {html.escape(outcome_label)}</p>')
html_parts.append('</div>')
# Project Parameters
variables = _get_session_variables(session)
if variables:
html_parts.append('<div class="params">')
html_parts.append('<h2>Project Parameters</h2>')
html_parts.append('<table>')
for key, value in variables.items():
html_parts.append(f'<tr><td>{html.escape(key)}</td><td>{html.escape(value)}</td></tr>')
html_parts.append('</table>')
html_parts.append('</div>')
html_parts.append('<h2>Procedure Steps</h2>')
decisions = session.decisions or []
if options.max_step_index is not None:
decisions = decisions[:options.max_step_index]
for i, decision in enumerate(decisions, 1):
title = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
notes = html.escape(decision.get("notes", ""))
verification = _get_command_output(decision)
completed = decision.get("answer") == "completed"
css_class = "step done" if completed else "step"
marker = "&#x2705;" if completed else "&#x2B1C;"
html_parts.append(f'<div class="{css_class}">')
html_parts.append(f'<h3>{marker} Step {i}: {title}</h3>')
if notes:
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
if verification:
html_parts.append(f'<p>Verification: {html.escape(verification)}</p>')
duration_seconds = _get_step_duration_seconds(decision)
if duration_seconds is not None:
html_parts.append(f'<p style="color: #888; font-size: 0.85em;">Duration: {_format_step_duration(duration_seconds)}</p>')
html_parts.append('</div>')
# Resolution
_raw_notes = getattr(session, 'outcome_notes', None)
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
if outcome_notes.strip() and options.include_outcome_notes:
html_parts.append('<h2>Notes</h2>')
html_parts.append(f'<div style="white-space: pre-wrap;">{html.escape(outcome_notes.strip())}</div>')
html_parts.extend(['</body>', '</html>'])
return "\n".join(html_parts)
def _generate_procedural_psa(session: Session, options: SessionExport) -> str:
"""Generate PSA/ticket export for procedural sessions."""
lines = []
outcome_label = _get_outcome_label(session)
tree_name = session.tree_snapshot.get("name", "Procedure")
ticket_number = session.ticket_number or "N/A"
client_name = session.client_name or "N/A"
date_str = session.started_at.strftime("%Y-%m-%d %H:%M")
lines.append("=== PROCEDURE NOTES ===")
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
lines.append(f"Procedure: {tree_name} | Date: {date_str}")
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Project Parameters
variables = _get_session_variables(session)
if variables:
lines.append("--- PROJECT PARAMETERS ---")
for key, value in variables.items():
lines.append(f" {key}: {value}")
lines.append("")
# Steps
lines.append("--- STEPS COMPLETED ---")
decisions = session.decisions or []
if options.max_step_index is not None:
decisions = decisions[:options.max_step_index]
if decisions:
for i, decision in enumerate(decisions, 1):
title = decision.get("question") or decision.get("action_performed", "Step")
notes = decision.get("notes", "")
completed = decision.get("answer") == "completed"
marker = "[DONE]" if completed else "[ ]"
duration_seconds = _get_step_duration_seconds(decision)
line = f"{marker} {i}. {title}"
if duration_seconds is not None:
line += f" ({_format_step_duration(duration_seconds)})"
lines.append(line)
if notes:
lines.append(f" Notes: {notes}")
else:
lines.append("No steps recorded.")
lines.append("")
# Resolution
if session.completed_at:
lines.append("--- RESOLUTION ---")
_raw_notes = getattr(session, 'outcome_notes', None)
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
if outcome_notes.strip() and options.include_outcome_notes:
lines.append(outcome_notes.strip())
else:
lines.append("Procedure completed successfully.")
if outcome_label:
lines.append(f"Outcome: {outcome_label}")
lines.append("")
# Time spent
lines.append("--- TIME SPENT ---")
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
return "\n".join(lines)