Add step-by-step process generation to workflow documentation

- Implemented `_extract_or_generate_steps` method to extract notes from workflow nodes or generate steps based on the workflow structure.
- Enhanced `generate_documentation.py` to include detailed step descriptions in the generated HTML documentation.
- Updated CSS styles for improved presentation of workflow steps in the HTML output.
- Added logic to handle cases where no steps are available, providing user-friendly feedback in the documentation.

This update enhances the clarity and usability of the generated documentation, making it easier for users to understand the workflow processes.
This commit is contained in:
prateekh777 2025-06-15 19:43:54 +02:00
parent c53949366e
commit 77b721288a
2 changed files with 88080 additions and 6772 deletions

View File

@ -113,6 +113,14 @@ class WorkflowAnalyzer:
# Generate description # Generate description
workflow['description'] = self._generate_description(workflow, trigger_type, integrations) workflow['description'] = self._generate_description(workflow, trigger_type, integrations)
# Extract or generate step-by-step process
steps = self._extract_or_generate_steps(workflow['nodes'], workflow['connections'])
workflow['steps'] = steps
# Debug logging
if steps:
print(f"Found/Generated {len(steps)} steps in workflow: {workflow['name']}")
# Extract raw JSON for viewer # Extract raw JSON for viewer
workflow['rawJson'] = json.dumps(data, indent=2) workflow['rawJson'] = json.dumps(data, indent=2)
@ -198,6 +206,204 @@ class WorkflowAnalyzer:
return desc return desc
def _extract_or_generate_steps(self, nodes: List[Dict], connections: Dict) -> List[Dict]:
"""Extract notes from nodes or generate steps from workflow structure."""
steps = []
# First, try to extract existing notes
nodes_with_notes = []
for node in nodes:
note = node.get('notes')
if note:
nodes_with_notes.append({
'name': node.get('name', ''),
'type': node.get('type', ''),
'note': note,
'id': node.get('id', '')
})
# If we have notes, use them
if nodes_with_notes:
return nodes_with_notes
# Otherwise, generate steps from workflow structure
return self._generate_steps_from_structure(nodes, connections)
def _generate_steps_from_structure(self, nodes: List[Dict], connections: Dict) -> List[Dict]:
"""Generate step descriptions from workflow node structure and connections."""
if not nodes:
return []
# Create a map of nodes by ID for easy lookup
node_map = {node.get('id', ''): node for node in nodes}
# Find the starting node (trigger or first node)
start_node = self._find_start_node(nodes, connections)
if not start_node:
# Fallback: just describe all nodes in order
return self._generate_basic_steps(nodes)
# Follow the workflow path
steps = []
visited = set()
self._traverse_workflow(start_node, node_map, connections, steps, visited)
return steps
def _find_start_node(self, nodes: List[Dict], connections: Dict) -> Optional[Dict]:
"""Find the starting node of the workflow (trigger or node with no inputs)."""
# Look for trigger nodes first
for node in nodes:
node_type = node.get('type', '').lower()
if any(trigger in node_type for trigger in ['trigger', 'webhook', 'cron', 'schedule', 'manual']):
return node
# Find nodes that are not targets of any connections
target_nodes = set()
for source_connections in connections.values():
if isinstance(source_connections, dict) and 'main' in source_connections:
for main_connections in source_connections['main']:
if isinstance(main_connections, list):
for connection in main_connections:
if isinstance(connection, dict) and 'node' in connection:
target_nodes.add(connection['node'])
# Return first node that's not a target
for node in nodes:
if node.get('name', '') not in target_nodes:
return node
# Fallback: return first node
return nodes[0] if nodes else None
def _traverse_workflow(self, current_node: Dict, node_map: Dict, connections: Dict, steps: List[Dict], visited: set):
"""Traverse the workflow following connections and generate step descriptions."""
node_name = current_node.get('name', '')
node_id = current_node.get('id', '')
if node_id in visited:
return
visited.add(node_id)
# Generate step description for current node
step_description = self._generate_step_description(current_node)
if step_description:
steps.append({
'name': node_name,
'type': current_node.get('type', ''),
'note': step_description,
'id': node_id
})
# Find next nodes
if node_name in connections:
node_connections = connections[node_name]
if isinstance(node_connections, dict) and 'main' in node_connections:
for main_connections in node_connections['main']:
if isinstance(main_connections, list):
for connection in main_connections:
if isinstance(connection, dict) and 'node' in connection:
next_node_name = connection['node']
next_node = None
for node in node_map.values():
if node.get('name') == next_node_name:
next_node = node
break
if next_node:
self._traverse_workflow(next_node, node_map, connections, steps, visited)
def _generate_step_description(self, node: Dict) -> str:
"""Generate a meaningful description for a workflow node based on its type and parameters."""
node_type = node.get('type', '')
node_name = node.get('name', '')
parameters = node.get('parameters', {})
# Clean up node type
clean_type = node_type.replace('n8n-nodes-base.', '').replace('Trigger', '').replace('trigger', '')
# Generate description based on node type
if 'webhook' in node_type.lower():
return f"Receives incoming webhook requests to trigger the workflow"
elif 'cron' in node_type.lower() or 'schedule' in node_type.lower():
return f"Runs on a scheduled basis to trigger the workflow automatically"
elif 'manual' in node_type.lower():
return f"Manual trigger to start the workflow execution"
elif 'http' in node_type.lower() or 'httpRequest' in node_type:
url = parameters.get('url', '')
method = parameters.get('method', 'GET')
return f"Makes {method} HTTP request" + (f" to {url}" if url else "")
elif 'set' in node_type.lower():
return f"Sets and transforms data values for use in subsequent steps"
elif 'if' in node_type.lower():
return f"Evaluates conditions to determine workflow path"
elif 'switch' in node_type.lower():
return f"Routes workflow execution based on multiple conditions"
elif 'function' in node_type.lower() or 'code' in node_type.lower():
return f"Executes custom JavaScript code for data processing"
elif 'merge' in node_type.lower():
return f"Combines data from multiple workflow branches"
elif 'split' in node_type.lower():
return f"Splits data into multiple items for parallel processing"
elif 'filter' in node_type.lower():
return f"Filters data based on specified conditions"
elif 'gmail' in node_type.lower():
operation = parameters.get('operation', 'send')
return f"Performs Gmail {operation} operation"
elif 'slack' in node_type.lower():
return f"Sends message or performs action in Slack"
elif 'discord' in node_type.lower():
return f"Sends message or performs action in Discord"
elif 'telegram' in node_type.lower():
return f"Sends message or performs action in Telegram"
elif 'airtable' in node_type.lower():
operation = parameters.get('operation', 'create')
return f"Performs Airtable {operation} operation on records"
elif 'google' in node_type.lower():
if 'sheets' in node_type.lower():
return f"Reads from or writes to Google Sheets"
elif 'drive' in node_type.lower():
return f"Manages files in Google Drive"
elif 'calendar' in node_type.lower():
return f"Manages Google Calendar events"
else:
return f"Integrates with Google {clean_type} service"
elif 'microsoft' in node_type.lower():
if 'outlook' in node_type.lower():
return f"Manages Microsoft Outlook emails"
elif 'excel' in node_type.lower():
return f"Works with Microsoft Excel files"
else:
return f"Integrates with Microsoft {clean_type} service"
elif 'openai' in node_type.lower():
return f"Processes data using OpenAI AI models"
elif 'anthropic' in node_type.lower():
return f"Processes data using Anthropic Claude AI"
elif 'database' in node_type.lower() or 'mysql' in node_type.lower() or 'postgres' in node_type.lower():
return f"Executes database operations"
elif 'wait' in node_type.lower():
return f"Pauses workflow execution for specified duration"
elif 'error' in node_type.lower():
return f"Handles errors and stops workflow execution"
else:
# Generic description based on service name
service_name = clean_type.title()
return f"Integrates with {service_name} to process data"
def _generate_basic_steps(self, nodes: List[Dict]) -> List[Dict]:
"""Generate basic steps when workflow structure is unclear."""
steps = []
for i, node in enumerate(nodes, 1):
description = self._generate_step_description(node)
if description:
steps.append({
'name': node.get('name', f'Step {i}'),
'type': node.get('type', ''),
'note': description,
'id': node.get('id', '')
})
return steps
def _calculate_stats(self): def _calculate_stats(self):
"""Calculate statistics from analyzed workflows.""" """Calculate statistics from analyzed workflows."""
self.stats['total'] = len(self.workflows) self.stats['total'] = len(self.workflows)
@ -638,13 +844,40 @@ def generate_html_documentation(data: Dict[str, Any]) -> str:
.workflow-description { .workflow-description {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.95rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.4;
margin-bottom: 15px; margin-bottom: 0;
} }
.dark-mode .workflow-description { .workflow-integrations {
color: var(--text-muted); margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
.dark-mode .workflow-integrations {
border-top-color: var(--border-color-dark);
}
.integrations-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
margin: 0 0 8px 0;
}
.integrations-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.integration-tag {
background: var(--secondary-color);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
} }
.workflow-footer { .workflow-footer {
@ -652,7 +885,7 @@ def generate_html_documentation(data: Dict[str, Any]) -> str:
background: var(--surface-hover); background: var(--surface-hover);
border-radius: 0 0 12px 12px; border-radius: 0 0 12px 12px;
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
@ -745,18 +978,72 @@ def generate_html_documentation(data: Dict[str, Any]) -> str:
color: var(--text-light); color: var(--text-light);
} }
.integrations-list { .workflow-steps {
display: flex; list-style: none;
flex-wrap: wrap; counter-reset: step-counter;
gap: 6px; padding-left: 0;
} }
.integration-tag { .workflow-step {
background: var(--secondary-color); counter-increment: step-counter;
margin-bottom: 15px;
padding: 12px;
background: var(--surface-hover);
border-radius: 8px;
border-left: 3px solid var(--accent-color);
position: relative;
}
.dark-mode .workflow-step {
background: var(--surface-hover-dark);
}
.workflow-step::before {
content: counter(step-counter);
position: absolute;
left: -15px;
top: 12px;
background: var(--accent-color);
color: white; color: white;
padding: 4px 8px; width: 24px;
border-radius: 4px; height: 24px;
font-size: 0.75rem; border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: bold;
}
.step-header {
margin-bottom: 6px;
}
.step-type {
color: var(--text-muted);
font-size: 0.85rem;
font-weight: normal;
margin-left: 8px;
}
.step-note {
color: var(--text-secondary);
line-height: 1.4;
white-space: pre-wrap;
}
.dark-mode .step-note {
color: var(--text-muted);
}
.workflow-howitworks {
color: var(--text-secondary);
line-height: 1.5;
padding: 10px 0;
}
.dark-mode .workflow-howitworks {
color: var(--text-muted);
} }
@keyframes pulse-green { @keyframes pulse-green {
@ -1215,6 +1502,35 @@ def generate_html_documentation(data: Dict[str, Any]) -> str:
`<span class="integration-tag">${integration}</span>` `<span class="integration-tag">${integration}</span>`
).join(''); ).join('');
// Step-by-step process section
let stepsSection = '';
if (workflow.steps && workflow.steps.length > 0) {
stepsSection = `
<div class="details-section">
<h4 class="details-title">Step-by-step process (${workflow.steps.length} steps)</h4>
<ol class="workflow-steps">
${workflow.steps.map(step => `
<li class="workflow-step">
<div class="step-header">
<strong>${step.name ? step.name : '(Unnamed node)'}</strong>
<span class="step-type">[${step.type.replace('n8n-nodes-base.', '')}]</span>
</div>
<div class="step-note">${step.note}</div>
</li>
`).join('')}
</ol>
</div>
`;
} else {
// Debug: Show when no steps are found
stepsSection = `
<div class="details-section">
<h4 class="details-title">Step-by-step process</h4>
<p style="color: var(--text-muted); font-style: italic;">No step-by-step notes available for this workflow.</p>
</div>
`;
}
return ` return `
<div class="workflow-card" data-trigger="${workflow.triggerType}" data-name="${workflow.name.toLowerCase()}" data-description="${workflow.description.toLowerCase()}" data-integrations="${workflow.integrations.join(' ').toLowerCase()}"> <div class="workflow-card" data-trigger="${workflow.triggerType}" data-name="${workflow.name.toLowerCase()}" data-description="${workflow.description.toLowerCase()}" data-integrations="${workflow.integrations.join(' ').toLowerCase()}">
<div class="workflow-header"> <div class="workflow-header">
@ -1233,16 +1549,19 @@ def generate_html_documentation(data: Dict[str, Any]) -> str:
</div> </div>
<h3 class="workflow-title">${workflow.name}</h3> <h3 class="workflow-title">${workflow.name}</h3>
<p class="workflow-description">${workflow.description}</p> <p class="workflow-description">${workflow.description}</p>
</div>
<div class="workflow-details"> <!-- Integrations moved to main card -->
<div class="details-section"> <div class="workflow-integrations">
<h4 class="details-title">Integrations (${workflow.integrations.length})</h4> <h4 class="integrations-title">Integrations (${workflow.integrations.length})</h4>
<div class="integrations-list"> <div class="integrations-list">
${integrations} ${integrations}
${workflow.integrations.length > 5 ? `<span class="integration-tag">+${workflow.integrations.length - 5} more</span>` : ''} ${workflow.integrations.length > 5 ? `<span class="integration-tag">+${workflow.integrations.length - 5} more</span>` : ''}
</div> </div>
</div> </div>
</div>
<div class="workflow-details">
${stepsSection}
${workflow.tags.length > 0 ? ` ${workflow.tags.length > 0 ? `
<div class="details-section"> <div class="details-section">
<h4 class="details-title">Tags</h4> <h4 class="details-title">Tags</h4>
@ -1262,7 +1581,6 @@ def generate_html_documentation(data: Dict[str, Any]) -> str:
</div> </div>
<div class="workflow-footer"> <div class="workflow-footer">
<div class="workflow-tags">${tags}</div>
<div class="action-buttons"> <div class="action-buttons">
<button class="btn toggle-details">View Details</button> <button class="btn toggle-details">View Details</button>
<button class="btn view-json" data-workflow-name="${workflow.name}" data-filename="${workflow.filename}">View File</button> <button class="btn view-json" data-workflow-name="${workflow.name}" data-filename="${workflow.filename}">View File</button>

File diff suppressed because one or more lines are too long