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:
chihlasm
2026-03-06 02:20:14 -05:00
parent 07a723c687
commit f86e16661a
7 changed files with 1298 additions and 38 deletions

View 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>
)
}

View File

@@ -5,10 +5,11 @@ import { ChatPanel } from '@/components/ai-chat/ChatPanel'
import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
import { StaticStepsPreview } from '@/components/ai-chat/StaticStepsPreview'
import { Spinner } from '@/components/common/Spinner'
import { getTreeEditorPath } from '@/lib/routing'
import { toast } from '@/lib/toast'
import type { TreeStructure } from '@/types'
import type { TreeStructure, ProceduralStep } from '@/types'
export function AIChatBuilderPage() {
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 (
<div className="flex h-full flex-col">
@@ -139,13 +141,20 @@ export function AIChatBuilderPage() {
/>
</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">
{previewTree ? (
<StaticTreePreview
tree={previewTree}
name={treeMetadata?.name}
/>
{previewData ? (
isProceduralPreview ? (
<StaticStepsPreview
steps={(previewData as { steps: ProceduralStep[] }).steps}
name={treeMetadata?.name}
/>
) : (
<StaticTreePreview
tree={previewData as TreeStructure}
name={treeMetadata?.name}
/>
)
) : (
<EmptyPreview />
)}

View File

@@ -5,6 +5,7 @@ import type {
ChatMessage,
InterviewPhase,
TreeStructure,
ProceduralStep,
} from '@/types'
interface TreeMetadata {
@@ -25,12 +26,12 @@ interface AIChatState {
messages: ChatMessage[]
isResponding: boolean
// Progressive tree
workingTree: TreeStructure | null
// Progressive tree (troubleshooting = TreeStructure, procedural = {steps})
workingTree: TreeStructure | { steps: ProceduralStep[] } | null
treeMetadata: TreeMetadata | null
// Final generation
generatedTree: TreeStructure | null
generatedTree: TreeStructure | { steps: ProceduralStep[] } | null
isGenerating: boolean
importedTreeId: string | null
@@ -121,7 +122,7 @@ export const useAIChatStore = create<AIChatState>((set, get) => ({
set((state) => ({
messages: [...state.messages, aiMessage],
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,
isResponding: false,
}))
@@ -139,8 +140,8 @@ export const useAIChatStore = create<AIChatState>((set, get) => ({
try {
const response = await aiChatApi.generateTree(sessionId)
set({
generatedTree: response.tree_structure as unknown as TreeStructure,
workingTree: 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 | { steps: ProceduralStep[] },
treeMetadata: response.tree_metadata as TreeMetadata,
status: 'completed',
isGenerating: false,
@@ -182,9 +183,9 @@ export const useAIChatStore = create<AIChatState>((set, get) => ({
currentPhase: session.current_phase,
flowType: session.flow_type,
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,
generatedTree: session.generated_tree as TreeStructure | null,
generatedTree: session.generated_tree as TreeStructure | { steps: ProceduralStep[] } | null,
isResponding: false,
})
} catch (e: unknown) {