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 */} +
+
+