From 0dc6123c0c9a614dce8c8a6e1b80dc25f5098e00 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 7 Mar 2026 15:51:37 -0500 Subject: [PATCH] feat: flow export/import + procedural Flow Assist (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add flow export/import backend (migration, endpoints, schemas) Add .rfflow file export/import support: - Migration 050: import_metadata JSONB column on trees - GET /trees/{id}/export?format=json|xml endpoint - POST /trees/import endpoint (creates draft, resolves categories/tags) - FlowExportEnvelope, FlowImportRequest/Response schemas - import_metadata field on TreeResponse Co-Authored-By: Claude Opus 4.6 * feat: add flow export/import frontend + backend tests Frontend: - ExportFlowModal with JSON/XML format selection + download - ImportFlowModal with drag-drop file picker + preview step - rfflowParser for client-side JSON/XML .rfflow parsing - Export buttons on editor toolbar and library action menus - Import button on library page next to Create New - Provenance display for imported flows in editor - flowTransfer API client + types Backend: - Fix regex->pattern deprecation in export endpoint - 12 integration tests covering export, import, round-trip, access control, tag/category creation, version validation Co-Authored-By: Claude Opus 4.6 * refactor: remove XML export, JSON-only for .rfflow files - Remove XML builder, format query param, and XML tests - Simplify ExportFlowModal (no format picker) - Simplify rfflowParser (JSON-only) - Remove format field from schemas and types Co-Authored-By: Claude Opus 4.6 * fix: match Flow Assist chat input to AI Assistant styling + strengthen one-question prompt Co-Authored-By: Claude Opus 4.6 * 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 * feat: add flow type selection to Flow Assist entry points - CreateFlowDropdown now shows "Build with Flow Assist" under each flow type - Library page "Flow Assist" button respects current type filter - Clean up unused AIFlowBuilderModal references Co-Authored-By: Claude Opus 4.6 * docs: update CLAUDE.md with AI chat builder and intake form learnings Co-Authored-By: Claude Opus 4.6 * fix: refine assistant chat prompt for concise answers and focused questions Co-Authored-By: Claude Opus 4.6 * feat: switch AI provider to Claude Sonnet 4.6 + add shift+enter hint to chat inputs - Default AI_PROVIDER changed from gemini to anthropic - AI_MODEL and AI_MODEL_ANTHROPIC updated to claude-sonnet-4-6 - Added "Shift + Enter for a new line" hint below all chat textareas Co-Authored-By: Claude Opus 4.6 * docs: update CLAUDE.md with AI provider and chat input learnings Co-Authored-By: Claude Opus 4.6 * docs: add editor-embedded Flow Assist design document Design for replacing the standalone /ai/chat page with context-aware AI side panels embedded in each editor (Troubleshooting + Procedural). Covers ghost node suggestion system, output-based thresholds, config-driven model routing, knowledge integration, and per-flow chat persistence. Co-Authored-By: Claude Opus 4.6 * docs: add editor-embedded Flow Assist implementation plan 25-task plan across 9 phases covering backend foundation, frontend infrastructure, tree/procedural editor integration, AI-assisted create, old code removal, action-type dispatch, suggestion audit trail, and build verification. Co-Authored-By: Claude Opus 4.6 * fix: use actual root node ID in orphan validation check AI-generated trees use descriptive IDs (e.g., "verify-account-exists") instead of "root", causing the root node to be falsely flagged as orphaned. Co-Authored-By: Claude Opus 4.6 * feat: add config-driven AI model tier routing Co-Authored-By: Claude Opus 4.6 * feat: extend AI chat session with tree_id and archived_at Add tree_id FK (CASCADE) for editor-embedded sessions and archived_at timestamp column to ai_chat_sessions table. Co-Authored-By: Claude Opus 4.6 * feat: add AI suggestion audit trail table Co-Authored-By: Claude Opus 4.6 * feat: add action_type and focal_node_id to AI chat message API - Add VALID_ACTION_TYPES literal and action_type/focal_node_id fields to AIChatMessageRequest schema - Add tree_id field to AIChatStartRequest schema for editor-embedded sessions - Update send_message() signature with action_type and focal_node_id params - Update start_chat_session() signature with tree_id param - Pass new params through endpoints to service functions - All new params have defaults so existing behavior is unchanged Co-Authored-By: Claude Opus 4.6 * feat: route AI model selection through action-type config Update get_ai_provider() to accept an optional model override parameter (applied only to AnthropicProvider; Gemini always uses its own model). Thread action_type-based model resolution through send_message() and generate_final_tree() in the AI chat service. Co-Authored-By: Claude Opus 4.6 * feat: add TypeScript types for editor-embedded AI Co-Authored-By: Claude Opus 4.6 * feat: add shared ContextMenu component Co-Authored-By: Claude Opus 4.6 * feat: add useEditorAI hook and editorAI API client Co-Authored-By: Claude Opus 4.6 * feat: add EditorAIPanel component with Chat and Suggestions tabs Co-Authored-By: Claude Opus 4.6 * feat: integrate AI panel, context menu, and ghost nodes in tree editor - Add AI Assist panel toggle button to tree editor toolbar - Wire EditorAIPanel alongside TreeEditorLayout with single-panel rule - Thread onNodeContextMenu through TreeEditorLayout → FlowCanvas → FlowCanvasNode - Add right-click context menu with Generate Branch, Explain Node, Delete actions - Add ghost node detection (_suggestion flag) with dashed border + opacity styling - Add Accept/Dismiss overlay buttons on ghost nodes for future suggestion handling Co-Authored-By: Claude Opus 4.6 * feat: integrate AI panel, context menu, and ghost steps in procedural editor Co-Authored-By: Claude Opus 4.6 * feat: add AI prompt dialog and wire into CreateFlowDropdown Replace navigation to /ai/chat with an inline AIPromptDialog modal that collects a single prompt, generates a flow via the editor AI API, imports it, and navigates to the editor with the AI panel open. Co-Authored-By: Claude Opus 4.6 * fix: add glassmorphism to AI prompt dialog + maintenance Flow Assist button - Use .glass-card-static on AIPromptDialog card for consistent design system - Add "Build with Flow Assist" button to maintenance section in CreateFlowDropdown Co-Authored-By: Claude Opus 4.6 * refactor: remove standalone Flow Assist page and old AI chat components Remove the old /ai/chat page, AI wizard modal, and all associated components/stores/types now replaced by the editor-embedded AI panel. Deleted: - AIChatBuilderPage, ai-chat/ components, aiChatStore, aiChat API, ai-chat types - AIFlowBuilderModal, ai-builder/ components, aiFlowBuilderStore Cleaned up: - Router (removed /ai/chat route) - Sidebar (removed Flow Assist nav item) - MyTreesPage (removed AI builder modal and button) - TreeLibraryPage (removed Flow Assist button) - API and type barrel exports Co-Authored-By: Claude Opus 4.6 * feat: add delta response parsing and action-type prompt dispatch Co-Authored-By: Claude Opus 4.6 * feat: add AI suggestion audit trail endpoints Create/list/resolve endpoints for tracking AI-applied changes to flows. Co-Authored-By: Claude Opus 4.6 * feat: add APScheduler task to auto-archive stale AI chat sessions Archives AI chat sessions with no activity for 30 days, runs daily at 3 AM. Co-Authored-By: Claude Opus 4.6 * docs: update project status for editor-embedded Flow Assist - Add Editor-Embedded Flow Assist to CURRENT-STATE.md in-progress items - Update CLAUDE.md: fix stale lessons (#41, #46), add new patterns (#47 editor AI architecture, #48 orphan validation) Co-Authored-By: Claude Opus 4.6 * fix: use correct model alias in AI_MODEL_TIERS standard tier The dated model ID `claude-sonnet-4-6-20250514` was causing 502 errors. Use the alias `claude-sonnet-4-6` which matches AI_MODEL_ANTHROPIC. Co-Authored-By: Claude Opus 4.6 * feat: send live flow context to AI Assist for full editor awareness The AI panel now sends the current tree structure (troubleshooting) or steps + intake form (procedural/maintenance) with each message. This gives the AI full visibility into node details, questions, descriptions, options, and intake form fields — not just the node ID. - Backend: add flow_context param to schema, endpoint, and service - Frontend: add getFlowContext callback to useEditorAI hook - TreeEditorPage: passes treeStructure as flow context - ProceduralEditorPage: passes steps + intakeForm as flow context Co-Authored-By: Claude Opus 4.6 * feat: include flow name and description in AI Assist context Both editors now send name and description alongside the flow structure, so the AI can reference what the flow is about when responding. Co-Authored-By: Claude Opus 4.6 * fix: increase AI timeout to 120s and limit retries to 1 The 45s timeout was too short for generation tasks with full flow context in the system prompt. The Anthropic SDK's default 2 retries caused requests to hang for ~136s before failing. Now: 120s timeout with max 1 retry = faster failure if it does timeout. Co-Authored-By: Claude Opus 4.6 * fix: wire AI-generated flow structures into editor stores The useEditorAI hook was ignoring result.working_tree from AI responses, so generated steps/trees never appeared in the editor. Now: - useEditorAI calls onFlowUpdate when working_tree is present in response - ProceduralEditorPage handles steps + intake form updates via replaceSteps - TreeEditorPage handles tree structure updates via replaceTreeStructure - Both stores have new bulk-replace methods for AI integration Co-Authored-By: Claude Opus 4.6 * docs: add lessons learned for full-stack integration, Anthropic retries, model tiers #49 Always verify frontend consumes backend response fields #50 Anthropic SDK max_retries=1 to avoid 3× timeout #51 AI model tier routing via settings.get_model_for_action() Co-Authored-By: Claude Opus 4.6 * fix: move AI Assist panel to full-height side layout in both editors The AI panel was nested inside the content area, only spanning the step list / canvas section. Now it sits at the outermost flex level, spanning the full page height alongside all content (toolbar, collapsible sections, steps/canvas). This prevents the panel from overlapping content and lets the editor area properly shrink. Co-Authored-By: Claude Opus 4.6 * fix: AI Assist panel as fixed right drawer (matching Copilot/Scratchpad) Convert EditorAIPanel from in-flow flex child to fixed right-side drawer overlay, same pattern as CopilotPanel and ScratchpadSidebar. The panel is fixed at right:0 spanning full viewport height, and editor pages add pr-[380px] padding when open so content shifts left without overlap. Co-Authored-By: Claude Opus 4.6 * fix: AI Assist panel sits below topbar with slide-in animation - Panel now uses top:56px to sit below the app shell topbar instead of covering it (matches the main-content grid cell area) - Added slideInRight CSS animation for smooth drawer entrance - Editor pages use dynamic paddingRight via PANEL_WIDTH constant - ChatTab upgraded: markdown rendering, CopilotPanel-style message bubbles, auto-focus input, Shift+Enter hint - All borders use --glass-border for consistent glassmorphism Co-Authored-By: Claude Opus 4.6 * fix: AI Assist panel as in-flow flex sibling (not fixed/overlay) Replace fixed positioning with in-flow flex layout. The outermost div is now a horizontal flex row: content column (flex-1 min-w-0) + panel (w-[380px] shrink-0). When the panel opens, the content column automatically shrinks — no padding hacks or z-index stacking needed. This guarantees the content shifts left and stays fully visible. Co-Authored-By: Claude Opus 4.6 * fix: AI Copilot panel as in-flow flex sibling in session navigation pages Changed CopilotPanel from fixed overlay to flex layout sibling so it pushes main content instead of covering it during active sessions. Co-Authored-By: Claude Opus 4.6 * docs: remove duplicate CLAUDE.md lessons #47-48 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CLAUDE.md | 20 +- CURRENT-STATE.md | 1 + backend/alembic/env.py | 1 + .../050_add_import_metadata_to_trees.py | 23 + .../versions/051_extend_ai_chat_session.py | 39 + .../versions/052_add_ai_suggestion_table.py | 37 + backend/app/api/endpoints/ai_chat.py | 13 +- backend/app/api/endpoints/ai_suggestions.py | 79 + backend/app/api/endpoints/tree_transfer.py | 281 ++ backend/app/api/endpoints/trees.py | 3 +- backend/app/api/router.py | 4 + backend/app/core/ai_chat_service.py | 365 ++- backend/app/core/ai_provider.py | 11 +- backend/app/core/ai_tree_validator.py | 93 + backend/app/core/config.py | 29 +- backend/app/main.py | 30 + backend/app/models/ai_chat_session.py | 11 + backend/app/models/ai_suggestion.py | 55 + backend/app/models/tree.py | 7 + backend/app/schemas/ai_chat.py | 27 + backend/app/schemas/ai_suggestion.py | 33 + backend/app/schemas/tree.py | 1 + backend/app/schemas/tree_export.py | 52 + .../app/services/assistant_chat_service.py | 15 +- backend/tests/test_ai_delta_response.py | 116 + backend/tests/test_ai_suggestions.py | 41 + backend/tests/test_config_model_tiers.py | 24 + backend/tests/test_tree_transfer.py | 282 ++ ...3-06-editor-embedded-flow-assist-design.md | 361 +++ ...-03-06-editor-embedded-flow-assist-plan.md | 2802 +++++++++++++++++ .../2026-03-06-procedural-flow-assist.md | 835 +++++ frontend/src/api/aiChat.ts | 44 - frontend/src/api/editorAI.ts | 44 + frontend/src/api/flowTransfer.ts | 17 + frontend/src/api/index.ts | 2 +- .../ai-builder/AIFlowBuilderModal.tsx | 168 - .../ai-builder/BranchDetailView.tsx | 259 -- .../components/ai-builder/BranchSelector.tsx | 292 -- .../components/ai-builder/FoundationForm.tsx | 163 - .../ai-builder/GeneratingAnimation.tsx | 24 - .../components/ai-builder/QuotaDisplay.tsx | 48 - .../components/ai-builder/TreePreviewCard.tsx | 98 - .../ai-builder/WizardStepIndicator.tsx | 70 - frontend/src/components/ai-chat/ChatInput.tsx | 72 - .../src/components/ai-chat/ChatMessage.tsx | 41 - frontend/src/components/ai-chat/ChatPanel.tsx | 47 - .../src/components/ai-chat/ChatToolbar.tsx | 98 - .../src/components/ai-chat/EmptyPreview.tsx | 13 - .../src/components/ai-chat/PhaseIndicator.tsx | 50 - .../components/ai-chat/StaticTreePreview.tsx | 80 - .../src/components/common/ContextMenu.tsx | 104 + .../components/common/CreateFlowDropdown.tsx | 135 +- .../src/components/copilot/CopilotPanel.tsx | 5 +- .../components/editor-ai/AIPromptDialog.tsx | 111 + frontend/src/components/editor-ai/ChatTab.tsx | 96 + .../components/editor-ai/EditorAIPanel.tsx | 114 + .../src/components/editor-ai/NodeSummary.tsx | 59 + .../components/editor-ai/SuggestionsTab.tsx | 50 + frontend/src/components/layout/Sidebar.tsx | 4 +- .../components/library/ExportFlowModal.tsx | 100 + .../components/library/ImportFlowModal.tsx | 257 ++ .../src/components/library/TreeGridView.tsx | 18 +- .../src/components/library/TreeListView.tsx | 18 +- .../src/components/library/TreeTableView.tsx | 18 +- .../components/procedural-editor/StepList.tsx | 118 +- .../src/components/tree-editor/FlowCanvas.tsx | 7 +- .../components/tree-editor/FlowCanvasNode.tsx | 34 +- .../tree-editor/TreeEditorLayout.tsx | 3 + frontend/src/hooks/useEditorAI.ts | 161 + frontend/src/index.css | 5 + frontend/src/lib/rfflowParser.ts | 52 + frontend/src/pages/AIChatBuilderPage.tsx | 158 - frontend/src/pages/AssistantChatPage.tsx | 61 +- frontend/src/pages/MyTreesPage.tsx | 34 +- frontend/src/pages/ProceduralEditorPage.tsx | 100 +- .../src/pages/ProceduralNavigationPage.tsx | 26 +- frontend/src/pages/QuickStartPage.tsx | 14 +- frontend/src/pages/TreeEditorPage.tsx | 203 +- frontend/src/pages/TreeLibraryPage.tsx | 58 +- frontend/src/pages/TreeNavigationPage.tsx | 28 +- frontend/src/router.tsx | 9 - frontend/src/store/aiChatStore.ts | 196 -- frontend/src/store/aiFlowBuilderStore.ts | 237 -- frontend/src/store/proceduralEditorStore.ts | 12 + frontend/src/store/treeEditorStore.ts | 14 +- frontend/src/types/ai-chat.ts | 43 - frontend/src/types/editor-ai.ts | 62 + frontend/src/types/flowTransfer.ts | 33 + frontend/src/types/index.ts | 25 +- frontend/src/types/tree.ts | 6 + 90 files changed, 7637 insertions(+), 2472 deletions(-) create mode 100644 backend/alembic/versions/050_add_import_metadata_to_trees.py create mode 100644 backend/alembic/versions/051_extend_ai_chat_session.py create mode 100644 backend/alembic/versions/052_add_ai_suggestion_table.py create mode 100644 backend/app/api/endpoints/ai_suggestions.py create mode 100644 backend/app/api/endpoints/tree_transfer.py create mode 100644 backend/app/models/ai_suggestion.py create mode 100644 backend/app/schemas/ai_suggestion.py create mode 100644 backend/app/schemas/tree_export.py create mode 100644 backend/tests/test_ai_delta_response.py create mode 100644 backend/tests/test_ai_suggestions.py create mode 100644 backend/tests/test_config_model_tiers.py create mode 100644 backend/tests/test_tree_transfer.py create mode 100644 docs/plans/2026-03-06-editor-embedded-flow-assist-design.md create mode 100644 docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md create mode 100644 docs/plans/2026-03-06-procedural-flow-assist.md delete mode 100644 frontend/src/api/aiChat.ts create mode 100644 frontend/src/api/editorAI.ts create mode 100644 frontend/src/api/flowTransfer.ts delete mode 100644 frontend/src/components/ai-builder/AIFlowBuilderModal.tsx delete mode 100644 frontend/src/components/ai-builder/BranchDetailView.tsx delete mode 100644 frontend/src/components/ai-builder/BranchSelector.tsx delete mode 100644 frontend/src/components/ai-builder/FoundationForm.tsx delete mode 100644 frontend/src/components/ai-builder/GeneratingAnimation.tsx delete mode 100644 frontend/src/components/ai-builder/QuotaDisplay.tsx delete mode 100644 frontend/src/components/ai-builder/TreePreviewCard.tsx delete mode 100644 frontend/src/components/ai-builder/WizardStepIndicator.tsx delete mode 100644 frontend/src/components/ai-chat/ChatInput.tsx delete mode 100644 frontend/src/components/ai-chat/ChatMessage.tsx delete mode 100644 frontend/src/components/ai-chat/ChatPanel.tsx delete mode 100644 frontend/src/components/ai-chat/ChatToolbar.tsx delete mode 100644 frontend/src/components/ai-chat/EmptyPreview.tsx delete mode 100644 frontend/src/components/ai-chat/PhaseIndicator.tsx delete mode 100644 frontend/src/components/ai-chat/StaticTreePreview.tsx create mode 100644 frontend/src/components/common/ContextMenu.tsx create mode 100644 frontend/src/components/editor-ai/AIPromptDialog.tsx create mode 100644 frontend/src/components/editor-ai/ChatTab.tsx create mode 100644 frontend/src/components/editor-ai/EditorAIPanel.tsx create mode 100644 frontend/src/components/editor-ai/NodeSummary.tsx create mode 100644 frontend/src/components/editor-ai/SuggestionsTab.tsx create mode 100644 frontend/src/components/library/ExportFlowModal.tsx create mode 100644 frontend/src/components/library/ImportFlowModal.tsx create mode 100644 frontend/src/hooks/useEditorAI.ts create mode 100644 frontend/src/lib/rfflowParser.ts delete mode 100644 frontend/src/pages/AIChatBuilderPage.tsx delete mode 100644 frontend/src/store/aiChatStore.ts delete mode 100644 frontend/src/store/aiFlowBuilderStore.ts delete mode 100644 frontend/src/types/ai-chat.ts create mode 100644 frontend/src/types/editor-ai.ts create mode 100644 frontend/src/types/flowTransfer.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0446a7bf..1e08a8e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,7 +279,7 @@ navigate(`/trees/${newTree.id}/edit`) **23. Action nodes navigate via `next_node_id`, not `children`:** `TreeNavigationPage.tsx` handles action nodes by following `next_node_id` only — the `children` array on action nodes is ignored at runtime. Action nodes without `next_node_id` render no "Continue" button (dead end). Any AI generation or manual tree editing must set `next_node_id` on action nodes. -**24. Anthropic model IDs require full dated version string:** `claude-haiku-4-5` is invalid; must be `claude-haiku-4-5-20251001`. See `backend/app/core/config.py` → `AI_MODEL`. +**24. Anthropic model IDs:** Both alias (`claude-sonnet-4-6`) and dated (`claude-haiku-4-5-20251001`) forms work. Current default: `claude-sonnet-4-6`. See `backend/app/core/config.py` → `AI_MODEL_ANTHROPIC`. **25. Claude API may wrap JSON responses in markdown fences:** When parsing AI-generated JSON, always strip ` ```json ... ``` ` fences before parsing. See `_strip_markdown_fences()` in `ai_tree_generator_service.py`. @@ -313,12 +313,28 @@ navigate(`/trees/${newTree.id}/edit`) **38. Alembic migrations MUST use sequential numbered prefixes:** Check `backend/alembic/versions/` for the highest numbered migration and use the next number. Format: `XXX_descriptive_name.py` (e.g., `040_add_whatever.py`). NEVER use auto-generated revision IDs like `0f1ca2af3647`. Always pass `--rev-id` flag: `alembic revision --autogenerate -m "desc" --rev-id=040`. -**41. Assistant chat uses local React state, not a Zustand store:** `AssistantChatPage.tsx` manages `chats`, `activeChatId`, `messages`, `input`, `loading` as `useState`. The `aiChatStore.ts` is for the AI Chat Builder (tree generation), NOT the standalone assistant. Don't look for a store when modifying assistant chat. +**41. Assistant chat uses local React state, not a Zustand store:** `AssistantChatPage.tsx` manages `chats`, `activeChatId`, `messages`, `input`, `loading` as `useState`. Don't look for a store when modifying assistant chat. **42. Public pages use raw `fetch()`, not `apiClient`:** Survey, shared sessions, and other no-auth pages call the API directly via `fetch()` with `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1/...`. Don't use `apiClient` — it requires auth tokens and uses relative paths. **43. Adding new email types:** Add static async method to `EmailService` in `core/email.py`. Pattern: check `settings.email_enabled`, import `resend`, build HTML string, call `resend.Emails.send()`, return `bool`. Always fire-and-forget from endpoints (log errors, don't fail the request). +**44. AI Chat Builder (Flow Assist) is flow-type-aware:** `ai_chat_service.py` dispatches system prompts, response markers, and validation by `flow_type`. Troubleshooting uses `[TREE_UPDATE]` markers + `validate_generated_tree()`. Procedural/maintenance uses `[STEPS_UPDATE]` markers + `validate_generated_procedural_steps()`. Both support `[METADATA]`; procedural also supports `[INTAKE_FORM]`. + +**45. Intake form field schema uses `variable_name` and `field_type`:** NOT `name` and `type`. Pattern: `{"variable_name": "server_name", "label": "Server Name", "field_type": "text", "required": true, "display_order": 1}`. Used in `tree_validation.py` and AI prompt examples. + +**46. `CreateFlowDropdown` uses `AIPromptDialog` for AI-assisted creation:** Opens a simple prompt dialog modal (not a separate page). The dialog starts an AI session, generates the flow, imports it, and navigates to the editor with `{ state: { aiPanelOpen: true, sessionId } }`. The old standalone `/ai/chat` page and `AIFlowBuilderModal` have been removed. + +**47. Editor-Embedded Flow Assist architecture:** AI assistance is embedded in each editor via `EditorAIPanel` (320px side panel) + `useEditorAI` hook + `ContextMenu`. Tree editor: panel replaces node editor panel (single-panel rule). Procedural editor: panel sits alongside step list (flex layout). Ghost nodes/steps use `_suggestion: true` flag with dashed borders + accept/dismiss controls. Action types (`generate_branch`, `modify_node`, `add_steps`, etc.) route to model tiers via `settings.get_model_for_action()`. Delta responses use `[DELTA]...[/DELTA]` markers. Suggestion audit trail in `ai_suggestions` table. + +**48. Tree orphan validation uses dynamic root ID:** `treeEditorStore.ts` orphan check compares against `state.treeStructure?.id` (NOT hardcoded `'root'`). AI-generated trees use descriptive root IDs like `"verify-account-exists"`. + +**49. Full-stack features — verify both ends:** When adding a field to a backend API response (e.g., `working_tree` in `AIChatMessageResponse`), always verify the frontend consumer actually reads and uses it. Similarly, when a frontend hook/component expects data from an API, confirm the backend populates it. Check the full data flow: schema → endpoint → API client → hook → store → UI. + +**50. Anthropic SDK retry behavior:** Default `max_retries=2` with exponential backoff can cause requests to take 3× the timeout (e.g., 45s × 3 = 135s). Set `max_retries=1` in `AnthropicProvider` to fail fast. Current timeout is `AI_REQUEST_TIMEOUT_SECONDS=120`. + +**51. AI model tier routing:** `config.py` has `AI_MODEL_TIERS` (fast/standard) and `ACTION_MODEL_MAP` mapping action types to tiers. Use `settings.get_model_for_action(action_type)` to resolve concrete model names. Model IDs must be valid — use alias form (`claude-sonnet-4-6`) not invented dated forms. + --- ## RBAC & Permissions diff --git a/CURRENT-STATE.md b/CURRENT-STATE.md index 75925e64..2008e8a1 100644 --- a/CURRENT-STATE.md +++ b/CURRENT-STATE.md @@ -86,6 +86,7 @@ | Task | Status | Notes | |------|--------|-------| +| Editor-Embedded Flow Assist | In Progress | AI panel in tree + procedural editors, ghost node suggestions, action-type routing | | Step Library Frontend | In Progress | Backend complete, frontend UI pending | | Procedural Flows Lifecycle | In Progress | Resume support done, full run chooser/reuse pending | | Tree Forking UI | Planning | Backend schema complete (migration 022) | diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 2361292a..2b521236 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -16,6 +16,7 @@ from app.models.copilot_conversation import CopilotConversation from app.models.assistant_chat import AssistantChat from app.models.survey_response import SurveyResponse from app.models.survey_invite import SurveyInvite +from app.models.ai_suggestion import AISuggestion # noqa: F401 from app.core.config import settings # this is the Alembic Config object diff --git a/backend/alembic/versions/050_add_import_metadata_to_trees.py b/backend/alembic/versions/050_add_import_metadata_to_trees.py new file mode 100644 index 00000000..6c75390e --- /dev/null +++ b/backend/alembic/versions/050_add_import_metadata_to_trees.py @@ -0,0 +1,23 @@ +"""Add import_metadata JSONB column to trees table. + +Revision ID: 050 +Revises: 049 +Create Date: 2026-03-05 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +# revision identifiers +revision = '050' +down_revision = '049' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('trees', sa.Column('import_metadata', JSONB, nullable=True)) + + +def downgrade() -> None: + op.drop_column('trees', 'import_metadata') diff --git a/backend/alembic/versions/051_extend_ai_chat_session.py b/backend/alembic/versions/051_extend_ai_chat_session.py new file mode 100644 index 00000000..9c267300 --- /dev/null +++ b/backend/alembic/versions/051_extend_ai_chat_session.py @@ -0,0 +1,39 @@ +"""extend ai chat session with tree_id and archived_at + +Revision ID: 051 +Revises: 050 +""" +from alembic import op +import sqlalchemy as sa + +revision = "051" +down_revision = "050" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ai_chat_sessions", + sa.Column("tree_id", sa.UUID(), nullable=True), + ) + op.add_column( + "ai_chat_sessions", + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_ai_chat_sessions_tree_id", "ai_chat_sessions", ["tree_id"]) + op.create_foreign_key( + "fk_ai_chat_sessions_tree_id", + "ai_chat_sessions", + "trees", + ["tree_id"], + ["id"], + ondelete="CASCADE", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_ai_chat_sessions_tree_id", "ai_chat_sessions", type_="foreignkey") + op.drop_index("ix_ai_chat_sessions_tree_id", table_name="ai_chat_sessions") + op.drop_column("ai_chat_sessions", "archived_at") + op.drop_column("ai_chat_sessions", "tree_id") diff --git a/backend/alembic/versions/052_add_ai_suggestion_table.py b/backend/alembic/versions/052_add_ai_suggestion_table.py new file mode 100644 index 00000000..0727d945 --- /dev/null +++ b/backend/alembic/versions/052_add_ai_suggestion_table.py @@ -0,0 +1,37 @@ +"""add ai suggestion table + +Revision ID: 052 +Revises: 051 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + +revision = "052" +down_revision = "051" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "ai_suggestions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("tree_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"), nullable=True), + sa.Column("action_type", sa.String(50), nullable=False), + sa.Column("target_node_id", sa.String(255), nullable=True), + sa.Column("changes_json", JSONB, nullable=False, server_default="{}"), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_ai_suggestions_tree_id", "ai_suggestions", ["tree_id"]) + op.create_index("ix_ai_suggestions_user_id", "ai_suggestions", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_ai_suggestions_user_id", table_name="ai_suggestions") + op.drop_index("ix_ai_suggestions_tree_id", table_name="ai_suggestions") + op.drop_table("ai_suggestions") diff --git a/backend/app/api/endpoints/ai_chat.py b/backend/app/api/endpoints/ai_chat.py index defebd5e..56ca3b88 100644 --- a/backend/app/api/endpoints/ai_chat.py +++ b/backend/app/api/endpoints/ai_chat.py @@ -95,6 +95,7 @@ async def create_session( user_id=current_user.id, account_id=current_user.account_id, db=db, + tree_id=data.tree_id, ) except Exception as e: logger.exception("AI chat session start failed: %s", e) @@ -168,7 +169,10 @@ async def post_message( try: ai_content, tree_update, new_phase, metadata = await send_message( - session, data.content, db + session, data.content, db, + action_type=data.action_type or "open_chat", + focal_node_id=data.focal_node_id, + flow_context=data.flow_context, ) except Exception as e: logger.exception("AI chat message failed: %s", e) @@ -390,11 +394,18 @@ async def import_tree( # Always create a new Tree record (no duplicate check — user may # want multiple copies or re-import after edits) 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( 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, diff --git a/backend/app/api/endpoints/ai_suggestions.py b/backend/app/api/endpoints/ai_suggestions.py new file mode 100644 index 00000000..e5f0919f --- /dev/null +++ b/backend/app/api/endpoints/ai_suggestions.py @@ -0,0 +1,79 @@ +"""AI Suggestion audit trail endpoints.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from datetime import datetime, timezone + +from app.api.deps import get_current_active_user, get_db +from app.models.user import User +from app.models.ai_suggestion import AISuggestion +from app.schemas.ai_suggestion import ( + AISuggestionCreate, + AISuggestionResponse, + AISuggestionResolve, +) + +router = APIRouter(prefix="/ai/suggestions", tags=["ai-suggestions"]) + + +@router.get("/tree/{tree_id}", response_model=list[AISuggestionResponse]) +async def list_suggestions( + tree_id: UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """List all suggestions for a flow, filtered to current user.""" + result = await db.execute( + select(AISuggestion) + .where(AISuggestion.tree_id == tree_id, AISuggestion.user_id == current_user.id) + .order_by(AISuggestion.created_at.desc()) + ) + return result.scalars().all() + + +@router.post("", response_model=AISuggestionResponse, status_code=201) +async def create_suggestion( + data: AISuggestionCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Record a new AI suggestion.""" + suggestion = AISuggestion( + tree_id=data.tree_id, + user_id=current_user.id, + session_id=data.session_id, + action_type=data.action_type, + target_node_id=data.target_node_id, + changes_json=data.changes_json, + ) + db.add(suggestion) + await db.commit() + await db.refresh(suggestion) + return suggestion + + +@router.patch("/{suggestion_id}", response_model=AISuggestionResponse) +async def resolve_suggestion( + suggestion_id: UUID, + data: AISuggestionResolve, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_active_user), +): + """Accept or dismiss a suggestion.""" + result = await db.execute( + select(AISuggestion).where( + AISuggestion.id == suggestion_id, + AISuggestion.user_id == current_user.id, + ) + ) + suggestion = result.scalar_one_or_none() + if not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found") + + suggestion.status = data.status + suggestion.resolved_at = datetime.now(timezone.utc) + await db.commit() + await db.refresh(suggestion) + return suggestion diff --git a/backend/app/api/endpoints/tree_transfer.py b/backend/app/api/endpoints/tree_transfer.py new file mode 100644 index 00000000..9dee0abe --- /dev/null +++ b/backend/app/api/endpoints/tree_transfer.py @@ -0,0 +1,281 @@ +"""Flow export/import endpoints (.rfflow files).""" +import logging +import re +from datetime import datetime, timezone +from typing import Annotated, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import Response +from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.api.deps import get_current_active_user, require_engineer_or_admin +from app.core.audit import log_audit +from app.core.database import get_db +from app.core.permissions import can_access_tree +from app.core.subscriptions import check_tree_limit +from app.core.tree_validation import can_publish_tree +from app.models.category import TreeCategory +from app.models.tag import TreeTag, tree_tag_assignments +from app.models.tree import Tree +from app.models.user import User +from app.schemas.tree_export import ( + FlowExportCategory, + FlowExportData, + FlowExportEnvelope, + FlowImportRequest, + FlowImportResponse, +) +from app.services.rag_service import index_tree as rag_index_tree + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/trees", tags=["tree-transfer"]) + + +def _slugify(name: str) -> str: + """Create a filename-safe slug from a name.""" + slug = re.sub(r'[^\w\s-]', '', name.lower().strip()) + return re.sub(r'[-\s]+', '-', slug) + + +# --- Export --- + +@router.get("/{tree_id}/export") +async def export_tree( + tree_id: UUID, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +): + """Export a tree as a downloadable .rfflow JSON file.""" + # Load tree with relationships + author name + result = await db.execute( + select(Tree) + .options( + selectinload(Tree.category_rel), + selectinload(Tree.tags), + selectinload(Tree.author), + ) + .where(Tree.id == tree_id) + ) + tree = result.scalar_one_or_none() + + if not tree: + raise HTTPException(status_code=404, detail="Tree not found") + + if not tree.is_active or not can_access_tree(current_user, tree): + raise HTTPException(status_code=403, detail="You don't have access to this tree") + + # Build export category + export_category = None + if tree.category_rel: + export_category = FlowExportCategory( + name=tree.category_rel.name, + slug=tree.category_rel.slug, + ) + + # Build export data + author_name = None + if tree.author: + author_name = tree.author.name or tree.author.email + + flow_data = FlowExportData( + name=tree.name, + description=tree.description, + tree_type=tree.tree_type, + version=tree.version, + author_name=author_name, + category=export_category, + tags=tree.tag_names, + tree_structure=tree.tree_structure, + intake_form=tree.intake_form, + ) + + envelope = FlowExportEnvelope( + rfflow_version="1.0", + exported_at=datetime.now(timezone.utc), + source_app="ResolutionFlow", + flow=flow_data, + ) + + slug = _slugify(tree.name) + + # Audit log + await log_audit(db, current_user.id, "tree.export", "tree", tree.id) + await db.commit() + + content = envelope.model_dump_json(indent=2) + return Response( + content=content, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{slug}.rfflow"'}, + ) + + +# --- Import --- + +@router.post("/import", response_model=FlowImportResponse, status_code=status.HTTP_201_CREATED) +async def import_tree( + data: FlowImportRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(require_engineer_or_admin)], + name_override: Optional[str] = Query(None, max_length=255), +): + """Import a flow from a parsed .rfflow file. Creates as draft.""" + # Validate version + if data.rfflow_version != "1.0": + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Unsupported rfflow version: {data.rfflow_version}. Only '1.0' is supported.", + ) + + flow = data.flow + + # Check subscription tree limit + if current_user.account_id: + can_create, limit, count = await check_tree_limit(current_user.account_id, db) + if not can_create: + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail=f"Tree limit reached ({count}/{limit}). Upgrade your plan to create more trees.", + ) + + # --- Category resolution --- + category_id = None + category_created = False + if flow.category: + # Try to match by slug within user's account + cat_result = await db.execute( + select(TreeCategory).where( + TreeCategory.slug == flow.category.slug, + or_( + TreeCategory.account_id.is_(None), + TreeCategory.account_id == current_user.account_id, + ), + ) + ) + category = cat_result.scalar_one_or_none() + + if category: + category_id = category.id + else: + # Create new category + new_cat = TreeCategory( + name=flow.category.name, + slug=flow.category.slug, + account_id=current_user.account_id, + ) + db.add(new_cat) + await db.flush() + category_id = new_cat.id + category_created = True + + # --- Tag resolution --- + tags_created: list[str] = [] + tags_to_add: list[TreeTag] = [] + tree_account_id = current_user.account_id + + for tag_name in flow.tags: + slug = TreeTag.slugify(tag_name) + + tag_result = await db.execute( + select(TreeTag).where( + TreeTag.slug == slug, + or_( + TreeTag.account_id.is_(None), + TreeTag.account_id == tree_account_id, + ), + ) + ) + tag = tag_result.scalar_one_or_none() + + if not tag: + tag = TreeTag( + name=tag_name, + slug=slug, + account_id=tree_account_id, + created_by=current_user.id, + ) + db.add(tag) + await db.flush() + tags_created.append(tag_name) + + tags_to_add.append(tag) + tag.usage_count += 1 + + # --- Validation warnings (non-blocking since status=draft) --- + warnings: list[str] = [] + intake_form_dicts = flow.intake_form + can_pub, validation_errors = can_publish_tree( + flow.tree_structure, + flow.name, + flow.description, + tree_type=flow.tree_type, + intake_form=intake_form_dicts, + ) + if not can_pub: + for err in validation_errors: + msg = err.get("message", str(err)) if isinstance(err, dict) else str(err) + warnings.append(msg) + + # --- Create tree --- + tree_name = name_override or flow.name + import_metadata = { + "original_author_name": flow.author_name, + "exported_at": data.exported_at.isoformat(), + "imported_at": datetime.now(timezone.utc).isoformat(), + "source_app": data.source_app, + } + + new_tree = Tree( + name=tree_name, + description=flow.description, + tree_type=flow.tree_type, + tree_structure=flow.tree_structure, + intake_form=intake_form_dicts, + category_id=category_id, + author_id=current_user.id, + account_id=current_user.account_id, + status="draft", + version=1, + import_metadata=import_metadata, + ) + db.add(new_tree) + await db.flush() + + # Tag junction table inserts + for tag in tags_to_add: + await db.execute( + tree_tag_assignments.insert().values( + tree_id=new_tree.id, + tag_id=tag.id, + assigned_by=current_user.id, + ) + ) + + # Audit log + await log_audit(db, current_user.id, "tree.import", "tree", new_tree.id, { + "source_app": data.source_app, + "original_author": flow.author_name, + }) + + await db.commit() + + # RAG index (best-effort) + try: + await rag_index_tree(new_tree.id, db) + await db.commit() + except Exception: + logger.warning("RAG indexing failed for imported tree %s", new_tree.id) + + return FlowImportResponse( + tree_id=str(new_tree.id), + name=tree_name, + tree_type=flow.tree_type, + status="draft", + category_created=category_created, + tags_created=tags_created, + validation_warnings=warnings, + ) diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index 4ec8717d..c9fe3027 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -116,7 +116,8 @@ def build_full_tree_response(tree: Tree, parent_tree: Tree = None) -> TreeRespon version=tree.version, usage_count=tree.usage_count, created_at=tree.created_at, - updated_at=tree.updated_at + updated_at=tree.updated_at, + import_metadata=tree.import_metadata ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 256d0de9..6b2d8d2a 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -12,6 +12,8 @@ from app.api.endpoints import copilot from app.api.endpoints import assistant_chat from app.api.endpoints import survey from app.api.endpoints import admin_survey +from app.api.endpoints import tree_transfer +from app.api.endpoints import ai_suggestions api_router = APIRouter() @@ -48,3 +50,5 @@ api_router.include_router(copilot.router) api_router.include_router(assistant_chat.router) api_router.include_router(survey.router) api_router.include_router(admin_survey.router) +api_router.include_router(tree_transfer.router) +api_router.include_router(ai_suggestions.router) diff --git a/backend/app/core/ai_chat_service.py b/backend/app/core/ai_chat_service.py index 948f4701..c4c4ed75 100644 --- a/backend/app/core/ai_chat_service.py +++ b/backend/app/core/ai_chat_service.py @@ -15,7 +15,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession 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.models.ai_chat_session import AIChatSession @@ -44,7 +44,7 @@ CRITICAL BEHAVIORS: - Include expected outcomes for every action: what does success look like? - Surface edge cases proactively: "What about multi-forest environments?" or "Does this change if they have conditional access policies?" - Explain WHY the diagnostic order matters: "We check connectivity before auth because a network issue masquerades as an auth failure." -- Ask ONE focused question at a time. Do not overwhelm with multiple questions. +- Ask ONE focused question at a time. NEVER ask multiple questions in a single response — no numbered lists of questions, no "also, what about X?", no follow-up questions tacked on. One question, then wait for the answer. - Use plain, collegial language. Sound like a colleague, not a form.""" SCHEMA_CONTEXT = """ @@ -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: """Assemble the full system prompt for the chat builder.""" - 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." - if flow_type == "troubleshooting" - 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{SCHEMA_CONTEXT}\n\n{INTERVIEW_PROTOCOL}\n\n{RESPONSE_FORMAT}" + if flow_type in ("procedural", "maintenance"): + flow_context = ( + "The user wants to build a PROCEDURAL flow — a step-by-step process guide " + "with ordered phases, verification checkpoints, and optional intake form variables. " + "This is NOT a branching decision tree — it is a flat, sequential procedure." + ) + return ( + f"{ROLE_PERSONA}\n\n{flow_context}\n\n" + f"{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}" def _strip_markdown_fences(text: str) -> str: @@ -163,6 +284,92 @@ def _strip_markdown_fences(text: str) -> str: return text +def _parse_delta(response: str) -> dict | None: + """Extract [DELTA]...[/DELTA] JSON from AI response.""" + match = re.search(r'\[DELTA\](.*?)\[/DELTA\]', response, re.DOTALL) + if not match: + return None + raw = _strip_markdown_fences(match.group(1).strip()) + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +def _find_node_by_id(tree: dict, node_id: str) -> dict | None: + """Find a node by ID in a tree structure (recursive).""" + if tree.get("id") == node_id: + return tree + for child in tree.get("children", []): + found = _find_node_by_id(child, node_id) + if found: + return found + for step in tree.get("steps", []): + if step.get("id") == node_id: + return step + return None + + +def _build_action_prompt( + action_type: str, + focal_node_id: str | None, + tree_structure: dict, + flow_type: str, +) -> str: + """Build action-specific system prompt supplement.""" + tree_json = json.dumps(tree_structure, indent=2) + + focal_context = "" + if focal_node_id: + focal_node = _find_node_by_id(tree_structure, focal_node_id) + if focal_node: + focal_context = f"\n\nFOCAL NODE (the node being acted on):\n{json.dumps(focal_node, indent=2)}" + + prompts = { + "generate_branch": ( + f"Generate a complete branch of child nodes for the focal node. " + f"Return the new nodes wrapped in [DELTA]...[/DELTA] markers as JSON with " + f"action='add', target_node_id='{focal_node_id}', and nodes array." + f"{focal_context}" + ), + "modify_node": ( + f"Modify the focal node based on the user's instruction. " + f"Return the updated node in [DELTA]...[/DELTA] markers with action='modify'." + f"{focal_context}" + ), + "add_steps": ( + f"Generate new procedural steps to insert after the focal step. " + f"Return them in [DELTA]...[/DELTA] markers with action='add'." + f"{focal_context}" + ), + "quick_action": ( + f"Respond to the user's quick action request about the focal node. " + f"If the action modifies the node, return changes in [DELTA]...[/DELTA] markers. " + f"If it's informational (e.g. explain), just respond in text." + f"{focal_context}" + ), + "open_chat": ( + "Have a helpful conversation about the flow. If the user asks for changes, " + "return them in [DELTA]...[/DELTA] markers. Otherwise respond in text." + ), + "generate_full": ( + "Generate a complete flow structure based on the user's description." + ), + "variable_inference": ( + "Analyze the procedural steps for implicit variables. Look for references to " + "specific servers, clients, credentials, or other values that should be captured " + "in an intake form. Return suggestions as JSON." + ), + } + + action_prompt = prompts.get(action_type, prompts["open_chat"]) + + return ( + f"CURRENT FLOW STRUCTURE ({flow_type}):\n{tree_json}\n\n" + f"ACTION: {action_type}\n{action_prompt}" + ) + + def _parse_ai_response(raw_response: str) -> dict[str, Any]: """Parse structured markers from AI response. @@ -177,6 +384,7 @@ def _parse_ai_response(raw_response: str) -> dict[str, Any]: "tree_update": None, "phase": None, "metadata": None, + "intake_form": None, } # Extract [TREE_UPDATE]...[/TREE_UPDATE] @@ -198,6 +406,40 @@ def _parse_ai_response(raw_response: str) -> dict[str, Any]: logger.warning("Truncated [TREE_UPDATE] block detected (no closing tag) — stripping from display") 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] phase_match = re.search(r"\[PHASE:(\w+)\]", result["content"]) if phase_match: @@ -235,6 +477,7 @@ async def start_chat_session( user_id: uuid.UUID, account_id: uuid.UUID, db: AsyncSession, + tree_id: str | None = None, ) -> tuple[AIChatSession, str]: """Create a chat session and return the AI's opening greeting. @@ -244,6 +487,7 @@ async def start_chat_session( user_id=user_id, account_id=account_id, flow_type=flow_type, + tree_id=uuid.UUID(tree_id) if tree_id else None, expires_at=datetime.now(timezone.utc) + timedelta(hours=settings.AI_CONVERSATION_TTL_HOURS), ) db.add(session) @@ -287,13 +531,35 @@ async def send_message( session: AIChatSession, user_message: str, db: AsyncSession, + action_type: str = "open_chat", + focal_node_id: str | None = None, + flow_context: dict | None = None, ) -> tuple[str, Optional[dict], Optional[str], Optional[dict]]: """Send a user message and get AI response. + Args: + flow_context: Live flow structure from the editor. Contains the current + tree_structure (troubleshooting) or steps + intake_form (procedural). + This gives the AI full awareness of the flow being edited. + Returns (ai_content, working_tree_update, new_phase, metadata_update). """ system_prompt = _build_system_prompt(session.flow_type) + # Inject live flow context so the AI can see current editor state + if flow_context: + context_json = json.dumps(flow_context, indent=2) + system_prompt += ( + f"\n\nCURRENT FLOW STATE (live from editor):\n{context_json}" + ) + if focal_node_id: + focal_node = _find_node_by_id(flow_context, focal_node_id) + if focal_node: + system_prompt += ( + f"\n\nFOCAL NODE/STEP (the item being acted on):\n" + f"{json.dumps(focal_node, indent=2)}" + ) + # Build messages array from conversation history now_iso = datetime.now(timezone.utc).isoformat() history = list(session.conversation_history) @@ -305,7 +571,9 @@ async def send_message( for msg in history ] - provider = get_ai_provider() + # Resolve model for this action type + action_model = settings.get_model_for_action(action_type) + provider = get_ai_provider(model=action_model) response_text, input_tokens, output_tokens = await provider.generate_text( system_prompt=system_prompt, messages=provider_messages, @@ -318,12 +586,19 @@ async def send_message( # only require valid root structure, not min node counts) tree_update = parsed["tree_update"] if tree_update: - 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 + if session.flow_type in ("procedural", "maintenance"): + # Procedural: must be a dict with a "steps" list + if not isinstance(tree_update, dict) or not isinstance(tree_update.get("steps"), list): + logger.warning("AI steps update rejected: must be a dict with a 'steps' list") + 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 history.append({"role": "assistant", "content": parsed["content"], "timestamp": now_iso}) @@ -345,6 +620,11 @@ async def send_message( merged.update(parsed["metadata"]) 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) return parsed["content"], tree_update, parsed["phase"], parsed["metadata"] @@ -367,7 +647,33 @@ async def generate_final_tree( 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: - Include ALL branches, steps, and solutions we discussed @@ -386,7 +692,7 @@ Also provide metadata as a separate JSON object after the tree: provider_messages.append({"role": "user", "content": generation_instruction}) - provider = get_ai_provider() + provider = get_ai_provider(model=settings.get_model_for_action("generate_full")) for attempt in range(2): # One try + one retry response_text, input_tokens, output_tokens = await provider.generate_text( @@ -421,21 +727,30 @@ Also provide metadata as a separate JSON object after the tree: continue raise ValueError("AI failed to produce valid JSON after retry") - errors = validate_generated_tree(tree) - if errors: + 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 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." ) provider_messages.append({"role": "user", "content": correction}) continue - raise ValueError(f"Generated tree failed validation: {'; '.join(errors)}") + raise ValueError(f"Generated structure failed validation: {'; '.join(val_errors)}") # Success session.working_tree = tree 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.updated_at = datetime.now(timezone.utc) diff --git a/backend/app/core/ai_provider.py b/backend/app/core/ai_provider.py index 993012c6..4e38cceb 100644 --- a/backend/app/core/ai_provider.py +++ b/backend/app/core/ai_provider.py @@ -184,6 +184,7 @@ class AnthropicProvider(AIProvider): client = anthropic.AsyncAnthropic( api_key=self._api_key, timeout=self._timeout, + max_retries=1, ) response = await client.messages.create( @@ -209,9 +210,13 @@ class AnthropicProvider(AIProvider): return await self.generate_json(system_prompt, messages, max_tokens) -def get_ai_provider() -> AIProvider: +def get_ai_provider(model: str | None = None) -> AIProvider: """Factory that returns the configured AI provider. + Args: + model: Optional model override (Anthropic model ID). Only applied to + AnthropicProvider; Gemini always uses settings.AI_MODEL_GEMINI. + Selection logic: 1. If AI_PROVIDER == "gemini" and GOOGLE_AI_API_KEY is set -> GeminiProvider 2. If AI_PROVIDER == "anthropic" and ANTHROPIC_API_KEY is set -> AnthropicProvider @@ -230,7 +235,7 @@ def get_ai_provider() -> AIProvider: if settings.ANTHROPIC_API_KEY: return AnthropicProvider( api_key=settings.ANTHROPIC_API_KEY, - model=settings.AI_MODEL_ANTHROPIC, + model=model or settings.AI_MODEL_ANTHROPIC, timeout=settings.AI_REQUEST_TIMEOUT_SECONDS, ) @@ -238,7 +243,7 @@ def get_ai_provider() -> AIProvider: if settings.ANTHROPIC_API_KEY: return AnthropicProvider( api_key=settings.ANTHROPIC_API_KEY, - model=settings.AI_MODEL_ANTHROPIC, + model=model or settings.AI_MODEL_ANTHROPIC, timeout=settings.AI_REQUEST_TIMEOUT_SECONDS, ) # Fallback to Gemini diff --git a/backend/app/core/ai_tree_validator.py b/backend/app/core/ai_tree_validator.py index 351a223f..850aa219 100644 --- a/backend/app/core/ai_tree_validator.py +++ b/backend/app/core/ai_tree_validator.py @@ -230,3 +230,96 @@ def count_tree_stats(tree: dict[str, Any]) -> dict[str, int]: _count(tree, 1) 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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f1b0edc2..bdf02ce8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -74,15 +74,36 @@ class Settings(BaseSettings): # AI Flow Builder ANTHROPIC_API_KEY: Optional[str] = None - AI_MODEL: str = "claude-haiku-4-5-20251001" + AI_MODEL: str = "claude-sonnet-4-6" AI_CONVERSATION_TTL_HOURS: int = 24 AI_MAX_CALLS_PER_FLOW: int = 10 - AI_REQUEST_TIMEOUT_SECONDS: int = 45 + AI_REQUEST_TIMEOUT_SECONDS: int = 120 # AI Provider selection - AI_PROVIDER: str = "gemini" # "gemini" or "anthropic" + AI_PROVIDER: str = "anthropic" # "gemini" or "anthropic" GOOGLE_AI_API_KEY: Optional[str] = None AI_MODEL_GEMINI: str = "gemini-2.5-flash" - AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001" + AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6" + + # Model tier routing — maps action types to model tiers + AI_MODEL_TIERS: dict[str, str] = { + "fast": "claude-haiku-4-5-20251001", + "standard": "claude-sonnet-4-6", + } + + ACTION_MODEL_MAP: dict[str, str] = { + "generate_full": "standard", + "generate_branch": "standard", + "modify_node": "fast", + "add_steps": "standard", + "quick_action": "fast", + "open_chat": "standard", + "variable_inference": "fast", + } + + def get_model_for_action(self, action_type: str) -> str: + """Resolve an action type to a concrete model name via tier routing.""" + tier = self.ACTION_MODEL_MAP.get(action_type, "standard") + return self.AI_MODEL_TIERS.get(tier, self.AI_MODEL_TIERS["standard"]) # MCP (Model Context Protocol) integrations ENABLE_MCP_MICROSOFT_LEARN: bool = True diff --git a/backend/app/main.py b/backend/app/main.py index f8e8c388..dbfedb41 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,6 +22,27 @@ setup_logging() logger = logging.getLogger(__name__) +async def archive_stale_ai_sessions(): + """Archive AI chat sessions with no activity for 30 days.""" + from app.models.ai_chat_session import AIChatSession + from sqlalchemy import update + from datetime import datetime, timezone, timedelta + + cutoff = datetime.now(timezone.utc) - timedelta(days=30) + async with async_session_maker() as db: + result = await db.execute( + update(AIChatSession) + .where( + AIChatSession.updated_at < cutoff, + AIChatSession.archived_at.is_(None), + AIChatSession.status != "abandoned", + ) + .values(archived_at=datetime.now(timezone.utc)) + ) + await db.commit() + logger.info(f"[archive] Archived {result.rowcount} stale AI chat sessions") + + def _configure_seed_module(mod: object, api_url: str, email: str, password: str) -> None: """Set globals on a seed script module.""" mod.API_BASE_URL = api_url # type: ignore[attr-defined] @@ -132,6 +153,15 @@ async def lifespan(app: FastAPI): replace_existing=True, ) + # Auto-archive stale AI chat sessions (daily at 3 AM) + scheduler.add_job( + archive_stale_ai_sessions, + "cron", + hour=3, + id="archive_stale_ai_sessions", + replace_existing=True, + ) + # Auto-seed trees in background on PR environments seed_task = None if settings.SEED_ON_DEPLOY: diff --git a/backend/app/models/ai_chat_session.py b/backend/app/models/ai_chat_session.py index 8fbde1c6..d92cbfed 100644 --- a/backend/app/models/ai_chat_session.py +++ b/backend/app/models/ai_chat_session.py @@ -86,3 +86,14 @@ class AIChatSession(Base): default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) + # Editor-embedded session: links to a specific tree/flow + tree_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("trees.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + archived_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) diff --git a/backend/app/models/ai_suggestion.py b/backend/app/models/ai_suggestion.py new file mode 100644 index 00000000..8ee65dd5 --- /dev/null +++ b/backend/app/models/ai_suggestion.py @@ -0,0 +1,55 @@ +"""AI Suggestion model for tracking AI-applied changes to flows.""" +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base + + +class AISuggestion(Base): + __tablename__ = "ai_suggestions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + tree_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("trees.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + session_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"), + nullable=True, + ) + action_type: Mapped[str] = mapped_column( + String(50), nullable=False + ) + target_node_id: Mapped[Optional[str]] = mapped_column( + String(255), nullable=True + ) + changes_json: Mapped[dict] = mapped_column( + JSONB, nullable=False, default=dict + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="pending" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + resolved_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index 825e1d6c..eb05576c 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -154,6 +154,13 @@ class Tree(Base): comment="Fork depth: 0 = original, 1 = direct fork, 2 = fork of fork, etc." ) + # Import provenance + import_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSONB, + nullable=True, + comment="Provenance metadata from .rfflow file import" + ) + # Relationships author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees") team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees") diff --git a/backend/app/schemas/ai_chat.py b/backend/app/schemas/ai_chat.py index 35ae66b7..01f40d88 100644 --- a/backend/app/schemas/ai_chat.py +++ b/backend/app/schemas/ai_chat.py @@ -14,12 +14,39 @@ class AIChatStartRequest(BaseModel): flow_type: Literal["troubleshooting", "procedural"] = Field( ..., description="Type of flow to build" ) + tree_id: Optional[str] = Field( + default=None, + description="ID of existing tree for editor-embedded sessions", + ) + + +VALID_ACTION_TYPES = Literal[ + "generate_full", + "generate_branch", + "modify_node", + "add_steps", + "quick_action", + "open_chat", + "variable_inference", +] class AIChatMessageRequest(BaseModel): """Send a user message in a chat session.""" content: str = Field(..., min_length=1, max_length=5000) + action_type: Optional[VALID_ACTION_TYPES] = Field( + default="open_chat", + description="Type of AI action to perform", + ) + focal_node_id: Optional[str] = Field( + default=None, + description="ID of the node/step being acted on", + ) + flow_context: Optional[dict[str, Any]] = Field( + default=None, + description="Live flow structure from the editor (tree structure, steps, intake form)", + ) class AIChatImportRequest(BaseModel): diff --git a/backend/app/schemas/ai_suggestion.py b/backend/app/schemas/ai_suggestion.py new file mode 100644 index 00000000..f1ca1426 --- /dev/null +++ b/backend/app/schemas/ai_suggestion.py @@ -0,0 +1,33 @@ +"""Schemas for AI suggestion audit trail.""" +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel, Field + + +class AISuggestionCreate(BaseModel): + tree_id: UUID + session_id: Optional[UUID] = None + action_type: str + target_node_id: Optional[str] = None + changes_json: dict = Field(default_factory=dict) + + +class AISuggestionResponse(BaseModel): + id: UUID + tree_id: UUID + user_id: UUID + session_id: Optional[UUID] + action_type: str + target_node_id: Optional[str] + changes_json: dict + status: str + created_at: datetime + resolved_at: Optional[datetime] + + model_config = {"from_attributes": True} + + +class AISuggestionResolve(BaseModel): + status: str = Field(..., pattern="^(accepted|dismissed)$") diff --git a/backend/app/schemas/tree.py b/backend/app/schemas/tree.py index dc9cf116..c9c8546b 100644 --- a/backend/app/schemas/tree.py +++ b/backend/app/schemas/tree.py @@ -161,6 +161,7 @@ class TreeResponse(TreeBase): created_at: datetime updated_at: datetime usage_count: int + import_metadata: Optional[dict[str, Any]] = None class Config: from_attributes = True diff --git a/backend/app/schemas/tree_export.py b/backend/app/schemas/tree_export.py new file mode 100644 index 00000000..730467e0 --- /dev/null +++ b/backend/app/schemas/tree_export.py @@ -0,0 +1,52 @@ +"""Schemas for .rfflow file export and import.""" +from datetime import datetime +from typing import Optional, Any +from pydantic import BaseModel, Field + +from app.schemas.tree import TreeType + + +class FlowExportCategory(BaseModel): + """Category info embedded in export file.""" + name: str + slug: str + + +class FlowExportData(BaseModel): + """The flow payload inside an .rfflow file.""" + name: str + description: Optional[str] = None + tree_type: TreeType + version: int = 1 + author_name: Optional[str] = None + category: Optional[FlowExportCategory] = None + tags: list[str] = [] + tree_structure: dict[str, Any] + intake_form: Optional[list[dict[str, Any]]] = None + + +class FlowExportEnvelope(BaseModel): + """Top-level .rfflow file structure.""" + rfflow_version: str = "1.0" + exported_at: datetime + source_app: str = "ResolutionFlow" + flow: FlowExportData + + +class FlowImportRequest(BaseModel): + """What the frontend sends after parsing a .rfflow file.""" + rfflow_version: str = Field(..., description="Must be '1.0'") + exported_at: datetime + source_app: str = "ResolutionFlow" + flow: FlowExportData + + +class FlowImportResponse(BaseModel): + """Response after importing a flow.""" + tree_id: str + name: str + tree_type: str + status: str = "draft" + category_created: bool = False + tags_created: list[str] = [] + validation_warnings: list[str] = [] diff --git a/backend/app/services/assistant_chat_service.py b/backend/app/services/assistant_chat_service.py index 22d8bb26..86390608 100644 --- a/backend/app/services/assistant_chat_service.py +++ b/backend/app/services/assistant_chat_service.py @@ -40,8 +40,8 @@ deep expertise across the MSP technology stack: - Security: MFA, Conditional Access, EDR, backup/DR ## How to Answer -- **Be direct and actionable.** Engineers are mid-ticket — give them the answer, \ -not a lecture. Lead with the fix, then explain why. +- **Be direct and actionable.** Engineers are mid-ticket — lead with the fix or next \ +diagnostic step, then explain why in one sentence if helpful. Skip background unless asked. - **Include specifics.** Exact commands, registry paths, config values, port numbers. \ Vague advice wastes time. - **Warn before you wreck.** If a step could cause downtime, data loss, or a lockout, \ @@ -51,6 +51,17 @@ bold for key terms. Engineers scan, they don't read essays. - **Say when you're unsure.** If you don't know the exact answer, say so. Suggest \ where to verify (vendor docs, a specific KB article) rather than guessing. +## How to Ask Questions +- **Default to a single focused question.** Ask what you need to know right now to make progress. +- **Use contextual bullets sparingly.** If the question could be ambiguous (e.g., "what error?" \ +when there are multiple common patterns), add 2-3 sub-bullets to help the engineer recognize \ +what you're asking for — but keep it short. +- **Multiple questions only when blocking.** If you genuinely cannot proceed without knowing \ +two things (e.g., both the error message AND which users are affected), preface it clearly: \ +"Before continuing troubleshooting, I need to know: 1) [question], 2) [question]." Use this rarely. +- **Avoid interrogation mode.** Don't fire off 5 questions in a row. Get one answer, make \ +progress, then ask the next question if needed. + ## Using the Team's Flow Library Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \ appear in the context below, reference them by name so the engineer can launch them \ diff --git a/backend/tests/test_ai_delta_response.py b/backend/tests/test_ai_delta_response.py new file mode 100644 index 00000000..d0fea499 --- /dev/null +++ b/backend/tests/test_ai_delta_response.py @@ -0,0 +1,116 @@ +"""Tests for AI delta response parsing and action-type prompt dispatch.""" +from app.core.ai_chat_service import _parse_delta, _build_action_prompt, _find_node_by_id + + +def test_parse_delta_from_response(): + """Service extracts [DELTA] markers from AI responses.""" + response = '''Here's a new branch for that node. + +[DELTA] +{"action": "add", "target_node_id": "check-dns", "nodes": [{"id": "verify-dns-server", "type": "decision", "question": "Is the DNS server responding?"}], "explanation": "Added DNS verification branch"} +[/DELTA] + +Let me know if you'd like to adjust this.''' + + parsed = _parse_delta(response) + assert parsed is not None + assert parsed["action"] == "add" + assert parsed["target_node_id"] == "check-dns" + assert len(parsed["nodes"]) == 1 + + +def test_parse_delta_none_when_absent(): + """Returns None when no delta marker present.""" + response = "Sure, I can explain that node. It checks connectivity." + parsed = _parse_delta(response) + assert parsed is None + + +def test_parse_delta_with_markdown_fences(): + """Handles delta JSON wrapped in markdown code fences.""" + response = '''[DELTA] +```json +{"action": "modify", "target_node_id": "node-1", "nodes": [{"id": "node-1", "type": "action", "title": "Updated"}], "explanation": "Modified title"} +``` +[/DELTA]''' + parsed = _parse_delta(response) + assert parsed is not None + assert parsed["action"] == "modify" + + +def test_parse_delta_invalid_json(): + """Returns None for invalid JSON inside delta markers.""" + response = "[DELTA]not valid json[/DELTA]" + parsed = _parse_delta(response) + assert parsed is None + + +def test_build_action_prompt_generate_branch(): + """Generate branch action includes focal node context.""" + tree = { + "id": "root", + "type": "decision", + "question": "Is the server up?", + "children": [], + "options": [], + } + prompt = _build_action_prompt( + action_type="generate_branch", + focal_node_id="root", + tree_structure=tree, + flow_type="troubleshooting", + ) + assert "root" in prompt + assert "generate" in prompt.lower() or "branch" in prompt.lower() + + +def test_build_action_prompt_open_chat(): + """Open chat action is general conversation.""" + prompt = _build_action_prompt( + action_type="open_chat", + focal_node_id=None, + tree_structure={"id": "root", "type": "decision"}, + flow_type="troubleshooting", + ) + assert isinstance(prompt, str) + assert len(prompt) > 0 + + +def test_find_node_by_id_root(): + """Finds root node.""" + tree = {"id": "root", "type": "decision", "children": []} + assert _find_node_by_id(tree, "root") is not None + + +def test_find_node_by_id_nested(): + """Finds nested child node.""" + tree = { + "id": "root", + "type": "decision", + "children": [ + {"id": "child-1", "type": "action", "children": []}, + {"id": "child-2", "type": "solution", "children": []}, + ], + } + found = _find_node_by_id(tree, "child-2") + assert found is not None + assert found["id"] == "child-2" + + +def test_find_node_by_id_not_found(): + """Returns None for non-existent node.""" + tree = {"id": "root", "type": "decision", "children": []} + assert _find_node_by_id(tree, "nonexistent") is None + + +def test_find_node_by_id_in_steps(): + """Finds node in procedural steps array.""" + tree = { + "steps": [ + {"id": "step-1", "type": "procedure_step"}, + {"id": "step-2", "type": "procedure_step"}, + ] + } + found = _find_node_by_id(tree, "step-2") + assert found is not None + assert found["id"] == "step-2" diff --git a/backend/tests/test_ai_suggestions.py b/backend/tests/test_ai_suggestions.py new file mode 100644 index 00000000..f06b3ac4 --- /dev/null +++ b/backend/tests/test_ai_suggestions.py @@ -0,0 +1,41 @@ +"""Tests for AI suggestion endpoints.""" +import pytest + + +@pytest.mark.asyncio +async def test_create_and_list_suggestions(client, auth_headers, test_tree): + """Can create and list suggestions for a tree.""" + tree_id = test_tree["id"] + + # Create suggestion + resp = await client.post( + "/api/v1/ai/suggestions", + json={ + "tree_id": tree_id, + "action_type": "generate_branch", + "target_node_id": "some-node", + "changes_json": {"before": {}, "after": {"id": "new-node"}}, + }, + headers=auth_headers, + ) + assert resp.status_code == 201 + suggestion_id = resp.json()["id"] + assert resp.json()["status"] == "pending" + + # List suggestions + resp = await client.get( + f"/api/v1/ai/suggestions/tree/{tree_id}", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + # Resolve suggestion + resp = await client.patch( + f"/api/v1/ai/suggestions/{suggestion_id}", + json={"status": "accepted"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "accepted" + assert resp.json()["resolved_at"] is not None diff --git a/backend/tests/test_config_model_tiers.py b/backend/tests/test_config_model_tiers.py new file mode 100644 index 00000000..ddcf6704 --- /dev/null +++ b/backend/tests/test_config_model_tiers.py @@ -0,0 +1,24 @@ +"""Tests for AI model tier configuration.""" +from app.core.config import settings + + +def test_ai_model_tiers_exist(): + assert "fast" in settings.AI_MODEL_TIERS + assert "standard" in settings.AI_MODEL_TIERS + + +def test_action_model_map_covers_all_actions(): + valid_tiers = set(settings.AI_MODEL_TIERS.keys()) + for action, tier in settings.ACTION_MODEL_MAP.items(): + assert tier in valid_tiers, f"Action '{action}' maps to unknown tier '{tier}'" + + +def test_get_model_for_action(): + model = settings.get_model_for_action("generate_full") + assert isinstance(model, str) + assert len(model) > 0 + + +def test_get_model_for_action_unknown_falls_back(): + model = settings.get_model_for_action("nonexistent_action") + assert model == settings.AI_MODEL_TIERS["standard"] diff --git a/backend/tests/test_tree_transfer.py b/backend/tests/test_tree_transfer.py new file mode 100644 index 00000000..0a18d7bf --- /dev/null +++ b/backend/tests/test_tree_transfer.py @@ -0,0 +1,282 @@ +"""Tests for flow export/import (.rfflow) endpoints.""" +import pytest +from httpx import AsyncClient + + +# --- Helpers --- + +TREE_DATA = { + "name": "DNS Troubleshooting", + "description": "Diagnose DNS resolution issues", + "category": "Networking", + "tree_structure": { + "id": "root", + "type": "decision", + "question": "Is DNS resolving?", + "options": [ + {"id": "yes", "label": "Yes", "next_node_id": "sol1"}, + {"id": "no", "label": "No", "next_node_id": "sol2"}, + ], + "children": [ + {"id": "sol1", "type": "solution", "title": "DNS OK", "description": "DNS is working", "solution": "No action needed"}, + {"id": "sol2", "type": "solution", "title": "DNS Fail", "description": "DNS is not resolving", "solution": "Check DNS server config"}, + ], + }, + "tags": ["dns", "networking"], +} + + +async def create_tree_with_tags(client: AsyncClient, headers: dict, data: dict | None = None) -> dict: + """Create a tree and return the response.""" + resp = await client.post("/api/v1/trees", json=data or TREE_DATA, headers=headers) + assert resp.status_code == 201 + return resp.json() + + +# --- Export Tests --- + +@pytest.mark.asyncio +async def test_export_json_format(client, auth_headers, test_tree): + """Export should return valid .rfflow JSON with correct structure.""" + resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert "attachment" in resp.headers.get("content-disposition", "") + assert ".rfflow" in resp.headers.get("content-disposition", "") + + data = resp.json() + assert data["rfflow_version"] == "1.0" + assert data["source_app"] == "ResolutionFlow" + assert data["exported_at"] is not None + + flow = data["flow"] + assert flow["name"] == test_tree["name"] + assert flow["tree_structure"] is not None + assert flow["tree_type"] == "troubleshooting" + + # No IDs leaked + assert "id" not in flow or flow.get("id") is None + assert "author_id" not in flow + assert "account_id" not in flow + + +@pytest.mark.asyncio +async def test_export_with_category_and_tags(client, auth_headers): + """Export should include category and tag data.""" + tree = await create_tree_with_tags(client, auth_headers) + resp = await client.get( + f"/api/v1/trees/{tree['id']}/export", + headers=auth_headers, + ) + assert resp.status_code == 200 + flow = resp.json()["flow"] + assert len(flow["tags"]) == 2 + assert "dns" in flow["tags"] + assert "networking" in flow["tags"] + + +@pytest.mark.asyncio +async def test_export_access_control(client, auth_headers, test_admin, admin_auth_headers, test_tree): + """Users should only export trees they can access.""" + # Create a second user who can't access the tree + user2_data = { + "email": "other@example.com", + "password": "OtherPass123!", + "name": "Other User", + } + await client.post("/api/v1/auth/register", json=user2_data) + login_resp = await client.post("/api/v1/auth/login/json", json={ + "email": user2_data["email"], + "password": user2_data["password"], + }) + other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"} + + resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export", + headers=other_headers, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_export_nonexistent_tree(client, auth_headers): + """Export of non-existent tree returns 404.""" + import uuid + resp = await client.get( + f"/api/v1/trees/{uuid.uuid4()}/export", + headers=auth_headers, + ) + assert resp.status_code == 404 + + +# --- Import Tests --- + +@pytest.mark.asyncio +async def test_import_happy_path(client, auth_headers, test_tree): + """Import should create a draft tree owned by the importing user.""" + # First export + export_resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export", + headers=auth_headers, + ) + rfflow_data = export_resp.json() + + # Import + import_resp = await client.post( + "/api/v1/trees/import", + json=rfflow_data, + headers=auth_headers, + ) + assert import_resp.status_code == 201 + result = import_resp.json() + assert result["status"] == "draft" + assert result["name"] == test_tree["name"] + assert result["tree_id"] is not None + + # Verify the created tree + tree_resp = await client.get( + f"/api/v1/trees/{result['tree_id']}", + headers=auth_headers, + ) + assert tree_resp.status_code == 200 + tree = tree_resp.json() + assert tree["status"] == "draft" + assert tree["import_metadata"] is not None + assert tree["import_metadata"]["source_app"] == "ResolutionFlow" + + +@pytest.mark.asyncio +async def test_import_with_name_override(client, auth_headers, test_tree): + """Import with name_override should use the override name.""" + export_resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export", + headers=auth_headers, + ) + rfflow_data = export_resp.json() + + import_resp = await client.post( + "/api/v1/trees/import?name_override=Custom%20Name", + json=rfflow_data, + headers=auth_headers, + ) + assert import_resp.status_code == 201 + assert import_resp.json()["name"] == "Custom Name" + + +@pytest.mark.asyncio +async def test_import_with_new_tags(client, auth_headers): + """Import with new tags should create them automatically.""" + rfflow = { + "rfflow_version": "1.0", + "exported_at": "2026-03-05T14:30:00+00:00", + "source_app": "ResolutionFlow", + "flow": { + "name": "Test Import Tags", + "description": "Testing tag creation", + "tree_type": "troubleshooting", + "version": 1, + "author_name": "Test Author", + "category": None, + "tags": ["brand-new-tag", "another-tag"], + "tree_structure": { + "id": "root", + "type": "decision", + "question": "Q?", + "options": [{"id": "a", "label": "A", "next_node_id": "s1"}], + "children": [{"id": "s1", "type": "solution", "title": "S", "description": "D", "solution": "S"}], + }, + "intake_form": None, + }, + } + + resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) + assert resp.status_code == 201 + result = resp.json() + assert "brand-new-tag" in result["tags_created"] + assert "another-tag" in result["tags_created"] + + +@pytest.mark.asyncio +async def test_import_with_category_creation(client, auth_headers): + """Import with a new category should create it.""" + rfflow = { + "rfflow_version": "1.0", + "exported_at": "2026-03-05T14:30:00+00:00", + "source_app": "ResolutionFlow", + "flow": { + "name": "Import Category Test", + "description": None, + "tree_type": "troubleshooting", + "version": 1, + "author_name": None, + "category": {"name": "New Category", "slug": "new-category"}, + "tags": [], + "tree_structure": { + "id": "root", + "type": "decision", + "question": "Q?", + "options": [{"id": "a", "label": "A", "next_node_id": "s1"}], + "children": [{"id": "s1", "type": "solution", "title": "S", "description": "D", "solution": "S"}], + }, + "intake_form": None, + }, + } + + resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) + assert resp.status_code == 201 + assert resp.json()["category_created"] is True + + +@pytest.mark.asyncio +async def test_import_invalid_version(client, auth_headers): + """Import with unsupported rfflow version should return 422.""" + rfflow = { + "rfflow_version": "99.0", + "exported_at": "2026-03-05T14:30:00+00:00", + "source_app": "ResolutionFlow", + "flow": { + "name": "Bad Version", + "tree_type": "troubleshooting", + "version": 1, + "tags": [], + "tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []}, + }, + } + + resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_import_round_trip(client, auth_headers): + """Export then import should produce a tree with matching data.""" + original = await create_tree_with_tags(client, auth_headers) + + # Export + export_resp = await client.get( + f"/api/v1/trees/{original['id']}/export", + headers=auth_headers, + ) + rfflow = export_resp.json() + + # Import + import_resp = await client.post( + "/api/v1/trees/import", + json=rfflow, + headers=auth_headers, + ) + assert import_resp.status_code == 201 + result = import_resp.json() + + # Verify imported tree matches original structure + tree_resp = await client.get( + f"/api/v1/trees/{result['tree_id']}", + headers=auth_headers, + ) + imported_tree = tree_resp.json() + assert imported_tree["name"] == original["name"] + assert imported_tree["tree_structure"]["id"] == original["tree_structure"]["id"] + assert imported_tree["tree_type"] == original["tree_type"] + assert imported_tree["status"] == "draft" # Always draft on import diff --git a/docs/plans/2026-03-06-editor-embedded-flow-assist-design.md b/docs/plans/2026-03-06-editor-embedded-flow-assist-design.md new file mode 100644 index 00000000..94425c50 --- /dev/null +++ b/docs/plans/2026-03-06-editor-embedded-flow-assist-design.md @@ -0,0 +1,361 @@ +# Editor-Embedded Flow Assist - Design Document + +> **Date:** 2026-03-06 +> **Status:** Approved +> **Replaces:** Standalone AI Chat Builder (`/ai/chat`) + +--- + +## Overview + +Replace the standalone `/ai/chat` page with a context-aware AI side panel embedded directly in each editor (Troubleshooting + Procedural). The panel knows which node/step is focused, supports targeted and open-ended actions, and applies changes via a tiered suggestion system. Knowledge integration and variable inference are phased features built on the same panel architecture. + +**Key Principles:** +- Context-aware: panel knows the full tree/step structure + focal node +- Targeted actions auto-apply; open-ended suggestions require acceptance +- Output-based thresholds determine suggestion UX +- Model routing is config-driven, not hardcoded +- Chat history persists per-flow, per-user + +--- + +## Panel Layout & Behavior + +### Dimensions & Styling +- **Width:** 320px fixed, right side +- **Styling:** Glassmorphism (`.glass-card-static` bg, backdrop blur, `border-l border-border`) +- **Z-index:** Same layer as node editor panel (not overlay) + +### Single-Panel Rule +- **Tree editor:** AI panel occupies the right panel slot, closing the node editor panel. When AI panel closes, if a node was previously being edited, the node editor panel reopens for that node. +- **Procedural editor:** AI panel slides in from right, narrowing the step list (step list takes `flex-1`). No existing panel to replace. + +### Top Section: Context Summary +- **Node/step selected:** Read-only summary showing type, title, question/description of the focused item. +- **No selection:** Flow summary showing name, node/step count, flow type. +- Switching selection updates the summary live. + +### Tabs +- **Chat** — conversation + inline suggestions +- **Suggestions** — audit trail of all AI-applied changes to this flow (accepted, dismissed, pending) + +### Visibility +- Hidden by default +- Auto-opens on: AI-assisted flow creation, right-click AI action, toolbar toggle +- Auto-contextual: opens with focal node already set when triggered via context menu + +--- + +## Entry Points + +### 1. Create Flow Dropdown (AI-Assisted) +- "Blank" or "AI-assisted" option per flow type (Troubleshooting, Project, Maintenance) +- **AI-assisted** shows a simple prompt dialog modal: + - Text area: "Describe the flow you want to build" + - Flow type already known from dropdown selection + - Loading state during generation + - On failure: error message + retry button (stays in dialog) + - On success: creates tree via API, navigates to editor with AI panel auto-opened and generation chat history loaded +- No multi-phase interview, no preview — just prompt and go + +### 2. Right-Click Context Menu +- New `` component (no existing context menus in either editor) +- Positioned absolutely at right-click point +- Closes on click-away, Escape, or action selection +- **Tree editor items:** Generate branch, Add decision/action/solution, Explain node, Find known fixes, Delete +- **Procedural editor items:** Generate steps after, Add verification step, Expand step, Generate section, Delete +- Selecting an AI action sets the focal node/step and opens the AI panel + +### 3. Toolbar Toggle +- "AI Assist" button in editor toolbar to manually open/close the panel + +### 4. Existing Flows +- AI panel works on any flow — new or existing, AI-created or manually built +- No restriction to AI-created flows + +--- + +## Suggestion & Apply System + +### Ghost Node/Step Mechanics + +Ghost nodes/steps are added to `treeStructure`/steps array with a `_suggestion: true` flag: +- Canvas/step list renders them normally (auto-layout works) but with **dashed borders + reduced opacity** +- Zundo temporal store **paused** while suggestions are pending +- On **accept**: remove `_suggestion` flag, unpause zundo (creates one clean undo point) +- On **dismiss**: remove ghost nodes from structure, unpause zundo (no undo point created) +- Ghost nodes participate in auto-layout and connection drawing but are visually distinct + +### Addition vs Modification + +| Change Type | Visual Treatment | +|---|---| +| **New nodes/steps** | Ghost nodes: dashed borders, reduced opacity | +| **Modified existing nodes** | Subtle highlight + badge showing what changed | +| **Modified selected node** | Before/after shown in chat message with Apply button (not inline ghost) | + +### Output-Based Threshold + +| Output Size | Behavior | +|---|---| +| **1 node/step** | Auto-apply + toast notification with undo link | +| **2-4 nodes/steps** | Individual ghost suggestions + "Accept All" shortcut button | +| **5+ nodes/steps** | Ghost suggestions grouped by branch (tree) or section (procedural) with "Accept Branch"/"Accept Section" and "Accept All" controls + summary card in panel | + +All changes (accepted or dismissed) logged in the Suggestions tab as an audit trail. + +--- + +## Backend Action Types + +Each message to the AI includes an `action_type` that determines prompt construction, response schema, and model routing: + +| Action Type | Description | Model Tier | Response Format | +|---|---|---|---| +| `generate_full` | Initial skeleton from prompt dialog | standard | Full tree structure or step array | +| `generate_branch` | Generate children for a specific node | standard | Subtree delta (node + children) | +| `modify_node` | Update a specific node's content | fast | Single node delta (before/after) | +| `add_steps` | Add steps after a specific step | standard | Step array delta | +| `quick_action` | Single-node operations (explain, expand) | fast | Single node delta or text response | +| `open_chat` | General conversation about the flow | standard | Text + optional delta | +| `variable_inference` | Detect implicit variables in step content | fast | Variable suggestions | + +### Prompt Construction + +Each action type gets a tailored system prompt: +- **Full tree context** always included (so AI understands the complete flow) +- **Focal node** highlighted when present (the specific node/step being acted on) +- **Action instruction** describes what the AI should return +- **Response schema** constrains output format (full tree, subtree delta, single node, text) + +### Delta Response Format + +For partial updates, the AI returns a delta object: +```json +{ + "action": "add" | "modify" | "delete", + "target_node_id": "node-to-modify-or-insert-after", + "nodes": [{ /* node objects */ }], + "explanation": "What was changed and why" +} +``` + +The frontend applies the delta to the tree structure and renders ghost nodes as appropriate. + +--- + +## Model Routing (Config-Driven) + +### Configuration + +```python +# backend/app/core/config.py +AI_MODEL_TIERS = { + "fast": "claude-haiku-4-5-20251001", + "standard": "claude-sonnet-4-6-20250514", +} + +ACTION_MODEL_MAP = { + "generate_full": "standard", + "generate_branch": "standard", + "modify_node": "fast", + "add_steps": "standard", + "quick_action": "fast", + "open_chat": "standard", + "variable_inference": "fast", +} +``` + +### Routing Logic + +1. Message endpoint receives `action_type` parameter +2. Look up tier from `ACTION_MODEL_MAP` +3. Resolve model name from `AI_MODEL_TIERS` +4. Pass to Anthropic API call + +Both tiers can map to the same model initially. Changing model assignment is a config change, not a code change. + +--- + +## Knowledge Integration (Phased) + +### Phase 1 (Initial Release) +- Uses existing Microsoft Learn MCP server +- AI can cite KB articles, known issues, and official fix procedures in chat responses +- Citations rendered inline as collapsible cards with source URL and title +- AI response marker: `[KNOWLEDGE]{"title": "...", "url": "...", "excerpt": "..."}[/KNOWLEDGE]` + +### Phase 2 (Future) +- Additional vendor documentation sources +- Community knowledge bases +- Proactive suggestions ("Microsoft released KB5034441 addressing this scenario") + +--- + +## Chat Persistence + +### Session Model +- `ai_chat_session` model extended with: + - `tree_id` FK (which flow this session belongs to) + - `archived_at` timestamp (null = active) +- Per-flow, per-user sessions: multiple engineers on the same flow get separate chat histories +- Session loads on panel open if one exists for this flow + user + +### Suggestions Audit Trail +New `ai_suggestion` table: + +| Column | Type | Description | +|---|---|---| +| `id` | UUID | Primary key | +| `tree_id` | UUID FK | Which flow | +| `user_id` | UUID FK | Who triggered | +| `session_id` | UUID FK | Which chat session | +| `action_type` | String | Action that generated this suggestion | +| `target_node_id` | String | Node/step acted on (nullable) | +| `changes_json` | JSONB | Before/after snapshot | +| `status` | Enum | `pending`, `accepted`, `dismissed` | +| `created_at` | DateTime(tz) | When suggested | +| `resolved_at` | DateTime(tz) | When accepted/dismissed (nullable) | + +### Auto-Archive +- APScheduler task runs daily +- Archives sessions with no activity for 30 days (`archived_at = now()`) +- Archived sessions viewable in Suggestions tab but not resumable for chat + +--- + +## Troubleshooting Editor Integration + +### Panel Context +- Full tree structure included in AI context +- Focal node (when selected/right-clicked) highlighted in context +- Node summary at panel top shows: type icon, node ID, question/title, option count + +### Context Menu Actions +| Action | Description | Model Tier | +|---|---|---| +| Generate branch | Create child nodes from this decision | standard | +| Add decision node | Add a decision child | fast | +| Add action node | Add an action child | fast | +| Add solution node | Add a solution child | fast | +| Explain node | AI explains what this node does | fast | +| Find known fixes | Search knowledge sources for this scenario | standard | + +### Ghost Node Rendering +- Dashed `border-dashed border-primary/40` borders +- `opacity-60` on the node card +- Connection lines drawn with dashed stroke +- Accept/dismiss buttons overlaid on each ghost node +- "Accept All" button in the panel when 2+ ghost nodes + +--- + +## Procedural Editor Integration + +### Panel Context +- Full step list included in AI context +- Focal step (when selected/right-clicked) highlighted in context +- Step summary at panel top shows: step number, type badge, title, content type + +### Context Menu Actions +| Action | Description | Model Tier | +|---|---|---| +| Generate steps after | Add steps following this one | standard | +| Add verification step | Insert a verification step | fast | +| Expand step | Break this step into substeps | standard | +| Generate section | Create a section header + steps | standard | + +### Ghost Step Rendering +- Dashed left border (`border-l-2 border-dashed border-primary/40`) +- `opacity-60` background +- Accept/dismiss buttons on each ghost step +- Grouped by section when 5+ suggestions + +### Intake Variable Detection (Three Tiers) + +| Tier | Trigger | Timing | Model Tier | +|---|---|---|---| +| **Explicit** | `[VAR:name]` syntax in step content | Immediate on content save | None (regex match) | +| **Inference** | Natural language suggests variable ("check the customer's server") | Debounced on step save/blur | fast | +| **Cross-step** | Same implicit variable in 2+ steps | On panel open + when steps modified | fast | + +**Behavior:** +- Explicit: immediate inline suggestion card in panel ("Add `server_name` to intake form?") +- Inference: non-blocking suggestion in panel, lower confidence indicator +- Cross-step: promoted suggestion with gap flag ("Variable `server_name` used in steps 3, 7, 12 but not captured in intake form") +- Results cached per-session until step content changes + +--- + +## What Gets Removed + +| Item | Location | +|---|---| +| `AIChatBuilderPage.tsx` | `frontend/src/pages/` | +| `aiChatStore.ts` | `frontend/src/store/` | +| `ai-chat/` component directory | `frontend/src/components/` | +| `AIFlowBuilderModal` | `frontend/src/components/` | +| `/ai/chat` route | `frontend/src/router.tsx` | +| Flow type selection routing | URL params `?type=...` | + +--- + +## What Gets Repurposed + +| Item | Changes | +|---|---| +| `ai_chat_service.py` | Action-type dispatch, partial generation prompts, model routing, focal node context | +| `ai_tree_validator.py` | Validates AI-generated fragments (subtree, step batch) in addition to full trees | +| `ai_chat_session` model | Extended with `tree_id` FK, `archived_at` timestamp | +| AI chat endpoints | Tree-scoped sessions, `action_type` parameter, model tier routing | + +--- + +## What Gets Built (New) + +| Item | Description | +|---|---| +| `EditorAIPanel` component | Shared panel with Chat + Suggestions tabs, node summary, input | +| `ContextMenu` component | Shared right-click menu for nodes and steps | +| `useEditorAI` hook | Panel state, focal node, suggestion management, ghost node lifecycle | +| Prompt dialog modal | Simple "describe your flow" modal for AI-assisted create | +| `ai_suggestion` DB model | Audit trail table + Alembic migration | +| Ghost node CSS | Dashed borders, reduced opacity, accept/dismiss overlays | +| Model tier config | `AI_MODEL_TIERS` + `ACTION_MODEL_MAP` in `config.py` | +| APScheduler archive task | Daily job to archive stale sessions | + +--- + +## What Gets Modified + +| Item | Changes | +|---|---| +| `TreeEditorPage` | Right panel slot for AI, context menu handler, ghost node support | +| `TreeCanvas` / `TreeCanvasNode` | Ghost node rendering (dashed borders, overlays) | +| `ProceduralEditorPage` | Flex layout for AI panel, context menu on steps | +| `StepList` / `StepEditor` | Ghost step rendering | +| `treeEditorStore` | Ghost node state slice, zundo pause/resume, orphan bug fix | +| `proceduralEditorStore` | Ghost step state slice | +| `ai_chat_service.py` | Action-type dispatch, delta response format, model routing | +| `ai_chat_session` model | `tree_id` FK, `archived_at` | +| `config.py` | Model tier configuration | +| `CreateFlowDropdown` | AI-assisted option + prompt dialog trigger | +| `router.tsx` | Remove `/ai/chat` route | + +--- + +## Bug Fix (Included) + +**File:** `frontend/src/store/treeEditorStore.ts` line 858 + +**Current code:** +```typescript +if (id !== 'root' && !referencedIds.has(id)) { +``` + +**Fixed code:** +```typescript +if (id !== state.treeStructure?.id && !referencedIds.has(id)) { +``` + +**Root cause:** Orphan check hardcodes `'root'` as the expected root node ID. AI-generated trees use descriptive IDs (e.g., `"verify-account-exists"`). Since the root is never referenced by any other node's `next_node_id`, it gets flagged as orphaned. This is a false positive. diff --git a/docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md b/docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md new file mode 100644 index 00000000..bf18ae93 --- /dev/null +++ b/docs/plans/2026-03-06-editor-embedded-flow-assist-plan.md @@ -0,0 +1,2802 @@ +# Editor-Embedded Flow Assist - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the standalone `/ai/chat` page with context-aware AI side panels embedded in the Troubleshooting and Procedural editors, supporting targeted actions, ghost node suggestions, and config-driven model routing. + +**Architecture:** Backend extends existing AI chat service with action-type dispatch, delta responses, and model tier routing. Frontend adds a shared `EditorAIPanel` component, `ContextMenu` component, and `useEditorAI` hook that integrate into both editors. Ghost nodes use a `_suggestion` flag in tree/step structures with zundo pause/resume for clean undo history. + +**Tech Stack:** FastAPI, SQLAlchemy, Alembic, React 19, Zustand (zundo), Tailwind CSS, Anthropic/Google AI SDK + +**Design doc:** `docs/plans/2026-03-06-editor-embedded-flow-assist-design.md` + +--- + +## Phase 1: Bug Fix + Backend Foundation + +### Task 1: Fix Orphan Validation False Positive + +**Files:** +- Modify: `frontend/src/store/treeEditorStore.ts:858` + +**Step 1: Locate and fix the bug** + +In `frontend/src/store/treeEditorStore.ts`, line 858, change: + +```typescript +// BEFORE (line 858) +if (id !== 'root' && !referencedIds.has(id)) { + +// AFTER +if (id !== state.treeStructure?.id && !referencedIds.has(id)) { +``` + +The root node's ID is not always `'root'` — AI-generated trees use descriptive IDs like `"verify-account-exists"`. The root is never referenced by `next_node_id` so it gets flagged as orphaned. + +**Step 2: Verify the fix** + +Run: `cd frontend && npm run build` +Expected: Build succeeds with no type errors. + +**Step 3: Commit** + +```bash +git add frontend/src/store/treeEditorStore.ts +git commit -m "fix: use actual root node ID in orphan validation check" +``` + +--- + +### Task 2: Add Model Tier Configuration + +**Files:** +- Modify: `backend/app/core/config.py:77-85` + +**Step 1: Write the failing test** + +Create test in `backend/tests/test_config_model_tiers.py`: + +```python +"""Tests for AI model tier configuration.""" +from app.core.config import settings + + +def test_ai_model_tiers_exist(): + """Model tier config has fast and standard entries.""" + assert "fast" in settings.AI_MODEL_TIERS + assert "standard" in settings.AI_MODEL_TIERS + + +def test_action_model_map_covers_all_actions(): + """Every action type maps to a valid tier.""" + valid_tiers = set(settings.AI_MODEL_TIERS.keys()) + for action, tier in settings.ACTION_MODEL_MAP.items(): + assert tier in valid_tiers, f"Action '{action}' maps to unknown tier '{tier}'" + + +def test_get_model_for_action(): + """get_model_for_action resolves tier to model name.""" + model = settings.get_model_for_action("generate_full") + assert isinstance(model, str) + assert len(model) > 0 + + +def test_get_model_for_action_unknown_falls_back(): + """Unknown action types fall back to standard tier.""" + model = settings.get_model_for_action("nonexistent_action") + assert model == settings.AI_MODEL_TIERS["standard"] +``` + +**Step 2: Run test to verify it fails** + +Run: `cd backend && python -m pytest tests/test_config_model_tiers.py -v` +Expected: FAIL — `AI_MODEL_TIERS` attribute does not exist. + +**Step 3: Add model tier config to Settings class** + +In `backend/app/core/config.py`, after the existing AI config lines (~line 85), add: + +```python + # Model tier routing + AI_MODEL_TIERS: dict[str, str] = { + "fast": "claude-haiku-4-5-20251001", + "standard": "claude-sonnet-4-6-20250514", + } + + ACTION_MODEL_MAP: dict[str, str] = { + "generate_full": "standard", + "generate_branch": "standard", + "modify_node": "fast", + "add_steps": "standard", + "quick_action": "fast", + "open_chat": "standard", + "variable_inference": "fast", + } + + def get_model_for_action(self, action_type: str) -> str: + """Resolve an action type to a concrete model name.""" + tier = self.ACTION_MODEL_MAP.get(action_type, "standard") + return self.AI_MODEL_TIERS.get(tier, self.AI_MODEL_TIERS["standard"]) +``` + +**Step 4: Run test to verify it passes** + +Run: `cd backend && python -m pytest tests/test_config_model_tiers.py -v` +Expected: All 4 tests PASS. + +**Step 5: Commit** + +```bash +git add backend/app/core/config.py backend/tests/test_config_model_tiers.py +git commit -m "feat: add config-driven AI model tier routing" +``` + +--- + +### Task 3: Extend AI Chat Session Model + +**Files:** +- Modify: `backend/app/models/ai_chat_session.py` +- Create: `backend/alembic/versions/051_extend_ai_chat_session.py` + +**Step 1: Add columns to AIChatSession model** + +In `backend/app/models/ai_chat_session.py`, add after the existing column definitions: + +```python + # Editor-embedded session: links to a specific tree/flow + tree_id: Mapped[Optional[uuid.UUID]] = mapped_column( + ForeignKey("trees.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + archived_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) +``` + +Add the relationship (if `Tree` model import is needed, add it): + +```python + tree: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[tree_id]) +``` + +**Step 2: Create the Alembic migration** + +Run: `cd backend && alembic revision -m "extend ai chat session with tree_id and archived_at" --rev-id=051` + +Then edit the generated file `backend/alembic/versions/051_extend_ai_chat_session.py`: + +```python +"""extend ai chat session with tree_id and archived_at + +Revision ID: 051 +""" +from alembic import op +import sqlalchemy as sa + +revision = "051" +down_revision = "050" # Verify this matches the actual previous migration +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ai_chat_sessions", + sa.Column("tree_id", sa.UUID(), nullable=True), + ) + op.add_column( + "ai_chat_sessions", + sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_ai_chat_sessions_tree_id", "ai_chat_sessions", ["tree_id"]) + op.create_foreign_key( + "fk_ai_chat_sessions_tree_id", + "ai_chat_sessions", + "trees", + ["tree_id"], + ["id"], + ondelete="CASCADE", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_ai_chat_sessions_tree_id", "ai_chat_sessions", type_="foreignkey") + op.drop_index("ix_ai_chat_sessions_tree_id", table_name="ai_chat_sessions") + op.drop_column("ai_chat_sessions", "archived_at") + op.drop_column("ai_chat_sessions", "tree_id") +``` + +**Step 3: Run migration** + +Run: `cd backend && alembic upgrade head` +Expected: Migration applies successfully. + +**Step 4: Verify with psql** + +Run: `docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d ai_chat_sessions" | grep -E "tree_id|archived_at"` +Expected: Both columns visible. + +**Step 5: Commit** + +```bash +git add backend/app/models/ai_chat_session.py backend/alembic/versions/051_extend_ai_chat_session.py +git commit -m "feat: extend AI chat session with tree_id and archived_at" +``` + +--- + +### Task 4: Create AI Suggestion Model + Migration + +**Files:** +- Create: `backend/app/models/ai_suggestion.py` +- Create: `backend/alembic/versions/052_add_ai_suggestion_table.py` +- Modify: `backend/alembic/env.py` (import new model) + +**Step 1: Create the model** + +Create `backend/app/models/ai_suggestion.py`: + +```python +"""AI Suggestion model for tracking AI-applied changes to flows.""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class AISuggestion(Base): + __tablename__ = "ai_suggestions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + tree_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("trees.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + session_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"), + nullable=True, + ) + action_type: Mapped[str] = mapped_column( + String(50), nullable=False + ) + target_node_id: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + changes_json: Mapped[dict] = mapped_column( + JSONB, nullable=False, default=dict + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="pending" + ) # pending, accepted, dismissed + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + resolved_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # Relationships + tree: Mapped["Tree"] = relationship("Tree", foreign_keys=[tree_id]) + user: Mapped["User"] = relationship("User", foreign_keys=[user_id]) + session: Mapped["AIChatSession"] = relationship( + "AIChatSession", foreign_keys=[session_id] + ) +``` + +**Step 2: Import in alembic/env.py** + +In `backend/alembic/env.py`, add with the other model imports: + +```python +from app.models.ai_suggestion import AISuggestion # noqa: F401 +``` + +**Step 3: Create migration manually** + +Create `backend/alembic/versions/052_add_ai_suggestion_table.py`: + +```python +"""add ai suggestion table + +Revision ID: 052 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID, JSONB + +revision = "052" +down_revision = "051" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "ai_suggestions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("tree_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("session_id", UUID(as_uuid=True), sa.ForeignKey("ai_chat_sessions.id", ondelete="SET NULL"), nullable=True), + sa.Column("action_type", sa.String(50), nullable=False), + sa.Column("target_node_id", sa.String(255), nullable=True), + sa.Column("changes_json", JSONB, nullable=False, server_default="{}"), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_ai_suggestions_tree_id", "ai_suggestions", ["tree_id"]) + op.create_index("ix_ai_suggestions_user_id", "ai_suggestions", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_ai_suggestions_user_id", table_name="ai_suggestions") + op.drop_index("ix_ai_suggestions_tree_id", table_name="ai_suggestions") + op.drop_table("ai_suggestions") +``` + +**Step 4: Run migration** + +Run: `cd backend && alembic upgrade head` +Expected: Table created successfully. + +**Step 5: Verify** + +Run: `docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d ai_suggestions"` +Expected: Table schema displayed with all columns. + +**Step 6: Commit** + +```bash +git add backend/app/models/ai_suggestion.py backend/alembic/versions/052_add_ai_suggestion_table.py backend/alembic/env.py +git commit -m "feat: add AI suggestion audit trail table" +``` + +--- + +### Task 5: Add Action Type to AI Chat Schemas + Endpoints + +**Files:** +- Modify: `backend/app/schemas/ai_chat.py` +- Modify: `backend/app/api/endpoints/ai_chat.py` + +**Step 1: Write the failing test** + +Add to `backend/tests/test_ai_chat.py` (or create new file `backend/tests/test_ai_chat_action_types.py`): + +```python +"""Tests for action-type routing in AI chat endpoints.""" +import pytest +from unittest.mock import AsyncMock, PropertyMock, patch + + +@pytest.fixture +def _enable_ai(monkeypatch): + """Enable AI for tests without API key.""" + from app.core.config import Settings + monkeypatch.setattr(Settings, "ai_enabled", PropertyMock(return_value=True)) + + +@pytest.mark.asyncio +async def test_send_message_with_action_type(client, auth_headers, _enable_ai): + """Messages can include an action_type parameter.""" + # Start a session first + with patch("app.api.endpoints.ai_chat.ai_chat_service") as mock_svc: + mock_svc.start_chat_session = AsyncMock(return_value={ + "session_id": "test-123", + "greeting": "Hello", + "current_phase": "scoping", + }) + start_resp = await client.post( + "/api/v1/ai/chat/sessions", + json={"flow_type": "troubleshooting"}, + headers=auth_headers, + ) + assert start_resp.status_code == 200 + + # Send message with action_type + mock_svc.send_message = AsyncMock(return_value={ + "content": "Here's a branch", + "current_phase": "enrichment", + "working_tree": None, + "tree_metadata": None, + }) + resp = await client.post( + f"/api/v1/ai/chat/sessions/test-123/messages", + json={ + "content": "Generate a branch from this node", + "action_type": "generate_branch", + "focal_node_id": "check-connectivity", + }, + headers=auth_headers, + ) + assert resp.status_code == 200 +``` + +**Step 2: Run test to verify it fails** + +Run: `cd backend && python -m pytest tests/test_ai_chat_action_types.py -v` +Expected: FAIL — `action_type` not accepted in schema. + +**Step 3: Update schemas** + +In `backend/app/schemas/ai_chat.py`, update `AIChatMessageRequest`: + +```python +from typing import Literal, Optional + +VALID_ACTION_TYPES = Literal[ + "generate_full", + "generate_branch", + "modify_node", + "add_steps", + "quick_action", + "open_chat", + "variable_inference", +] + + +class AIChatMessageRequest(BaseModel): + content: str = Field(..., min_length=1, max_length=5000) + action_type: Optional[VALID_ACTION_TYPES] = Field( + default="open_chat", + description="Type of AI action to perform, determines model tier and prompt", + ) + focal_node_id: Optional[str] = Field( + default=None, + description="ID of the node/step being acted on (for targeted actions)", + ) +``` + +Also update `AIChatStartRequest` to accept optional `tree_id`: + +```python +class AIChatStartRequest(BaseModel): + flow_type: Literal["troubleshooting", "procedural"] + tree_id: Optional[str] = Field( + default=None, + description="ID of existing tree to attach this session to (editor-embedded mode)", + ) +``` + +**Step 4: Update endpoint to pass action_type through** + +In `backend/app/api/endpoints/ai_chat.py`, update the `send_message` endpoint to pass `action_type` and `focal_node_id` to the service: + +```python +# In the send_message endpoint, after extracting request data: +result = await ai_chat_service.send_message( + session_id=session_id, + user_message=data.content, + user_id=str(current_user.id), + db=db, + action_type=data.action_type, + focal_node_id=data.focal_node_id, +) +``` + +In the `start_session` endpoint, pass `tree_id` if provided: + +```python +# When creating session, pass tree_id +result = await ai_chat_service.start_chat_session( + user_id=str(current_user.id), + flow_type=data.flow_type, + db=db, + account_id=str(current_user.account_id) if current_user.account_id else None, + tree_id=data.tree_id, +) +``` + +**Step 5: Update ai_chat_service.send_message signature** + +In `backend/app/core/ai_chat_service.py`, update `send_message` to accept the new params (add `action_type: str = "open_chat"` and `focal_node_id: str | None = None` to the signature). For now, just accept them — prompt dispatch will come in a later task. + +Also update `start_chat_session` to accept and store `tree_id`: + +```python +async def start_chat_session( + self, + user_id: str, + flow_type: str, + db: AsyncSession, + account_id: str | None = None, + tree_id: str | None = None, +) -> dict: + # ... existing code ... + session = AIChatSession( + # ... existing fields ... + tree_id=uuid.UUID(tree_id) if tree_id else None, + ) +``` + +**Step 6: Run tests** + +Run: `cd backend && python -m pytest tests/test_ai_chat_action_types.py -v` +Expected: PASS. + +Run: `cd backend && python -m pytest tests/test_ai_chat.py -v` +Expected: Existing tests still PASS. + +**Step 7: Commit** + +```bash +git add backend/app/schemas/ai_chat.py backend/app/api/endpoints/ai_chat.py backend/app/core/ai_chat_service.py backend/tests/test_ai_chat_action_types.py +git commit -m "feat: add action_type and focal_node_id to AI chat message API" +``` + +--- + +### Task 6: Add Model Tier Routing to AI Chat Service + +**Files:** +- Modify: `backend/app/core/ai_chat_service.py` + +**Step 1: Write the failing test** + +Add to `backend/tests/test_ai_chat_action_types.py`: + +```python +def test_model_routing_uses_action_type(): + """Service selects model based on action_type.""" + from app.core.config import settings + + # Fast actions should use fast model + fast_model = settings.get_model_for_action("quick_action") + assert fast_model == settings.AI_MODEL_TIERS["fast"] + + # Standard actions should use standard model + standard_model = settings.get_model_for_action("generate_branch") + assert standard_model == settings.AI_MODEL_TIERS["standard"] +``` + +**Step 2: Run test — should pass (config already done in Task 2)** + +Run: `cd backend && python -m pytest tests/test_ai_chat_action_types.py::test_model_routing_uses_action_type -v` +Expected: PASS (config was added in Task 2). + +**Step 3: Update AI chat service to use model routing** + +In `backend/app/core/ai_chat_service.py`, find where the AI model is selected (look for `settings.AI_MODEL` or `settings.AI_MODEL_ANTHROPIC` or `settings.AI_MODEL_GEMINI`). Replace the hardcoded model selection with: + +```python +# Instead of: +# model = settings.AI_MODEL_ANTHROPIC (or similar) + +# Use: +model = settings.get_model_for_action(action_type) +``` + +This applies to both `send_message` and `generate_final_tree` methods. The `generate_final_tree` method should use `action_type="generate_full"`. + +**Important:** The service may use either Anthropic or Gemini provider. The model tier config currently has Anthropic model names. If using Gemini provider, fall back to `settings.AI_MODEL_GEMINI`. Add a provider check: + +```python +def _get_model(self, action_type: str) -> str: + """Get the model name for this action, respecting provider.""" + if settings.AI_PROVIDER == "gemini": + return settings.AI_MODEL_GEMINI + return settings.get_model_for_action(action_type) +``` + +**Step 4: Run full AI chat tests** + +Run: `cd backend && python -m pytest tests/test_ai_chat.py tests/test_ai_chat_action_types.py -v` +Expected: All PASS. + +**Step 5: Commit** + +```bash +git add backend/app/core/ai_chat_service.py backend/tests/test_ai_chat_action_types.py +git commit -m "feat: route AI model selection through action-type config" +``` + +--- + +## Phase 2: Core Frontend Infrastructure + +### Task 7: Create Frontend Types for Editor AI + +**Files:** +- Create: `frontend/src/types/editor-ai.ts` +- Modify: `frontend/src/types/index.ts` + +**Step 1: Create the types file** + +Create `frontend/src/types/editor-ai.ts`: + +```typescript +export type AIActionType = + | 'generate_full' + | 'generate_branch' + | 'modify_node' + | 'add_steps' + | 'quick_action' + | 'open_chat' + | 'variable_inference' + +export interface AIDelta { + action: 'add' | 'modify' | 'delete' + target_node_id: string + nodes: Record[] + explanation: string +} + +export interface AISuggestion { + id: string + action_type: AIActionType + target_node_id: string | null + changes_json: { + before?: Record + after?: Record + delta?: AIDelta + } + status: 'pending' | 'accepted' | 'dismissed' + created_at: string + resolved_at: string | null +} + +export interface EditorAIChatMessage { + role: 'user' | 'assistant' + content: string + timestamp: string + action_type?: AIActionType + delta?: AIDelta + knowledge?: KnowledgeCitation[] +} + +export interface KnowledgeCitation { + title: string + url: string + excerpt: string +} + +export interface EditorAISessionResponse { + session_id: string + status: 'active' | 'completed' | 'archived' + flow_type: 'troubleshooting' | 'procedural' + conversation_history: EditorAIChatMessage[] + tree_id: string | null + message_count: number +} + +export interface ContextMenuAction { + id: string + label: string + icon: string // Lucide icon name + action_type: AIActionType + description?: string +} + +export interface ContextMenuPosition { + x: number + y: number +} + +/** Ghost node/step marker — mixed into TreeStructure or ProceduralStep */ +export interface SuggestionMarker { + _suggestion?: true + _suggestion_id?: string // links to AISuggestion.id +} +``` + +**Step 2: Export from types/index.ts** + +Add to `frontend/src/types/index.ts`: + +```typescript +export type { + AIActionType, + AIDelta, + AISuggestion, + EditorAIChatMessage, + KnowledgeCitation, + EditorAISessionResponse, + ContextMenuAction, + ContextMenuPosition, + SuggestionMarker, +} from './editor-ai' +``` + +**Step 3: Verify build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +**Step 4: Commit** + +```bash +git add frontend/src/types/editor-ai.ts frontend/src/types/index.ts +git commit -m "feat: add TypeScript types for editor-embedded AI" +``` + +--- + +### Task 8: Create ContextMenu Component + +**Files:** +- Create: `frontend/src/components/common/ContextMenu.tsx` + +**Step 1: Create the component** + +Create `frontend/src/components/common/ContextMenu.tsx`: + +```tsx +import { useEffect, useRef, useCallback } from 'react' +import { cn } from '@/lib/utils' +import type { ContextMenuPosition } from '@/types' + +interface ContextMenuItem { + id: string + label: string + icon?: React.ReactNode + onClick: () => void + variant?: 'default' | 'danger' + separator?: boolean +} + +interface ContextMenuProps { + position: ContextMenuPosition + items: ContextMenuItem[] + onClose: () => void +} + +export function ContextMenu({ position, items, onClose }: ContextMenuProps) { + const menuRef = useRef(null) + + const handleClickOutside = useCallback( + (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose() + } + }, + [onClose] + ) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, + [onClose] + ) + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleKeyDown) + } + }, [handleClickOutside, handleKeyDown]) + + // Adjust position to stay within viewport + const adjustedStyle = (() => { + const style: React.CSSProperties = { + position: 'fixed', + zIndex: 100, + left: position.x, + top: position.y, + } + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect() + if (position.x + rect.width > window.innerWidth) { + style.left = position.x - rect.width + } + if (position.y + rect.height > window.innerHeight) { + style.top = position.y - rect.height + } + } + return style + })() + + return ( +
+ {items.map((item) => ( +
+ {item.separator && ( +
+ )} + +
+ ))} +
+ ) +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Build succeeds. + +**Step 3: Commit** + +```bash +git add frontend/src/components/common/ContextMenu.tsx +git commit -m "feat: add shared ContextMenu component" +``` + +--- + +### Task 9: Create EditorAIPanel Component (Shell) + +**Files:** +- Create: `frontend/src/components/editor-ai/EditorAIPanel.tsx` +- Create: `frontend/src/components/editor-ai/ChatTab.tsx` +- Create: `frontend/src/components/editor-ai/SuggestionsTab.tsx` +- Create: `frontend/src/components/editor-ai/NodeSummary.tsx` + +**Step 1: Create NodeSummary component** + +Create `frontend/src/components/editor-ai/NodeSummary.tsx`: + +```tsx +import { HelpCircle, Zap, CheckCircle, FileText, Layout } from 'lucide-react' +import type { TreeStructure } from '@/types' + +interface NodeSummaryProps { + node?: TreeStructure | null + flowName?: string + flowType?: 'troubleshooting' | 'procedural' | 'maintenance' + nodeCount?: number +} + +const NODE_ICONS = { + decision: HelpCircle, + action: Zap, + solution: CheckCircle, +} + +const NODE_COLORS = { + decision: 'text-blue-400', + action: 'text-yellow-400', + solution: 'text-green-400', +} + +export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) { + if (!node) { + // Flow summary when no node selected + return ( +
+
+ + + {flowName || 'Untitled Flow'} + +
+
+ {flowType || 'flow'} + {nodeCount !== undefined && {nodeCount} nodes} +
+
+ ) + } + + const Icon = NODE_ICONS[node.type as keyof typeof NODE_ICONS] || FileText + const colorClass = NODE_COLORS[node.type as keyof typeof NODE_COLORS] || 'text-muted-foreground' + + return ( +
+
+ + + {node.type} + +
+

+ {node.question || node.title || node.id} +

+ {node.description && ( +

+ {node.description} +

+ )} +
+ ) +} +``` + +**Step 2: Create ChatTab component** + +Create `frontend/src/components/editor-ai/ChatTab.tsx`: + +```tsx +import { useRef, useEffect } from 'react' +import { Send, Sparkles } from 'lucide-react' +import type { EditorAIChatMessage } from '@/types' + +interface ChatTabProps { + messages: EditorAIChatMessage[] + input: string + onInputChange: (value: string) => void + onSend: () => void + isLoading: boolean +} + +export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: ChatTabProps) { + const messagesEndRef = useRef(null) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + if (input.trim() && !isLoading) onSend() + } + } + + return ( +
+ {/* Messages */} +
+ {messages.length === 0 && ( +
+ +

Ask me to help build your flow

+
+ )} + {messages.map((msg, i) => ( +
+

{msg.content}

+
+ ))} + {isLoading && ( +
+
+
+
+
+
+
+ )} +
+
+ + {/* Input */} +
+
+