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:
@@ -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 = "✅" if completed else "⬜"
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user