feat: AI Chat Builder (Flow Assist) + cross-reference loop-back support

- AI conversational flow builder with interview phases (scoping, discovery, enrichment)
- Flow Assist sidebar nav, toolbar, chat panel, tree preview
- Cross-reference / loop-back support: nodes can reference any node in the tree
- Dashed purple arrows on canvas for cross-references
- Node picker dropdowns for action and decision forms
- Backend validator relaxed for cross-references
- AI system prompt updated for loop-back patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-28 20:43:48 -05:00
37 changed files with 3279 additions and 38 deletions

View File

@@ -0,0 +1,46 @@
"""add ai_chat_sessions table
Revision ID: e2d81e82ea5e
Revises: 1490781700bc
Create Date: 2026-02-27 03:41:33.832260
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
# revision identifiers, used by Alembic.
revision: str = 'e2d81e82ea5e'
down_revision: Union[str, None] = '1490781700bc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"ai_chat_sessions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("current_phase", sa.String(20), nullable=False, server_default="scoping"),
sa.Column("flow_type", sa.String(20), nullable=False),
sa.Column("conversation_history", JSONB, nullable=False, server_default="[]"),
sa.Column("working_tree", JSONB, nullable=True),
sa.Column("tree_metadata", JSONB, nullable=False, server_default="{}"),
sa.Column("provider_used", sa.String(20), nullable=True),
sa.Column("message_count", sa.Integer, nullable=False, server_default="0"),
sa.Column("total_input_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("total_output_tokens", sa.Integer, nullable=False, server_default="0"),
sa.Column("generated_tree_id", UUID(as_uuid=True), sa.ForeignKey("trees.id", ondelete="SET NULL"), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("ai_chat_sessions")

View File

@@ -0,0 +1,426 @@
"""AI Chat Builder endpoints.
Conversational flow builder:
POST /ai/chat/sessions — Start session, get AI greeting
POST /ai/chat/sessions/{id}/messages — Send message, get AI response
GET /ai/chat/sessions/{id} — Get session state (for resume)
POST /ai/chat/sessions/{id}/generate — Generate final TreeStructure
POST /ai/chat/sessions/{id}/import — Create Tree from generated structure
DELETE /ai/chat/sessions/{id} — Abandon session
"""
import logging
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rate_limit import limiter
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
from app.core.config import settings
from app.core.ai_chat_service import (
start_chat_session,
send_message,
generate_final_tree,
get_chat_session,
MAX_MESSAGES_FREE,
MAX_MESSAGES_PAID,
)
from app.core.ai_quota_service import check_ai_quota, record_ai_usage, get_user_plan
from app.models.user import User
from app.models.tree import Tree
from app.schemas.ai_chat import (
AIChatStartRequest,
AIChatStartResponse,
AIChatMessageRequest,
AIChatMessageResponse,
AIChatSessionResponse,
AIChatGenerateResponse,
AIChatImportRequest,
AIChatImportResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ai/chat", tags=["ai-chat-builder"])
def _require_ai_enabled() -> None:
if not settings.ai_enabled:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI is not configured. Set GOOGLE_AI_API_KEY or ANTHROPIC_API_KEY.",
)
@router.post("/sessions", response_model=AIChatStartResponse, status_code=201)
@limiter.limit("10/minute")
async def create_session(
request: Request,
data: AIChatStartRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Start a new AI chat builder session."""
_require_ai_enabled()
allowed, quota_status = await check_ai_quota(
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
billing_anchor=current_user.ai_billing_cycle_anchor_at,
is_super_admin=current_user.is_super_admin,
)
if not allowed:
reset_key = (
"daily_reset_at"
if quota_status.get("deny_reason") == "daily"
else "monthly_reset_at"
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={
"message": f"AI build limit exceeded ({quota_status['deny_reason']})",
"reset_at": quota_status.get(reset_key),
"quota": quota_status,
},
)
plan = await get_user_plan(current_user.account_id, db)
try:
session, greeting = await start_chat_session(
flow_type=data.flow_type,
user_id=current_user.id,
account_id=current_user.account_id,
db=db,
)
except Exception as e:
logger.exception("AI chat session start failed: %s", e)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="chat_message",
tier=plan,
input_tokens=session.total_input_tokens,
output_tokens=session.total_output_tokens,
estimated_cost=(
session.total_input_tokens * 1.0 / 1_000_000
+ session.total_output_tokens * 5.0 / 1_000_000
),
succeeded=True,
counts_toward_quota=False,
error_code=None,
extra_data={"phase": "scoping", "chat_session_id": str(session.id)},
db=db,
)
await db.commit()
return AIChatStartResponse(
session_id=session.id,
greeting=greeting,
current_phase=session.current_phase,
)
@router.post("/sessions/{session_id}/messages", response_model=AIChatMessageResponse)
@limiter.limit("10/minute")
async def post_message(
request: Request,
session_id: UUID,
data: AIChatMessageRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Send a user message and get AI response."""
_require_ai_enabled()
session = await get_chat_session(session_id, current_user.id, db)
if session.status != "active":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Session is {session.status}, cannot send messages",
)
plan = await get_user_plan(current_user.account_id, db)
max_messages = MAX_MESSAGES_PAID if plan != "free" else MAX_MESSAGES_FREE
if current_user.is_super_admin:
max_messages = 999
if session.message_count >= max_messages:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Maximum messages per session reached ({max_messages}). Generate your tree or start a new session.",
)
prev_input = session.total_input_tokens
prev_output = session.total_output_tokens
try:
ai_content, tree_update, new_phase, metadata = await send_message(
session, data.content, db
)
except Exception as e:
logger.exception("AI chat message failed: %s", e)
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="chat_message",
tier=plan,
input_tokens=0,
output_tokens=0,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code=type(e).__name__,
extra_data={"chat_session_id": str(session.id)},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({type(e).__name__}). Please try again.",
)
input_delta = session.total_input_tokens - prev_input
output_delta = session.total_output_tokens - prev_output
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="chat_message",
tier=plan,
input_tokens=input_delta,
output_tokens=output_delta,
estimated_cost=(
input_delta * 1.0 / 1_000_000
+ output_delta * 5.0 / 1_000_000
),
succeeded=True,
counts_toward_quota=False,
error_code=None,
extra_data={"phase": session.current_phase, "chat_session_id": str(session.id)},
db=db,
)
await db.commit()
return AIChatMessageResponse(
content=ai_content,
current_phase=session.current_phase,
working_tree=session.working_tree,
tree_metadata=session.tree_metadata if session.tree_metadata else None,
)
@router.get("/sessions/{session_id}", response_model=AIChatSessionResponse)
async def get_session(
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get full session state for resume after page reload."""
session = await get_chat_session(session_id, current_user.id, db)
visible_history = [
msg for msg in session.conversation_history
if not msg.get("hidden")
]
return AIChatSessionResponse(
session_id=session.id,
status=session.status,
current_phase=session.current_phase,
flow_type=session.flow_type,
conversation_history=visible_history,
working_tree=session.working_tree,
tree_metadata=session.tree_metadata if session.tree_metadata else None,
message_count=session.message_count,
generated_tree=session.working_tree if session.status == "completed" else None,
)
@router.post("/sessions/{session_id}/generate", response_model=AIChatGenerateResponse)
@limiter.limit("10/minute")
async def generate_tree(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Generate final TreeStructure JSON from conversation."""
_require_ai_enabled()
session = await get_chat_session(session_id, current_user.id, db)
if session.status == "completed" and session.working_tree:
return AIChatGenerateResponse(
tree_structure=session.working_tree,
tree_metadata=session.tree_metadata,
status="completed",
)
if session.status != "active":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Session is {session.status}, cannot generate",
)
plan = await get_user_plan(current_user.account_id, db)
prev_input = session.total_input_tokens
prev_output = session.total_output_tokens
try:
tree_structure, metadata = await generate_final_tree(session, db)
except ValueError as e:
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="chat_generate",
tier=plan,
input_tokens=session.total_input_tokens - prev_input,
output_tokens=session.total_output_tokens - prev_output,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code="invalid_output",
extra_data={"error": str(e), "chat_session_id": str(session.id)},
db=db,
)
await db.commit()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Tree generation failed: {e}",
)
except Exception as e:
logger.exception("AI chat generate failed: %s", e)
input_delta = session.total_input_tokens - prev_input
output_delta = session.total_output_tokens - prev_output
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="chat_generate",
tier=plan,
input_tokens=input_delta,
output_tokens=output_delta,
estimated_cost=0,
succeeded=False,
counts_toward_quota=False,
error_code=type(e).__name__,
extra_data={"error": str(e), "chat_session_id": str(session.id)},
db=db,
)
await db.commit()
error_name = type(e).__name__
if "timeout" in error_name.lower() or "Timeout" in str(e):
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail="Tree generation timed out. Please try again.",
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider error ({error_name}). Please try again.",
)
input_delta = session.total_input_tokens - prev_input
output_delta = session.total_output_tokens - prev_output
await record_ai_usage(
user_id=current_user.id,
account_id=current_user.account_id,
conversation_id=None,
generation_type="chat_generate",
tier=plan,
input_tokens=input_delta,
output_tokens=output_delta,
estimated_cost=(
input_delta * 1.0 / 1_000_000
+ output_delta * 5.0 / 1_000_000
),
succeeded=True,
counts_toward_quota=True,
error_code=None,
extra_data={"chat_session_id": str(session.id)},
db=db,
)
session.status = "completed"
await db.commit()
return AIChatGenerateResponse(
tree_structure=tree_structure,
tree_metadata=metadata,
status="completed",
)
@router.post("/sessions/{session_id}/import", response_model=AIChatImportResponse)
@limiter.limit("10/minute")
async def import_tree(
request: Request,
session_id: UUID,
data: AIChatImportRequest,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
):
"""Create a Tree record from the generated tree structure."""
session = await get_chat_session(session_id, current_user.id, db)
if session.status != "completed" or not session.working_tree:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session must be completed with a generated tree before importing",
)
# Always create a new Tree record (no duplicate check — user may
# want multiple copies or re-import after edits)
metadata = session.tree_metadata or {}
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,
author_id=current_user.id,
account_id=current_user.account_id,
category_id=data.category_id,
is_public=False,
)
db.add(tree)
await db.flush()
session.generated_tree_id = tree.id
await db.commit()
return AIChatImportResponse(
tree_id=tree.id,
tree_type=session.flow_type,
)
@router.delete("/sessions/{session_id}", status_code=204)
@limiter.limit("10/minute")
async def abandon_session(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Abandon a chat session."""
session = await get_chat_session(session_id, current_user.id, db)
session.status = "abandoned"
await db.commit()

View File

@@ -7,6 +7,7 @@ from app.api.endpoints import maintenance_schedules
from app.api.endpoints import feedback
from app.api.endpoints import ai_builder
from app.api.endpoints import ai_fix
from app.api.endpoints import ai_chat
api_router = APIRouter()
@@ -38,3 +39,4 @@ api_router.include_router(maintenance_schedules.router)
api_router.include_router(feedback.router)
api_router.include_router(ai_builder.router)
api_router.include_router(ai_fix.router)
api_router.include_router(ai_chat.router)

View File

@@ -0,0 +1,474 @@
"""AI Chat Builder service.
Manages the conversational flow builder: system prompt construction,
message exchange with AI provider, and response parsing (extracting
tree updates, phase transitions, and metadata from structured markers).
"""
import json
import logging
import re
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any, Optional
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.config import settings
from app.models.ai_chat_session import AIChatSession
logger = logging.getLogger(__name__)
# ── Cost estimation ──
COST_PER_INPUT_TOKEN = 1.0 / 1_000_000
COST_PER_OUTPUT_TOKEN = 5.0 / 1_000_000
# ── Max messages per session ──
MAX_MESSAGES_FREE = 10
MAX_MESSAGES_PAID = 25
# ── System Prompt ──
ROLE_PERSONA = """You are a senior IT engineer embedded in ResolutionFlow, a troubleshooting platform for MSP (Managed Service Provider) engineers. You have 15+ years of hands-on experience across Windows Server, Active Directory, Entra ID/Azure AD, Microsoft 365, networking (DNS, DHCP, routing, VPN, firewalls), virtualization (Hyper-V, VMware), security, backup/DR, and cloud infrastructure.
Your job is to help engineers build troubleshooting decision trees by interviewing them about a problem space. You are NOT a generic assistant. You are a colleague who has seen these issues hundreds of times and knows the optimal diagnostic order.
CRITICAL BEHAVIORS:
- Act as a senior engineer, not a chatbot. Use your domain knowledge to SUGGEST diagnostic steps, not just record what the user says.
- When the user describes a problem area, demonstrate understanding by naming specific sub-categories, common causes, and relevant tools.
- Challenge assumptions constructively: "Before we go down that path, have you considered checking X first? In my experience, that resolves 60% of these cases."
- Capture SPECIFIC commands with exact syntax. Not "check the service" but "Get-Service ADSync | Select-Object Status, StartType".
- 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.
- Use plain, collegial language. Sound like a colleague, not a form."""
SCHEMA_CONTEXT = """
TREESTRUCTURE SCHEMA — This is what you are building:
The tree is a recursive JSON structure. Each node has a "type" field:
1. decision — A diagnostic question with branching options
Required: id (string), type ("decision"), question (string), options (array), children (array)
Optional: help_text (string)
Each option: { id (string), label (string), next_node_id (string — must match a child's id) }
2. action — A step the engineer performs
Required: id (string), type ("action"), title (string), description (string)
Optional: commands (string array — exact CLI/PowerShell syntax), expected_outcome (string), help_text (string), next_node_id (string — ID of the next node to navigate to)
3. solution — A resolution endpoint
Required: id (string), type ("solution"), title (string), description (string)
Optional: resolution_steps (string array)
STRUCTURAL RULES:
- Root node MUST be type "decision"
- Decision nodes contain their children in the "children" array
- Each decision option's next_node_id typically references a child node's id, BUT can also reference ANY other node in the tree for loop-back / re-verification patterns
- Action nodes use next_node_id to chain to the next step — this can point to any node in the tree, including ancestors, for loop-backs (e.g., "remediate → re-verify from earlier checkpoint")
- Solution nodes are terminal — no next_node_id or children
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
CROSS-REFERENCE / LOOP-BACK PATTERN:
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID. Example: an action node "restart-ssh-service" can set next_node_id to "verify-ssh-connection" (an ancestor decision node) to create a re-verification loop.
"""
INTERVIEW_PROTOCOL = """
INTERVIEW PHASES — Follow this progression:
PHASE 1 - SCOPING (current_phase: scoping):
Ask broad questions to understand the problem domain and scope:
- What type of issue is this flow for?
- Who is the target audience? (Tier 1 help desk, Tier 2, Tier 3?)
- What environment assumptions? (On-prem, hybrid, specific vendors?)
Demonstrate domain expertise immediately. If the user says "Azure AD Sync failures," show understanding: "Are you primarily seeing password hash sync issues, object attribute sync failures, or full directory sync errors?"
DO NOT emit [TREE_UPDATE] during scoping. You are still understanding the problem.
PHASE 2 - DISCOVERY (current_phase: discovery):
Work through the troubleshooting logic branch by branch:
- Establish the first diagnostic question (the root decision node)
- For each branch, ask what the engineer would check next
- Suggest checks the user might not have considered
- Capture specific commands, tools, and procedures
EMIT [TREE_UPDATE] ONLY when you and the user have agreed on a concrete node — a decision with clear options, or an action with a specific command. If you are asking a question, you are NOT updating the tree.
PHASE 3 - ENRICHMENT (current_phase: enrichment):
Circle back to enrich existing nodes:
- Add exact PowerShell/CLI commands with syntax
- Add help text with relevant documentation links
- Add expected outcomes for action nodes
- Suggest edge cases needing additional branches
EMIT [TREE_UPDATE] when enriching existing nodes or adding edge case branches.
PHASE 4 - REVIEW (current_phase: review):
Present a summary:
- Total node count by type
- Text outline of the flow structure
- Flag any areas of uncertainty
- Offer chance to add/remove/modify branches
EMIT [TREE_UPDATE] only if the user requests structural 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.
"""
RESPONSE_FORMAT = """
RESPONSE FORMAT:
Your response is natural conversational text. When the tree structure changes, include structured markers that will be parsed by the system (the user will NOT see these markers):
1. Tree update (only when structure changes — see phase rules above):
[TREE_UPDATE]
{...valid TreeStructure JSON...}
[/TREE_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]
IMPORTANT:
- Include [TREE_UPDATE] sparingly. Only when concrete nodes are established or modified.
- The tree update should be the COMPLETE working tree, not a diff.
- Always include conversational text OUTSIDE the markers — never respond with only markers.
"""
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}"
def _strip_markdown_fences(text: str) -> str:
"""Strip markdown code fences if the model wrapped its JSON response."""
text = text.strip()
match = re.match(r"^```(?:json)?\s*([\s\S]*?)```$", text)
if match:
return match.group(1).strip()
return text
def _parse_ai_response(raw_response: str) -> dict[str, Any]:
"""Parse structured markers from AI response.
Returns dict with:
- content: str (conversational text with markers stripped)
- tree_update: dict | None (parsed TreeStructure JSON)
- phase: str | None (new phase name)
- metadata: dict | None (name, description, tags)
"""
result: dict[str, Any] = {
"content": raw_response,
"tree_update": None,
"phase": None,
"metadata": None,
}
# Extract [TREE_UPDATE]...[/TREE_UPDATE]
tree_match = re.search(
r"\[TREE_UPDATE\]\s*([\s\S]*?)\s*\[/TREE_UPDATE\]", raw_response
)
if tree_match:
try:
raw_json = _strip_markdown_fences(tree_match.group(1))
result["tree_update"] = json.loads(raw_json)
except (json.JSONDecodeError, ValueError) as e:
logger.warning("Failed to parse tree update JSON: %s", e)
result["content"] = raw_response[: tree_match.start()] + raw_response[tree_match.end() :]
else:
# Handle truncated response — opening tag exists but no closing tag
# (happens when max_tokens cuts off the JSON block)
truncated_match = re.search(r"\[TREE_UPDATE\][\s\S]*$", raw_response)
if truncated_match:
logger.warning("Truncated [TREE_UPDATE] block detected (no closing tag) — stripping from display")
result["content"] = raw_response[: truncated_match.start()]
# Extract [PHASE:name]
phase_match = re.search(r"\[PHASE:(\w+)\]", result["content"])
if phase_match:
result["phase"] = phase_match.group(1)
result["content"] = result["content"][: phase_match.start()] + result["content"][phase_match.end() :]
# Extract [METADATA]...[/METADATA]
meta_match = re.search(
r"\[METADATA\]\s*([\s\S]*?)\s*\[/METADATA\]", result["content"]
)
if meta_match:
try:
raw_json = _strip_markdown_fences(meta_match.group(1))
result["metadata"] = json.loads(raw_json)
except (json.JSONDecodeError, ValueError) as e:
logger.warning("Failed to parse metadata JSON: %s", e)
result["content"] = result["content"][: meta_match.start()] + result["content"][meta_match.end() :]
else:
truncated_meta = re.search(r"\[METADATA\][\s\S]*$", result["content"])
if truncated_meta:
logger.warning("Truncated [METADATA] block detected — stripping from display")
result["content"] = result["content"][: truncated_meta.start()]
# Clean up extra whitespace from marker removal
result["content"] = re.sub(r"\n{3,}", "\n\n", result["content"]).strip()
return result
# ── Main Service Functions ──
async def start_chat_session(
flow_type: str,
user_id: uuid.UUID,
account_id: uuid.UUID,
db: AsyncSession,
) -> tuple[AIChatSession, str]:
"""Create a chat session and return the AI's opening greeting.
Returns (session, greeting_text).
"""
session = AIChatSession(
user_id=user_id,
account_id=account_id,
flow_type=flow_type,
expires_at=datetime.now(timezone.utc) + timedelta(hours=settings.AI_CONVERSATION_TTL_HOURS),
)
db.add(session)
await db.flush()
# Build system prompt and get opening message
system_prompt = _build_system_prompt(flow_type)
primer = f"I want to build a {flow_type} flow. Help me get started."
provider = get_ai_provider()
provider_name = settings.AI_PROVIDER
messages = [{"role": "user", "content": primer}]
response_text, input_tokens, output_tokens = await provider.generate_text(
system_prompt=system_prompt,
messages=messages,
max_tokens=1500,
)
# Parse response (greeting shouldn't have tree updates, but handle gracefully)
parsed = _parse_ai_response(response_text)
# Store conversation history
now_iso = datetime.now(timezone.utc).isoformat()
session.conversation_history = [
{"role": "user", "content": primer, "timestamp": now_iso, "hidden": True},
{"role": "assistant", "content": parsed["content"], "timestamp": now_iso},
]
session.provider_used = provider_name
session.message_count = 1
session.total_input_tokens = input_tokens
session.total_output_tokens = output_tokens
if parsed["metadata"]:
session.tree_metadata = parsed["metadata"]
return session, parsed["content"]
async def send_message(
session: AIChatSession,
user_message: str,
db: AsyncSession,
) -> tuple[str, Optional[dict], Optional[str], Optional[dict]]:
"""Send a user message and get AI response.
Returns (ai_content, working_tree_update, new_phase, metadata_update).
"""
system_prompt = _build_system_prompt(session.flow_type)
# Build messages array from conversation history
now_iso = datetime.now(timezone.utc).isoformat()
history = list(session.conversation_history)
history.append({"role": "user", "content": user_message, "timestamp": now_iso})
# Convert to provider format (just role + content)
provider_messages = [
{"role": msg["role"], "content": msg["content"]}
for msg in history
]
provider = get_ai_provider()
response_text, input_tokens, output_tokens = await provider.generate_text(
system_prompt=system_prompt,
messages=provider_messages,
max_tokens=8000,
)
parsed = _parse_ai_response(response_text)
# Validate tree update if present (lightweight check for progressive builds —
# 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
# Update session state
history.append({"role": "assistant", "content": parsed["content"], "timestamp": now_iso})
session.conversation_history = history
session.message_count = session.message_count + 1
session.total_input_tokens = session.total_input_tokens + input_tokens
session.total_output_tokens = session.total_output_tokens + output_tokens
if tree_update:
session.working_tree = tree_update
if parsed["phase"]:
valid_phases = {"scoping", "discovery", "enrichment", "review", "generation"}
if parsed["phase"] in valid_phases:
session.current_phase = parsed["phase"]
if parsed["metadata"]:
merged = dict(session.tree_metadata)
merged.update(parsed["metadata"])
session.tree_metadata = merged
session.updated_at = datetime.now(timezone.utc)
return parsed["content"], tree_update, parsed["phase"], parsed["metadata"]
async def generate_final_tree(
session: AIChatSession,
db: AsyncSession,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Generate the final validated TreeStructure from the conversation.
Returns (tree_structure, metadata).
Raises ValueError if generation fails after retry.
"""
system_prompt = _build_system_prompt(session.flow_type)
# Build generation prompt from full conversation
provider_messages = [
{"role": msg["role"], "content": msg["content"]}
for msg in session.conversation_history
]
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
- Use descriptive node IDs (slugs, not UUIDs)
- Root node must be type "decision"
- Every decision option must have a valid next_node_id pointing to a child
- Every action node should have commands with exact syntax where discussed
- Every action node should have expected_outcome where discussed
- Solution nodes should have resolution_steps
- Respond with ONLY the JSON — no conversational text, no markdown fences
Also provide metadata as a separate JSON object after the tree:
[METADATA]
{"name": "...", "description": "...", "tags": ["..."]}
[/METADATA]"""
provider_messages.append({"role": "user", "content": generation_instruction})
provider = get_ai_provider()
for attempt in range(2): # One try + one retry
response_text, input_tokens, output_tokens = await provider.generate_text(
system_prompt=system_prompt,
messages=provider_messages,
max_tokens=8000,
)
session.total_input_tokens = session.total_input_tokens + input_tokens
session.total_output_tokens = session.total_output_tokens + output_tokens
# Extract metadata first
parsed = _parse_ai_response(response_text)
metadata = parsed["metadata"] or dict(session.tree_metadata)
# Parse tree JSON — could be in tree_update marker or raw
tree = parsed["tree_update"]
if not tree:
try:
raw = _strip_markdown_fences(parsed["content"])
tree = json.loads(raw)
except (json.JSONDecodeError, ValueError):
pass
if not tree:
if attempt == 0:
provider_messages.append({"role": "assistant", "content": response_text})
provider_messages.append({
"role": "user",
"content": "That response was not valid JSON. Please respond with ONLY the TreeStructure JSON object, starting with { and ending with }. No markdown fences, no explanatory text.",
})
continue
raise ValueError("AI failed to produce valid JSON after retry")
errors = validate_generated_tree(tree)
if errors:
if attempt == 0:
provider_messages.append({"role": "assistant", "content": response_text})
correction = (
f"The tree has validation errors: {'; '.join(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)}")
# Success
session.working_tree = tree
session.tree_metadata = metadata
session.current_phase = "generation"
session.updated_at = datetime.now(timezone.utc)
return tree, metadata
raise ValueError("AI failed to generate a valid tree")
async def get_chat_session(
session_id: uuid.UUID,
user_id: uuid.UUID,
db: AsyncSession,
) -> AIChatSession:
"""Get a chat session, validating ownership and expiry.
Raises HTTPException on not found, forbidden, or expired.
"""
from fastapi import HTTPException, status
result = await db.execute(
select(AIChatSession).where(AIChatSession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Chat session not found")
if session.user_id != user_id:
raise HTTPException(status_code=403, detail="Access denied")
if session.expires_at < datetime.now(timezone.utc):
session.status = "abandoned"
await db.flush()
raise HTTPException(status_code=410, detail="Chat session has expired")
return session

View File

@@ -35,6 +35,25 @@ class AIProvider(ABC):
"""
...
@abstractmethod
async def generate_text(
self,
system_prompt: str,
messages: list[dict[str, str]],
max_tokens: int = 4096,
) -> tuple[str, int, int]:
"""Generate a text response from the AI model (no JSON constraint).
Args:
system_prompt: System-level instruction for the model.
messages: List of message dicts with "role" and "content" keys.
max_tokens: Maximum output tokens.
Returns:
Tuple of (response_text, input_tokens, output_tokens).
"""
...
class GeminiProvider(AIProvider):
"""Google Gemini provider using the google-genai SDK."""
@@ -95,6 +114,56 @@ class GeminiProvider(AIProvider):
return text, input_tokens, output_tokens
async def generate_text(
self,
system_prompt: str,
messages: list[dict[str, str]],
max_tokens: int = 4096,
) -> tuple[str, int, int]:
from google import genai
from google.genai import types as genai_types
client = genai.Client(api_key=self._api_key)
contents: list[genai_types.Content] = []
for msg in messages:
role = "model" if msg["role"] == "assistant" else "user"
contents.append(
genai_types.Content(
role=role,
parts=[genai_types.Part(text=msg["content"])],
)
)
config = genai_types.GenerateContentConfig(
system_instruction=system_prompt,
max_output_tokens=max_tokens,
# No response_mime_type — allow free-form text
)
response = await client.aio.models.generate_content(
model=self._model,
contents=contents,
config=config,
)
if response.candidates:
finish_reason = getattr(response.candidates[0], "finish_reason", None)
logger.info("Gemini finish_reason=%s model=%s", finish_reason, self._model)
if str(finish_reason) == "MAX_TOKENS":
logger.warning(
"Gemini output truncated (MAX_TOKENS). max_output_tokens=%d",
max_tokens,
)
text = response.text or ""
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0) or 0
output_tokens = (
getattr(response.usage_metadata, "candidates_token_count", 0) or 0
)
return text, input_tokens, output_tokens
class AnthropicProvider(AIProvider):
"""Anthropic Claude provider using the anthropic SDK."""
@@ -130,6 +199,15 @@ class AnthropicProvider(AIProvider):
return text, input_tokens, output_tokens
async def generate_text(
self,
system_prompt: str,
messages: list[dict[str, str]],
max_tokens: int = 4096,
) -> tuple[str, int, int]:
# Anthropic doesn't differentiate between JSON and text mode
return await self.generate_json(system_prompt, messages, max_tokens)
def get_ai_provider() -> AIProvider:
"""Factory that returns the configured AI provider.

View File

@@ -115,7 +115,7 @@ async def check_ai_quota(
select(func.count(AIUsage.id)).where(
AIUsage.user_id == user_id,
AIUsage.succeeded == True, # noqa: E712
AIUsage.generation_type.in_(["scaffold", "branch_detail"]),
AIUsage.generation_type.in_(["scaffold", "branch_detail", "chat_message", "chat_generate"]),
AIUsage.created_at >= day_start,
)
) or 0

View File

@@ -40,7 +40,7 @@ def validate_generated_tree(tree: dict[str, Any]) -> list[str]:
# Collect all node IDs and validate structure
all_ids: set[str] = set()
all_referenced_ids: set[str] = set() # option next_node_ids (already checked locally)
all_referenced_ids: set[str] = set() # option next_node_ids (checked globally below)
action_next_ids: set[str] = set() # action next_node_ids (checked globally below)
node_count = 0
solution_count = 0
@@ -111,11 +111,6 @@ def validate_generated_tree(tree: dict[str, Any]) -> list[str]:
next_id = opt.get("next_node_id")
if next_id:
all_referenced_ids.add(next_id)
if child_ids and next_id not in child_ids:
errors.append(
f"Option '{opt.get('label', '?')}' in node '{node_id}' "
f"references non-existent child '{next_id}'"
)
elif node_type == "action":
next_id = node.get("next_node_id")
@@ -144,6 +139,13 @@ def validate_generated_tree(tree: dict[str, Any]) -> list[str]:
f"Action next_node_id '{ref_id}' references a node that does not exist in the tree"
)
# Check that all option next_node_ids exist in the tree (allows cross-references)
for ref_id in all_referenced_ids - action_next_ids:
if ref_id not in all_ids:
errors.append(
f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
)
# Global checks
if node_count < 5:
errors.append(

View File

@@ -28,6 +28,7 @@ from .maintenance_schedule import MaintenanceSchedule
from .feedback import Feedback
from .ai_conversation import AIConversation
from .ai_usage import AIUsage
from .ai_chat_session import AIChatSession
__all__ = [
"User",
@@ -67,4 +68,5 @@ __all__ = [
"Feedback",
"AIConversation",
"AIUsage",
"AIChatSession",
]

View File

@@ -0,0 +1,88 @@
"""AI Chat Builder session tracking.
Stores conversational flow builder state across the multi-phase interview.
Sessions expire after 24 hours.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, Any
from sqlalchemy import String, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
class AIChatSession(Base):
__tablename__ = "ai_chat_sessions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="active",
comment="active | completed | abandoned",
)
current_phase: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="scoping",
comment="scoping | discovery | enrichment | review | generation",
)
flow_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="troubleshooting | procedural",
)
conversation_history: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list
)
working_tree: Mapped[Optional[dict[str, Any]]] = mapped_column(
JSONB, nullable=True
)
tree_metadata: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, default=dict
)
provider_used: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True
)
message_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_input_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
total_output_tokens: Mapped[int] = mapped_column(
Integer, nullable=False, default=0
)
generated_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="SET NULL"),
nullable=True,
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,80 @@
"""Pydantic schemas for the AI Chat Builder."""
from typing import Any, Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
# ── Requests ──
class AIChatStartRequest(BaseModel):
"""Start a new chat builder session."""
flow_type: Literal["troubleshooting", "procedural"] = Field(
..., description="Type of flow to build"
)
class AIChatMessageRequest(BaseModel):
"""Send a user message in a chat session."""
content: str = Field(..., min_length=1, max_length=5000)
class AIChatImportRequest(BaseModel):
"""Import generated tree with optional metadata overrides."""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=2000)
category_id: Optional[UUID] = None
tags: list[str] = Field(default_factory=list)
# ── Responses ──
class AIChatStartResponse(BaseModel):
"""Response after creating a chat session."""
session_id: UUID
greeting: str
current_phase: str
class AIChatMessageResponse(BaseModel):
"""Response after sending a message."""
content: str
current_phase: str
working_tree: Optional[dict[str, Any]] = None
tree_metadata: Optional[dict[str, Any]] = None
class AIChatSessionResponse(BaseModel):
"""Full session state for resume."""
session_id: UUID
status: str
current_phase: str
flow_type: str
conversation_history: list[dict[str, Any]]
working_tree: Optional[dict[str, Any]] = None
tree_metadata: Optional[dict[str, Any]] = None
message_count: int
generated_tree: Optional[dict[str, Any]] = None
class AIChatGenerateResponse(BaseModel):
"""Response with the final generated tree."""
tree_structure: dict[str, Any]
tree_metadata: dict[str, Any]
status: str
class AIChatImportResponse(BaseModel):
"""Response after importing tree to editor."""
tree_id: UUID
tree_type: str

View File

@@ -0,0 +1,187 @@
"""Integration tests for AI Chat Builder endpoints.
These tests mock the AI provider to avoid real API calls.
"""
import pytest
from unittest.mock import AsyncMock, patch
pytestmark = pytest.mark.asyncio
@pytest.fixture
def mock_ai_provider():
"""Mock AI provider that returns realistic responses."""
provider = AsyncMock()
provider.generate_text = AsyncMock(return_value=(
"Great question! Let's build a troubleshooting flow for DNS resolution issues. "
"To start, I need to understand the scope.\n\n"
"Who is the target audience for this flow? Are we targeting:\n"
"- Tier 1 help desk (basic checks only)\n"
"- Tier 2 desktop support (intermediate diagnostics)\n"
"- Tier 3 systems engineers (deep DNS troubleshooting)\n\n"
"[PHASE:scoping]",
500, # input tokens
200, # output tokens
))
return provider
async def test_create_chat_session(client, auth_headers, mock_ai_provider):
"""POST /ai/chat/sessions creates a session and returns AI greeting."""
with patch("app.core.ai_chat_service.get_ai_provider", return_value=mock_ai_provider):
resp = await client.post(
"/api/v1/ai/chat/sessions",
json={"flow_type": "troubleshooting"},
headers=auth_headers,
)
assert resp.status_code == 201
data = resp.json()
assert "session_id" in data
assert "greeting" in data
assert data["current_phase"] == "scoping"
assert len(data["greeting"]) > 0
async def test_send_message(client, auth_headers, mock_ai_provider):
"""POST /ai/chat/sessions/{id}/messages returns AI response."""
# Create session first
with patch("app.core.ai_chat_service.get_ai_provider", return_value=mock_ai_provider):
create_resp = await client.post(
"/api/v1/ai/chat/sessions",
json={"flow_type": "troubleshooting"},
headers=auth_headers,
)
session_id = create_resp.json()["session_id"]
# Mock response with tree update — must pass validate_generated_tree (min 5 nodes)
import json
tree_obj = {
"id": "root", "type": "decision",
"question": "What DNS symptom is the user experiencing?",
"options": [
{"id": "opt-1", "label": "Cannot resolve any domains", "next_node_id": "dns-check"},
{"id": "opt-2", "label": "Intermittent failures", "next_node_id": "dns-cache-fix"},
],
"children": [
{
"id": "dns-check", "type": "decision",
"question": "Is the DNS Client service running?",
"options": [
{"id": "dc-1", "label": "Yes", "next_node_id": "dns-fwd-fix"},
{"id": "dc-2", "label": "No", "next_node_id": "dns-svc-fix"},
],
"children": [
{"id": "dns-fwd-fix", "type": "solution", "title": "Check DNS Forwarders",
"description": "DNS forwarders may be misconfigured",
"resolution_steps": ["Check forwarder config"]},
{"id": "dns-svc-fix", "type": "solution", "title": "Restart DNS Service",
"description": "DNS Client service is stopped",
"resolution_steps": ["Start-Service Dnscache"]},
],
},
{"id": "dns-cache-fix", "type": "solution", "title": "Stale DNS Cache",
"description": "DNS cache has stale entries",
"resolution_steps": ["ipconfig /flushdns"]},
],
}
tree_json = json.dumps(tree_obj)
mock_ai_provider.generate_text = AsyncMock(return_value=(
"Good, targeting Tier 2 support. Let's start with the first diagnostic question.\n\n"
"The root question should be: 'What DNS symptom is the user experiencing?'\n\n"
f"[TREE_UPDATE]\n{tree_json}\n[/TREE_UPDATE]\n\n"
"[PHASE:discovery]",
800,
400,
))
with patch("app.core.ai_chat_service.get_ai_provider", return_value=mock_ai_provider):
resp = await client.post(
f"/api/v1/ai/chat/sessions/{session_id}/messages",
json={"content": "This is for Tier 2 support, hybrid environment with on-prem AD."},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert "content" in data
assert data["current_phase"] == "discovery"
assert data["working_tree"] is not None
assert data["working_tree"]["type"] == "decision"
# Markers should be stripped from content
assert "[TREE_UPDATE]" not in data["content"]
assert "[PHASE:" not in data["content"]
async def test_get_session(client, auth_headers, mock_ai_provider):
"""GET /ai/chat/sessions/{id} returns full session state."""
with patch("app.core.ai_chat_service.get_ai_provider", return_value=mock_ai_provider):
create_resp = await client.post(
"/api/v1/ai/chat/sessions",
json={"flow_type": "troubleshooting"},
headers=auth_headers,
)
session_id = create_resp.json()["session_id"]
resp = await client.get(
f"/api/v1/ai/chat/sessions/{session_id}",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["session_id"] == session_id
assert data["status"] == "active"
assert data["flow_type"] == "troubleshooting"
# Hidden primer message should be filtered out
assert all(
msg.get("role") == "assistant" or not msg.get("hidden")
for msg in data["conversation_history"]
)
async def test_abandon_session(client, auth_headers, mock_ai_provider):
"""DELETE /ai/chat/sessions/{id} sets status to abandoned."""
with patch("app.core.ai_chat_service.get_ai_provider", return_value=mock_ai_provider):
create_resp = await client.post(
"/api/v1/ai/chat/sessions",
json={"flow_type": "troubleshooting"},
headers=auth_headers,
)
session_id = create_resp.json()["session_id"]
resp = await client.delete(
f"/api/v1/ai/chat/sessions/{session_id}",
headers=auth_headers,
)
assert resp.status_code == 204
# Verify session is abandoned
get_resp = await client.get(
f"/api/v1/ai/chat/sessions/{session_id}",
headers=auth_headers,
)
assert get_resp.json()["status"] == "abandoned"
async def test_session_not_found(client, auth_headers):
"""Accessing nonexistent session returns 404."""
import uuid
fake_id = str(uuid.uuid4())
resp = await client.get(
f"/api/v1/ai/chat/sessions/{fake_id}",
headers=auth_headers,
)
assert resp.status_code == 404
async def test_ai_disabled_returns_503(client, auth_headers):
"""When AI is not configured, endpoints return 503."""
with patch("app.api.endpoints.ai_chat.settings") as mock_settings:
mock_settings.ai_enabled = False
resp = await client.post(
"/api/v1/ai/chat/sessions",
json={"flow_type": "troubleshooting"},
headers=auth_headers,
)
assert resp.status_code == 503

View File

@@ -122,7 +122,7 @@ class TestReferenceIntegrity:
tree = _make_valid_tree()
tree["options"][0]["next_node_id"] = "nonexistent"
errors = validate_generated_tree(tree)
assert any("non-existent child" in e for e in errors)
assert any("does not exist" in e for e in errors)
def test_action_next_node_id_references_nonexistent_node(self):
"""Action next_node_id pointing to a node that doesn't exist anywhere in the tree."""
@@ -188,6 +188,31 @@ class TestDeadEndDetection:
assert any("dead end" in e for e in errors)
class TestCrossReferenceSupport:
def test_option_referencing_non_child_node_in_tree_is_valid(self):
"""A decision option can reference any node in the tree, not just direct children."""
tree = _make_valid_tree()
# Make root option point to a grandchild (not a direct child) — cross-reference
tree["options"][0]["next_node_id"] = "fix-errors" # grandchild of root
errors = validate_generated_tree(tree)
assert not any("non-existent child" in e for e in errors)
assert not any("does not exist" in e for e in errors)
def test_option_referencing_nonexistent_node_still_fails(self):
"""Cross-references must still point to nodes that exist in the tree."""
tree = _make_valid_tree()
tree["options"][0]["next_node_id"] = "totally-fake-id"
errors = validate_generated_tree(tree)
assert any("does not exist" in e for e in errors)
def test_action_next_node_id_to_ancestor_is_valid(self):
"""Action node can loop back to an ancestor node."""
tree = _make_valid_tree()
tree["children"][1]["next_node_id"] = "root"
errors = validate_generated_tree(tree)
assert not any("does not exist" in e for e in errors)
class TestCountTreeStats:
def test_stats_correct(self):
tree = _make_valid_tree()

View File

@@ -0,0 +1,80 @@
# Cross-Reference / Loop-Back Support — Design
**Goal:** Allow tree nodes to reference any other node in the tree (not just direct children), enabling loop-back patterns like "remediate → re-verify from earlier checkpoint."
**Architecture:** Ghost references on existing tree structure. No schema change, no migration. A cross-reference is any `next_node_id` that points outside the current node's `children` array. The canvas renders these as dashed SVG overlay arrows. Navigation already supports this.
**Approach chosen:** Approach 1 — "Ghost references" (keep tree structure, add visual cross-ref edges)
---
## 1. Data Model — No Changes
The `TreeStructure` type and database stay as-is. The distinction is semantic:
- **Local link:** `next_node_id` → direct child → normal tree edge
- **Cross-reference:** `next_node_id` → node elsewhere in tree → dashed overlay arrow
No new fields, no new node types, no migration.
## 2. Validation Changes
### Backend (`ai_tree_validator.py`)
- Relax decision option validation: `option.next_node_id` can reference any node in the tree (not just children). Check existence only, same as action nodes.
### Frontend circular reference detector (`treeEditorStore.ts`)
- Change loop detection from **error** to **warning**. Loops are now intentional. Warning text: "This path loops back to [node title]."
### Frontend orphan detection
- Keep as-is. Orphaned nodes still flagged as warnings.
## 3. Canvas Rendering — Cross-Reference Edges
- **SVG overlay** layer on top of the canvas (absolute positioned)
- **Dashed line** with **arrowhead** pointing at target node
- **Purple/primary color** to distinguish from normal gray tree connectors
- Small label on the arrow (option label or "loops back")
- After dagre layout, scan all nodes for `next_node_id` values not matching a direct child
- Look up source/target positions from layout, draw curved SVG bezier path
- Target node gets a subtle badge/indicator for inbound cross-references
- Hovering the badge highlights source nodes
## 4. Editor UX — Creating Cross-References
### A. Node picker dropdown (in node form)
- Action nodes and decision option rows get "Link to existing node" dropdown
- Lists all nodes by title/question, grouped by type
- Selecting sets `next_node_id`; orphaned answer stubs cleaned up
- "Clear link" option to remove
### B. Canvas drag-to-link
- Small output port (dot) at bottom of each node
- Drag from port starts a dashed line following cursor
- Drop on any node creates cross-reference
- Drop on empty space cancels
- Existing answer stubs cleaned up if replaced
### Visual feedback
- Node form: "Linked to: [node title]" with navigate + remove actions
- Canvas: dashed arrow (Section 3)
## 5. AI Flow Assist — Prompt Changes
- Update system prompt STRUCTURAL RULES: "Action nodes can set `next_node_id` to any node in the tree, including ancestors, for loop-backs."
- Add SSH loop example to schema context
- No changes to generation or progressive validation
## 6. Navigation — No Changes
`findNode` already searches the full tree. `handleSelectOption` and `handleContinue` follow `next_node_id` without hierarchy checks. Session `pathTaken` will contain repeated IDs for loops — this is correct behavior.
## 7. Testing
- Backend: extend validator tests for cross-references
- Frontend: `npm run build` after each piece, manual testing of editor + navigation loops

View File

@@ -0,0 +1,656 @@
# Cross-Reference / Loop-Back Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Enable tree nodes to reference any other node in the tree (not just direct children), supporting loop-back patterns like "remediate → re-verify from earlier checkpoint."
**Architecture:** Ghost references on existing tree structure. No schema change, no migration. A cross-reference is any `next_node_id` that points outside the current node's `children` array. The canvas renders these as dashed overlay arrows. Navigation already supports this pattern.
**Tech Stack:** Python FastAPI (backend validation), React + @xyflow/react (canvas rendering), Zustand (store validation), TypeScript
**Design Doc:** `docs/plans/2026-02-28-cross-reference-loopback-design.md`
---
## Task 1: Backend — Relax Decision Option Validation
Allow decision option `next_node_id` to reference ANY node in the tree, not just direct children.
**Files:**
- Modify: `backend/app/core/ai_tree_validator.py:111-118`
- Test: `backend/tests/test_ai_tree_validator.py`
**Step 1: Write the failing test — cross-reference option passes validation**
Add a new test class `TestCrossReferenceSupport` at the bottom of `test_ai_tree_validator.py`:
```python
class TestCrossReferenceSupport:
def test_option_referencing_non_child_node_in_tree_is_valid(self):
"""A decision option can reference any node in the tree, not just direct children."""
tree = _make_valid_tree()
# Make root option point to a grandchild (not a direct child) — cross-reference
tree["options"][0]["next_node_id"] = "fix-errors" # grandchild of root
errors = validate_generated_tree(tree)
# Should NOT have the "non-existent child" error for this reference
assert not any("non-existent child" in e for e in errors)
def test_option_referencing_nonexistent_node_still_fails(self):
"""Cross-references must still point to nodes that exist in the tree."""
tree = _make_valid_tree()
tree["options"][0]["next_node_id"] = "totally-fake-id"
errors = validate_generated_tree(tree)
assert any("does not exist" in e for e in errors)
def test_action_next_node_id_to_ancestor_is_valid(self):
"""Action node can loop back to an ancestor node (the whole point of cross-refs)."""
tree = _make_valid_tree()
# Make the action node loop back to root
tree["children"][1]["next_node_id"] = "root"
errors = validate_generated_tree(tree)
assert not any("does not exist" in e for e in errors)
```
**Step 2: Run the tests to verify they fail**
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py::TestCrossReferenceSupport -v`
Expected: `test_option_referencing_non_child_node_in_tree_is_valid` FAILS (currently raises "non-existent child" error). The other two should already pass.
**Step 3: Update validator — check global existence, not just children**
In `backend/app/core/ai_tree_validator.py`, replace lines 111-118 (the decision option next_node_id check):
Old code (lines 111-118):
```python
next_id = opt.get("next_node_id")
if next_id:
all_referenced_ids.add(next_id)
if child_ids and next_id not in child_ids:
errors.append(
f"Option '{opt.get('label', '?')}' in node '{node_id}' "
f"references non-existent child '{next_id}'"
)
```
New code:
```python
next_id = opt.get("next_node_id")
if next_id:
all_referenced_ids.add(next_id)
```
Then add a new global check after line 145 (after the action next_node_id existence check). This checks ALL option references exist anywhere in the tree:
After the existing `for ref_id in action_next_ids:` block, add:
```python
# Check that all option next_node_ids exist in the tree (allows cross-references)
for ref_id in all_referenced_ids:
if ref_id not in all_ids:
errors.append(
f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
)
```
**Step 4: Run all validator tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL tests pass. The old `test_option_references_nonexistent_child` test in `TestReferenceIntegrity` will now fail because the error message changed from "non-existent child" to "does not exist in the tree". Update that test:
In `TestReferenceIntegrity.test_option_references_nonexistent_child`, change:
```python
def test_option_references_nonexistent_child(self):
tree = _make_valid_tree()
tree["options"][0]["next_node_id"] = "nonexistent"
errors = validate_generated_tree(tree)
assert any("does not exist" in e for e in errors)
```
Run again: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL PASS
**Step 5: Commit**
```bash
git add backend/app/core/ai_tree_validator.py backend/tests/test_ai_tree_validator.py
git commit -m "feat: relax decision option validation — allow cross-references to any node in tree"
```
---
## Task 2: Frontend — Change Circular Reference Detection From Error to Warning
Loop-backs are now intentional. The circular reference detector should warn instead of error.
**Files:**
- Modify: `frontend/src/store/treeEditorStore.ts:791-824`
**Step 1: Update severity from 'error' to 'warning' and improve messages**
In `frontend/src/store/treeEditorStore.ts`, find the `detectCircularRefs` function (lines 792-824). Change both `severity: 'error'` to `severity: 'warning'` and update messages:
Replace line 803-807:
```typescript
errors.push({
nodeId: node.id,
message: `Circular reference detected: "${opt.label}" creates a loop`,
severity: 'error'
})
```
With:
```typescript
errors.push({
nodeId: node.id,
message: `This path loops back to an earlier node via "${opt.label}"`,
severity: 'warning'
})
```
Replace lines 815-819:
```typescript
errors.push({
nodeId: node.id,
message: `Circular reference detected in node "${node.title || node.id}"`,
severity: 'error'
})
```
With:
```typescript
errors.push({
nodeId: node.id,
message: `This node loops back to an earlier node ("${node.title || node.id}")`,
severity: 'warning'
})
```
**Step 2: Build to verify**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors.
**Step 3: Commit**
```bash
git add frontend/src/store/treeEditorStore.ts
git commit -m "feat: change circular reference detection from error to warning — loops are intentional"
```
---
## Task 3: Canvas — Render Cross-Reference Edges as Dashed Arrows
Add dashed purple overlay edges for any `next_node_id` pointing outside the current node's children.
**Files:**
- Modify: `frontend/src/components/tree-editor/useTreeLayout.ts`
**Step 1: Add cross-reference edge collection to the `walk` function**
In `useTreeLayout.ts`, inside the `useMemo` callback (line 57), after the `walk(treeStructure, null)` call on line 129, add a second pass to collect cross-reference edges.
Add this helper function before the `useTreeLayout` export (around line 40):
```typescript
/** Collect all node IDs in the tree. */
function collectAllIds(root: TreeStructure): Set<string> {
const ids = new Set<string>()
function walk(node: TreeStructure) {
ids.add(node.id)
node.children?.forEach(walk)
}
walk(root)
return ids
}
/** Find all cross-reference edges (next_node_id pointing outside children). */
function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; target: string; label?: string }> {
const refs: Array<{ source: string; target: string; label?: string }> = []
const allIds = collectAllIds(root)
function walk(node: TreeStructure) {
const childIds = new Set(node.children?.map(c => c.id) ?? [])
// Decision options pointing outside children
if (node.type === 'decision' && node.options) {
for (const opt of node.options) {
if (opt.next_node_id && !childIds.has(opt.next_node_id) && allIds.has(opt.next_node_id)) {
refs.push({ source: node.id, target: opt.next_node_id, label: opt.label })
}
}
}
// Action next_node_id pointing to non-child (always a cross-ref since actions use next_node_id not children)
if (node.type === 'action' && node.next_node_id && allIds.has(node.next_node_id) && !childIds.has(node.next_node_id)) {
refs.push({ source: node.id, target: node.next_node_id, label: 'loops back' })
}
node.children?.forEach(walk)
}
walk(root)
return refs
}
```
**Step 2: Add cross-reference edges to the edges array**
In the `useMemo` callback, after `walk(treeStructure, null)` (line 129) and before the return (line 131), add:
```typescript
// Add cross-reference edges (dashed, purple)
if (treeStructure) {
const crossRefs = collectCrossRefEdges(treeStructure)
for (const ref of crossRefs) {
// Only add if both source and target nodes are visible (not collapsed away)
const sourceVisible = nodes.some(n => n.id === ref.source)
const targetVisible = nodes.some(n => n.id === ref.target)
if (sourceVisible && targetVisible) {
edges.push({
id: `xref-${ref.source}->${ref.target}`,
source: ref.source,
target: ref.target,
type: 'smoothstep',
animated: true,
label: ref.label ? truncateLabel(ref.label) : undefined,
labelStyle: { fill: 'hsl(var(--primary))', fontSize: 10, fontWeight: 500 },
labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.95 },
labelBgPadding: [4, 2] as [number, number],
style: {
stroke: 'hsl(var(--primary))',
strokeWidth: 2,
strokeDasharray: '6 3',
},
markerEnd: {
type: 'arrowclosed' as const,
color: 'hsl(var(--primary))',
width: 16,
height: 16,
},
})
}
}
}
```
**Step 3: Build to verify**
Run: `cd frontend && npm run build`
Expected: Build succeeds. Cross-reference edges render as animated, dashed purple arrows with arrowheads.
**Step 4: Commit**
```bash
git add frontend/src/components/tree-editor/useTreeLayout.ts
git commit -m "feat: render cross-reference edges as dashed purple arrows on canvas"
```
---
## Task 4: Editor UX — Node Picker Dropdown for Action Nodes
Add a "Link to existing node" dropdown to `NodeFormAction.tsx`.
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
- Modify: `frontend/src/store/treeEditorStore.ts` (add helper to collect all nodes)
**Step 1: Add `collectAllNodes` helper to the tree editor store**
In `frontend/src/store/treeEditorStore.ts`, add a standalone exported helper function (near the top of the file, after imports, or as a utility):
Find the `findNodeInTree` helper function. Near it, add:
```typescript
/** Collect all nodes in the tree as a flat list with depth info. */
export function collectAllNodesFlat(
root: TreeStructure | null
): Array<{ id: string; label: string; type: string; depth: number }> {
if (!root) return []
const result: Array<{ id: string; label: string; type: string; depth: number }> = []
function walk(node: TreeStructure, depth: number) {
const label = node.type === 'decision'
? (node.question || 'Untitled Decision')
: (node.title || `Untitled ${node.type}`)
result.push({ id: node.id, label, type: node.type, depth })
node.children?.forEach(child => walk(child, depth + 1))
}
walk(root, 0)
return result
}
```
**Step 2: Update NodeFormAction to include the node picker**
Replace the "Next step hint" section at the bottom of `NodeFormAction.tsx` (lines 161-170) with a full node picker:
```tsx
import { Link2, X } from 'lucide-react'
import { collectAllNodesFlat } from '@/store/treeEditorStore'
```
(Add these to existing imports at top of file)
Replace lines 161-170 (the `{hasNextNode ? ... : ...}` block):
```tsx
{/* Link to existing node */}
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Link2 className="h-3.5 w-3.5" />
Next Step
</label>
{hasNextNode ? (
<div className="mt-1 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2">
<span className="flex-1 truncate text-sm text-foreground">
Linked to: {(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
const target = allNodes.find(n => n.id === node.next_node_id)
return target ? target.label : node.next_node_id
})()}
</span>
<button
type="button"
onClick={() => onUpdate({ next_node_id: undefined })}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Remove link"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<select
value=""
onChange={(e) => {
if (e.target.value) {
onUpdate({ next_node_id: e.target.value })
}
}}
className={cn(
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
'bg-card text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="">Link to existing node...</option>
{(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
return allNodes
.filter(n => n.id !== node.id && n.type !== 'answer')
.map(n => (
<option key={n.id} value={n.id}>
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
</option>
))
})()}
</select>
)}
<p className="mt-1 text-xs text-muted-foreground">
{hasNextNode
? 'This action will navigate to the linked node.'
: 'Select a node to navigate to after this action, or save to create a new placeholder.'}
</p>
</div>
```
**Step 3: Build to verify**
Run: `cd frontend && npm run build`
Expected: Build succeeds.
**Step 4: Commit**
```bash
git add frontend/src/components/tree-editor/NodeFormAction.tsx frontend/src/store/treeEditorStore.ts
git commit -m "feat: add node picker dropdown to action node form for cross-references"
```
---
## Task 5: Editor UX — Node Picker for Decision Option Rows
Add "Link to existing node" capability to each decision option row.
**Files:**
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
**Step 1: Add link icon and dropdown per option row**
Add imports at top of `NodeFormDecision.tsx`:
```tsx
import { Link2 } from 'lucide-react'
import { collectAllNodesFlat } from '@/store/treeEditorStore'
```
In the option render callback (inside `renderItem` around line 161), after the label input and its error message, add a cross-reference link indicator per option. After the closing `</div>` of the `flex-1` wrapper (around line 197), add:
```tsx
{/* Cross-reference link indicator */}
{option.next_node_id && (() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const childIds = new Set(node.children?.map(c => c.id) ?? [])
// Only show if it's a cross-reference (points outside children)
if (childIds.has(option.next_node_id)) return null
const allNodes = collectAllNodesFlat(treeStructure)
const target = allNodes.find(n => n.id === option.next_node_id)
if (!target) return null
return (
<div className="flex items-center gap-1 text-xs text-primary" title={`Links to: ${target.label}`}>
<Link2 className="h-3 w-3" />
</div>
)
})()}
```
**Step 2: Add "Link to node" option below the options list**
After the `DynamicArrayField` closing tag (line 201, before the root tip), add:
```tsx
{/* Quick-link: assign an option to an existing node */}
<details className="mt-2">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
<Link2 className="inline h-3 w-3 mr-1" />
Link an option to an existing node (cross-reference)
</summary>
<div className="mt-2 space-y-2 rounded-md border border-border bg-accent/30 p-3">
<p className="text-xs text-muted-foreground">
Select an option, then pick a target node. This creates a loop-back or cross-reference.
</p>
<div className="flex gap-2">
<select
id="xref-option-select"
className={cn(
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
'bg-card text-foreground'
)}
defaultValue=""
>
<option value="">Select option...</option>
{(node.options || []).map((opt, i) => (
<option key={opt.id} value={i}>
{indexToLetter(i)}: {opt.label || '(empty)'}
</option>
))}
</select>
<select
id="xref-target-select"
className={cn(
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
'bg-card text-foreground'
)}
defaultValue=""
onChange={(e) => {
const optSelect = document.getElementById('xref-option-select') as HTMLSelectElement
const optIndex = parseInt(optSelect?.value, 10)
const targetId = e.target.value
if (!isNaN(optIndex) && targetId) {
handleUpdateOption(optIndex, { next_node_id: targetId })
// Reset selects
optSelect.value = ''
e.target.value = ''
}
}}
>
<option value="">Select target node...</option>
{(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
return allNodes
.filter(n => n.id !== node.id && n.type !== 'answer')
.map(n => (
<option key={n.id} value={n.id}>
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
</option>
))
})()}
</select>
</div>
</div>
</details>
```
**Step 2: Build to verify**
Run: `cd frontend && npm run build`
Expected: Build succeeds.
**Step 3: Commit**
```bash
git add frontend/src/components/tree-editor/NodeFormDecision.tsx
git commit -m "feat: add cross-reference node picker to decision option rows"
```
---
## Task 6: AI System Prompt — Update Structural Rules for Cross-References
Update the AI chat system prompt to allow and encourage loop-back patterns.
**Files:**
- Modify: `backend/app/core/ai_chat_service.py:68-75`
**Step 1: Update STRUCTURAL RULES in SCHEMA_CONTEXT**
Replace the STRUCTURAL RULES section (lines 68-75 of `ai_chat_service.py`):
Old:
```python
STRUCTURAL RULES:
- Root node MUST be type "decision"
- Decision nodes contain their children in the "children" array
- Each decision option's next_node_id must reference a child node's id
- Action nodes use next_node_id to chain to the next step (NOT children)
- Solution nodes are terminal no next_node_id or children
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
```
New:
```python
STRUCTURAL RULES:
- Root node MUST be type "decision"
- Decision nodes contain their children in the "children" array
- Each decision option's next_node_id typically references a child node's id, BUT can also reference ANY other node in the tree for loop-back / re-verification patterns
- Action nodes use next_node_id to chain to the next step this can point to any node in the tree, including ancestors, for loop-backs (e.g., "remediate → re-verify from earlier checkpoint")
- Solution nodes are terminal no next_node_id or children
- All IDs must be unique strings (use descriptive slugs like "check-service-status")
CROSS-REFERENCE / LOOP-BACK PATTERN:
When a troubleshooting path needs to loop back (e.g., after remediation, re-verify from an earlier checkpoint), set next_node_id to the target node's ID. Example: an action node "restart-ssh-service" can set next_node_id to "verify-ssh-connection" (an ancestor decision node) to create a re-verification loop.
```
**Step 2: Build backend to verify syntax**
Run: `cd backend && python -c "from app.core.ai_chat_service import SCHEMA_CONTEXT; print('OK')"`
Expected: Prints "OK" with no import errors.
**Step 3: Commit**
```bash
git add backend/app/core/ai_chat_service.py
git commit -m "feat: update AI system prompt to allow cross-reference loop-back patterns"
```
---
## Task 7: Backend — Update Option Validation Error for `all_referenced_ids`
The `all_referenced_ids` set currently holds only option `next_node_id` values. After Task 1's change, the global existence check also needs to handle the case where `action_next_ids` and `all_referenced_ids` may overlap.
**Files:**
- Modify: `backend/app/core/ai_tree_validator.py`
- Test: `backend/tests/test_ai_tree_validator.py`
**Step 1: Verify no double-counting between action and option refs**
Check: `action_next_ids` are added to `all_referenced_ids` on line 128. After Task 1, we added a global check for `all_referenced_ids`. This means action refs get checked twice — once in the action-specific loop (lines 141-145) and once in the new option loop. We should only check option refs in the new loop.
Update the global check added in Task 1 to exclude action refs:
```python
# Check that all option next_node_ids exist in the tree (allows cross-references)
for ref_id in all_referenced_ids - action_next_ids:
if ref_id not in all_ids:
errors.append(
f"Option next_node_id '{ref_id}' references a node that does not exist in the tree"
)
```
**Step 2: Run all validator tests**
Run: `cd backend && python -m pytest tests/test_ai_tree_validator.py -v`
Expected: ALL PASS
**Step 3: Commit**
```bash
git add backend/app/core/ai_tree_validator.py
git commit -m "fix: prevent double-counting action refs in global option cross-reference check"
```
---
## Task 8: Full Integration Test
Run the full backend test suite and frontend build to verify nothing is broken.
**Files:** (none — testing only)
**Step 1: Run backend tests**
Run: `cd backend && python -m pytest --override-ini="addopts=" -v`
Expected: ALL PASS
**Step 2: Run frontend build**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors.
**Step 3: Manual smoke test**
1. Start backend: `cd backend && uvicorn app.main:app --reload`
2. Start frontend: `cd frontend && npm run dev`
3. Open tree editor with an existing tree
4. Edit an action node → verify "Next Step" dropdown appears with all nodes listed
5. Select a node from a different branch → verify dashed purple arrow appears on canvas
6. Edit a decision node → expand "Link an option to an existing node" → create a cross-reference
7. Verify circular reference warning (not error) appears in validation panel
8. Navigate the tree → verify loop-back works (session follows `next_node_id`)
---
## Summary
| Task | What | Files |
|------|------|-------|
| 1 | Backend: relax option validation | `ai_tree_validator.py`, `test_ai_tree_validator.py` |
| 2 | Frontend: circular ref → warning | `treeEditorStore.ts` |
| 3 | Canvas: dashed purple cross-ref edges | `useTreeLayout.ts` |
| 4 | Editor: action node picker | `NodeFormAction.tsx`, `treeEditorStore.ts` |
| 5 | Editor: decision option picker | `NodeFormDecision.tsx` |
| 6 | AI prompt: loop-back awareness | `ai_chat_service.py` |
| 7 | Backend: fix ref overlap check | `ai_tree_validator.py` |
| 8 | Integration test | (testing only) |

View File

@@ -0,0 +1,44 @@
import { apiClient } from './client'
import type {
AIChatStartResponse,
AIChatMessageResponse,
AIChatSessionResponse,
AIChatGenerateResponse,
AIChatImportResponse,
} from '@/types'
export const aiChatApi = {
startSession: async (flowType: 'troubleshooting' | 'procedural'): Promise<AIChatStartResponse> => {
const { data } = await apiClient.post('/ai/chat/sessions', { flow_type: flowType })
return data
},
sendMessage: async (sessionId: string, content: string): Promise<AIChatMessageResponse> => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, { content })
return data
},
getSession: async (sessionId: string): Promise<AIChatSessionResponse> => {
const { data } = await apiClient.get(`/ai/chat/sessions/${sessionId}`)
return data
},
generateTree: async (sessionId: string): Promise<AIChatGenerateResponse> => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/generate`)
return data
},
importTree: async (
sessionId: string,
params?: { name?: string; description?: string; category_id?: string; tags?: string[] }
): Promise<AIChatImportResponse> => {
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/import`, params || {})
return data
},
abandonSession: async (sessionId: string): Promise<void> => {
await apiClient.delete(`/ai/chat/sessions/${sessionId}`)
},
}
export default aiChatApi

View File

@@ -17,3 +17,4 @@ export { targetListsApi } from './targetLists'
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
export { default as feedbackApi } from './feedback'
export { default as aiBuilderApi } from './aiBuilder'
export { default as aiChatApi } from './aiChat'

View File

@@ -98,7 +98,7 @@ export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps)
const getTitle = () => {
switch (phase) {
case 'foundation':
return 'Build with AI'
return 'Flow Assist'
case 'scaffolding':
case 'generating':
return 'AI Scaffold'
@@ -107,9 +107,9 @@ export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps)
case 'reviewing':
return 'Review & Assemble'
case 'error':
return 'AI Flow Builder'
return 'Flow Assist'
default:
return 'Build with AI'
return 'Flow Assist'
}
}

View File

@@ -0,0 +1,72 @@
import { useState, useRef, useCallback } from 'react'
import { Send } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ChatInputProps {
onSend: (content: string) => void
disabled?: boolean
placeholder?: string
}
export function ChatInput({ onSend, disabled, placeholder = 'Type a message...' }: ChatInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSend = useCallback(() => {
const trimmed = value.trim()
if (!trimmed || disabled) return
onSend(trimmed)
setValue('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [value, disabled, onSend])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleInput = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 160) + 'px'
}
}
return (
<div className="flex items-end gap-2 border-t border-border bg-card px-4 py-3">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
'flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2',
'text-sm text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-none',
'disabled:opacity-50 disabled:cursor-not-allowed',
'max-h-40'
)}
/>
<button
onClick={handleSend}
disabled={disabled || !value.trim()}
className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg',
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
'hover:opacity-90 transition-opacity',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<Send className="h-4 w-4" />
</button>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { Bot, User } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { cn } from '@/lib/utils'
import type { ChatMessage as ChatMessageType } from '@/types'
interface ChatMessageProps {
message: ChatMessageType
}
export function ChatMessage({ message }: ChatMessageProps) {
const isAI = message.role === 'assistant'
return (
<div className={cn('flex gap-3', isAI ? 'items-start' : 'items-start justify-end')}>
{isAI && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Bot className="h-4 w-4 text-primary" />
</div>
)}
<div
className={cn(
'max-w-[85%] rounded-xl px-4 py-3',
isAI
? 'bg-card border border-border'
: 'bg-primary/10 border border-primary/20'
)}
>
{isAI ? (
<MarkdownContent content={message.content} className="text-sm" />
) : (
<p className="text-sm text-foreground whitespace-pre-wrap">{message.content}</p>
)}
</div>
{!isAI && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent">
<User className="h-4 w-4 text-foreground" />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react'
import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
import { Spinner } from '@/components/common/Spinner'
import type { ChatMessage as ChatMessageType } from '@/types'
interface ChatPanelProps {
messages: ChatMessageType[]
isResponding: boolean
onSendMessage: (content: string) => void
disabled?: boolean
}
export function ChatPanel({ messages, isResponding, onSendMessage, disabled }: ChatPanelProps) {
const scrollRef = useRef<HTMLDivElement>(null)
// Auto-scroll to bottom on new messages
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, isResponding])
return (
<div className="flex h-full flex-col">
{/* Messages */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.map((msg, i) => (
<ChatMessage key={i} message={msg} />
))}
{isResponding && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Thinking...</span>
</div>
)}
</div>
{/* Input */}
<ChatInput
onSend={onSendMessage}
disabled={disabled || isResponding}
placeholder={isResponding ? 'Waiting for response...' : 'Type a message...'}
/>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { Sparkles, Save, RotateCcw, Loader2 } from 'lucide-react'
import { PhaseIndicator } from './PhaseIndicator'
import { cn } from '@/lib/utils'
import type { InterviewPhase } from '@/types'
interface ChatToolbarProps {
currentPhase: InterviewPhase
status: 'idle' | 'active' | 'completed' | 'abandoned'
isGenerating: boolean
hasGeneratedTree: boolean
isSaving: boolean
onGenerate: () => void
onSave: () => void
onReset: () => void
}
export function ChatToolbar({
currentPhase,
status,
isGenerating,
hasGeneratedTree,
isSaving,
onGenerate,
onSave,
onReset,
}: ChatToolbarProps) {
return (
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<Sparkles className="h-4 w-4 text-primary" />
Flow Assist
</div>
<PhaseIndicator currentPhase={currentPhase} />
</div>
<div className="flex items-center gap-2">
{status === 'active' && !hasGeneratedTree && (
<button
onClick={onGenerate}
disabled={isGenerating}
className={cn(
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
'hover:opacity-90 transition-opacity',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isGenerating ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="h-3.5 w-3.5" />
Generate Flow
</>
)}
</button>
)}
{hasGeneratedTree && (
<button
onClick={onSave}
disabled={isSaving}
className={cn(
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
'hover:opacity-90 transition-opacity',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save to Flow Library
</>
)}
</button>
)}
<button
onClick={onReset}
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
Start Over
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { TreeDeciduous } from 'lucide-react'
export function EmptyPreview() {
return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
<TreeDeciduous className="h-12 w-12 text-muted-foreground/30 mb-4" />
<h3 className="text-sm font-medium text-muted-foreground mb-1">Flow Preview</h3>
<p className="text-xs text-muted-foreground/70 max-w-48">
Your flow will appear here as you describe it to the AI
</p>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { cn } from '@/lib/utils'
import type { InterviewPhase } from '@/types'
const PHASES: { key: InterviewPhase; label: string }[] = [
{ key: 'scoping', label: 'Scoping' },
{ key: 'discovery', label: 'Discovery' },
{ key: 'enrichment', label: 'Enrichment' },
{ key: 'review', label: 'Review' },
{ key: 'generation', label: 'Generate' },
]
interface PhaseIndicatorProps {
currentPhase: InterviewPhase
}
export function PhaseIndicator({ currentPhase }: PhaseIndicatorProps) {
const currentIndex = PHASES.findIndex((p) => p.key === currentPhase)
return (
<div className="flex items-center gap-1">
{PHASES.map((phase, i) => {
const isActive = phase.key === currentPhase
const isCompleted = i < currentIndex
return (
<div key={phase.key} className="flex items-center">
{i > 0 && (
<div
className={cn(
'mx-1 h-px w-4',
isCompleted ? 'bg-primary' : 'bg-border'
)}
/>
)}
<span
className={cn(
'font-label text-[0.6875rem] uppercase tracking-wide px-2 py-0.5 rounded',
isActive && 'text-primary bg-primary/10 font-medium',
isCompleted && 'text-primary',
!isActive && !isCompleted && 'text-muted-foreground'
)}
>
{phase.label}
</span>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,80 @@
import { useState, useMemo, useCallback } from 'react'
import { TreePreviewNode } from '@/components/tree-preview/TreePreviewNode'
import type { SharedLinksMap } from '@/components/tree-preview/TreePreviewPanel'
import type { TreeStructure } from '@/types'
interface StaticTreePreviewProps {
tree: TreeStructure
name?: string
}
function findNodeInTree(nodeId: string, tree: TreeStructure): TreeStructure | null {
if (tree.id === nodeId) return tree
if (tree.children) {
for (const child of tree.children) {
const found = findNodeInTree(nodeId, child)
if (found) return found
}
}
return null
}
function buildSharedLinksMap(node: TreeStructure, map: SharedLinksMap = new Map()): SharedLinksMap {
const nodeLabel = node.type === 'decision' ? node.question : node.title
if (node.type === 'decision' && node.options) {
for (const opt of node.options) {
if (opt.next_node_id) {
const existing = map.get(opt.next_node_id) || []
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
map.set(opt.next_node_id, existing)
}
}
}
if (node.type === 'action' && node.next_node_id) {
const existing = map.get(node.next_node_id) || []
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
map.set(node.next_node_id, existing)
}
if (node.children) {
for (const child of node.children) {
buildSharedLinksMap(child, map)
}
}
return map
}
export function StaticTreePreview({ tree, name }: StaticTreePreviewProps) {
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const findNode = useCallback(
(nodeId: string) => findNodeInTree(nodeId, tree),
[tree]
)
const sharedLinksMap = useMemo(() => buildSharedLinksMap(tree), [tree])
return (
<div className="flex h-full flex-col">
<div className="border-b border-border px-4 py-2">
<h3 className="text-sm font-semibold text-foreground">
Preview: {name || 'Untitled Flow'}
</h3>
<p className="text-xs text-muted-foreground">
Click a node to select
</p>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="inline-block min-w-full">
<TreePreviewNode
node={tree}
selectedNodeId={selectedNodeId}
onSelect={setSelectedNodeId}
depth={0}
findNode={findNode}
sharedLinksMap={sharedLinksMap}
/>
</div>
</div>
</div>
)
}

View File

@@ -79,8 +79,8 @@ export function CreateFlowDropdown({
>
<Sparkles className="h-4 w-4 text-primary" />
<div className="text-left">
<div className="font-medium">Build with AI</div>
<div className="text-xs text-muted-foreground">AI-assisted flow creation</div>
<div className="font-medium">Flow Assist</div>
<div className="text-xs text-muted-foreground">AI-powered flow builder</div>
</div>
</button>
</>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText } from 'lucide-react'
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
@@ -75,6 +75,7 @@ export function Sidebar() {
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" collapsed />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
@@ -105,6 +106,7 @@ export function Sidebar() {
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
<NavItem href="/shares" icon={FileText} label="Exports" />
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" />
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
</div>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { DynamicArrayField } from './DynamicArrayField'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { useTreeEditorStore, collectAllNodesFlat } from '@/store/treeEditorStore'
import { Link2, X } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { InfoTip } from '@/components/common/InfoTip'
import type { TreeStructure } from '@/types'
@@ -158,16 +159,65 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
/>
</div>
{/* Next step hint */}
{hasNextNode ? (
<p className="text-xs text-muted-foreground">
Next step is linked click it on the canvas to edit.
{/* Link to existing node */}
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<Link2 className="h-3.5 w-3.5" />
Next Step
</label>
{hasNextNode ? (
<div className="mt-1 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2">
<span className="flex-1 truncate text-sm text-foreground">
Linked to: {(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
const target = allNodes.find(n => n.id === node.next_node_id)
return target ? target.label : node.next_node_id
})()}
</span>
<button
type="button"
onClick={() => onUpdate({ next_node_id: undefined })}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
title="Remove link"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<select
value=""
onChange={(e) => {
if (e.target.value) {
onUpdate({ next_node_id: e.target.value })
}
}}
className={cn(
'mt-1 block w-full rounded-md border border-border px-3 py-2 text-sm',
'bg-card text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
>
<option value="">Link to existing node...</option>
{(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
return allNodes
.filter(n => n.id !== node.id && n.type !== 'answer')
.map(n => (
<option key={n.id} value={n.id}>
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
</option>
))
})()}
</select>
)}
<p className="mt-1 text-xs text-muted-foreground">
{hasNextNode
? 'This action will navigate to the linked node.'
: 'Select a node to navigate to after this action, or save to create a new placeholder.'}
</p>
) : (
<p className="text-xs text-yellow-400/70">
Save to create a placeholder for the next step.
</p>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { useRef, useEffect } from 'react'
import { Play } from 'lucide-react'
import { Play, Link2 } from 'lucide-react'
import { DynamicArrayField } from './DynamicArrayField'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { useTreeEditorStore, collectAllNodesFlat } from '@/store/treeEditorStore'
import type { TreeStructure, TreeOption } from '@/types'
import { cn } from '@/lib/utils'
import { InfoTip } from '@/components/common/InfoTip'
@@ -195,12 +195,89 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
)}
</div>
{/* Cross-reference link indicator */}
{option.next_node_id && (() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const childIds = new Set(node.children?.map(c => c.id) ?? [])
// Only show if it's a cross-reference (points outside children)
if (childIds.has(option.next_node_id)) return null
const allNodes = collectAllNodesFlat(treeStructure)
const target = allNodes.find(n => n.id === option.next_node_id)
if (!target) return null
return (
<div className="flex items-center gap-1 text-xs text-primary" title={`Links to: ${target.label}`}>
<Link2 className="h-3 w-3" />
</div>
)
})()}
</div>
)
}}
/>
</div>
{/* Quick-link: assign an option to an existing node */}
<details className="mt-2">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
<Link2 className="inline h-3 w-3 mr-1" />
Link an option to an existing node (cross-reference)
</summary>
<div className="mt-2 space-y-2 rounded-md border border-border bg-accent/30 p-3">
<p className="text-xs text-muted-foreground">
Select an option, then pick a target node. This creates a loop-back or cross-reference.
</p>
<div className="flex gap-2">
<select
id="xref-option-select"
className={cn(
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
'bg-card text-foreground'
)}
defaultValue=""
>
<option value="">Select option...</option>
{(node.options || []).map((opt, i) => (
<option key={opt.id} value={i}>
{indexToLetter(i)}: {opt.label || '(empty)'}
</option>
))}
</select>
<select
id="xref-target-select"
className={cn(
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
'bg-card text-foreground'
)}
defaultValue=""
onChange={(e) => {
const optSelect = document.getElementById('xref-option-select') as HTMLSelectElement
const optIndex = parseInt(optSelect?.value, 10)
const targetId = e.target.value
if (!isNaN(optIndex) && targetId) {
handleUpdateOption(optIndex, { next_node_id: targetId })
// Reset selects
optSelect.value = ''
e.target.value = ''
}
}}
>
<option value="">Select target node...</option>
{(() => {
const treeStructure = useTreeEditorStore.getState().treeStructure
const allNodes = collectAllNodesFlat(treeStructure)
return allNodes
.filter(n => n.id !== node.id && n.type !== 'answer')
.map(n => (
<option key={n.id} value={n.id}>
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
</option>
))
})()}
</select>
</div>
</div>
</details>
{/* Example hint for root node */}
{isRootNode && (node.options?.length || 0) < 2 && (
<div className="mt-3 rounded-md border border-dashed border-border bg-accent/50 p-3 text-xs text-muted-foreground">

View File

@@ -29,6 +29,46 @@ function estimateNodeHeight(node: TreeStructure): number {
return height
}
/** Collect all node IDs in the tree. */
function collectAllIds(root: TreeStructure): Set<string> {
const ids = new Set<string>()
function walk(node: TreeStructure) {
ids.add(node.id)
node.children?.forEach(walk)
}
walk(root)
return ids
}
/** Find all cross-reference edges (next_node_id pointing outside children). */
function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; target: string; label?: string }> {
const refs: Array<{ source: string; target: string; label?: string }> = []
const allIds = collectAllIds(root)
function walk(node: TreeStructure) {
const childIds = new Set(node.children?.map(c => c.id) ?? [])
// Decision options pointing outside children
if (node.type === 'decision' && node.options) {
for (const opt of node.options) {
if (opt.next_node_id && !childIds.has(opt.next_node_id) && allIds.has(opt.next_node_id)) {
refs.push({ source: node.id, target: opt.next_node_id, label: opt.label })
}
}
}
// Action next_node_id pointing to non-child (always a cross-ref since actions use next_node_id not children)
if (node.type === 'action' && node.next_node_id && allIds.has(node.next_node_id) && !childIds.has(node.next_node_id)) {
refs.push({ source: node.id, target: node.next_node_id, label: 'loops back' })
}
node.children?.forEach(walk)
}
walk(root)
return refs
}
interface UseTreeLayoutResult {
nodes: Node[]
edges: Edge[]
@@ -128,6 +168,40 @@ export function useTreeLayout(): UseTreeLayoutResult {
walk(treeStructure, null)
// Add cross-reference edges (dashed, purple)
if (treeStructure) {
const crossRefs = collectCrossRefEdges(treeStructure)
for (const ref of crossRefs) {
// Only add if both source and target nodes are visible (not collapsed away)
const sourceVisible = nodes.some(n => n.id === ref.source)
const targetVisible = nodes.some(n => n.id === ref.target)
if (sourceVisible && targetVisible) {
edges.push({
id: `xref-${ref.source}->${ref.target}`,
source: ref.source,
target: ref.target,
type: 'smoothstep',
animated: true,
label: ref.label ? truncateLabel(ref.label) : undefined,
labelStyle: { fill: 'hsl(var(--primary))', fontSize: 10, fontWeight: 500 },
labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.95 },
labelBgPadding: [4, 2] as [number, number],
style: {
stroke: 'hsl(var(--primary))',
strokeWidth: 2,
strokeDasharray: '6 3',
},
markerEnd: {
type: 'arrowclosed' as const,
color: 'hsl(var(--primary))',
width: 16,
height: 16,
},
})
}
}
}
return { rawNodes: nodes, rawEdges: edges }
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])

View File

@@ -0,0 +1,158 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAIChatStore } from '@/store/aiChatStore'
import { ChatPanel } from '@/components/ai-chat/ChatPanel'
import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
import { Spinner } from '@/components/common/Spinner'
import { getTreeEditorPath } from '@/lib/routing'
import { toast } from '@/lib/toast'
import type { TreeStructure } from '@/types'
export function AIChatBuilderPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const flowType = searchParams.get('type') === 'procedural' ? 'procedural' : 'troubleshooting'
const {
sessionId,
status,
currentPhase,
messages,
isResponding,
workingTree,
treeMetadata,
generatedTree,
isGenerating,
error,
startSession,
sendMessage,
generateTree,
importToEditor,
abandonSession,
resumeSession,
} = useAIChatStore()
// Start or resume session on mount
useEffect(() => {
const resumeId = searchParams.get('session')
if (resumeId && !sessionId) {
resumeSession(resumeId)
} else if (!sessionId && status === 'idle') {
startSession(flowType)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Store sessionId in URL for resume support
useEffect(() => {
if (sessionId && !searchParams.get('session')) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.set('session', sessionId)
return next
}, { replace: true })
}
}, [sessionId, searchParams, setSearchParams])
const handleSendMessage = useCallback(
(content: string) => {
sendMessage(content)
},
[sendMessage]
)
const [isSaving, setIsSaving] = useState(false)
const handleGenerate = useCallback(() => {
generateTree()
}, [generateTree])
const handleSave = useCallback(async () => {
if (isSaving) return
setIsSaving(true)
try {
const treeId = await importToEditor({
name: treeMetadata?.name,
description: treeMetadata?.description,
tags: treeMetadata?.tags,
})
const path = getTreeEditorPath(treeId, flowType)
navigate(path)
toast.success('Flow saved to library')
} catch {
toast.error('Failed to save flow')
} finally {
setIsSaving(false)
}
}, [isSaving, importToEditor, treeMetadata, flowType, navigate])
const handleReset = useCallback(async () => {
await abandonSession()
// Clear session from URL
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.delete('session')
return next
}, { replace: true })
startSession(flowType)
}, [abandonSession, startSession, flowType, setSearchParams])
// Show error toast
useEffect(() => {
if (error) {
toast.error(error)
}
}, [error])
if (status === 'idle' && !sessionId) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
)
}
const previewTree = (generatedTree || workingTree) as TreeStructure | null
return (
<div className="flex h-full flex-col">
<ChatToolbar
currentPhase={currentPhase}
status={status}
isGenerating={isGenerating}
hasGeneratedTree={!!generatedTree}
isSaving={isSaving}
onGenerate={handleGenerate}
onSave={handleSave}
onReset={handleReset}
/>
<div className="flex flex-1 overflow-hidden">
{/* Left panel: Chat (60%) */}
<div className="flex w-3/5 flex-col border-r border-border max-lg:w-full">
<ChatPanel
messages={messages}
isResponding={isResponding}
onSendMessage={handleSendMessage}
disabled={status !== 'active'}
/>
</div>
{/* Right panel: Tree preview (40%) — hidden below 1024px */}
<div className="w-2/5 overflow-hidden bg-background max-lg:hidden">
{previewTree ? (
<StaticTreePreview
tree={previewTree}
name={treeMetadata?.name}
/>
) : (
<EmptyPreview />
)}
</div>
</div>
</div>
)
}
export default AIChatBuilderPage

View File

@@ -191,8 +191,8 @@ export function MyTreesPage() {
>
<Sparkles className="h-4 w-4 text-primary" />
<div className="text-left">
<div className="font-medium">Build with AI</div>
<div className="text-xs text-muted-foreground">AI-assisted flow creation</div>
<div className="font-medium">Flow Assist</div>
<div className="text-xs text-muted-foreground">AI-powered flow builder</div>
</div>
</button>
</>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { X, RotateCcw, Play } from 'lucide-react'
import { X, RotateCcw, Play, Sparkles } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders'
@@ -272,11 +272,22 @@ export function TreeLibraryPage() {
</p>
</div>
{canCreateTrees && (
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
label="Create New"
/>
<div className="flex items-center gap-2">
{aiEnabled && (
<button
onClick={() => navigate('/ai/chat')}
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
>
<Sparkles className="h-4 w-4" />
Flow Assist
</button>
)}
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
label="Create New"
/>
</div>
)}
</div>

View File

@@ -33,6 +33,7 @@ const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
// Admin pages
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
@@ -253,6 +254,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'ai/chat',
element: (
<Suspense fallback={<PageLoader />}>
<AIChatBuilderPage />
</Suspense>
),
},
// Admin routes
{
path: 'admin',

View File

@@ -0,0 +1,196 @@
import { create } from 'zustand'
import { AxiosError } from 'axios'
import { aiChatApi } from '@/api/aiChat'
import type {
ChatMessage,
InterviewPhase,
TreeStructure,
} from '@/types'
interface TreeMetadata {
name?: string
description?: string
tags?: string[]
category_id?: string
}
interface AIChatState {
// Session
sessionId: string | null
status: 'idle' | 'active' | 'completed' | 'abandoned'
currentPhase: InterviewPhase
flowType: 'troubleshooting' | 'procedural' | null
// Conversation
messages: ChatMessage[]
isResponding: boolean
// Progressive tree
workingTree: TreeStructure | null
treeMetadata: TreeMetadata | null
// Final generation
generatedTree: TreeStructure | null
isGenerating: boolean
importedTreeId: string | null
// Error
error: string | null
// Actions
startSession: (flowType: 'troubleshooting' | 'procedural') => Promise<void>
sendMessage: (content: string) => Promise<void>
generateTree: () => Promise<void>
importToEditor: (params?: { name?: string; description?: string; category_id?: string; tags?: string[] }) => Promise<string>
abandonSession: () => Promise<void>
resumeSession: (sessionId: string) => Promise<void>
reset: () => void
}
const initialState = {
sessionId: null,
status: 'idle' as const,
currentPhase: 'scoping' as InterviewPhase,
flowType: null,
messages: [],
isResponding: false,
workingTree: null,
treeMetadata: null,
generatedTree: null,
isGenerating: false,
importedTreeId: null,
error: null,
}
function extractErrorMessage(e: unknown, fallback: string): string {
if (e instanceof AxiosError && e.response?.data?.detail) {
const detail = e.response.data.detail
return typeof detail === 'string' ? detail : detail.message || fallback
}
if (e instanceof Error) return e.message
return fallback
}
export const useAIChatStore = create<AIChatState>((set, get) => ({
...initialState,
startSession: async (flowType) => {
set({ ...initialState, status: 'active', flowType, isResponding: true, error: null })
try {
const response = await aiChatApi.startSession(flowType)
set({
sessionId: response.session_id,
currentPhase: response.current_phase,
messages: [{
role: 'assistant',
content: response.greeting,
timestamp: new Date().toISOString(),
}],
isResponding: false,
})
} catch (e: unknown) {
set({ error: extractErrorMessage(e, 'Failed to start session'), isResponding: false, status: 'idle' })
}
},
sendMessage: async (content) => {
const { sessionId, messages, isResponding } = get()
if (!sessionId || isResponding) return
const userMessage: ChatMessage = {
role: 'user',
content,
timestamp: new Date().toISOString(),
}
set({
messages: [...messages, userMessage],
isResponding: true,
error: null,
})
try {
const response = await aiChatApi.sendMessage(sessionId, content)
const aiMessage: ChatMessage = {
role: 'assistant',
content: response.content,
timestamp: new Date().toISOString(),
}
set((state) => ({
messages: [...state.messages, aiMessage],
currentPhase: response.current_phase,
workingTree: (response.working_tree as TreeStructure | null) ?? state.workingTree,
treeMetadata: (response.tree_metadata as TreeMetadata | null) ?? state.treeMetadata,
isResponding: false,
}))
} catch (e: unknown) {
set({ error: extractErrorMessage(e, 'Failed to send message'), isResponding: false })
}
},
generateTree: async () => {
const { sessionId, isGenerating } = get()
if (!sessionId || isGenerating) return
set({ isGenerating: true, error: null })
try {
const response = await aiChatApi.generateTree(sessionId)
set({
generatedTree: response.tree_structure as unknown as TreeStructure,
workingTree: response.tree_structure as unknown as TreeStructure,
treeMetadata: response.tree_metadata as TreeMetadata,
status: 'completed',
isGenerating: false,
})
} catch (e: unknown) {
set({ error: extractErrorMessage(e, 'Failed to generate tree'), isGenerating: false })
}
},
importToEditor: async (params) => {
const { sessionId } = get()
if (!sessionId) throw new Error('No active session')
const response = await aiChatApi.importTree(sessionId, params)
set({ importedTreeId: response.tree_id })
return response.tree_id
},
abandonSession: async () => {
const { sessionId } = get()
if (!sessionId) return
try {
await aiChatApi.abandonSession(sessionId)
} catch {
// Best effort — session may have already expired
}
set({ ...initialState })
},
resumeSession: async (sessionId) => {
set({ isResponding: true, error: null })
try {
const session = await aiChatApi.getSession(sessionId)
set({
sessionId: session.session_id,
status: session.status === 'active' ? 'active' : (session.status as 'completed' | 'abandoned'),
currentPhase: session.current_phase,
flowType: session.flow_type,
messages: session.conversation_history as ChatMessage[],
workingTree: session.working_tree as TreeStructure | null,
treeMetadata: session.tree_metadata as TreeMetadata | null,
generatedTree: session.generated_tree as TreeStructure | null,
isResponding: false,
})
} catch (e: unknown) {
set({ error: extractErrorMessage(e, 'Failed to resume session'), isResponding: false })
}
},
reset: () => set({ ...initialState }),
}))

View File

@@ -123,6 +123,25 @@ export const findNodeInTree = (
return null
}
/** Collect all nodes in the tree as a flat list with depth info. */
export function collectAllNodesFlat(
root: TreeStructure | null
): Array<{ id: string; label: string; type: string; depth: number }> {
if (!root) return []
const result: Array<{ id: string; label: string; type: string; depth: number }> = []
function walk(node: TreeStructure, depth: number) {
const label = node.type === 'decision'
? (node.question || 'Untitled Decision')
: (node.title || `Untitled ${node.type}`)
result.push({ id: node.id, label, type: node.type, depth })
node.children?.forEach(child => walk(child, depth + 1))
}
walk(root, 0)
return result
}
// Helper to find parent of a node
const findParentNode = (
nodeId: string,
@@ -802,8 +821,8 @@ export const useTreeEditorStore = create<TreeEditorState>()(
if (opt.next_node_id && detectCircularRefs(opt.next_node_id, new Set(visited))) {
errors.push({
nodeId: node.id,
message: `Circular reference detected: "${opt.label}" creates a loop`,
severity: 'error'
message: `This path loops back to an earlier node via "${opt.label}"`,
severity: 'warning'
})
return true
}
@@ -814,8 +833,8 @@ export const useTreeEditorStore = create<TreeEditorState>()(
if (node.next_node_id && detectCircularRefs(node.next_node_id, new Set(visited))) {
errors.push({
nodeId: node.id,
message: `Circular reference detected in node "${node.title || node.id}"`,
severity: 'error'
message: `This node loops back to an earlier node ("${node.title || node.id}")`,
severity: 'warning'
})
return true
}

View File

@@ -0,0 +1,43 @@
export type InterviewPhase = 'scoping' | 'discovery' | 'enrichment' | 'review' | 'generation'
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
timestamp: string
}
export interface AIChatStartResponse {
session_id: string
greeting: string
current_phase: InterviewPhase
}
export interface AIChatMessageResponse {
content: string
current_phase: InterviewPhase
working_tree: Record<string, unknown> | null
tree_metadata: Record<string, unknown> | null
}
export interface AIChatSessionResponse {
session_id: string
status: 'active' | 'completed' | 'abandoned'
current_phase: InterviewPhase
flow_type: 'troubleshooting' | 'procedural'
conversation_history: ChatMessage[]
working_tree: Record<string, unknown> | null
tree_metadata: Record<string, unknown> | null
message_count: number
generated_tree: Record<string, unknown> | null
}
export interface AIChatGenerateResponse {
tree_structure: Record<string, unknown>
tree_metadata: Record<string, unknown>
status: string
}
export interface AIChatImportResponse {
tree_id: string
tree_type: string
}

View File

@@ -52,3 +52,13 @@ export type {
AIFixProposal,
AIFixValidationError,
} from './ai-fix'
export type {
InterviewPhase,
ChatMessage,
AIChatStartResponse,
AIChatMessageResponse,
AIChatSessionResponse,
AIChatGenerateResponse,
AIChatImportResponse,
} from './ai-chat'