feat: add procedural flow support to AI chat builder (Flow Assist)
- Add procedural-specific system prompts (schema, interview protocol, response format) - Dispatch prompts by flow_type: procedural/maintenance use flat steps schema, troubleshooting uses decision tree schema - Parse [STEPS_UPDATE] and [INTAKE_FORM] markers in AI responses - Add validate_generated_procedural_steps() validator - Handle intake form extraction in AI chat import endpoint - Add StaticStepsPreview component for procedural flow preview - Update store and page to render correct preview by flow type Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -390,11 +390,18 @@ async def import_tree(
|
|||||||
# Always create a new Tree record (no duplicate check — user may
|
# Always create a new Tree record (no duplicate check — user may
|
||||||
# want multiple copies or re-import after edits)
|
# want multiple copies or re-import after edits)
|
||||||
metadata = session.tree_metadata or {}
|
metadata = session.tree_metadata or {}
|
||||||
|
|
||||||
|
# Extract intake form from metadata if present (procedural flows)
|
||||||
|
intake_form = None
|
||||||
|
if isinstance(metadata.get("intake_form"), list):
|
||||||
|
intake_form = metadata.pop("intake_form")
|
||||||
|
|
||||||
tree = Tree(
|
tree = Tree(
|
||||||
name=data.name or metadata.get("name", "AI-Generated Flow"),
|
name=data.name or metadata.get("name", "AI-Generated Flow"),
|
||||||
description=data.description or metadata.get("description", ""),
|
description=data.description or metadata.get("description", ""),
|
||||||
tree_type=session.flow_type,
|
tree_type=session.flow_type,
|
||||||
tree_structure=session.working_tree,
|
tree_structure=session.working_tree,
|
||||||
|
intake_form=intake_form,
|
||||||
author_id=current_user.id,
|
author_id=current_user.id,
|
||||||
account_id=current_user.account_id,
|
account_id=current_user.account_id,
|
||||||
category_id=data.category_id,
|
category_id=data.category_id,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.ai_provider import get_ai_provider
|
from app.core.ai_provider import get_ai_provider
|
||||||
from app.core.ai_tree_validator import validate_generated_tree
|
from app.core.ai_tree_validator import validate_generated_tree, validate_generated_procedural_steps
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.ai_chat_session import AIChatSession
|
from app.models.ai_chat_session import AIChatSession
|
||||||
|
|
||||||
@@ -140,18 +140,139 @@ IMPORTANT:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
PROCEDURAL_SCHEMA_CONTEXT = """
|
||||||
|
PROCEDURAL STEP SCHEMA — This is what you are building:
|
||||||
|
|
||||||
|
The flow is an ordered array of steps in a JSON object: {"steps": [...]}
|
||||||
|
|
||||||
|
Each step has a "type" field:
|
||||||
|
|
||||||
|
1. procedure_step — A concrete step the engineer performs
|
||||||
|
Required: id (string), type ("procedure_step"), title (string), description (string)
|
||||||
|
Optional:
|
||||||
|
- content_type ("action"|"informational"|"verification"|"warning") — default "action"
|
||||||
|
- estimated_minutes (number)
|
||||||
|
- commands (array of objects: {code: string, label?: string, language?: string}) — exact CLI/PowerShell syntax
|
||||||
|
- expected_outcome (string) — what success looks like
|
||||||
|
- verification_prompt (string) — question to confirm completion
|
||||||
|
- verification_type ("checkbox"|"text_input") — how the engineer confirms
|
||||||
|
- warning_text (string) — caution or prerequisite info
|
||||||
|
- notes_enabled (boolean) — allow engineer to capture notes on this step
|
||||||
|
- reference_url (string) — link to documentation
|
||||||
|
|
||||||
|
2. section_header — Groups steps into logical phases
|
||||||
|
Required: id (string), type ("section_header"), title (string)
|
||||||
|
Section headers apply to all subsequent steps until the next section_header.
|
||||||
|
|
||||||
|
3. procedure_end — Terminal marker (always the last step)
|
||||||
|
Required: id (string), type ("procedure_end"), title (string)
|
||||||
|
|
||||||
|
STRUCTURAL RULES:
|
||||||
|
- Steps are executed in array order (flat list, no branching)
|
||||||
|
- All IDs must be unique descriptive slugs (e.g., "check-dns-resolution", not UUIDs)
|
||||||
|
- The last step MUST be type "procedure_end"
|
||||||
|
- Use section_headers to organize steps into logical phases
|
||||||
|
- Commands are arrays of objects: [{"code": "Get-Service ADSync", "label": "Check sync service", "language": "powershell"}]
|
||||||
|
- Descriptions support [VAR:variable_name] interpolation for intake form variables (e.g., "Connect to [VAR:server_name] via RDP")
|
||||||
|
|
||||||
|
VARIABLE INTERPOLATION:
|
||||||
|
When the procedure needs per-execution input (server name, IP address, client name, etc.), use [VAR:variable_name] syntax in descriptions and commands. These map to intake form fields that the engineer fills in before starting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROCEDURAL_INTERVIEW_PROTOCOL = """
|
||||||
|
INTERVIEW PHASES — Follow this progression:
|
||||||
|
|
||||||
|
PHASE 1 - SCOPING (current_phase: scoping):
|
||||||
|
Understand the process being documented:
|
||||||
|
- What process or procedure is this flow for?
|
||||||
|
- Who will execute it? (Tier 1 help desk, Tier 2, senior engineers?)
|
||||||
|
- What environment context? (Specific vendor, on-prem vs cloud, tools available?)
|
||||||
|
- Will this need per-execution input? (server name, client info, IP addresses → intake form fields)
|
||||||
|
Demonstrate domain expertise: if the user says "Exchange Online mailbox migration," show understanding: "Are we covering full tenant-to-tenant migration, on-prem to Exchange Online cutover, or individual mailbox moves with hybrid?"
|
||||||
|
DO NOT emit [STEPS_UPDATE] during scoping. You are still understanding the process.
|
||||||
|
|
||||||
|
PHASE 2 - DISCOVERY (current_phase: discovery):
|
||||||
|
Build the procedure step by step IN ORDER:
|
||||||
|
- Start with prerequisites and initial verification
|
||||||
|
- Walk through each step sequentially — ask what happens first, then next, then next
|
||||||
|
- Suggest section headers to organize logical phases (e.g., "Pre-Flight Checks", "Migration", "Verification")
|
||||||
|
- Capture specific commands, tools, and expected outcomes for each step
|
||||||
|
- Identify where [VAR:variable_name] placeholders are needed
|
||||||
|
EMIT [STEPS_UPDATE] when you and the user have agreed on concrete steps. Build progressively — emit partial step lists as you go.
|
||||||
|
|
||||||
|
PHASE 3 - ENRICHMENT (current_phase: enrichment):
|
||||||
|
Circle back to enrich existing steps:
|
||||||
|
- Add exact PowerShell/CLI commands with full syntax
|
||||||
|
- Add verification prompts for critical steps
|
||||||
|
- Add warning_text for steps with risk (data loss, downtime, etc.)
|
||||||
|
- Add estimated_minutes for time-critical procedures
|
||||||
|
- Add expected_outcome for action steps
|
||||||
|
- Suggest reference_url links to documentation
|
||||||
|
- Identify missing edge cases or safety checks
|
||||||
|
EMIT [STEPS_UPDATE] when enriching steps with additional detail.
|
||||||
|
|
||||||
|
PHASE 4 - REVIEW (current_phase: review):
|
||||||
|
Present a summary:
|
||||||
|
- Total step count by content_type
|
||||||
|
- Outline of sections and steps
|
||||||
|
- List of intake form variables ([VAR:...]) used
|
||||||
|
- Flag any steps missing commands or verification
|
||||||
|
- Offer chance to reorder, add, or remove steps
|
||||||
|
EMIT [STEPS_UPDATE] only if the user requests changes.
|
||||||
|
|
||||||
|
TRANSITION between phases by emitting [PHASE:phase_name] when the conversation naturally moves to the next stage. You decide when enough information has been gathered for each phase.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROCEDURAL_RESPONSE_FORMAT = """
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
|
||||||
|
Your response is natural conversational text. When the step structure changes, include structured markers that will be parsed by the system (the user will NOT see these markers):
|
||||||
|
|
||||||
|
1. Steps update (only when structure changes — see phase rules above):
|
||||||
|
[STEPS_UPDATE]
|
||||||
|
{"steps": [...valid steps array...]}
|
||||||
|
[/STEPS_UPDATE]
|
||||||
|
|
||||||
|
2. Phase transition (when moving to next phase):
|
||||||
|
[PHASE:discovery]
|
||||||
|
|
||||||
|
3. Metadata capture (when you learn the flow's name, description, or tags):
|
||||||
|
[METADATA]
|
||||||
|
{"name": "...", "description": "...", "tags": ["..."]}
|
||||||
|
[/METADATA]
|
||||||
|
|
||||||
|
4. Intake form suggestion (when intake form fields are identified):
|
||||||
|
[INTAKE_FORM]
|
||||||
|
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
||||||
|
[/INTAKE_FORM]
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Include [STEPS_UPDATE] sparingly. Only when concrete steps are established or modified.
|
||||||
|
- The steps update should be the COMPLETE working step list, not a diff.
|
||||||
|
- Always include conversational text OUTSIDE the markers — never respond with only markers.
|
||||||
|
- The procedure_end step is always included as the last step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _build_system_prompt(flow_type: str) -> str:
|
def _build_system_prompt(flow_type: str) -> str:
|
||||||
"""Assemble the full system prompt for the chat builder."""
|
"""Assemble the full system prompt for the chat builder."""
|
||||||
flow_context = (
|
if flow_type in ("procedural", "maintenance"):
|
||||||
"The user wants to build a TROUBLESHOOTING flow — a diagnostic decision tree "
|
flow_context = (
|
||||||
"that guides engineers through symptom identification, diagnostic checks, and "
|
"The user wants to build a PROCEDURAL flow — a step-by-step process guide "
|
||||||
"resolution steps."
|
"with ordered phases, verification checkpoints, and optional intake form variables. "
|
||||||
if flow_type == "troubleshooting"
|
"This is NOT a branching decision tree — it is a flat, sequential procedure."
|
||||||
else "The user wants to build a PROCEDURAL flow — a step-by-step process guide "
|
)
|
||||||
"with phases, checklists, and verification steps."
|
return (
|
||||||
)
|
f"{ROLE_PERSONA}\n\n{flow_context}\n\n"
|
||||||
|
f"{PROCEDURAL_SCHEMA_CONTEXT}\n\n{PROCEDURAL_INTERVIEW_PROTOCOL}\n\n{PROCEDURAL_RESPONSE_FORMAT}"
|
||||||
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}"
|
)
|
||||||
|
else:
|
||||||
|
flow_context = (
|
||||||
|
"The user wants to build a TROUBLESHOOTING flow — a diagnostic decision tree "
|
||||||
|
"that guides engineers through symptom identification, diagnostic checks, and "
|
||||||
|
"resolution steps."
|
||||||
|
)
|
||||||
|
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}"
|
||||||
|
|
||||||
|
|
||||||
def _strip_markdown_fences(text: str) -> str:
|
def _strip_markdown_fences(text: str) -> str:
|
||||||
@@ -177,6 +298,7 @@ def _parse_ai_response(raw_response: str) -> dict[str, Any]:
|
|||||||
"tree_update": None,
|
"tree_update": None,
|
||||||
"phase": None,
|
"phase": None,
|
||||||
"metadata": None,
|
"metadata": None,
|
||||||
|
"intake_form": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract [TREE_UPDATE]...[/TREE_UPDATE]
|
# Extract [TREE_UPDATE]...[/TREE_UPDATE]
|
||||||
@@ -198,6 +320,40 @@ def _parse_ai_response(raw_response: str) -> dict[str, Any]:
|
|||||||
logger.warning("Truncated [TREE_UPDATE] block detected (no closing tag) — stripping from display")
|
logger.warning("Truncated [TREE_UPDATE] block detected (no closing tag) — stripping from display")
|
||||||
result["content"] = raw_response[: truncated_match.start()]
|
result["content"] = raw_response[: truncated_match.start()]
|
||||||
|
|
||||||
|
# Extract [STEPS_UPDATE]...[/STEPS_UPDATE] (procedural flows)
|
||||||
|
steps_match = re.search(
|
||||||
|
r"\[STEPS_UPDATE\]\s*([\s\S]*?)\s*\[/STEPS_UPDATE\]", result["content"]
|
||||||
|
)
|
||||||
|
if steps_match:
|
||||||
|
try:
|
||||||
|
raw_json = _strip_markdown_fences(steps_match.group(1))
|
||||||
|
result["tree_update"] = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse steps update JSON: %s", e)
|
||||||
|
result["content"] = result["content"][: steps_match.start()] + result["content"][steps_match.end() :]
|
||||||
|
else:
|
||||||
|
truncated_steps = re.search(r"\[STEPS_UPDATE\][\s\S]*$", result["content"])
|
||||||
|
if truncated_steps:
|
||||||
|
logger.warning("Truncated [STEPS_UPDATE] block detected (no closing tag) — stripping from display")
|
||||||
|
result["content"] = result["content"][: truncated_steps.start()]
|
||||||
|
|
||||||
|
# Extract [INTAKE_FORM]...[/INTAKE_FORM] (procedural flows)
|
||||||
|
intake_match = re.search(
|
||||||
|
r"\[INTAKE_FORM\]\s*([\s\S]*?)\s*\[/INTAKE_FORM\]", result["content"]
|
||||||
|
)
|
||||||
|
if intake_match:
|
||||||
|
try:
|
||||||
|
raw_json = _strip_markdown_fences(intake_match.group(1))
|
||||||
|
result["intake_form"] = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse intake form JSON: %s", e)
|
||||||
|
result["content"] = result["content"][: intake_match.start()] + result["content"][intake_match.end() :]
|
||||||
|
else:
|
||||||
|
truncated_intake = re.search(r"\[INTAKE_FORM\][\s\S]*$", result["content"])
|
||||||
|
if truncated_intake:
|
||||||
|
logger.warning("Truncated [INTAKE_FORM] block detected — stripping from display")
|
||||||
|
result["content"] = result["content"][: truncated_intake.start()]
|
||||||
|
|
||||||
# Extract [PHASE:name]
|
# Extract [PHASE:name]
|
||||||
phase_match = re.search(r"\[PHASE:(\w+)\]", result["content"])
|
phase_match = re.search(r"\[PHASE:(\w+)\]", result["content"])
|
||||||
if phase_match:
|
if phase_match:
|
||||||
@@ -318,12 +474,19 @@ async def send_message(
|
|||||||
# only require valid root structure, not min node counts)
|
# only require valid root structure, not min node counts)
|
||||||
tree_update = parsed["tree_update"]
|
tree_update = parsed["tree_update"]
|
||||||
if tree_update:
|
if tree_update:
|
||||||
if not isinstance(tree_update, dict) or tree_update.get("type") != "decision":
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
logger.warning("AI tree update rejected: root must be a decision node")
|
# Procedural: must be a dict with a "steps" list
|
||||||
tree_update = None
|
if not isinstance(tree_update, dict) or not isinstance(tree_update.get("steps"), list):
|
||||||
elif not tree_update.get("id"):
|
logger.warning("AI steps update rejected: must be a dict with a 'steps' list")
|
||||||
logger.warning("AI tree update rejected: root node missing id")
|
tree_update = None
|
||||||
tree_update = None
|
else:
|
||||||
|
# Troubleshooting: root must be a decision node
|
||||||
|
if not isinstance(tree_update, dict) or tree_update.get("type") != "decision":
|
||||||
|
logger.warning("AI tree update rejected: root must be a decision node")
|
||||||
|
tree_update = None
|
||||||
|
elif not tree_update.get("id"):
|
||||||
|
logger.warning("AI tree update rejected: root node missing id")
|
||||||
|
tree_update = None
|
||||||
|
|
||||||
# Update session state
|
# Update session state
|
||||||
history.append({"role": "assistant", "content": parsed["content"], "timestamp": now_iso})
|
history.append({"role": "assistant", "content": parsed["content"], "timestamp": now_iso})
|
||||||
@@ -345,6 +508,11 @@ async def send_message(
|
|||||||
merged.update(parsed["metadata"])
|
merged.update(parsed["metadata"])
|
||||||
session.tree_metadata = merged
|
session.tree_metadata = merged
|
||||||
|
|
||||||
|
if parsed.get("intake_form"):
|
||||||
|
merged = dict(session.tree_metadata)
|
||||||
|
merged["intake_form"] = parsed["intake_form"]
|
||||||
|
session.tree_metadata = merged
|
||||||
|
|
||||||
session.updated_at = datetime.now(timezone.utc)
|
session.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
return parsed["content"], tree_update, parsed["phase"], parsed["metadata"]
|
return parsed["content"], tree_update, parsed["phase"], parsed["metadata"]
|
||||||
@@ -367,7 +535,33 @@ async def generate_final_tree(
|
|||||||
for msg in session.conversation_history
|
for msg in session.conversation_history
|
||||||
]
|
]
|
||||||
|
|
||||||
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL procedural steps JSON for this flow.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Output format: {"steps": [...]} — a JSON object with a "steps" array
|
||||||
|
- Include ALL steps, section headers, and details we discussed
|
||||||
|
- Use descriptive step IDs (slugs, not UUIDs)
|
||||||
|
- Steps are in execution order (flat list, no branching)
|
||||||
|
- Use section_header steps to organize into logical phases
|
||||||
|
- Every procedure_step should have commands with exact syntax where discussed
|
||||||
|
- Every procedure_step should have expected_outcome and verification_prompt where discussed
|
||||||
|
- Include content_type, estimated_minutes, warning_text, and reference_url where discussed
|
||||||
|
- Use [VAR:variable_name] syntax in descriptions/commands for intake form variables
|
||||||
|
- The LAST step MUST be type "procedure_end"
|
||||||
|
- Respond with ONLY the JSON — no conversational text, no markdown fences
|
||||||
|
|
||||||
|
Also provide metadata as a separate JSON object after the steps:
|
||||||
|
[METADATA]
|
||||||
|
{"name": "...", "description": "...", "tags": ["..."]}
|
||||||
|
[/METADATA]
|
||||||
|
|
||||||
|
If we discussed intake form fields, also include:
|
||||||
|
[INTAKE_FORM]
|
||||||
|
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
||||||
|
[/INTAKE_FORM]"""
|
||||||
|
else:
|
||||||
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Include ALL branches, steps, and solutions we discussed
|
- Include ALL branches, steps, and solutions we discussed
|
||||||
@@ -421,21 +615,30 @@ Also provide metadata as a separate JSON object after the tree:
|
|||||||
continue
|
continue
|
||||||
raise ValueError("AI failed to produce valid JSON after retry")
|
raise ValueError("AI failed to produce valid JSON after retry")
|
||||||
|
|
||||||
errors = validate_generated_tree(tree)
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
if errors:
|
val_errors = validate_generated_procedural_steps(tree)
|
||||||
|
else:
|
||||||
|
val_errors = validate_generated_tree(tree)
|
||||||
|
|
||||||
|
if val_errors:
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
provider_messages.append({"role": "assistant", "content": response_text})
|
provider_messages.append({"role": "assistant", "content": response_text})
|
||||||
correction = (
|
correction = (
|
||||||
f"The tree has validation errors: {'; '.join(errors)}. "
|
f"The generated structure has validation errors: {'; '.join(val_errors)}. "
|
||||||
"Please fix these issues and respond with the corrected JSON only."
|
"Please fix these issues and respond with the corrected JSON only."
|
||||||
)
|
)
|
||||||
provider_messages.append({"role": "user", "content": correction})
|
provider_messages.append({"role": "user", "content": correction})
|
||||||
continue
|
continue
|
||||||
raise ValueError(f"Generated tree failed validation: {'; '.join(errors)}")
|
raise ValueError(f"Generated structure failed validation: {'; '.join(val_errors)}")
|
||||||
|
|
||||||
# Success
|
# Success
|
||||||
session.working_tree = tree
|
session.working_tree = tree
|
||||||
session.tree_metadata = metadata
|
session.tree_metadata = metadata
|
||||||
|
if parsed.get("intake_form"):
|
||||||
|
merged = dict(session.tree_metadata)
|
||||||
|
merged["intake_form"] = parsed["intake_form"]
|
||||||
|
session.tree_metadata = merged
|
||||||
|
metadata = session.tree_metadata
|
||||||
session.current_phase = "generation"
|
session.current_phase = "generation"
|
||||||
session.updated_at = datetime.now(timezone.utc)
|
session.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|||||||
@@ -230,3 +230,96 @@ def count_tree_stats(tree: dict[str, Any]) -> dict[str, int]:
|
|||||||
|
|
||||||
_count(tree, 1)
|
_count(tree, 1)
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
# --- Procedural flow validation ---
|
||||||
|
|
||||||
|
VALID_PROCEDURAL_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
|
||||||
|
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_generated_procedural_steps(tree: dict[str, Any]) -> list[str]:
|
||||||
|
"""Validate an AI-generated procedural step array.
|
||||||
|
|
||||||
|
Expects a dict with a 'steps' key containing a list of step objects.
|
||||||
|
Returns a list of error strings. Empty list means valid.
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not isinstance(tree, dict):
|
||||||
|
return ["Procedural flow must be a JSON object"]
|
||||||
|
|
||||||
|
steps = tree.get("steps")
|
||||||
|
if not isinstance(steps, list) or len(steps) == 0:
|
||||||
|
return ["Procedural flow must have a non-empty 'steps' array"]
|
||||||
|
|
||||||
|
if len(steps) > 100:
|
||||||
|
errors.append(
|
||||||
|
f"Procedural flow has {len(steps)} steps. Maximum 100 allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
all_ids: set[str] = set()
|
||||||
|
procedure_step_count = 0
|
||||||
|
procedure_end_count = 0
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
errors.append(f"Step at index {i} is not an object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check required fields
|
||||||
|
step_id = step.get("id")
|
||||||
|
step_type = step.get("type")
|
||||||
|
step_title = step.get("title")
|
||||||
|
|
||||||
|
if not step_id or not isinstance(step_id, str):
|
||||||
|
errors.append(f"Step at index {i} missing or invalid 'id' (must be a string)")
|
||||||
|
elif step_id in all_ids:
|
||||||
|
errors.append(f"Duplicate step ID: '{step_id}'")
|
||||||
|
else:
|
||||||
|
all_ids.add(step_id)
|
||||||
|
|
||||||
|
if not step_type or step_type not in VALID_PROCEDURAL_STEP_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"Step '{step_id or f'index {i}'}' has invalid type '{step_type}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(VALID_PROCEDURAL_STEP_TYPES))}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if step_type == "procedure_step":
|
||||||
|
procedure_step_count += 1
|
||||||
|
elif step_type == "procedure_end":
|
||||||
|
procedure_end_count += 1
|
||||||
|
|
||||||
|
if not step_title or not isinstance(step_title, str):
|
||||||
|
errors.append(f"Step '{step_id or f'index {i}'}' missing or invalid 'title' (must be a string)")
|
||||||
|
|
||||||
|
# Validate content_type if present
|
||||||
|
content_type = step.get("content_type")
|
||||||
|
if content_type is not None and content_type not in VALID_CONTENT_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"Step '{step_id or f'index {i}'}' has invalid content_type '{content_type}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(VALID_CONTENT_TYPES))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Must have exactly one procedure_end as the last step
|
||||||
|
if procedure_end_count == 0:
|
||||||
|
errors.append("Procedural flow must have exactly one 'procedure_end' step")
|
||||||
|
elif procedure_end_count > 1:
|
||||||
|
errors.append(
|
||||||
|
f"Procedural flow has {procedure_end_count} 'procedure_end' steps. "
|
||||||
|
"Must have exactly one."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Exactly one — check it's the last step
|
||||||
|
last_step = steps[-1]
|
||||||
|
if isinstance(last_step, dict) and last_step.get("type") != "procedure_end":
|
||||||
|
errors.append("The 'procedure_end' step must be the last step in the array")
|
||||||
|
|
||||||
|
# Need at least 2 procedure_step items
|
||||||
|
if procedure_step_count < 2:
|
||||||
|
errors.append(
|
||||||
|
f"Procedural flow has only {procedure_step_count} 'procedure_step' items. "
|
||||||
|
"Need at least 2 for a useful procedure."
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|||||||
835
docs/plans/2026-03-06-procedural-flow-assist.md
Normal file
835
docs/plans/2026-03-06-procedural-flow-assist.md
Normal file
@@ -0,0 +1,835 @@
|
|||||||
|
# Procedural Flow Assist — AI Chat Builder Support
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make the Flow Assist AI chat builder correctly generate procedural/maintenance flows using the flat steps-array schema instead of the troubleshooting decision-tree schema.
|
||||||
|
|
||||||
|
**Architecture:** The AI chat service (`ai_chat_service.py`) currently has hardcoded troubleshooting-specific prompts (schema, interview protocol, response format). We add parallel procedural versions and dispatch based on `flow_type`. The AI validator gets a procedural counterpart. The frontend gets a procedural steps preview component and the store/page handle the different data shape.
|
||||||
|
|
||||||
|
**Tech Stack:** Python/FastAPI (backend), React/TypeScript (frontend), Zustand (state), Tailwind CSS (styling)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context: Procedural vs Troubleshooting Structure
|
||||||
|
|
||||||
|
**Troubleshooting** flows use a recursive tree: `{ id, type: "decision", question, options, children: [...] }` — branching paths ending in solution nodes.
|
||||||
|
|
||||||
|
**Procedural** flows use a flat ordered array: `{ steps: [{ id, type, title, ... }, ...] }` — sequential steps with a `procedure_end` as the final step.
|
||||||
|
|
||||||
|
### Procedural Step Schema
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "unique-slug",
|
||||||
|
"type": "procedure_step | procedure_end | section_header",
|
||||||
|
"title": "Step title",
|
||||||
|
"description": "Detailed instructions (supports [VAR:variable_name] interpolation)",
|
||||||
|
"content_type": "action | informational | verification | warning",
|
||||||
|
"estimated_minutes": 5,
|
||||||
|
"commands": [{ "code": "Get-Service ...", "label": "Check service", "language": "powershell" }],
|
||||||
|
"expected_outcome": "What success looks like",
|
||||||
|
"verification_prompt": "Confirm the service is running",
|
||||||
|
"verification_type": "checkbox | text_input",
|
||||||
|
"warning_text": "Caution text for warning content_type",
|
||||||
|
"notes_enabled": true,
|
||||||
|
"reference_url": "https://docs.microsoft.com/...",
|
||||||
|
"section_header": "Optional section label"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structural Rules
|
||||||
|
- `steps` array must be non-empty
|
||||||
|
- Each step needs `id`, `type`, `title`
|
||||||
|
- Valid types: `procedure_step`, `procedure_end`, `section_header`
|
||||||
|
- Exactly ONE `procedure_end` as the LAST step
|
||||||
|
- No duplicate step IDs
|
||||||
|
- `content_type` if present must be: `action`, `informational`, `verification`, `warning`
|
||||||
|
- Commands can be a string or array of `{ code, label?, language? }`
|
||||||
|
|
||||||
|
### Intake Form (Optional)
|
||||||
|
Procedural flows can have an intake form that captures variables before execution. Fields use `variable_name` (e.g., `server_name`) referenced in step descriptions as `[VAR:server_name]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add Procedural System Prompts to AI Chat Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/core/ai_chat_service.py`
|
||||||
|
|
||||||
|
**Step 1: Add procedural schema context constant**
|
||||||
|
|
||||||
|
After the existing `SCHEMA_CONTEXT` constant (~line 78), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PROCEDURAL_SCHEMA_CONTEXT = """
|
||||||
|
PROCEDURAL STEP SCHEMA — This is what you are building:
|
||||||
|
|
||||||
|
Procedural flows are a FLAT ORDERED ARRAY of steps (NOT a branching tree). The structure is:
|
||||||
|
{"steps": [step1, step2, ..., end_step]}
|
||||||
|
|
||||||
|
Each step has a "type" field:
|
||||||
|
|
||||||
|
1. procedure_step — A task the engineer performs
|
||||||
|
Required: id (string), type ("procedure_step"), title (string)
|
||||||
|
Optional: description (string — detailed instructions, supports [VAR:variable_name] interpolation),
|
||||||
|
content_type ("action" | "informational" | "verification" | "warning"),
|
||||||
|
estimated_minutes (integer),
|
||||||
|
commands (array of {code: string, label?: string, language?: string}),
|
||||||
|
expected_outcome (string),
|
||||||
|
verification_prompt (string — question to confirm step completion),
|
||||||
|
verification_type ("checkbox" | "text_input"),
|
||||||
|
warning_text (string — caution text, used with content_type "warning"),
|
||||||
|
notes_enabled (boolean, default true),
|
||||||
|
reference_url (string — documentation link)
|
||||||
|
|
||||||
|
2. section_header — A visual divider to organize steps into phases
|
||||||
|
Required: id (string), type ("section_header"), title (string)
|
||||||
|
Optional: description (string)
|
||||||
|
|
||||||
|
3. procedure_end — The final completion marker (exactly ONE, always LAST)
|
||||||
|
Required: id (string), type ("procedure_end"), title (string)
|
||||||
|
Optional: description (string — completion summary text)
|
||||||
|
|
||||||
|
CONTENT TYPES for procedure_step:
|
||||||
|
- "action" (default): Executable task with commands — shows terminal icon
|
||||||
|
- "informational": Read-only context or reference info — shows info icon
|
||||||
|
- "verification": Requires engineer confirmation before proceeding — shows checkmark icon
|
||||||
|
- "warning": Highlighted caution/danger step — shows alert icon
|
||||||
|
|
||||||
|
STRUCTURAL RULES:
|
||||||
|
- Steps are executed in array order — position determines sequence
|
||||||
|
- All IDs must be unique strings (use descriptive slugs like "install-ad-ds-role")
|
||||||
|
- The LAST step MUST be type "procedure_end"
|
||||||
|
- Section headers group related steps visually but don't affect execution order
|
||||||
|
- Use [VAR:variable_name] in descriptions to reference intake form variables (e.g., "Configure IP on [VAR:server_name]")
|
||||||
|
|
||||||
|
COMMAND FORMAT:
|
||||||
|
Commands are arrays of objects, each with:
|
||||||
|
- code (required): The exact command syntax (PowerShell, CMD, bash, etc.)
|
||||||
|
- label (optional): Short description of what the command does
|
||||||
|
- language (optional): "powershell", "cmd", "bash", etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROCEDURAL_INTERVIEW_PROTOCOL = """
|
||||||
|
INTERVIEW PHASES — Follow this progression:
|
||||||
|
|
||||||
|
PHASE 1 - SCOPING (current_phase: scoping):
|
||||||
|
Understand what procedure this flow covers:
|
||||||
|
- What process is this flow for? (e.g., "new domain controller build", "Exchange migration", "firewall replacement")
|
||||||
|
- What is the target environment? (on-prem, hybrid, cloud, specific vendors?)
|
||||||
|
- Who will execute this? (Tier level, experience assumptions)
|
||||||
|
- What information will the engineer need before starting? (This becomes the intake form — server name, IP, domain, credentials, etc.)
|
||||||
|
Demonstrate expertise: "For a DC build, we'd typically need server name, IP, subnet, gateway, domain name, DSRM password, and whether this is the first DC or joining an existing domain."
|
||||||
|
DO NOT emit [STEPS_UPDATE] during scoping.
|
||||||
|
|
||||||
|
PHASE 2 - DISCOVERY (current_phase: discovery):
|
||||||
|
Build out the procedure step by step:
|
||||||
|
- Establish the major phases (these become section_headers)
|
||||||
|
- For each phase, work through the steps in execution order
|
||||||
|
- Capture specific commands with exact syntax
|
||||||
|
- Add verification steps where the engineer should confirm something before proceeding
|
||||||
|
- Add warning steps for anything destructive or irreversible
|
||||||
|
EMIT [STEPS_UPDATE] when you and the user have agreed on concrete steps. Include ALL steps discussed so far.
|
||||||
|
|
||||||
|
PHASE 3 - ENRICHMENT (current_phase: enrichment):
|
||||||
|
Circle back to improve existing steps:
|
||||||
|
- Add exact PowerShell/CLI commands with syntax
|
||||||
|
- Add expected_outcome for action steps
|
||||||
|
- Add verification prompts for critical checkpoints
|
||||||
|
- Add estimated_minutes for time-sensitive procedures
|
||||||
|
- Add reference_url links to relevant documentation
|
||||||
|
- Add warning_text for dangerous operations
|
||||||
|
- Suggest intake form variables for values that change per execution
|
||||||
|
EMIT [STEPS_UPDATE] when enriching steps.
|
||||||
|
|
||||||
|
PHASE 4 - REVIEW (current_phase: review):
|
||||||
|
Present a summary:
|
||||||
|
- Total step count by content_type
|
||||||
|
- Section-by-section outline
|
||||||
|
- Estimated total time
|
||||||
|
- List of intake form variables suggested
|
||||||
|
- Flag any gaps or areas needing more detail
|
||||||
|
EMIT [STEPS_UPDATE] only if the user requests changes.
|
||||||
|
|
||||||
|
TRANSITION between phases by emitting [PHASE:phase_name] when the conversation naturally moves to the next stage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROCEDURAL_RESPONSE_FORMAT = """
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
|
||||||
|
Your response is natural conversational text. When the step structure changes, include structured markers that will be parsed by the system (the user will NOT see these markers):
|
||||||
|
|
||||||
|
1. Steps update (only when structure changes — see phase rules above):
|
||||||
|
[STEPS_UPDATE]
|
||||||
|
{"steps": [...valid steps array...]}
|
||||||
|
[/STEPS_UPDATE]
|
||||||
|
|
||||||
|
2. Phase transition (when moving to next phase):
|
||||||
|
[PHASE:discovery]
|
||||||
|
|
||||||
|
3. Metadata capture (when you learn the flow's name, description, or tags):
|
||||||
|
[METADATA]
|
||||||
|
{"name": "...", "description": "...", "tags": ["..."]}
|
||||||
|
[/METADATA]
|
||||||
|
|
||||||
|
4. Intake form suggestion (when you identify variables the engineer will need):
|
||||||
|
[INTAKE_FORM]
|
||||||
|
[{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "placeholder": "e.g., DC01", "group_name": "Server Details", "display_order": 1}]
|
||||||
|
[/INTAKE_FORM]
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Include [STEPS_UPDATE] sparingly. Only when concrete steps are established or modified.
|
||||||
|
- The steps update should be the COMPLETE working steps array, not a diff.
|
||||||
|
- Always include conversational text OUTSIDE the markers — never respond with only markers.
|
||||||
|
- The last step in the array MUST always be type "procedure_end".
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update `_build_system_prompt` to dispatch by flow_type**
|
||||||
|
|
||||||
|
Replace the existing `_build_system_prompt` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _build_system_prompt(flow_type: str) -> str:
|
||||||
|
"""Assemble the full system prompt for the chat builder."""
|
||||||
|
if flow_type in ("procedural", "maintenance"):
|
||||||
|
flow_context = (
|
||||||
|
f"The user wants to build a {'MAINTENANCE' if flow_type == 'maintenance' else 'PROCEDURAL'} flow — "
|
||||||
|
"a step-by-step process guide that walks engineers through a procedure in sequence. "
|
||||||
|
"Steps are executed in order, not branching paths."
|
||||||
|
)
|
||||||
|
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{PROCEDURAL_SCHEMA_CONTEXT}\n\n{PROCEDURAL_INTERVIEW_PROTOCOL}\n\n{PROCEDURAL_RESPONSE_FORMAT}"
|
||||||
|
else:
|
||||||
|
flow_context = (
|
||||||
|
"The user wants to build a TROUBLESHOOTING flow — a diagnostic decision tree "
|
||||||
|
"that guides engineers through symptom identification, diagnostic checks, and "
|
||||||
|
"resolution steps."
|
||||||
|
)
|
||||||
|
return f"{ROLE_PERSONA}\n\n{flow_context}\n\n{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update `_parse_ai_response` to handle `[STEPS_UPDATE]` and `[INTAKE_FORM]`**
|
||||||
|
|
||||||
|
Add extraction for the new markers. After the `[METADATA]` extraction block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract [STEPS_UPDATE]...[/STEPS_UPDATE]
|
||||||
|
steps_match = re.search(
|
||||||
|
r"\[STEPS_UPDATE\]\s*([\s\S]*?)\s*\[/STEPS_UPDATE\]", result["content"]
|
||||||
|
)
|
||||||
|
if steps_match:
|
||||||
|
try:
|
||||||
|
raw_json = _strip_markdown_fences(steps_match.group(1))
|
||||||
|
result["tree_update"] = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse steps update JSON: %s", e)
|
||||||
|
result["content"] = result["content"][: steps_match.start()] + result["content"][steps_match.end() :]
|
||||||
|
else:
|
||||||
|
truncated_steps = re.search(r"\[STEPS_UPDATE\][\s\S]*$", result["content"])
|
||||||
|
if truncated_steps:
|
||||||
|
logger.warning("Truncated [STEPS_UPDATE] block detected — stripping from display")
|
||||||
|
result["content"] = result["content"][: truncated_steps.start()]
|
||||||
|
|
||||||
|
# Extract [INTAKE_FORM]...[/INTAKE_FORM]
|
||||||
|
intake_match = re.search(
|
||||||
|
r"\[INTAKE_FORM\]\s*([\s\S]*?)\s*\[/INTAKE_FORM\]", result["content"]
|
||||||
|
)
|
||||||
|
if intake_match:
|
||||||
|
try:
|
||||||
|
raw_json = _strip_markdown_fences(intake_match.group(1))
|
||||||
|
result["intake_form"] = json.loads(raw_json)
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning("Failed to parse intake form JSON: %s", e)
|
||||||
|
result["content"] = result["content"][: intake_match.start()] + result["content"][intake_match.end() :]
|
||||||
|
else:
|
||||||
|
truncated_intake = re.search(r"\[INTAKE_FORM\][\s\S]*$", result["content"])
|
||||||
|
if truncated_intake:
|
||||||
|
logger.warning("Truncated [INTAKE_FORM] block detected — stripping from display")
|
||||||
|
result["content"] = result["content"][: truncated_intake.start()]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `"intake_form": None` to the initial `result` dict.
|
||||||
|
|
||||||
|
**Step 4: Update `send_message` to validate procedural structure**
|
||||||
|
|
||||||
|
In `send_message()`, replace the tree_update validation block (~line 320-326):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Validate tree update if present
|
||||||
|
tree_update = parsed["tree_update"]
|
||||||
|
if tree_update:
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
# Procedural: must have a steps array
|
||||||
|
if not isinstance(tree_update, dict) or not isinstance(tree_update.get("steps"), list):
|
||||||
|
logger.warning("AI steps update rejected: must have a steps array")
|
||||||
|
tree_update = None
|
||||||
|
else:
|
||||||
|
# Troubleshooting: root must be a decision node
|
||||||
|
if not isinstance(tree_update, dict) or tree_update.get("type") != "decision":
|
||||||
|
logger.warning("AI tree update rejected: root must be a decision node")
|
||||||
|
tree_update = None
|
||||||
|
elif not tree_update.get("id"):
|
||||||
|
logger.warning("AI tree update rejected: root node missing id")
|
||||||
|
tree_update = None
|
||||||
|
```
|
||||||
|
|
||||||
|
Also handle intake_form persistence after the metadata block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if parsed.get("intake_form"):
|
||||||
|
session.intake_form_draft = parsed["intake_form"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — `AIChatSession` may not have an `intake_form_draft` field. We'll store it in `tree_metadata` instead:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if parsed.get("intake_form"):
|
||||||
|
merged = dict(session.tree_metadata) if session.tree_metadata else {}
|
||||||
|
merged["intake_form"] = parsed["intake_form"]
|
||||||
|
session.tree_metadata = merged
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Update `generate_final_tree` for procedural flows**
|
||||||
|
|
||||||
|
Replace the `generation_instruction` string with flow-type-aware instructions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL procedural steps JSON for this flow.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Include ALL steps we discussed, organized into sections
|
||||||
|
- Use descriptive step IDs (slugs, not UUIDs)
|
||||||
|
- Each step needs: id, type, title, description
|
||||||
|
- Include commands with exact syntax where discussed
|
||||||
|
- Include content_type for each step (action, informational, verification, warning)
|
||||||
|
- Include estimated_minutes where discussed
|
||||||
|
- Include verification_prompt for verification steps
|
||||||
|
- Include warning_text for warning steps
|
||||||
|
- The LAST step MUST be type "procedure_end"
|
||||||
|
- Respond with ONLY the JSON — no conversational text, no markdown fences
|
||||||
|
|
||||||
|
Format: {"steps": [step1, step2, ..., end_step]}
|
||||||
|
|
||||||
|
Also provide metadata as a separate JSON object after the steps:
|
||||||
|
[METADATA]
|
||||||
|
{"name": "...", "description": "...", "tags": ["..."]}
|
||||||
|
[/METADATA]
|
||||||
|
|
||||||
|
If we discussed intake form variables, include them:
|
||||||
|
[INTAKE_FORM]
|
||||||
|
[{"variable_name": "...", "label": "...", "field_type": "text", "required": true, "display_order": 1}]
|
||||||
|
[/INTAKE_FORM]"""
|
||||||
|
else:
|
||||||
|
generation_instruction = """Based on our entire conversation, generate the COMPLETE and FINAL TreeStructure JSON for this flow.
|
||||||
|
...existing troubleshooting instruction..."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the validation inside the generation loop to handle procedural:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if not tree:
|
||||||
|
# ... existing retry logic ...
|
||||||
|
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
# Validate procedural structure
|
||||||
|
p_errors = validate_generated_procedural_steps(tree)
|
||||||
|
if p_errors:
|
||||||
|
if attempt == 0:
|
||||||
|
# ... retry with correction ...
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Generated steps failed validation: {'; '.join(p_errors)}")
|
||||||
|
else:
|
||||||
|
errors = validate_generated_tree(tree)
|
||||||
|
if errors:
|
||||||
|
# ... existing retry logic ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Run backend tests**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/test_ai_chat.py -v --override-ini="addopts="`
|
||||||
|
Expected: All existing tests pass (they test troubleshooting flow).
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/core/ai_chat_service.py
|
||||||
|
git commit -m "feat: add procedural flow prompts to AI chat builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add Procedural Validation to AI Tree Validator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/core/ai_tree_validator.py`
|
||||||
|
|
||||||
|
**Step 1: Add `validate_generated_procedural_steps` function**
|
||||||
|
|
||||||
|
Add after the existing `count_tree_stats` function:
|
||||||
|
|
||||||
|
```python
|
||||||
|
VALID_PROCEDURAL_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
|
||||||
|
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_generated_procedural_steps(tree: dict[str, Any]) -> list[str]:
|
||||||
|
"""Validate an AI-generated procedural steps structure.
|
||||||
|
|
||||||
|
Returns a list of error strings. Empty list means valid.
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not isinstance(tree, dict):
|
||||||
|
return ["Steps structure must be a JSON object"]
|
||||||
|
|
||||||
|
steps = tree.get("steps")
|
||||||
|
if not isinstance(steps, list) or len(steps) == 0:
|
||||||
|
return ["Must have a non-empty 'steps' array"]
|
||||||
|
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
end_count = 0
|
||||||
|
step_count = 0
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
if not isinstance(step, dict):
|
||||||
|
errors.append(f"Step at index {i} is not an object")
|
||||||
|
continue
|
||||||
|
|
||||||
|
step_id = step.get("id")
|
||||||
|
step_type = step.get("type")
|
||||||
|
|
||||||
|
# Check ID
|
||||||
|
if not step_id:
|
||||||
|
errors.append(f"Step at index {i} missing 'id'")
|
||||||
|
elif step_id in seen_ids:
|
||||||
|
errors.append(f"Duplicate step ID: '{step_id}'")
|
||||||
|
else:
|
||||||
|
seen_ids.add(step_id)
|
||||||
|
|
||||||
|
# Check type
|
||||||
|
if step_type not in VALID_PROCEDURAL_STEP_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"Step '{step_id or i}' has invalid type '{step_type}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(VALID_PROCEDURAL_STEP_TYPES))}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check title
|
||||||
|
if not step.get("title"):
|
||||||
|
errors.append(f"Step '{step_id}' missing 'title'")
|
||||||
|
|
||||||
|
# Content type validation
|
||||||
|
content_type = step.get("content_type")
|
||||||
|
if content_type and content_type not in VALID_CONTENT_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"Step '{step_id}' has invalid content_type '{content_type}'. "
|
||||||
|
f"Must be one of: {', '.join(sorted(VALID_CONTENT_TYPES))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if step_type == "procedure_step":
|
||||||
|
step_count += 1
|
||||||
|
elif step_type == "procedure_end":
|
||||||
|
end_count += 1
|
||||||
|
|
||||||
|
# Structural checks
|
||||||
|
if end_count == 0:
|
||||||
|
errors.append("Must have exactly one 'procedure_end' step as the last step")
|
||||||
|
elif end_count > 1:
|
||||||
|
errors.append(f"Found {end_count} procedure_end steps, must have exactly 1")
|
||||||
|
|
||||||
|
if end_count == 1 and steps[-1].get("type") != "procedure_end":
|
||||||
|
errors.append("The procedure_end step must be the last step in the array")
|
||||||
|
|
||||||
|
if step_count < 2:
|
||||||
|
errors.append(
|
||||||
|
f"Flow has only {step_count} procedure steps. "
|
||||||
|
"Need at least 2 for a useful procedure."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(steps) > 100:
|
||||||
|
errors.append(f"Flow has {len(steps)} steps. Maximum 100 allowed.")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/ -k "procedural" -v --override-ini="addopts="`
|
||||||
|
Expected: Existing procedural tests pass.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/core/ai_tree_validator.py
|
||||||
|
git commit -m "feat: add procedural steps validator for AI-generated flows"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Handle Intake Form + Procedural Import in AI Chat Endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/api/endpoints/ai_chat.py`
|
||||||
|
|
||||||
|
**Step 1: Update the `import_tree` endpoint to handle intake form from metadata**
|
||||||
|
|
||||||
|
In the `import_tree` function (~line 393), after building the Tree object, check for intake form:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract intake form from metadata if present
|
||||||
|
intake_form = None
|
||||||
|
if metadata.get("intake_form"):
|
||||||
|
intake_form = metadata.pop("intake_form")
|
||||||
|
|
||||||
|
tree = Tree(
|
||||||
|
name=data.name or metadata.get("name", "AI-Generated Flow"),
|
||||||
|
description=data.description or metadata.get("description", ""),
|
||||||
|
tree_type=session.flow_type,
|
||||||
|
tree_structure=session.working_tree,
|
||||||
|
intake_form=intake_form,
|
||||||
|
author_id=current_user.id,
|
||||||
|
account_id=current_user.account_id,
|
||||||
|
category_id=data.category_id,
|
||||||
|
is_public=False,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run tests**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/test_ai_chat.py -v --override-ini="addopts="`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/api/endpoints/ai_chat.py
|
||||||
|
git commit -m "feat: handle intake form in AI chat procedural import"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add Procedural Steps Preview Component (Frontend)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/components/ai-chat/StaticStepsPreview.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create the procedural steps preview component**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ProceduralStep } from '@/types'
|
||||||
|
import { Terminal, Info, CheckSquare, AlertTriangle, LayoutList } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface StaticStepsPreviewProps {
|
||||||
|
steps: ProceduralStep[]
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_TYPE_ICONS: Record<string, typeof Terminal> = {
|
||||||
|
action: Terminal,
|
||||||
|
informational: Info,
|
||||||
|
verification: CheckSquare,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaticStepsPreview({ steps, name }: StaticStepsPreviewProps) {
|
||||||
|
let stepNumber = 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Preview: {name || 'Untitled Flow'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{steps.filter((s) => s.type === 'procedure_step').length} steps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{steps.map((step) => {
|
||||||
|
if (step.type === 'section_header') {
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="pt-3 pb-1 first:pt-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LayoutList className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary">
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'procedure_end') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className="mt-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-emerald-400">
|
||||||
|
{step.title || 'Procedure Complete'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stepNumber++
|
||||||
|
const contentType = step.content_type || 'action'
|
||||||
|
const Icon = CONTENT_TYPE_ICONS[contentType] || Terminal
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-2 text-xs',
|
||||||
|
contentType === 'warning'
|
||||||
|
? 'border-amber-500/20 bg-amber-500/5'
|
||||||
|
: 'border-border bg-card'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded bg-primary/10 font-label text-[0.5rem] text-primary">
|
||||||
|
{stepNumber}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon className={cn(
|
||||||
|
'h-3 w-3 shrink-0',
|
||||||
|
contentType === 'warning' ? 'text-amber-400' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
'font-medium truncate',
|
||||||
|
contentType === 'warning' ? 'text-amber-400' : 'text-foreground'
|
||||||
|
)}>
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{step.commands && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Terminal className="h-2.5 w-2.5" />
|
||||||
|
<span className="font-label text-[0.5rem]">
|
||||||
|
{Array.isArray(step.commands) ? step.commands.length : 1} command{(Array.isArray(step.commands) ? step.commands.length : 1) !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.estimated_minutes && (
|
||||||
|
<span className="shrink-0 font-label text-[0.5rem] text-muted-foreground">
|
||||||
|
~{step.estimated_minutes}m
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run build**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/frontend && npm run build`
|
||||||
|
Expected: Build passes.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/ai-chat/StaticStepsPreview.tsx
|
||||||
|
git commit -m "feat: add procedural steps preview component for AI chat builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Update AI Chat Store + Page for Procedural Flows
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/store/aiChatStore.ts`
|
||||||
|
- Modify: `frontend/src/pages/AIChatBuilderPage.tsx`
|
||||||
|
|
||||||
|
**Step 1: Update `AIChatState` interface and `sendMessage` handler in store**
|
||||||
|
|
||||||
|
In `aiChatStore.ts`, update the `workingTree` type to also accept procedural structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Change line 29:
|
||||||
|
workingTree: TreeStructure | { steps: ProceduralStep[] } | null
|
||||||
|
// Change line 33:
|
||||||
|
generatedTree: TreeStructure | { steps: ProceduralStep[] } | null
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `ProceduralStep` to the imports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
ChatMessage,
|
||||||
|
InterviewPhase,
|
||||||
|
TreeStructure,
|
||||||
|
ProceduralStep,
|
||||||
|
} from '@/types'
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `sendMessage` (~line 121-127) — the response handling already works because `working_tree` is stored as-is from the API. The cast just needs updating:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
workingTree: (response.working_tree as TreeStructure | { steps: ProceduralStep[] } | null) ?? state.workingTree,
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `generateTree` (~line 142-143):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
generatedTree: response.tree_structure as unknown as TreeStructure | { steps: ProceduralStep[] },
|
||||||
|
workingTree: response.tree_structure as unknown as TreeStructure | { steps: ProceduralStep[] },
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `resumeSession` (~line 185-187):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
workingTree: session.working_tree as TreeStructure | { steps: ProceduralStep[] } | null,
|
||||||
|
generatedTree: session.generated_tree as TreeStructure | { steps: ProceduralStep[] } | null,
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update `AIChatBuilderPage.tsx` to render correct preview**
|
||||||
|
|
||||||
|
Add import for `StaticStepsPreview` and `ProceduralStep`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { StaticStepsPreview } from '@/components/ai-chat/StaticStepsPreview'
|
||||||
|
import type { ProceduralStep } from '@/types'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the preview tree logic (~line 116) and preview render (~line 143-151):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const previewData = generatedTree || workingTree
|
||||||
|
|
||||||
|
// Determine if this is a procedural preview
|
||||||
|
const isProceduralPreview = previewData && 'steps' in previewData
|
||||||
|
|
||||||
|
// ... in the JSX:
|
||||||
|
{previewData ? (
|
||||||
|
isProceduralPreview ? (
|
||||||
|
<StaticStepsPreview
|
||||||
|
steps={(previewData as { steps: ProceduralStep[] }).steps}
|
||||||
|
name={treeMetadata?.name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StaticTreePreview
|
||||||
|
tree={previewData as TreeStructure}
|
||||||
|
name={treeMetadata?.name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<EmptyPreview />
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove the now-unused `const previewTree = (generatedTree || workingTree) as TreeStructure | null` line.
|
||||||
|
|
||||||
|
**Step 3: Run build**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/frontend && npm run build`
|
||||||
|
Expected: Build passes.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/store/aiChatStore.ts frontend/src/pages/AIChatBuilderPage.tsx
|
||||||
|
git commit -m "feat: wire procedural steps preview into AI chat builder page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Update `generate_final_tree` Generation + Validation Wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/app/core/ai_chat_service.py`
|
||||||
|
|
||||||
|
This task ensures the full `generate_final_tree` function properly handles the procedural path end-to-end, including the retry loop and validation import.
|
||||||
|
|
||||||
|
**Step 1: Add import for the new validator**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.ai_tree_validator import validate_generated_tree, validate_generated_procedural_steps
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update the validation block in `generate_final_tree`**
|
||||||
|
|
||||||
|
Inside the `for attempt in range(2)` loop, after tree is extracted, replace the validation block:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if session.flow_type in ("procedural", "maintenance"):
|
||||||
|
val_errors = validate_generated_procedural_steps(tree)
|
||||||
|
else:
|
||||||
|
val_errors = validate_generated_tree(tree)
|
||||||
|
|
||||||
|
if val_errors:
|
||||||
|
if attempt == 0:
|
||||||
|
provider_messages.append({"role": "assistant", "content": response_text})
|
||||||
|
correction = (
|
||||||
|
f"The generated structure has validation errors: {'; '.join(val_errors)}. "
|
||||||
|
"Please fix these issues and respond with the corrected JSON only."
|
||||||
|
)
|
||||||
|
provider_messages.append({"role": "user", "content": correction})
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Generated structure failed validation: {'; '.join(val_errors)}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Handle intake form from final generation**
|
||||||
|
|
||||||
|
After the `# Success` comment, before returning:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Extract intake form from metadata if present
|
||||||
|
if parsed.get("intake_form") and isinstance(parsed["intake_form"], list):
|
||||||
|
metadata["intake_form"] = parsed["intake_form"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run backend tests**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/test_ai_chat.py -v --override-ini="addopts="`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/app/core/ai_chat_service.py
|
||||||
|
git commit -m "feat: wire procedural validation into AI chat generate flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Final Integration Test + Build Verification
|
||||||
|
|
||||||
|
**Step 1: Run full backend test suite**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/backend && python -m pytest tests/ --override-ini="addopts=" -v`
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
**Step 2: Run frontend build**
|
||||||
|
|
||||||
|
Run: `cd /home/michaelchihlas/dev/patherly/frontend && npm run build`
|
||||||
|
Expected: Build passes with zero errors.
|
||||||
|
|
||||||
|
**Step 3: Final commit (if any remaining changes)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore: final cleanup for procedural Flow Assist support"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `backend/app/core/ai_chat_service.py` | Add procedural schema/protocol/format prompts, dispatch by flow_type, parse `[STEPS_UPDATE]` + `[INTAKE_FORM]`, validate procedural structure, procedural generation instruction |
|
||||||
|
| `backend/app/core/ai_tree_validator.py` | Add `validate_generated_procedural_steps()` function |
|
||||||
|
| `backend/app/api/endpoints/ai_chat.py` | Handle intake form in import endpoint |
|
||||||
|
| `frontend/src/components/ai-chat/StaticStepsPreview.tsx` | New procedural steps preview component |
|
||||||
|
| `frontend/src/store/aiChatStore.ts` | Widen `workingTree`/`generatedTree` types for procedural |
|
||||||
|
| `frontend/src/pages/AIChatBuilderPage.tsx` | Render `StaticStepsPreview` for procedural flows |
|
||||||
112
frontend/src/components/ai-chat/StaticStepsPreview.tsx
Normal file
112
frontend/src/components/ai-chat/StaticStepsPreview.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { ProceduralStep } from '@/types'
|
||||||
|
import { Terminal, Info, CheckSquare, AlertTriangle, LayoutList } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface StaticStepsPreviewProps {
|
||||||
|
steps: ProceduralStep[]
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_TYPE_ICONS: Record<string, typeof Terminal> = {
|
||||||
|
action: Terminal,
|
||||||
|
informational: Info,
|
||||||
|
verification: CheckSquare,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaticStepsPreview({ steps, name }: StaticStepsPreviewProps) {
|
||||||
|
let stepNumber = 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Preview: {name || 'Untitled Flow'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{steps.filter((s) => s.type === 'procedure_step').length} steps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{steps.map((step) => {
|
||||||
|
if (step.type === 'section_header') {
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="pt-3 pb-1 first:pt-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LayoutList className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary">
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.type === 'procedure_end') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className="mt-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-emerald-400">
|
||||||
|
{step.title || 'Procedure Complete'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
stepNumber++
|
||||||
|
const contentType = step.content_type || 'action'
|
||||||
|
const Icon = CONTENT_TYPE_ICONS[contentType] || Terminal
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-2 text-xs',
|
||||||
|
contentType === 'warning'
|
||||||
|
? 'border-amber-500/20 bg-amber-500/5'
|
||||||
|
: 'border-border bg-card'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded bg-primary/10 font-label text-[0.5rem] text-primary">
|
||||||
|
{stepNumber}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon className={cn(
|
||||||
|
'h-3 w-3 shrink-0',
|
||||||
|
contentType === 'warning' ? 'text-amber-400' : 'text-muted-foreground'
|
||||||
|
)} />
|
||||||
|
<span className={cn(
|
||||||
|
'font-medium truncate',
|
||||||
|
contentType === 'warning' ? 'text-amber-400' : 'text-foreground'
|
||||||
|
)}>
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{step.commands && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Terminal className="h-2.5 w-2.5" />
|
||||||
|
<span className="font-label text-[0.5rem]">
|
||||||
|
{Array.isArray(step.commands) ? step.commands.length : 1} command{(Array.isArray(step.commands) ? step.commands.length : 1) !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.estimated_minutes && (
|
||||||
|
<span className="shrink-0 font-label text-[0.5rem] text-muted-foreground">
|
||||||
|
~{step.estimated_minutes}m
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ import { ChatPanel } from '@/components/ai-chat/ChatPanel'
|
|||||||
import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
|
import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
|
||||||
import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
|
import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
|
||||||
import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
|
import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
|
||||||
|
import { StaticStepsPreview } from '@/components/ai-chat/StaticStepsPreview'
|
||||||
import { Spinner } from '@/components/common/Spinner'
|
import { Spinner } from '@/components/common/Spinner'
|
||||||
import { getTreeEditorPath } from '@/lib/routing'
|
import { getTreeEditorPath } from '@/lib/routing'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import type { TreeStructure } from '@/types'
|
import type { TreeStructure, ProceduralStep } from '@/types'
|
||||||
|
|
||||||
export function AIChatBuilderPage() {
|
export function AIChatBuilderPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -113,7 +114,8 @@ export function AIChatBuilderPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewTree = (generatedTree || workingTree) as TreeStructure | null
|
const previewData = generatedTree || workingTree
|
||||||
|
const isProceduralPreview = previewData && 'steps' in previewData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@@ -139,13 +141,20 @@ export function AIChatBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel: Tree preview (40%) — hidden below 1024px */}
|
{/* Right panel: Preview (40%) — hidden below 1024px */}
|
||||||
<div className="w-2/5 overflow-hidden bg-background max-lg:hidden">
|
<div className="w-2/5 overflow-hidden bg-background max-lg:hidden">
|
||||||
{previewTree ? (
|
{previewData ? (
|
||||||
<StaticTreePreview
|
isProceduralPreview ? (
|
||||||
tree={previewTree}
|
<StaticStepsPreview
|
||||||
name={treeMetadata?.name}
|
steps={(previewData as { steps: ProceduralStep[] }).steps}
|
||||||
/>
|
name={treeMetadata?.name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StaticTreePreview
|
||||||
|
tree={previewData as TreeStructure}
|
||||||
|
name={treeMetadata?.name}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<EmptyPreview />
|
<EmptyPreview />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
InterviewPhase,
|
InterviewPhase,
|
||||||
TreeStructure,
|
TreeStructure,
|
||||||
|
ProceduralStep,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
interface TreeMetadata {
|
interface TreeMetadata {
|
||||||
@@ -25,12 +26,12 @@ interface AIChatState {
|
|||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
isResponding: boolean
|
isResponding: boolean
|
||||||
|
|
||||||
// Progressive tree
|
// Progressive tree (troubleshooting = TreeStructure, procedural = {steps})
|
||||||
workingTree: TreeStructure | null
|
workingTree: TreeStructure | { steps: ProceduralStep[] } | null
|
||||||
treeMetadata: TreeMetadata | null
|
treeMetadata: TreeMetadata | null
|
||||||
|
|
||||||
// Final generation
|
// Final generation
|
||||||
generatedTree: TreeStructure | null
|
generatedTree: TreeStructure | { steps: ProceduralStep[] } | null
|
||||||
isGenerating: boolean
|
isGenerating: boolean
|
||||||
importedTreeId: string | null
|
importedTreeId: string | null
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export const useAIChatStore = create<AIChatState>((set, get) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: [...state.messages, aiMessage],
|
messages: [...state.messages, aiMessage],
|
||||||
currentPhase: response.current_phase,
|
currentPhase: response.current_phase,
|
||||||
workingTree: (response.working_tree as TreeStructure | null) ?? state.workingTree,
|
workingTree: (response.working_tree as TreeStructure | { steps: ProceduralStep[] } | null) ?? state.workingTree,
|
||||||
treeMetadata: (response.tree_metadata as TreeMetadata | null) ?? state.treeMetadata,
|
treeMetadata: (response.tree_metadata as TreeMetadata | null) ?? state.treeMetadata,
|
||||||
isResponding: false,
|
isResponding: false,
|
||||||
}))
|
}))
|
||||||
@@ -139,8 +140,8 @@ export const useAIChatStore = create<AIChatState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const response = await aiChatApi.generateTree(sessionId)
|
const response = await aiChatApi.generateTree(sessionId)
|
||||||
set({
|
set({
|
||||||
generatedTree: response.tree_structure as unknown as TreeStructure,
|
generatedTree: response.tree_structure as unknown as TreeStructure | { steps: ProceduralStep[] },
|
||||||
workingTree: response.tree_structure as unknown as TreeStructure,
|
workingTree: response.tree_structure as unknown as TreeStructure | { steps: ProceduralStep[] },
|
||||||
treeMetadata: response.tree_metadata as TreeMetadata,
|
treeMetadata: response.tree_metadata as TreeMetadata,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
@@ -182,9 +183,9 @@ export const useAIChatStore = create<AIChatState>((set, get) => ({
|
|||||||
currentPhase: session.current_phase,
|
currentPhase: session.current_phase,
|
||||||
flowType: session.flow_type,
|
flowType: session.flow_type,
|
||||||
messages: session.conversation_history as ChatMessage[],
|
messages: session.conversation_history as ChatMessage[],
|
||||||
workingTree: session.working_tree as TreeStructure | null,
|
workingTree: session.working_tree as TreeStructure | { steps: ProceduralStep[] } | null,
|
||||||
treeMetadata: session.tree_metadata as TreeMetadata | null,
|
treeMetadata: session.tree_metadata as TreeMetadata | null,
|
||||||
generatedTree: session.generated_tree as TreeStructure | null,
|
generatedTree: session.generated_tree as TreeStructure | { steps: ProceduralStep[] } | null,
|
||||||
isResponding: false,
|
isResponding: false,
|
||||||
})
|
})
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user